@nebutra/agent-runtime 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/.turbo/turbo-build.log +65 -50
  2. package/.turbo/turbo-test.log +39 -36
  3. package/.turbo/turbo-typecheck.log +1 -1
  4. package/CHANGELOG.md +12 -15
  5. package/README.md +2 -0
  6. package/dist/adapters/dispatcher-sse.js +1 -0
  7. package/dist/adapters/index.js +1 -0
  8. package/dist/adapters/mcp-catalog.js +1 -0
  9. package/dist/adapters/prisma-rollout.js +1 -0
  10. package/dist/chunk-424PT5DM.js +23 -0
  11. package/dist/chunk-424PT5DM.js.map +1 -0
  12. package/dist/{chunk-NN7DATXA.js → chunk-4Y25ZTKI.js} +3 -3
  13. package/dist/chunk-4Y25ZTKI.js.map +1 -0
  14. package/dist/{chunk-BJBBR3QA.js → chunk-D4YAPLOW.js} +4 -4
  15. package/dist/chunk-D4YAPLOW.js.map +1 -0
  16. package/dist/{chunk-ZMYX5VBU.js → chunk-GQZKYWFT.js} +23 -7
  17. package/dist/chunk-GQZKYWFT.js.map +1 -0
  18. package/dist/chunk-KCNN4QUQ.js +255 -0
  19. package/dist/chunk-KCNN4QUQ.js.map +1 -0
  20. package/dist/chunk-MX2WL43P.js +90 -0
  21. package/dist/chunk-MX2WL43P.js.map +1 -0
  22. package/dist/{chunk-PGGWSUTM.js → chunk-NI4EDT4T.js} +2 -2
  23. package/dist/chunk-NI4EDT4T.js.map +1 -0
  24. package/dist/{chunk-5N4644PB.js → chunk-SD2ZJ7XG.js} +4 -4
  25. package/dist/cli.d.ts +2 -0
  26. package/dist/cli.js +52 -0
  27. package/dist/cli.js.map +1 -0
  28. package/dist/commands.js +1 -0
  29. package/dist/definitions.js +1 -0
  30. package/dist/dispatcher.js +1 -0
  31. package/dist/durable-turn.js +3 -2
  32. package/dist/hook-pipeline.js +1 -0
  33. package/dist/index.d.ts +8 -85
  34. package/dist/index.js +237 -119
  35. package/dist/index.js.map +1 -1
  36. package/dist/loop.d.ts +2 -2
  37. package/dist/loop.js +3 -2
  38. package/dist/mcp-bridge.d.ts +3 -3
  39. package/dist/mcp-bridge.js +3 -2
  40. package/dist/model.js +1 -0
  41. package/dist/orchestration.d.ts +43 -0
  42. package/dist/orchestration.js +12 -0
  43. package/dist/orchestration.js.map +1 -0
  44. package/dist/policy.js +1 -0
  45. package/dist/protocol.js +1 -0
  46. package/dist/pulsar.d.ts +78 -0
  47. package/dist/pulsar.js +17 -0
  48. package/dist/pulsar.js.map +1 -0
  49. package/dist/rollout-store-persistent.js +1 -0
  50. package/dist/rollout.js +1 -0
  51. package/dist/sandbox.js +1 -0
  52. package/dist/skills.js +2 -1
  53. package/dist/subagents.js +1 -0
  54. package/dist/tools.d.ts +4 -3
  55. package/dist/tools.js +5 -3
  56. package/examples/pulsar-quickstart.ts +35 -0
  57. package/examples/resume-branch.ts +45 -0
  58. package/examples/subagent-fanout.ts +20 -0
  59. package/package.json +12 -5
  60. package/src/cli.ts +49 -0
  61. package/src/context-compaction.ts +6 -2
  62. package/src/index.ts +2 -0
  63. package/src/loop.ts +2 -2
  64. package/src/mcp-bridge.ts +8 -3
  65. package/src/orchestration.test.ts +53 -0
  66. package/src/orchestration.ts +146 -0
  67. package/src/permission-ruleset.ts +1 -200
  68. package/src/pulsar.test.ts +156 -0
  69. package/src/pulsar.ts +322 -0
  70. package/src/skills.ts +30 -8
  71. package/src/tools.test.ts +12 -0
  72. package/src/tools.ts +5 -2
  73. package/tsup.config.ts +4 -1
  74. package/dist/chunk-BJBBR3QA.js.map +0 -1
  75. package/dist/chunk-NN7DATXA.js.map +0 -1
  76. package/dist/chunk-PGGWSUTM.js.map +0 -1
  77. package/dist/chunk-ZMYX5VBU.js.map +0 -1
  78. /package/dist/{chunk-5N4644PB.js.map → chunk-SD2ZJ7XG.js.map} +0 -0
