@nebutra/agent-runtime 0.2.1 → 2.0.0

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 (114) hide show
  1. package/LICENSE +21 -676
  2. package/README.md +33 -10
  3. package/dist/adapters/index.d.ts +24 -5
  4. package/dist/adapters/index.js +27 -0
  5. package/dist/adapters/index.js.map +1 -1
  6. package/dist/{chunk-KCNN4QUQ.js → chunk-5UGIUYJR.js} +4 -4
  7. package/dist/chunk-7ELA4SWE.js +73 -0
  8. package/dist/chunk-7ELA4SWE.js.map +1 -0
  9. package/dist/chunk-Q62VKHIT.js +178 -0
  10. package/dist/chunk-Q62VKHIT.js.map +1 -0
  11. package/dist/chunk-VXILZAXK.js +541 -0
  12. package/dist/chunk-VXILZAXK.js.map +1 -0
  13. package/dist/command-exec.d.ts +45 -0
  14. package/dist/command-exec.js +15 -0
  15. package/dist/command-exec.js.map +1 -0
  16. package/dist/index.d.ts +3 -2
  17. package/dist/index.js +81 -28
  18. package/dist/index.js.map +1 -1
  19. package/dist/orchestration.d.ts +42 -1
  20. package/dist/orchestration.js +7 -3
  21. package/dist/pulsar.js +2 -2
  22. package/dist/sandbox-DfcRttXt.d.ts +219 -0
  23. package/dist/sandbox.d.ts +2 -64
  24. package/dist/sandbox.js +25 -3
  25. package/package.json +81 -30
  26. package/.turbo/turbo-build.log +0 -130
  27. package/.turbo/turbo-test.log +0 -47
  28. package/.turbo/turbo-typecheck.log +0 -4
  29. package/CHANGELOG.md +0 -250
  30. package/dist/chunk-MUF7ZZTO.js +0 -57
  31. package/dist/chunk-MUF7ZZTO.js.map +0 -1
  32. package/dist/chunk-MX2WL43P.js +0 -90
  33. package/dist/chunk-MX2WL43P.js.map +0 -1
  34. package/examples/pulsar-quickstart.ts +0 -35
  35. package/examples/resume-branch.ts +0 -45
  36. package/examples/subagent-fanout.ts +0 -20
  37. package/src/adapters/dispatcher-sse.test.ts +0 -218
  38. package/src/adapters/dispatcher-sse.ts +0 -222
  39. package/src/adapters/index.ts +0 -18
  40. package/src/adapters/mcp-catalog.test.ts +0 -213
  41. package/src/adapters/mcp-catalog.ts +0 -188
  42. package/src/adapters/prisma-rollout.test.ts +0 -153
  43. package/src/adapters/prisma-rollout.ts +0 -104
  44. package/src/agent-runtime.test.ts +0 -176
  45. package/src/artifact-stream.test.ts +0 -330
  46. package/src/artifact-stream.ts +0 -453
  47. package/src/channel-gateway.test.ts +0 -432
  48. package/src/channel-gateway.ts +0 -357
  49. package/src/cli.ts +0 -49
  50. package/src/code-review.test.ts +0 -501
  51. package/src/code-review.ts +0 -495
  52. package/src/command-suggestions.test.ts +0 -251
  53. package/src/command-suggestions.ts +0 -338
  54. package/src/commands.test.ts +0 -184
  55. package/src/commands.ts +0 -140
  56. package/src/commit-message.test.ts +0 -249
  57. package/src/commit-message.ts +0 -180
  58. package/src/context-compaction.test.ts +0 -522
  59. package/src/context-compaction.ts +0 -438
  60. package/src/definitions.test.ts +0 -78
  61. package/src/definitions.ts +0 -190
  62. package/src/deployment-status.test.ts +0 -215
  63. package/src/deployment-status.ts +0 -227
  64. package/src/design-context.test.ts +0 -195
  65. package/src/design-context.ts +0 -198
  66. package/src/dispatcher.test.ts +0 -234
  67. package/src/dispatcher.ts +0 -189
  68. package/src/durable-turn.test.ts +0 -209
  69. package/src/durable-turn.ts +0 -135
  70. package/src/edit-planner.test.ts +0 -204
  71. package/src/edit-planner.ts +0 -325
  72. package/src/fuzzy-match.test.ts +0 -311
  73. package/src/fuzzy-match.ts +0 -444
  74. package/src/hook-pipeline.test.ts +0 -279
  75. package/src/hook-pipeline.ts +0 -373
  76. package/src/inbound-admission.test.ts +0 -394
  77. package/src/inbound-admission.ts +0 -246
  78. package/src/index.ts +0 -49
  79. package/src/loop.test.ts +0 -161
  80. package/src/loop.ts +0 -211
  81. package/src/mcp-bridge.test.ts +0 -165
  82. package/src/mcp-bridge.ts +0 -81
  83. package/src/memory-provider.test.ts +0 -232
  84. package/src/memory-provider.ts +0 -257
  85. package/src/model.ts +0 -168
  86. package/src/orchestration.test.ts +0 -53
  87. package/src/orchestration.ts +0 -146
  88. package/src/permission-ruleset.test.ts +0 -301
  89. package/src/permission-ruleset.ts +0 -1
  90. package/src/policy.ts +0 -151
  91. package/src/project-repo.test.ts +0 -232
  92. package/src/project-repo.ts +0 -311
  93. package/src/protocol.ts +0 -159
  94. package/src/pulsar.test.ts +0 -156
  95. package/src/pulsar.ts +0 -322
  96. package/src/rollout-store-persistent.test.ts +0 -217
  97. package/src/rollout-store-persistent.ts +0 -166
  98. package/src/rollout.ts +0 -150
  99. package/src/sandbox.ts +0 -113
  100. package/src/session-share.test.ts +0 -360
  101. package/src/session-share.ts +0 -310
  102. package/src/skill-distillation.test.ts +0 -177
  103. package/src/skill-distillation.ts +0 -369
  104. package/src/skills.test.ts +0 -277
  105. package/src/skills.ts +0 -277
  106. package/src/subagents.test.ts +0 -290
  107. package/src/subagents.ts +0 -332
  108. package/src/tools.test.ts +0 -12
  109. package/src/tools.ts +0 -129
  110. package/src/workbench.test.ts +0 -0
  111. package/src/workbench.ts +0 -0
  112. package/tsconfig.json +0 -12
  113. package/tsup.config.ts +0 -36
  114. /package/dist/{chunk-KCNN4QUQ.js.map → chunk-5UGIUYJR.js.map} +0 -0
@@ -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
- });