@nebutra/agent-runtime 0.2.1 → 0.2.2

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 (101) hide show
  1. package/LICENSE +21 -676
  2. package/dist/adapters/index.d.ts +24 -5
  3. package/dist/adapters/index.js +27 -0
  4. package/dist/adapters/index.js.map +1 -1
  5. package/dist/chunk-Q62VKHIT.js +178 -0
  6. package/dist/chunk-Q62VKHIT.js.map +1 -0
  7. package/dist/{chunk-MUF7ZZTO.js → chunk-R5HOSQUW.js} +2 -1
  8. package/dist/{chunk-MUF7ZZTO.js.map → chunk-R5HOSQUW.js.map} +1 -1
  9. package/dist/index.d.ts +1 -1
  10. package/dist/index.js +14 -17
  11. package/dist/index.js.map +1 -1
  12. package/dist/orchestration.d.ts +42 -1
  13. package/dist/orchestration.js +7 -3
  14. package/dist/sandbox.js +1 -1
  15. package/package.json +80 -30
  16. package/.turbo/turbo-build.log +0 -130
  17. package/.turbo/turbo-test.log +0 -47
  18. package/.turbo/turbo-typecheck.log +0 -4
  19. package/CHANGELOG.md +0 -250
  20. package/dist/chunk-MX2WL43P.js +0 -90
  21. package/dist/chunk-MX2WL43P.js.map +0 -1
  22. package/examples/pulsar-quickstart.ts +0 -35
  23. package/examples/resume-branch.ts +0 -45
  24. package/examples/subagent-fanout.ts +0 -20
  25. package/src/adapters/dispatcher-sse.test.ts +0 -218
  26. package/src/adapters/dispatcher-sse.ts +0 -222
  27. package/src/adapters/index.ts +0 -18
  28. package/src/adapters/mcp-catalog.test.ts +0 -213
  29. package/src/adapters/mcp-catalog.ts +0 -188
  30. package/src/adapters/prisma-rollout.test.ts +0 -153
  31. package/src/adapters/prisma-rollout.ts +0 -104
  32. package/src/agent-runtime.test.ts +0 -176
  33. package/src/artifact-stream.test.ts +0 -330
  34. package/src/artifact-stream.ts +0 -453
  35. package/src/channel-gateway.test.ts +0 -432
  36. package/src/channel-gateway.ts +0 -357
  37. package/src/cli.ts +0 -49
  38. package/src/code-review.test.ts +0 -501
  39. package/src/code-review.ts +0 -495
  40. package/src/command-suggestions.test.ts +0 -251
  41. package/src/command-suggestions.ts +0 -338
  42. package/src/commands.test.ts +0 -184
  43. package/src/commands.ts +0 -140
  44. package/src/commit-message.test.ts +0 -249
  45. package/src/commit-message.ts +0 -180
  46. package/src/context-compaction.test.ts +0 -522
  47. package/src/context-compaction.ts +0 -438
  48. package/src/definitions.test.ts +0 -78
  49. package/src/definitions.ts +0 -190
  50. package/src/deployment-status.test.ts +0 -215
  51. package/src/deployment-status.ts +0 -227
  52. package/src/design-context.test.ts +0 -195
  53. package/src/design-context.ts +0 -198
  54. package/src/dispatcher.test.ts +0 -234
  55. package/src/dispatcher.ts +0 -189
  56. package/src/durable-turn.test.ts +0 -209
  57. package/src/durable-turn.ts +0 -135
  58. package/src/edit-planner.test.ts +0 -204
  59. package/src/edit-planner.ts +0 -325
  60. package/src/fuzzy-match.test.ts +0 -311
  61. package/src/fuzzy-match.ts +0 -444
  62. package/src/hook-pipeline.test.ts +0 -279
  63. package/src/hook-pipeline.ts +0 -373
  64. package/src/inbound-admission.test.ts +0 -394
  65. package/src/inbound-admission.ts +0 -246
  66. package/src/index.ts +0 -49
  67. package/src/loop.test.ts +0 -161
  68. package/src/loop.ts +0 -211
  69. package/src/mcp-bridge.test.ts +0 -165
  70. package/src/mcp-bridge.ts +0 -81
  71. package/src/memory-provider.test.ts +0 -232
  72. package/src/memory-provider.ts +0 -257
  73. package/src/model.ts +0 -168
  74. package/src/orchestration.test.ts +0 -53
  75. package/src/orchestration.ts +0 -146
  76. package/src/permission-ruleset.test.ts +0 -301
  77. package/src/permission-ruleset.ts +0 -1
  78. package/src/policy.ts +0 -151
  79. package/src/project-repo.test.ts +0 -232
  80. package/src/project-repo.ts +0 -311
  81. package/src/protocol.ts +0 -159
  82. package/src/pulsar.test.ts +0 -156
  83. package/src/pulsar.ts +0 -322
  84. package/src/rollout-store-persistent.test.ts +0 -217
  85. package/src/rollout-store-persistent.ts +0 -166
  86. package/src/rollout.ts +0 -150
  87. package/src/sandbox.ts +0 -113
  88. package/src/session-share.test.ts +0 -360
  89. package/src/session-share.ts +0 -310
  90. package/src/skill-distillation.test.ts +0 -177
  91. package/src/skill-distillation.ts +0 -369
  92. package/src/skills.test.ts +0 -277
  93. package/src/skills.ts +0 -277
  94. package/src/subagents.test.ts +0 -290
  95. package/src/subagents.ts +0 -332
  96. package/src/tools.test.ts +0 -12
  97. package/src/tools.ts +0 -129
  98. package/src/workbench.test.ts +0 -0
  99. package/src/workbench.ts +0 -0
  100. package/tsconfig.json +0 -12
  101. package/tsup.config.ts +0 -36