@@ -0,0 +1,53 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { type Brief, costReport, fanOutSubagents, planSubagentDispatch } from "./orchestration";
3
+
4
+ function brief(name: string, dependsOn: readonly string[] = []): Brief {
5
+ return {
6
+ id: name,
7
+ objective: `do ${name}`,
8
+ outputFormat: { type: "object", properties: { value: { type: "string" } } },
9
+ allowedTools: ["read"],
10
+ contextRefs: [],
11
+ boundaries: ["do not write outside scope"],
12
+ budget: { durationMs: 1_000, costUsd: 0.01, tokenLimit: 1_000 },
13
+ dependsOn,
14
+ };
15
+ }
16
+
17
+ describe("subagent orchestration", () => {
18
+ it("defaults dependent briefs to sequential dispatch", () => {
19
+ const plan = planSubagentDispatch([brief("research"), brief("write", ["research"])]);
20
+ expect(plan.strategy).toBe("sequential");
21
+ expect(plan.order.map((item) => item.id)).toEqual(["research", "write"]);
22
+ });
23
+
24
+ it("refuses fan-out when briefs depend on each other", () => {
25
+ expect(() =>
26
+ planSubagentDispatch([brief("research"), brief("write", ["research"])], {
27
+ strategy: "fanout",
28
+ }),
29
+ ).toThrow(/fan-out|depend/i);
30
+ });
31
+
32
+ it("runs independent fan-out briefs and preserves result ownership", async () => {
33
+ const results = await fanOutSubagents([brief("logo"), brief("copy")], async (item) => ({
34
+ briefId: item.id,
35
+ output: { value: item.objective },
36
+ usage: { inputTokens: 10, cachedInputTokens: 0, outputTokens: 3, reasoningOutputTokens: 0 },
37
+ durationMs: 5,
38
+ }));
39
+
40
+ expect(results.map((result) => result.briefId)).toEqual(["logo", "copy"]);
41
+ expect(costReport(results)).toEqual({
42
+ totalInputTokens: 20,
43
+ totalOutputTokens: 6,
44
+ totalReasoningTokens: 0,
45
+ maxDurationMs: 5,
46
+ subagents: 2,
47
+ });
48
+ });
49
+
50
+ it("requires precise brief fields", () => {
51
+ expect(() => planSubagentDispatch([{ ...brief("bad"), objective: "" }])).toThrow(/objective/i);
52
+ });
53
+ });
@@ -0,0 +1,146 @@
1
+ import type { TurnUsage } from "./model";
2
+
3
+ export interface BudgetCap {
4
+ readonly durationMs: number;
5
+ readonly costUsd: number;
6
+ readonly tokenLimit: number;
7
+ }
8
+
9
+ export interface Brief {
10
+ readonly id: string;
11
+ readonly objective: string;
12
+ readonly outputFormat: Record<string, unknown>;
13
+ readonly allowedTools: readonly string[];
14
+ readonly contextRefs: readonly string[];
15
+ readonly boundaries: readonly string[];
16
+ readonly budget: BudgetCap;
17
+ readonly dependsOn?: readonly string[];
18
+ }
19
+
20
+ export type DispatchStrategy = "auto" | "sequential" | "fanout";
21
+
22
+ export interface DispatchPlan {
23
+ readonly strategy: Exclude<DispatchStrategy, "auto">;
24
+ readonly order: readonly Brief[];
25
+ }
26
+
27
+ export interface DispatchPlanOptions {
28
+ readonly strategy?: DispatchStrategy;
29
+ }
30
+
31
+ export interface SubagentResult {
32
+ readonly briefId: string;
33
+ readonly output: unknown;
34
+ readonly usage: TurnUsage;
35
+ readonly durationMs: number;
36
+ }
37
+
38
+ export interface SubagentCostReport {
39
+ readonly totalInputTokens: number;
40
+ readonly totalOutputTokens: number;
41
+ readonly totalReasoningTokens: number;
42
+ readonly maxDurationMs: number;
43
+ readonly subagents: number;
44
+ }
45
+
46
+ function fail(message: string, suggestion: string): never {
47
+ throw new Error(`${message}. Suggestion: ${suggestion}`);
48
+ }
49
+
50
+ function validateBrief(brief: Brief): void {
51
+ if (!brief.id.trim()) fail("brief.id is required", "Use a stable role or task id.");
52
+ if (!brief.objective.trim()) {
53
+ fail("brief.objective is required", "Write a concrete objective before dispatching.");
54
+ }
55
+ if (brief.allowedTools.length === 0) {
56
+ fail("brief.allowedTools is required", "Constrain each subagent to an explicit tool scope.");
57
+ }
58
+ if (brief.boundaries.length === 0) {
59
+ fail("brief.boundaries is required", "State at least one boundary for the worker.");
60
+ }
61
+ if (brief.budget.tokenLimit <= 0 || brief.budget.durationMs <= 0 || brief.budget.costUsd < 0) {
62
+ fail("brief.budget is invalid", "Set positive token/time caps and a non-negative cost cap.");
63
+ }
64
+ }
65
+
66
+ function dependencySet(briefs: readonly Brief[]): Set<string> {
67
+ const dependencies = new Set<string>();
68
+ for (const brief of briefs) {
69
+ for (const dep of brief.dependsOn ?? []) dependencies.add(dep);
70
+ }
71
+ return dependencies;
72
+ }
73
+
74
+ function topologicalOrder(briefs: readonly Brief[]): Brief[] {
75
+ const byId = new Map(briefs.map((brief) => [brief.id, brief]));
76
+ const visited = new Set<string>();
77
+ const visiting = new Set<string>();
78
+ const ordered: Brief[] = [];
79
+
80
+ const visit = (brief: Brief): void => {
81
+ if (visited.has(brief.id)) return;
82
+ if (visiting.has(brief.id)) {
83
+ fail(
84
+ `subagent dependency cycle includes '${brief.id}'`,
85
+ "Remove the cycle or collapse the dependent work into one sequential brief.",
86
+ );
87
+ }
88
+ visiting.add(brief.id);
89
+ for (const dep of brief.dependsOn ?? []) {
90
+ const dependency = byId.get(dep);
91
+ if (dependency) visit(dependency);
92
+ }
93
+ visiting.delete(brief.id);
94
+ visited.add(brief.id);
95
+ ordered.push(brief);
96
+ };
97
+
98
+ for (const brief of briefs) visit(brief);
99
+ return ordered;
100
+ }
101
+
102
+ export function planSubagentDispatch(
103
+ briefs: readonly Brief[],
104
+ options: DispatchPlanOptions = {},
105
+ ): DispatchPlan {
106
+ if (briefs.length === 0) {
107
+ fail("at least one brief is required", "Create a concrete worker brief before dispatching.");
108
+ }
109
+ for (const brief of briefs) validateBrief(brief);
110
+
111
+ const dependencies = dependencySet(briefs);
112
+ if ((options.strategy ?? "auto") === "fanout" && dependencies.size > 0) {
113
+ fail(
114
+ "fan-out cannot run briefs that depend on each other",
115
+ "Use sequential dispatch for dependent work or split independent briefs only.",
116
+ );
117
+ }
118
+
119
+ const order = topologicalOrder(briefs);
120
+ const strategy =
121
+ options.strategy === "fanout" || (options.strategy === "auto" && dependencies.size === 0)
122
+ ? "fanout"
123
+ : "sequential";
124
+ return { strategy, order };
125
+ }
126
+
127
+ export async function fanOutSubagents(
128
+ briefs: readonly Brief[],
129
+ run: (brief: Brief) => Promise<SubagentResult>,
130
+ ): Promise<readonly SubagentResult[]> {
131
+ const plan = planSubagentDispatch(briefs, { strategy: "fanout" });
132
+ return Promise.all(plan.order.map((brief) => run(brief)));
133
+ }
134
+
135
+ export function costReport(results: readonly SubagentResult[]): SubagentCostReport {
136
+ return {
137
+ totalInputTokens: results.reduce((sum, result) => sum + result.usage.inputTokens, 0),
138
+ totalOutputTokens: results.reduce((sum, result) => sum + result.usage.outputTokens, 0),
139
+ totalReasoningTokens: results.reduce(
140
+ (sum, result) => sum + result.usage.reasoningOutputTokens,
141
+ 0,
142
+ ),
143
+ maxDurationMs: results.reduce((max, result) => Math.max(max, result.durationMs), 0),
144
+ subagents: results.length,
145
+ };
146
+ }
@@ -1,200 +1 @@
1
- /**
2
- * Permission ruleset evaluator.
3
- *
4
- * A small, pure, stateless re-expression of a two-dimensional wildcard
5
- * permission model plus a bash-command-prefix extractor. No global state,
6
- * no I/O, no mutation of inputs — every function is referentially transparent.
7
- *
8
- * The model has two independent dimensions per rule:
9
- * - `permission` — the capability namespace (e.g. "bash", "edit", "net")
10
- * - `pattern` — the concrete subject within that namespace
11
- * A rule applies only when BOTH dimensions match the query via {@link wildcardMatch}.
12
- */
13
-
14
- /** The decision a rule yields. Unknown queries fail safe to "ask". */
15
- export type Action = "allow" | "deny" | "ask";
16
-
17
- /** A single permission rule. Both dimensions are matched as wildcard globs. */
18
- export interface Rule {
19
- permission: string;
20
- pattern: string;
21
- action: Action;
22
- }
23
-
24
- /** An ordered list of rules. Earlier rules take precedence. */
25
- export type Ruleset = Rule[];
26
-
27
- /**
28
- * Anchored full-string glob matcher.
29
- *
30
- * Semantics:
31
- * - `*` matches any run of characters, including the empty run.
32
- * - `?` matches exactly one character.
33
- * - Every other character is matched literally (regex metacharacters in
34
- * `pattern` carry no special meaning).
35
- * - The match is anchored: the entire `str` must be consumed.
36
- *
37
- * Special rule (ported faithfully): if `pattern` ends with `" *"` (a single
38
- * space immediately followed by `*`), that trailing ` *` is OPTIONAL. The
39
- * pattern then matches both `"<head> <rest>"` and exactly `"<head>"` with
40
- * nothing after it. For example `"git *"` matches `"git"` and `"git status"`.
41
- *
42
- * Implemented with a backtracking two-pointer scan whose `*` handling uses a
43
- * single saved restart position, giving O(|str| * |pattern|) worst case with
44
- * no catastrophic blow-up.
45
- */
46
- export function wildcardMatch(str: string, pattern: string): boolean {
47
- if (isOptionalTrailingStar(pattern)) {
48
- const head = pattern.slice(0, -2); // drop the trailing " *"
49
- // Head-only branch: the whole string equals the head, matched as a glob.
50
- if (globMatch(str, head)) {
51
- return true;
52
- }
53
- // Otherwise the full "<head> *" pattern must match (space is consumed).
54
- return globMatch(str, pattern);
55
- }
56
- return globMatch(str, pattern);
57
- }
58
-
59
- /**
60
- * True when `pattern` ends in a literal space followed by `*`, and that `*`
61
- * is the final character. A bare `"*"` (no preceding space) is NOT treated
62
- * as the optional-suffix form.
63
- */
64
- function isOptionalTrailingStar(pattern: string): boolean {
65
- return pattern.length >= 2 && pattern.endsWith(" *");
66
- }
67
-
68
- /**
69
- * Core anchored glob match using linear-time backtracking. Only `*` can
70
- * backtrack, and it uses a single restart marker (the classic two-pointer
71
- * algorithm), so there is no exponential backtracking.
72
- */
73
- function globMatch(str: string, pattern: string): boolean {
74
- let s = 0;
75
- let p = 0;
76
- let starP = -1;
77
- let starS = 0;
78
-
79
- while (s < str.length) {
80
- const pc = p < pattern.length ? pattern[p] : undefined;
81
- if (pc === "*") {
82
- // Record the restart point and tentatively consume zero chars.
83
- starP = p;
84
- starS = s;
85
- p += 1;
86
- } else if (pc === "?" || pc === str[s]) {
87
- p += 1;
88
- s += 1;
89
- } else if (starP !== -1) {
90
- // Backtrack: let the last `*` swallow one more character.
91
- p = starP + 1;
92
- starS += 1;
93
- s = starS;
94
- } else {
95
- return false;
96
- }
97
- }
98
-
99
- // Consume any trailing `*` segments in the pattern.
100
- while (p < pattern.length && pattern[p] === "*") {
101
- p += 1;
102
- }
103
-
104
- return p === pattern.length;
105
- }
106
-
107
- /**
108
- * Resolve a permission query against one or more rulesets.
109
- *
110
- * Rulesets are concatenated in argument order (no mutation) and scanned
111
- * front-to-back. The FIRST rule whose `permission` and `pattern` both match
112
- * the query (via {@link wildcardMatch}) is returned. If no rule matches, a
113
- * fail-safe default is returned: the queried permission/pattern with action
114
- * `"ask"` (unknown → ask, never silently allow).
115
- */
116
- export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
117
- for (const ruleset of rulesets) {
118
- for (const rule of ruleset) {
119
- if (wildcardMatch(permission, rule.permission) && wildcardMatch(pattern, rule.pattern)) {
120
- return rule;
121
- }
122
- }
123
- }
124
- return { permission, pattern: "*", action: "ask" };
125
- }
126
-
127
- /**
128
- * Built-in command arity table. Maps a (possibly multi-word) command prefix
129
- * to the number of leading tokens that constitute its "human-understandable
130
- * command" for permission matching. Longest matching prefix wins.
131
- *
132
- * Frozen so the shared default cannot be mutated by callers.
133
- */
134
- export const BUILTIN_ARITY: Readonly<Record<string, number>> = Object.freeze({
135
- git: 2,
136
- npm: 2,
137
- "npm run": 3,
138
- docker: 2,
139
- kubectl: 2,
140
- cargo: 2,
141
- pnpm: 2,
142
- yarn: 2,
143
- go: 2,
144
- ls: 1,
145
- cat: 1,
146
- cd: 1,
147
- rm: 1,
148
- cp: 1,
149
- mv: 1,
150
- mkdir: 1,
151
- echo: 1,
152
- grep: 1,
153
- python: 1,
154
- node: 1,
155
- });
156
-
157
- /**
158
- * Extract the human-understandable command from already-split, flag-free
159
- * shell tokens.
160
- *
161
- * Strategy: try the longest prefix first. For `len` from `tokens.length` down
162
- * to 1, if `tokens.slice(0, len).join(" ")` is a key in the (merged) arity
163
- * table, return `tokens.slice(0, arity[thatPrefix])`. If the tokens are empty,
164
- * return `[]`. Otherwise default to the first token only.
165
- *
166
- * `arity` is shallow-merged OVER the built-in table; neither the caller's
167
- * object nor the built-in table is mutated.
168
- */
169
- export function commandPrefix(
170
- tokens: string[],
171
- arity?: Record<string, number> | undefined,
172
- ): string[] {
173
- if (tokens.length === 0) {
174
- return [];
175
- }
176
- const table: Record<string, number> = { ...BUILTIN_ARITY, ...(arity ?? {}) };
177
-
178
- for (let len = tokens.length; len >= 1; len -= 1) {
179
- const prefixKey = tokens.slice(0, len).join(" ");
180
- const take = table[prefixKey];
181
- if (take !== undefined) {
182
- // Clamp to the available token count; never expand beyond the input.
183
- return tokens.slice(0, Math.min(take, tokens.length));
184
- }
185
- }
186
-
187
- return tokens.slice(0, 1);
188
- }
189
-
190
- /**
191
- * Derive the `pattern` to feed {@link evaluate} for a bash permission.
192
- *
193
- * Splits `command` on arbitrary whitespace, drops tokens that begin with `-`
194
- * (flags are not conceptually part of the command identity), applies
195
- * {@link commandPrefix}, and joins the result with single spaces.
196
- */
197
- export function commandPermissionKey(command: string): string {
198
- const tokens = command.split(/\s+/).filter((token) => token.length > 0 && !token.startsWith("-"));
199
- return commandPrefix(tokens).join(" ");
200
- }
1
+ export * from "@nebutra/execution-policy";
@@ -0,0 +1,156 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { z } from "zod";
3
+ import type { ApprovalGate, ModelInvoker, ModelRoundResult } from "./loop";
4
+ import type { ThreadEvent, TurnConfig } from "./model";
5
+ import type { ReviewDecision } from "./policy";
6
+ import { Pulsar } from "./pulsar";
7
+ import { InMemoryRolloutStore } from "./rollout";
8
+ import { ToolRegistry } from "./tools";
9
+
10
+ const config: TurnConfig = {
11
+ model: "m",
12
+ provider: "p",
13
+ approvalPolicy: "on_request",
14
+ capabilityPolicy: "external_sandbox",
15
+ };
16
+
17
+ const approveAll: ApprovalGate = {
18
+ async request() {
19
+ return { kind: "approved" } as ReviewDecision;
20
+ },
21
+ };
22
+
23
+ function quickModel(): ModelInvoker {
24
+ return {
25
+ async invoke(): Promise<ModelRoundResult> {
26
+ return {
27
+ emissions: [{ kind: "text", text: "done" }],
28
+ usage: { inputTokens: 2, outputTokens: 1 },
29
+ };
30
+ },
31
+ };
32
+ }
33
+
34
+ function registry(): ToolRegistry {
35
+ const tools = new ToolRegistry();
36
+ tools.register(
37
+ { name: "echo", description: "echo", inputSchema: z.object({ v: z.string() }) },
38
+ async (input: { v: string }) => input.v,
39
+ );
40
+ return tools;
41
+ }
42
+
43
+ async function collect(gen: AsyncGenerator<ThreadEvent>): Promise<ThreadEvent[]> {
44
+ const events: ThreadEvent[] = [];
45
+ for await (const event of gen) events.push(event);
46
+ return events;
47
+ }
48
+
49
+ function fakeEventLog() {
50
+ const commits: Array<{
51
+ traceId: string;
52
+ kind: string;
53
+ affected: readonly string[];
54
+ parent: string | null;
55
+ }> = [];
56
+ const branches: Array<{ id: string; name: string }> = [];
57
+ return {
58
+ commits,
59
+ branches,
60
+ async commit(event: {
61
+ traceId: string;
62
+ kind: string;
63
+ affected: readonly string[];
64
+ parent: string | null;
65
+ }) {
66
+ commits.push(event);
67
+ return `event_${commits.length}`;
68
+ },
69
+ async branchFrom(id: string, name: string) {
70
+ branches.push({ id, name });
71
+ return { name, from: id, at: "now" };
72
+ },
73
+ };
74
+ }
75
+
76
+ describe("Pulsar facade", () => {
77
+ it("starts a tenant-scoped thread, streams item-level events, and mirrors items to event-log", async () => {
78
+ const store = new InMemoryRolloutStore();
79
+ const eventLog = fakeEventLog();
80
+ const pulsar = Pulsar.builder()
81
+ .withTenant("org_1")
82
+ .withConfig(config)
83
+ .withModel(quickModel())
84
+ .withTools(registry())
85
+ .withRolloutStore(store)
86
+ .withApprovalGate(approveAll)
87
+ .withEventLog(eventLog)
88
+ .build();
89
+
90
+ const thread = await pulsar.startPlay("hello_play", "say hi");
91
+ const events = await collect(thread.subscribe());
92
+
93
+ expect(events[0]).toEqual({ type: "thread.started", threadId: thread.id });
94
+ expect(events.some((event) => event.type === "item.completed")).toBe(true);
95
+ expect(events.at(-1)?.type).toBe("turn.completed");
96
+ expect(eventLog.commits).toMatchObject([
97
+ { traceId: thread.id, kind: "llm_call", affected: [], parent: null },
98
+ ]);
99
+
100
+ const rollout = await store.read("org_1", thread.id);
101
+ expect(rollout[0]).toMatchObject({ type: "session_meta", threadId: thread.id });
102
+ expect(rollout.every((line) => line.tenantId === "org_1")).toBe(true);
103
+ });
104
+
105
+ it("resumes a completed thread by replaying recorded events instead of calling the model again", async () => {
106
+ let invokes = 0;
107
+ const model: ModelInvoker = {
108
+ async invoke() {
109
+ invokes += 1;
110
+ return { emissions: [{ kind: "text", text: "done" }] };
111
+ },
112
+ };
113
+ const pulsar = Pulsar.builder()
114
+ .withTenant("org_1")
115
+ .withConfig(config)
116
+ .withModel(model)
117
+ .withTools(registry())
118
+ .withRolloutStore(new InMemoryRolloutStore())
119
+ .withApprovalGate(approveAll)
120
+ .build();
121
+
122
+ const thread = await pulsar.startPlay("hello_play", "say hi");
123
+ const first = await collect(thread.subscribe());
124
+ const resumed = await pulsar.resume(thread.id);
125
+ const replayed = await collect(resumed.subscribe());
126
+
127
+ expect(invokes).toBe(1);
128
+ expect(replayed.map((event) => event.type)).toEqual(first.map((event) => event.type));
129
+ });
130
+
131
+ it("branches from an item by resolving the item-level event-log commit", async () => {
132
+ const eventLog = fakeEventLog();
133
+ const pulsar = Pulsar.builder()
134
+ .withTenant("org_1")
135
+ .withConfig(config)
136
+ .withModel(quickModel())
137
+ .withTools(registry())
138
+ .withRolloutStore(new InMemoryRolloutStore())
139
+ .withApprovalGate(approveAll)
140
+ .withEventLog(eventLog)
141
+ .build();
142
+
143
+ const thread = await pulsar.startPlay("hello_play", "say hi");
144
+ const events = await collect(thread.subscribe());
145
+ const item = events.find((event) => event.type === "item.completed")?.item;
146
+ if (!item) throw new Error("expected completed item");
147
+
148
+ await pulsar.branchFromItem(thread.id, item.id, "variant");
149
+
150
+ expect(eventLog.branches).toEqual([{ id: "event_1", name: "variant" }]);
151
+ });
152
+
153
+ it("fails closed without tenant context", () => {
154
+ expect(() => Pulsar.builder().withTenant("")).toThrow(/tenant/i);
155
+ });
156
+ });