@@ -1,156 +0,0 @@
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
- });
package/src/pulsar.ts DELETED
@@ -1,322 +0,0 @@
1
- import { type ApprovalGate, type ModelInvoker, runTurn } from "./loop";
2
- import type { ThreadEvent, ThreadItem, TurnConfig } from "./model";
3
- import { isTurnTerminal } from "./model";
4
- import type { ApprovalPolicy } from "./policy";
5
- import { DEFAULT_APPROVAL_POLICY } from "./policy";
6
- import type { RolloutLine, RolloutStore } from "./rollout";
7
- import { replay } from "./rollout";
8
- import { RuntimeToolRegistry } from "./tools";
9
-
10
- type EventLogKind = "llm_call" | "tool_call" | "sub_agent_call" | "content_write";
11
-
12
- export interface PulsarEventInput {
13
- readonly traceId: string;
14
- readonly kind: EventLogKind;
15
- readonly affected: readonly string[];
16
- readonly parent: string | null;
17
- readonly snapshot?: Record<string, string>;
18
- }
19
-
20
- export interface PulsarBranchRecord {
21
- readonly name: string;
22
- readonly from: string;
23
- readonly at: string;
24
- }
25
-
26
- export interface PulsarEventLogPort {
27
- commit(event: PulsarEventInput): Promise<string>;
28
- branchFrom(id: string, name: string): Promise<PulsarBranchRecord>;
29
- }
30
-
31
- export interface StartThreadOptions {
32
- readonly threadId?: string;
33
- readonly maxSteps?: number;
34
- }
35
-
36
- export interface PulsarDeps {
37
- readonly tenantId: string;
38
- readonly config: TurnConfig;
39
- readonly model: ModelInvoker;
40
- readonly tools: RuntimeToolRegistry;
41
- readonly store: RolloutStore;
42
- readonly approvalGate: ApprovalGate;
43
- readonly approvalPolicy: ApprovalPolicy;
44
- readonly eventLog?: PulsarEventLogPort;
45
- }
46
-
47
- function newId(prefix: string): string {
48
- return `${prefix}_${globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`}`;
49
- }
50
-
51
- function requireNonEmpty(value: string, label: string): string {
52
- const trimmed = value.trim();
53
- if (!trimmed) {
54
- throw new Error(`${label} is required. Suggestion: pass a non-empty ${label}.`);
55
- }
56
- return trimmed;
57
- }
58
-
59
- function recordedEvents(lines: readonly RolloutLine[]): ThreadEvent[] {
60
- return lines.flatMap((line) => (line.type === "event" ? [line.event] : []));
61
- }
62
-
63
- function mapItemToEvent(item: ThreadItem): PulsarEventInput["kind"] {
64
- switch (item.type) {
65
- case "agent_message":
66
- case "reasoning":
67
- return "llm_call";
68
- case "file_change":
69
- return "content_write";
70
- case "command_execution":
71
- case "mcp_tool_call":
72
- return "tool_call";
73
- case "error":
74
- case "todo_list":
75
- case "web_search":
76
- return "tool_call";
77
- }
78
- }
79
-
80
- function affectedPaths(item: ThreadItem): readonly string[] {
81
- return item.type === "file_change" ? item.changes.map((change) => change.path) : [];
82
- }
83
-
84
- export class PulsarThread {
85
- readonly id: string;
86
- readonly playId: string;
87
- readonly tenantId: string;
88
- readonly #deps: PulsarDeps;
89
- readonly #input: string;
90
- readonly #maxSteps: number | undefined;
91
- readonly #mode: "start" | "resume";
92
- #events: ThreadEvent[] | undefined;
93
-
94
- constructor(input: {
95
- readonly id: string;
96
- readonly playId: string;
97
- readonly tenantId: string;
98
- readonly deps: PulsarDeps;
99
- readonly mode: "start" | "resume";
100
- readonly userInput: string;
101
- readonly maxSteps?: number;
102
- }) {
103
- this.id = input.id;
104
- this.playId = input.playId;
105
- this.tenantId = input.tenantId;
106
- this.#deps = input.deps;
107
- this.#input = input.userInput;
108
- this.#mode = input.mode;
109
- this.#maxSteps = input.maxSteps;
110
- }
111
-
112
- async *subscribe(): AsyncGenerator<ThreadEvent> {
113
- if (this.#events) {
114
- for (const event of this.#events) yield event;
115
- return;
116
- }
117
-
118
- const events: ThreadEvent[] = [];
119
- const emit = async (event: ThreadEvent): Promise<ThreadEvent> => {
120
- events.push(event);
121
- if (event.type === "item.completed") {
122
- await this.#mirrorItem(event.item);
123
- }
124
- return event;
125
- };
126
-
127
- if (this.#mode === "resume") {
128
- const lines = await this.#deps.store.read(this.tenantId, this.id);
129
- const projection = replay(lines);
130
- const recorded = recordedEvents(lines);
131
- const last = recorded.at(-1);
132
- if (projection && last && isTurnTerminal(last)) {
133
- this.#events = recorded;
134
- for (const event of recorded) yield event;
135
- return;
136
- }
137
- }
138
-
139
- yield await emit({ type: "thread.started", threadId: this.id });
140
- await this.#deps.store.append({
141
- tenantId: this.tenantId,
142
- threadId: this.id,
143
- type: "event",
144
- event: { type: "thread.started", threadId: this.id },
145
- at: new Date().toISOString(),
146
- });
147
-
148
- const stream = runTurn(this.#input, {
149
- tenantId: this.tenantId,
150
- threadId: this.id,
151
- config: this.#deps.config,
152
- approvalPolicy: this.#deps.approvalPolicy,
153
- model: this.#deps.model,
154
- tools: this.#deps.tools,
155
- store: this.#deps.store,
156
- approvalGate: this.#deps.approvalGate,
157
- ruleEvaluator: () => "allow",
158
- ...(this.#maxSteps !== undefined ? { maxSteps: this.#maxSteps } : {}),
159
- });
160
-
161
- for await (const event of stream) {
162
- yield await emit(event);
163
- }
164
- this.#events = events;
165
- }
166
-
167
- async #mirrorItem(item: ThreadItem): Promise<void> {
168
- const eventLog = this.#deps.eventLog;
169
- if (!eventLog) return;
170
- const parent = Pulsar.itemCommitIds.get(`${this.id}::__last`) ?? null;
171
- const commitId = await eventLog.commit({
172
- traceId: this.id,
173
- kind: mapItemToEvent(item),
174
- affected: affectedPaths(item),
175
- parent,
176
- });
177
- Pulsar.itemCommitIds.set(`${this.id}:${item.id}`, commitId);
178
- Pulsar.itemCommitIds.set(`${this.id}::__last`, commitId);
179
- }
180
- }
181
-
182
- export class PulsarBuilder {
183
- #tenantId?: string;
184
- #config?: TurnConfig;
185
- #model?: ModelInvoker;
186
- #tools: RuntimeToolRegistry = new RuntimeToolRegistry();
187
- #store?: RolloutStore;
188
- #approvalGate?: ApprovalGate;
189
- #approvalPolicy: ApprovalPolicy = DEFAULT_APPROVAL_POLICY;
190
- #eventLog?: PulsarEventLogPort;
191
-
192
- withTenant(tenantId: string): this {
193
- this.#tenantId = requireNonEmpty(tenantId, "tenantId");
194
- return this;
195
- }
196
-
197
- withConfig(config: TurnConfig): this {
198
- this.#config = config;
199
- return this;
200
- }
201
-
202
- withModel(model: ModelInvoker): this {
203
- this.#model = model;
204
- return this;
205
- }
206
-
207
- withTools(tools: RuntimeToolRegistry): this {
208
- this.#tools = tools;
209
- return this;
210
- }
211
-
212
- withRolloutStore(store: RolloutStore): this {
213
- this.#store = store;
214
- return this;
215
- }
216
-
217
- withApprovalGate(approvalGate: ApprovalGate): this {
218
- this.#approvalGate = approvalGate;
219
- return this;
220
- }
221
-
222
- withApprovalPolicy(policy: ApprovalPolicy): this {
223
- this.#approvalPolicy = policy;
224
- return this;
225
- }
226
-
227
- withEventLog(eventLog: PulsarEventLogPort): this {
228
- this.#eventLog = eventLog;
229
- return this;
230
- }
231
-
232
- build(): Pulsar {
233
- if (!this.#tenantId) throw new Error("tenantId is required. Suggestion: call withTenant().");
234
- if (!this.#config) throw new Error("config is required. Suggestion: call withConfig().");
235
- if (!this.#model) throw new Error("model is required. Suggestion: call withModel().");
236
- if (!this.#store) {
237
- throw new Error("rollout store is required. Suggestion: call withRolloutStore().");
238
- }
239
- if (!this.#approvalGate) {
240
- throw new Error("approval gate is required. Suggestion: call withApprovalGate().");
241
- }
242
- const deps: PulsarDeps = {
243
- tenantId: this.#tenantId,
244
- config: this.#config,
245
- model: this.#model,
246
- tools: this.#tools,
247
- store: this.#store,
248
- approvalGate: this.#approvalGate,
249
- approvalPolicy: this.#approvalPolicy,
250
- ...(this.#eventLog !== undefined ? { eventLog: this.#eventLog } : {}),
251
- };
252
- return new Pulsar(deps);
253
- }
254
- }
255
-
256
- export class Pulsar {
257
- static readonly itemCommitIds = new Map<string, string>();
258
- readonly #deps: PulsarDeps;
259
-
260
- constructor(deps: PulsarDeps) {
261
- this.#deps = deps;
262
- }
263
-
264
- static builder(): PulsarBuilder {
265
- return new PulsarBuilder();
266
- }
267
-
268
- async startPlay(
269
- playId: string,
270
- input: string,
271
- options: StartThreadOptions = {},
272
- ): Promise<PulsarThread> {
273
- const threadId = options.threadId ?? newId("thread");
274
- await this.#deps.store.append({
275
- tenantId: this.#deps.tenantId,
276
- threadId,
277
- type: "session_meta",
278
- config: this.#deps.config,
279
- at: new Date().toISOString(),
280
- });
281
- return new PulsarThread({
282
- id: threadId,
283
- playId: requireNonEmpty(playId, "playId"),
284
- tenantId: this.#deps.tenantId,
285
- deps: this.#deps,
286
- mode: "start",
287
- userInput: input,
288
- ...(options.maxSteps !== undefined ? { maxSteps: options.maxSteps } : {}),
289
- });
290
- }
291
-
292
- async resume(threadId: string): Promise<PulsarThread> {
293
- return new PulsarThread({
294
- id: requireNonEmpty(threadId, "threadId"),
295
- playId: "resume",
296
- tenantId: this.#deps.tenantId,
297
- deps: this.#deps,
298
- mode: "resume",
299
- userInput: "",
300
- });
301
- }
302
-
303
- async branchFromItem(
304
- threadId: string,
305
- itemId: string,
306
- name: string,
307
- ): Promise<PulsarBranchRecord> {
308
- const eventLog = this.#deps.eventLog;
309
- if (!eventLog) {
310
- throw new Error("event-log is required. Suggestion: call withEventLog() before branching.");
311
- }
312
- const commitId = Pulsar.itemCommitIds.get(
313
- `${requireNonEmpty(threadId, "threadId")}:${requireNonEmpty(itemId, "itemId")}`,
314
- );
315
- if (!commitId) {
316
- throw new Error(
317
- "item has no event-log commit. Suggestion: subscribe to the thread before branching.",
318
- );
319
- }
320
- return eventLog.branchFrom(commitId, requireNonEmpty(name, "branch name"));
321
- }
322
- }
@@ -1,217 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import type { ThreadItem } from "./model";
3
- import { InMemoryRolloutStore, type RolloutLine, replay } from "./rollout";
4
- import {
5
- PersistentRolloutStore,
6
- type RolloutPersistencePort,
7
- RoundTripError,
8
- } from "./rollout-store-persistent";
9
-
10
- /** In-memory fake satisfying the injectable persistence port. */
11
- class FakePort implements RolloutPersistencePort {
12
- readonly records = new Map<string, { seq: number; payload: string }[]>();
13
-
14
- #key(tenantId: string, threadId: string): string {
15
- return `${tenantId}::${threadId}`;
16
- }
17
-
18
- async put(record: {
19
- tenantId: string;
20
- threadId: string;
21
- seq: number;
22
- at: string;
23
- payload: string;
24
- }): Promise<void> {
25
- const key = this.#key(record.tenantId, record.threadId);
26
- const list = this.records.get(key) ?? [];
27
- list.push({ seq: record.seq, payload: record.payload });
28
- this.records.set(key, list);
29
- }
30
-
31
- async list(tenantId: string, threadId: string): Promise<{ seq: number; payload: string }[]> {
32
- const list = this.records.get(this.#key(tenantId, threadId)) ?? [];
33
- // Return deliberately shuffled to prove the store re-sorts by seq.
34
- return [...list].sort(() => Math.random() - 0.5);
35
- }
36
- }
37
-
38
- const T = "org_A";
39
- const TH = "thread_1";
40
-
41
- function metaLine(tenantId = T, threadId = TH): RolloutLine {
42
- return {
43
- tenantId,
44
- threadId,
45
- type: "session_meta",
46
- config: {
47
- model: "m",
48
- provider: "p",
49
- approvalPolicy: "on_request",
50
- capabilityPolicy: "external_sandbox",
51
- },
52
- at: "2026-05-17T00:00:00.000Z",
53
- };
54
- }
55
-
56
- function eventLine(item: ThreadItem, tenantId = T, threadId = TH): RolloutLine {
57
- return {
58
- tenantId,
59
- threadId,
60
- type: "event",
61
- event: { type: "item.completed", item },
62
- at: "2026-05-17T00:00:01.000Z",
63
- };
64
- }
65
-
66
- function compactedLine(tenantId = T, threadId = TH): RolloutLine {
67
- return {
68
- tenantId,
69
- threadId,
70
- type: "compacted",
71
- summary: "earlier history summarized",
72
- droppedThrough: "item_9",
73
- at: "2026-05-17T00:00:02.000Z",
74
- };
75
- }
76
-
77
- const msgItem: ThreadItem = {
78
- id: "item_1",
79
- type: "agent_message",
80
- text: "hello world",
81
- };
82
-
83
- describe("PersistentRolloutStore round-trip", () => {
84
- it("round-trips every RolloutLine variant faithfully", async () => {
85
- const store = new PersistentRolloutStore(new FakePort());
86
- const lines: RolloutLine[] = [
87
- metaLine(),
88
- eventLine(msgItem),
89
- {
90
- tenantId: T,
91
- threadId: TH,
92
- type: "turn_context",
93
- config: {
94
- model: "m2",
95
- provider: "p2",
96
- approvalPolicy: "never",
97
- capabilityPolicy: "read_only",
98
- reasoningEffort: "high",
99
- },
100
- at: "2026-05-17T00:00:03.000Z",
101
- },
102
- compactedLine(),
103
- ];
104
- for (const l of lines) await store.append(l);
105
- const read = await store.read(T, TH);
106
- expect(read).toEqual(lines);
107
- });
108
-
109
- it("preserves append ordering", async () => {
110
- const store = new PersistentRolloutStore(new FakePort());
111
- for (let i = 0; i < 25; i++) {
112
- await store.append(eventLine({ id: `item_${i}`, type: "agent_message", text: `m${i}` }));
113
- }
114
- const read = await store.read(T, TH);
115
- expect(read.map((l) => (l.type === "event" ? l.event : null))).toEqual(
116
- Array.from({ length: 25 }, (_, i) => ({
117
- type: "item.completed",
118
- item: { id: `item_${i}`, type: "agent_message", text: `m${i}` },
119
- })),
120
- );
121
- });
122
-
123
- it("replay over persistent output equals replay over in-memory", async () => {
124
- const persistent = new PersistentRolloutStore(new FakePort());
125
- const memory = new InMemoryRolloutStore();
126
- const lines: RolloutLine[] = [
127
- metaLine(),
128
- eventLine({ id: "i1", type: "agent_message", text: "a" }),
129
- eventLine({ id: "i2", type: "reasoning", text: "thinking" }),
130
- compactedLine(),
131
- eventLine({ id: "i3", type: "agent_message", text: "post" }),
132
- ];
133
- for (const l of lines) {
134
- await persistent.append(l);
135
- await memory.append(l);
136
- }
137
- const pj = replay(await persistent.read(T, TH));
138
- const mj = replay(await memory.read(T, TH));
139
- expect(pj).toEqual(mj);
140
- });
141
- });
142
-
143
- describe("PersistentRolloutStore tenant isolation", () => {
144
- it("tenant B cannot read tenant A's thread", async () => {
145
- const store = new PersistentRolloutStore(new FakePort());
146
- await store.append(metaLine("org_A", "shared_thread"));
147
- await store.append(eventLine(msgItem, "org_A", "shared_thread"));
148
- const aRead = await store.read("org_A", "shared_thread");
149
- const bRead = await store.read("org_B", "shared_thread");
150
- expect(aRead).toHaveLength(2);
151
- expect(bRead).toEqual([]);
152
- });
153
-
154
- it("seq is independent per (tenant, thread)", async () => {
155
- const port = new FakePort();
156
- const store = new PersistentRolloutStore(port);
157
- await store.append(metaLine("org_A", "th"));
158
- await store.append(metaLine("org_B", "th"));
159
- await store.append(metaLine("org_A", "th"));
160
- expect(port.records.get("org_A::th")?.map((r) => r.seq)).toEqual([0, 1]);
161
- expect(port.records.get("org_B::th")?.map((r) => r.seq)).toEqual([0]);
162
- });
163
- });
164
-
165
- describe("PersistentRolloutStore fail-closed", () => {
166
- it("throws on empty tenantId in append", async () => {
167
- const store = new PersistentRolloutStore(new FakePort());
168
- await expect(store.append(metaLine("", TH))).rejects.toThrow();
169
- });
170
-
171
- it("throws on empty threadId in append", async () => {
172
- const store = new PersistentRolloutStore(new FakePort());
173
- await expect(store.append(metaLine(T, ""))).rejects.toThrow();
174
- });
175
-
176
- it("throws on empty tenantId in read", async () => {
177
- const store = new PersistentRolloutStore(new FakePort());
178
- await expect(store.read("", TH)).rejects.toThrow();
179
- });
180
-
181
- it("throws on empty threadId in read", async () => {
182
- const store = new PersistentRolloutStore(new FakePort());
183
- await expect(store.read(T, "")).rejects.toThrow();
184
- });
185
-
186
- it("throws RoundTripError on malformed stored payload (never silently drops)", async () => {
187
- const port = new FakePort();
188
- const store = new PersistentRolloutStore(port);
189
- await store.append(metaLine());
190
- port.records.get(`${T}::${TH}`)?.push({ seq: 1, payload: "{not json" });
191
- await expect(store.read(T, TH)).rejects.toBeInstanceOf(RoundTripError);
192
- });
193
-
194
- it("throws RoundTripError on structurally-invalid stored payload", async () => {
195
- const port = new FakePort();
196
- const store = new PersistentRolloutStore(port);
197
- await store.append(metaLine());
198
- port.records.get(`${T}::${TH}`)?.push({ seq: 1, payload: JSON.stringify({ type: "bogus" }) });
199
- await expect(store.read(T, TH)).rejects.toBeInstanceOf(RoundTripError);
200
- });
201
- });
202
-
203
- describe("PersistentRolloutStore concurrency", () => {
204
- it("concurrent appends to the same thread do not collide on seq", async () => {
205
- const port = new FakePort();
206
- const store = new PersistentRolloutStore(port);
207
- await Promise.all(
208
- Array.from({ length: 50 }, (_, i) =>
209
- store.append(eventLine({ id: `c_${i}`, type: "agent_message", text: `${i}` })),
210
- ),
211
- );
212
- const seqs = (port.records.get(`${T}::${TH}`) ?? []).map((r) => r.seq).sort((a, b) => a - b);
213
- expect(seqs).toEqual(Array.from({ length: 50 }, (_, i) => i));
214
- const read = await store.read(T, TH);
215
- expect(read).toHaveLength(50);
216
- });
217
- });