@assistant-ui/react-ag-ui 0.0.71 → 0.1.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 (70) hide show
  1. package/README.md +1 -1
  2. package/dist/agUiExtras.js +1 -2
  3. package/dist/hooks.js +1 -2
  4. package/dist/runtime/{AgUiThreadRuntimeCore.d.ts → AgUiThreadController.d.ts} +6 -12
  5. package/dist/runtime/AgUiThreadController.d.ts.map +1 -0
  6. package/dist/runtime/{AgUiThreadRuntimeCore.js → AgUiThreadController.js} +567 -194
  7. package/dist/runtime/AgUiThreadController.js.map +1 -0
  8. package/dist/runtime/adapter/conversions.d.ts +4 -4
  9. package/dist/runtime/adapter/conversions.d.ts.map +1 -1
  10. package/dist/runtime/adapter/conversions.js +120 -35
  11. package/dist/runtime/adapter/conversions.js.map +1 -1
  12. package/dist/runtime/adapter/replay-content.d.ts +9 -0
  13. package/dist/runtime/adapter/replay-content.d.ts.map +1 -0
  14. package/dist/runtime/adapter/replay-content.js +185 -0
  15. package/dist/runtime/adapter/replay-content.js.map +1 -0
  16. package/dist/runtime/adapter/run-aggregator.d.ts +3 -5
  17. package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
  18. package/dist/runtime/adapter/run-aggregator.js +48 -63
  19. package/dist/runtime/adapter/run-aggregator.js.map +1 -1
  20. package/dist/runtime/adapter/subscriber.d.ts +0 -24
  21. package/dist/runtime/adapter/subscriber.d.ts.map +1 -1
  22. package/dist/runtime/adapter/subscriber.js +2 -24
  23. package/dist/runtime/adapter/subscriber.js.map +1 -1
  24. package/dist/runtime/adapter/tool-approval.d.ts +2 -0
  25. package/dist/runtime/adapter/tool-approval.d.ts.map +1 -1
  26. package/dist/runtime/adapter/tool-approval.js +23 -6
  27. package/dist/runtime/adapter/tool-approval.js.map +1 -1
  28. package/dist/runtime/agUiThreadState.d.ts +60 -0
  29. package/dist/runtime/agUiThreadState.d.ts.map +1 -0
  30. package/dist/runtime/agUiThreadState.js +94 -0
  31. package/dist/runtime/agUiThreadState.js.map +1 -0
  32. package/dist/runtime/event-parser.d.ts.map +1 -1
  33. package/dist/runtime/event-parser.js +46 -50
  34. package/dist/runtime/event-parser.js.map +1 -1
  35. package/dist/runtime/interrupt-internals.js +1 -2
  36. package/dist/runtime/logger.js +1 -2
  37. package/dist/runtime/mcp-tool-result.d.ts +3 -1
  38. package/dist/runtime/mcp-tool-result.d.ts.map +1 -1
  39. package/dist/runtime/mcp-tool-result.js +7 -5
  40. package/dist/runtime/mcp-tool-result.js.map +1 -1
  41. package/dist/runtime/types.d.ts +11 -35
  42. package/dist/runtime/types.d.ts.map +1 -1
  43. package/dist/runtime/types.js +1 -2
  44. package/dist/runtime/types.js.map +1 -1
  45. package/dist/useAgUiRuntime.js +3 -4
  46. package/dist/useAgUiRuntime.js.map +1 -1
  47. package/package.json +12 -11
  48. package/src/runtime/AgUiThreadController.deferred-results.test.ts +548 -0
  49. package/src/runtime/{AgUiThreadRuntimeCore.interrupt-cancel.test.ts → AgUiThreadController.interrupt-cancel.test.ts} +4 -4
  50. package/src/runtime/AgUiThreadController.replay.test.ts +3932 -0
  51. package/src/runtime/{AgUiThreadRuntimeCore.test.ts → AgUiThreadController.test.ts} +114 -6
  52. package/src/runtime/{AgUiThreadRuntimeCore.ts → AgUiThreadController.ts} +1037 -236
  53. package/src/runtime/adapter/conversions.content.test.ts +149 -5
  54. package/src/runtime/adapter/conversions.tools.test.ts +2 -1
  55. package/src/runtime/adapter/conversions.ts +249 -62
  56. package/src/runtime/adapter/replay-content.test.ts +235 -0
  57. package/src/runtime/adapter/replay-content.ts +306 -0
  58. package/src/runtime/adapter/run-aggregator.ts +79 -114
  59. package/src/runtime/adapter/subscriber.ts +1 -44
  60. package/src/runtime/adapter/tool-approval.ts +41 -3
  61. package/src/runtime/agUiThreadState.test.ts +224 -0
  62. package/src/runtime/agUiThreadState.ts +139 -0
  63. package/src/runtime/event-parser.ts +40 -58
  64. package/src/runtime/mcp-tool-result.ts +16 -2
  65. package/src/runtime/types.ts +16 -28
  66. package/src/useAgUiRuntime.fast-refresh.test.tsx +3 -3
  67. package/src/useAgUiRuntime.thread-switch.test.tsx +4 -4
  68. package/src/useAgUiRuntime.ts +3 -3
  69. package/dist/runtime/AgUiThreadRuntimeCore.d.ts.map +0 -1
  70. package/dist/runtime/AgUiThreadRuntimeCore.js.map +0 -1
@@ -0,0 +1,3932 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import type { AbstractAgent } from "@ag-ui/client";
3
+ import type {
4
+ ThreadAssistantMessage,
5
+ ThreadHistoryAdapter,
6
+ ThreadMessage,
7
+ } from "@assistant-ui/core";
8
+ import { AgUiThreadController } from "./AgUiThreadController";
9
+ import { makeLogger } from "./logger";
10
+
11
+ type Subscriber = Record<
12
+ string,
13
+ ((payload: { event: unknown }) => void) | undefined
14
+ >;
15
+
16
+ function emit(subscriber: Subscriber, event: Record<string, unknown>) {
17
+ const name = `on${event.type
18
+ ?.toString()
19
+ .toLowerCase()
20
+ .split("_")
21
+ .map((word) => word[0]?.toUpperCase() + word.slice(1))
22
+ .join("")}Event`;
23
+ subscriber[name]?.({ event });
24
+ }
25
+
26
+ function text(subscriber: Subscriber, id: string, value: string) {
27
+ emit(subscriber, { type: "TEXT_MESSAGE_START", messageId: id });
28
+ emit(subscriber, {
29
+ type: "TEXT_MESSAGE_CONTENT",
30
+ messageId: id,
31
+ delta: value,
32
+ });
33
+ emit(subscriber, { type: "TEXT_MESSAGE_END", messageId: id });
34
+ }
35
+
36
+ function identifyAssistantFrame(subscriber: Subscriber, id: string) {
37
+ emit(subscriber, { type: "TEXT_MESSAGE_START", messageId: id });
38
+ emit(subscriber, { type: "TEXT_MESSAGE_END", messageId: id });
39
+ }
40
+
41
+ function user(id: string): ThreadMessage {
42
+ return {
43
+ id,
44
+ role: "user",
45
+ content: [{ type: "text", text: id }],
46
+ attachments: [],
47
+ createdAt: new Date(0),
48
+ metadata: { custom: {} },
49
+ };
50
+ }
51
+
52
+ function assistant(
53
+ id: string,
54
+ content: ThreadAssistantMessage["content"],
55
+ status: ThreadAssistantMessage["status"] = {
56
+ type: "complete",
57
+ reason: "unknown",
58
+ },
59
+ ): ThreadAssistantMessage {
60
+ return {
61
+ id,
62
+ role: "assistant",
63
+ content,
64
+ status,
65
+ createdAt: new Date(0),
66
+ metadata: {
67
+ custom: {},
68
+ unstable_state: null,
69
+ unstable_annotations: [],
70
+ unstable_data: [],
71
+ steps: [],
72
+ },
73
+ };
74
+ }
75
+
76
+ const call = (id: string, extras: Record<string, unknown> = {}) =>
77
+ ({
78
+ type: "tool-call" as const,
79
+ toolCallId: id,
80
+ toolName: "lookup",
81
+ args: {},
82
+ argsText: "{}",
83
+ ...extras,
84
+ }) as ThreadAssistantMessage["content"][number];
85
+
86
+ async function loaded(
87
+ messages: ThreadMessage[],
88
+ runAgent: (
89
+ input: { runId: string; messages?: readonly unknown[] },
90
+ subscriber: Subscriber,
91
+ ) => Promise<void>,
92
+ parentIds?: (string | null)[],
93
+ historyUpdate?: NonNullable<ThreadHistoryAdapter["update"]>,
94
+ onError?: (error: Error) => void,
95
+ ) {
96
+ const history: ThreadHistoryAdapter = {
97
+ load: vi.fn().mockResolvedValue({
98
+ headId: messages.at(-1)?.id ?? null,
99
+ messages: messages.map((message, index) => ({
100
+ parentId:
101
+ parentIds === undefined
102
+ ? (messages[index - 1]?.id ?? null)
103
+ : (parentIds[index] ?? null),
104
+ message,
105
+ })),
106
+ }),
107
+ append: vi.fn().mockResolvedValue(undefined),
108
+ ...(historyUpdate ? { update: historyUpdate } : {}),
109
+ };
110
+ const agent = {
111
+ runAgent: vi.fn(runAgent),
112
+ abortRun: vi.fn(),
113
+ } as unknown as AbstractAgent;
114
+ const core = new AgUiThreadController({
115
+ agent,
116
+ history,
117
+ logger: makeLogger(),
118
+ showThinking: true,
119
+ ...(onError ? { onError } : {}),
120
+ });
121
+ await core.__internal_load();
122
+ return core;
123
+ }
124
+
125
+ const ids = (core: AgUiThreadController) => core.getMessages().map((m) => m.id);
126
+ const contents = (core: AgUiThreadController, id: string) =>
127
+ (core.getMessages().find((m) => m.id === id) as ThreadAssistantMessage)
128
+ .content;
129
+ const toolResultCount = (messages: readonly unknown[], toolCallId: string) =>
130
+ messages.filter(
131
+ (message) =>
132
+ typeof message === "object" &&
133
+ message !== null &&
134
+ "role" in message &&
135
+ message.role === "tool" &&
136
+ "toolCallId" in message &&
137
+ message.toolCallId === toolCallId,
138
+ ).length;
139
+ const resume = (core: AgUiThreadController, parentId: string) =>
140
+ core.resume({ parentId, sourceId: null, runConfig: {} });
141
+
142
+ function streamToolCall(
143
+ subscriber: Subscriber,
144
+ toolCallId: string,
145
+ args: string,
146
+ ) {
147
+ emit(subscriber, {
148
+ type: "TOOL_CALL_START",
149
+ toolCallId,
150
+ toolCallName: "lookup",
151
+ });
152
+ emit(subscriber, { type: "TOOL_CALL_ARGS", toolCallId, delta: args });
153
+ emit(subscriber, { type: "TOOL_CALL_END", toolCallId });
154
+ }
155
+
156
+ describe("AgUiThreadController selected-turn replay", () => {
157
+ it.each(["user", "assistant"] as const)(
158
+ "adopts contiguous assistant IDs and parent links in forward order from the %s parent",
159
+ async (parent) => {
160
+ const core = await loaded(
161
+ [
162
+ user("u"),
163
+ assistant("a", [{ type: "text", text: "old A" }]),
164
+ assistant("b", [{ type: "text", text: "old B" }]),
165
+ ],
166
+ async (input, subscriber) => {
167
+ text(subscriber, "a", "new A");
168
+ text(subscriber, "b", "new B");
169
+ emit(subscriber, { type: "RUN_FINISHED", runId: input.runId });
170
+ },
171
+ );
172
+
173
+ await resume(core, parent === "user" ? "u" : "a");
174
+
175
+ expect(ids(core)).toEqual(["u", "a", "b"]);
176
+ expect(contents(core, "a")).toEqual([{ type: "text", text: "new A" }]);
177
+ expect(contents(core, "b")).toEqual([{ type: "text", text: "new B" }]);
178
+ expect(
179
+ core.getMessageRepository().messages.map((m) => m.parentId),
180
+ ).toEqual([null, "u", "a"]);
181
+ },
182
+ );
183
+
184
+ it.each(["before", "during"] as const)(
185
+ "keeps a later pending call addressable %s the first assistant frame",
186
+ async (moment) => {
187
+ let continueRun!: () => void;
188
+ const paused = new Promise<void>((resolve) => {
189
+ continueRun = resolve;
190
+ });
191
+ let reached!: () => void;
192
+ const atFirst = new Promise<void>((resolve) => {
193
+ reached = resolve;
194
+ });
195
+ let transportAttempts = 0;
196
+ const core = await loaded(
197
+ [
198
+ user("u"),
199
+ assistant("a", [{ type: "text", text: "old A" }]),
200
+ assistant("b", [call("b-call")], {
201
+ type: "requires-action",
202
+ reason: "tool-calls",
203
+ }),
204
+ ],
205
+ async (input, subscriber) => {
206
+ transportAttempts++;
207
+ if (transportAttempts > 1) {
208
+ text(subscriber, "continued", "Tool result received.");
209
+ emit(subscriber, { type: "RUN_FINISHED", runId: input.runId });
210
+ return;
211
+ }
212
+ if (moment === "during") text(subscriber, "a", "new A");
213
+ reached();
214
+ await paused;
215
+ if (moment === "before") text(subscriber, "a", "new A");
216
+ text(subscriber, "b", "new B");
217
+ emit(subscriber, { type: "RUN_FINISHED", runId: input.runId });
218
+ },
219
+ );
220
+
221
+ const running = resume(core, "u");
222
+ await atFirst;
223
+ try {
224
+ expect(ids(core)).toEqual(["u", "a", "b"]);
225
+ expect(contents(core, "a")).toEqual([
226
+ { type: "text", text: moment === "before" ? "old A" : "new A" },
227
+ ]);
228
+ expect(transportAttempts).toBe(1);
229
+ expect(contents(core, "b")).toContainEqual(
230
+ expect.objectContaining({ toolCallId: "b-call" }),
231
+ );
232
+ core.addToolResult({
233
+ messageId: "b",
234
+ toolCallId: "b-call",
235
+ toolName: "lookup",
236
+ result: "ready",
237
+ isError: false,
238
+ });
239
+ expect(contents(core, "b")).toContainEqual(
240
+ expect.objectContaining({ toolCallId: "b-call", result: "ready" }),
241
+ );
242
+ } finally {
243
+ continueRun();
244
+ await running;
245
+ }
246
+ expect(ids(core)).toEqual(["u", "a", "b", "continued"]);
247
+ expect(contents(core, "a")).toEqual([{ type: "text", text: "new A" }]);
248
+ expect(contents(core, "b")).toContainEqual(
249
+ expect.objectContaining({ toolCallId: "b-call", result: "ready" }),
250
+ );
251
+ expect(transportAttempts).toBe(2);
252
+ expect(
253
+ core
254
+ .getMessageRepository()
255
+ .messages.filter(({ message }) => message.id === "b"),
256
+ ).toHaveLength(1);
257
+ },
258
+ );
259
+
260
+ it.each(["error", "cancelled"] as const)(
261
+ "retains completed tool state across %s before call re-emission and later resume",
262
+ async (ending) => {
263
+ const stored = assistant("a", [
264
+ { type: "text", text: "old" },
265
+ call("done", {
266
+ result: "answer",
267
+ unstable_interactions: {
268
+ entries: [
269
+ { type: "action", occurredAt: 1, payload: { name: "confirm" } },
270
+ ],
271
+ },
272
+ }),
273
+ ]);
274
+ let attempts = 0;
275
+ const core = await loaded(
276
+ [user("u"), stored],
277
+ async (input, subscriber) => {
278
+ attempts++;
279
+ text(subscriber, "a", attempts === 1 ? "partial" : "finished");
280
+ if (attempts === 1) {
281
+ if (ending === "error") throw new Error("connection lost");
282
+ emit(subscriber, {
283
+ type: "RUN_FINISHED",
284
+ runId: input.runId,
285
+ outcome: { type: "cancelled" },
286
+ });
287
+ return;
288
+ }
289
+ emit(subscriber, {
290
+ type: "TOOL_CALL_START",
291
+ toolCallId: "done",
292
+ toolCallName: "lookup",
293
+ });
294
+ emit(subscriber, { type: "TOOL_CALL_END", toolCallId: "done" });
295
+ emit(subscriber, { type: "RUN_FINISHED", runId: input.runId });
296
+ },
297
+ );
298
+
299
+ if (ending === "error") {
300
+ await expect(resume(core, "u")).rejects.toThrow("connection lost");
301
+ } else {
302
+ await resume(core, "u");
303
+ expect(core.getMessages()[1]?.status).toMatchObject({
304
+ type: "incomplete",
305
+ reason: "cancelled",
306
+ });
307
+ }
308
+ expect(contents(core, "a")).toContainEqual({
309
+ type: "text",
310
+ text: "partial",
311
+ });
312
+ expect(contents(core, "a")).toContainEqual(
313
+ expect.objectContaining({
314
+ toolCallId: "done",
315
+ result: "answer",
316
+ unstable_interactions: expect.objectContaining({
317
+ entries: [
318
+ expect.objectContaining({ payload: { name: "confirm" } }),
319
+ ],
320
+ }),
321
+ }),
322
+ );
323
+ await resume(core, "u");
324
+ expect(ids(core)).toEqual(["u", "a"]);
325
+ expect(contents(core, "a")).toContainEqual(
326
+ expect.objectContaining({
327
+ toolCallId: "done",
328
+ result: "answer",
329
+ unstable_interactions: expect.any(Object),
330
+ }),
331
+ );
332
+ },
333
+ );
334
+
335
+ it("merges fresh subagent output with a pending nested child without flattening it", async () => {
336
+ const originalNested = assistant("sub-1", [call("pending-child")], {
337
+ type: "requires-action",
338
+ reason: "tool-calls",
339
+ });
340
+ const core = await loaded(
341
+ [
342
+ user("u"),
343
+ assistant("a", [call("root", { messages: [originalNested] })], {
344
+ type: "requires-action",
345
+ reason: "tool-calls",
346
+ }),
347
+ ],
348
+ async (input, subscriber) => {
349
+ text(subscriber, "a", "working");
350
+ emit(subscriber, {
351
+ type: "TOOL_CALL_START",
352
+ toolCallId: "root",
353
+ toolCallName: "delegate",
354
+ });
355
+ emit(subscriber, {
356
+ type: "SUBAGENT_STARTED",
357
+ subagentRunId: "sub-1",
358
+ parentToolCallId: "root",
359
+ name: "helper",
360
+ });
361
+ emit(subscriber, {
362
+ type: "TEXT_MESSAGE_START",
363
+ messageId: "sub-text",
364
+ subagentRunId: "sub-1",
365
+ });
366
+ emit(subscriber, {
367
+ type: "TEXT_MESSAGE_CONTENT",
368
+ messageId: "sub-text",
369
+ delta: "fresh child text",
370
+ subagentRunId: "sub-1",
371
+ });
372
+ emit(subscriber, {
373
+ type: "TEXT_MESSAGE_END",
374
+ messageId: "sub-text",
375
+ subagentRunId: "sub-1",
376
+ });
377
+ emit(subscriber, { type: "TOOL_CALL_END", toolCallId: "root" });
378
+ emit(subscriber, { type: "RUN_FINISHED", runId: input.runId });
379
+ },
380
+ );
381
+
382
+ await resume(core, "u");
383
+ expect(ids(core)).toEqual(["u", "a"]);
384
+ const roots = contents(core, "a").filter(
385
+ (part) => part.type === "tool-call" && part.toolCallId === "root",
386
+ );
387
+ expect(roots).toHaveLength(1);
388
+ const nested = roots[0]?.type === "tool-call" ? roots[0].messages : [];
389
+ expect(
390
+ nested?.flatMap((message) =>
391
+ message.role === "assistant" ? message.content : [],
392
+ ),
393
+ ).toEqual(
394
+ expect.arrayContaining([
395
+ { type: "text", text: "fresh child text" },
396
+ expect.objectContaining({ toolCallId: "pending-child" }),
397
+ ]),
398
+ );
399
+ expect(
400
+ contents(core, "a").some(
401
+ (part) =>
402
+ part.type === "tool-call" && part.toolCallId === "pending-child",
403
+ ),
404
+ ).toBe(false);
405
+ });
406
+
407
+ it("uses fork semantics after a genuinely new ID and never rewrites an older ID later in the stream", async () => {
408
+ const core = await loaded(
409
+ [
410
+ user("u"),
411
+ assistant("a", [{ type: "text", text: "old A" }]),
412
+ assistant("b", [{ type: "text", text: "old B" }]),
413
+ ],
414
+ async (input, subscriber) => {
415
+ text(subscriber, "a", "new A");
416
+ text(subscriber, "fresh", "fresh turn");
417
+ text(subscriber, "b", "late old ID");
418
+ emit(subscriber, { type: "RUN_FINISHED", runId: input.runId });
419
+ },
420
+ );
421
+
422
+ await resume(core, "u");
423
+ const repository = core.getMessageRepository().messages;
424
+ expect(
425
+ repository.find(({ message }) => message.id === "b")?.message.content,
426
+ ).toEqual([{ type: "text", text: "old B" }]);
427
+ expect(ids(core)).toEqual(["u", "a", "fresh"]);
428
+ expect(
429
+ repository.find(({ message }) => message.id === "fresh")?.parentId,
430
+ ).toBe("a");
431
+ });
432
+
433
+ it("does not adopt an assistant ID from before a later user boundary", async () => {
434
+ const core = await loaded(
435
+ [
436
+ user("u"),
437
+ assistant("a", [{ type: "text", text: "original" }]),
438
+ user("later-user"),
439
+ assistant("b", [{ type: "text", text: "later answer" }]),
440
+ ],
441
+ async (input, subscriber) => {
442
+ text(subscriber, "a", "unrelated replay");
443
+ emit(subscriber, { type: "RUN_FINISHED", runId: input.runId });
444
+ },
445
+ );
446
+
447
+ await resume(core, "later-user");
448
+ expect(
449
+ core
450
+ .getMessageRepository()
451
+ .messages.find(({ message }) => message.id === "a")?.message.content,
452
+ ).toEqual([{ type: "text", text: "original" }]);
453
+ expect(
454
+ core.getMessages().some((message) => message.id === "later-user"),
455
+ ).toBe(true);
456
+ expect(core.getMessages().at(-1)?.id).not.toBe("a");
457
+ });
458
+
459
+ it("replays an older selected turn and forks a new answer without deleting the later conversation", async () => {
460
+ const core = await loaded(
461
+ [
462
+ user("u1"),
463
+ assistant("a", [{ type: "text", text: "old A" }]),
464
+ assistant("b", [{ type: "text", text: "old B" }]),
465
+ user("u2"),
466
+ assistant("c", [{ type: "text", text: "later C" }]),
467
+ ],
468
+ async (input, subscriber) => {
469
+ text(subscriber, "a", "replayed A");
470
+ text(subscriber, "b", "replayed B");
471
+ text(subscriber, "d", "new answer");
472
+ emit(subscriber, { type: "RUN_FINISHED", runId: input.runId });
473
+ },
474
+ );
475
+
476
+ await resume(core, "u1");
477
+ expect(ids(core)).toEqual(["u1", "a", "b", "d"]);
478
+ expect(contents(core, "a")).toEqual([{ type: "text", text: "replayed A" }]);
479
+ expect(contents(core, "b")).toEqual([{ type: "text", text: "replayed B" }]);
480
+ expect(contents(core, "d")).toEqual([{ type: "text", text: "new answer" }]);
481
+ const repository = core.getMessageRepository();
482
+ expect(repository.headId).toBe("d");
483
+ expect(
484
+ repository.messages.find(({ message }) => message.id === "u2"),
485
+ ).toMatchObject({
486
+ parentId: "b",
487
+ message: { content: [{ type: "text", text: "u2" }] },
488
+ });
489
+ expect(
490
+ repository.messages.find(({ message }) => message.id === "c"),
491
+ ).toMatchObject({
492
+ parentId: "u2",
493
+ message: { content: [{ type: "text", text: "later C" }] },
494
+ });
495
+ expect(
496
+ repository.messages.find(({ message }) => message.id === "d")?.parentId,
497
+ ).toBe("b");
498
+ });
499
+
500
+ it("does not rewrite an assistant on a sibling branch", async () => {
501
+ const core = await loaded(
502
+ [
503
+ user("u"),
504
+ assistant("sibling", [{ type: "text", text: "other branch" }]),
505
+ assistant("selected", [{ type: "text", text: "selected branch" }]),
506
+ ],
507
+ async (input, subscriber) => {
508
+ text(subscriber, "sibling", "colliding stream");
509
+ emit(subscriber, { type: "RUN_FINISHED", runId: input.runId });
510
+ },
511
+ [null, "u", "u"],
512
+ );
513
+
514
+ await resume(core, "u");
515
+ expect(
516
+ core
517
+ .getMessageRepository()
518
+ .messages.find(({ message }) => message.id === "sibling")?.message
519
+ .content,
520
+ ).toEqual([{ type: "text", text: "other branch" }]);
521
+ expect(core.getMessages().at(-1)?.id).not.toBe("sibling");
522
+ });
523
+
524
+ it("keeps longer partial text when replay starts with its strict prefix", async () => {
525
+ const core = await loaded(
526
+ [
527
+ user("u"),
528
+ assistant("a", [{ type: "text", text: "already streamed long text" }], {
529
+ type: "incomplete",
530
+ reason: "error",
531
+ error: "disconnect",
532
+ }),
533
+ ],
534
+ async (_input, subscriber) => {
535
+ text(subscriber, "a", "already streamed");
536
+ throw new Error("failed before the suffix");
537
+ },
538
+ );
539
+ await expect(resume(core, "u")).rejects.toThrow("failed before the suffix");
540
+ expect(ids(core)).toEqual(["u", "a"]);
541
+ expect(contents(core, "a")).toContainEqual({
542
+ type: "text",
543
+ text: "already streamed long text",
544
+ });
545
+ expect(core.getMessages()[1]).toMatchObject({
546
+ status: {
547
+ type: "incomplete",
548
+ reason: "error",
549
+ error: "failed before the suffix",
550
+ },
551
+ });
552
+ });
553
+
554
+ it("starts a fresh selected branch when replay assigns a new assistant ID", async () => {
555
+ const core = await loaded(
556
+ [
557
+ user("u"),
558
+ assistant("a", [{ type: "text", text: "partial" }], {
559
+ type: "incomplete",
560
+ reason: "error",
561
+ error: "disconnected",
562
+ }),
563
+ ],
564
+ async (input, subscriber) => {
565
+ text(subscriber, "fresh", "complete answer");
566
+ emit(subscriber, { type: "RUN_FINISHED", runId: input.runId });
567
+ },
568
+ );
569
+
570
+ await resume(core, "u");
571
+
572
+ expect(ids(core)).toEqual(["u", "fresh"]);
573
+ expect(contents(core, "fresh")).toEqual([
574
+ { type: "text", text: "complete answer" },
575
+ ]);
576
+ expect(core.getMessageRepository().headId).toBe("fresh");
577
+ expect(
578
+ core
579
+ .getMessageRepository()
580
+ .messages.find(({ message }) => message.id === "fresh")?.parentId,
581
+ ).toBe("u");
582
+ });
583
+
584
+ it.each(["none", "reintroduce", "preserve"] as const)(
585
+ "honors replay removals and rebases ownership after snapshot=%s",
586
+ async (snapshot) => {
587
+ const core = await loaded(
588
+ [user("u"), assistant("a", [call("history", { result: "kept" })])],
589
+ async (input, subscriber) => {
590
+ text(subscriber, "a", "Reconnected");
591
+ emit(subscriber, {
592
+ type: "ACTIVITY_SNAPSHOT",
593
+ activityType: "a2ui-surface",
594
+ messageId: "activity",
595
+ content: {
596
+ a2ui_operations: [
597
+ { version: "v0.9", createSurface: { surfaceId: "surface" } },
598
+ {
599
+ version: "v0.9",
600
+ updateComponents: {
601
+ surfaceId: "surface",
602
+ components: [
603
+ { id: "root", component: "Text", text: "Ready" },
604
+ ],
605
+ },
606
+ },
607
+ ],
608
+ },
609
+ });
610
+ expect(contents(core, "a")).toContainEqual(
611
+ expect.objectContaining({ toolCallId: "a2ui:surface" }),
612
+ );
613
+ if (snapshot === "preserve") {
614
+ emit(subscriber, {
615
+ type: "MESSAGES_SNAPSHOT",
616
+ messages: [{ id: "u", role: "user", content: "question" }],
617
+ });
618
+ }
619
+ emit(subscriber, {
620
+ type: "ACTIVITY_SNAPSHOT",
621
+ activityType: "a2ui-surface",
622
+ messageId: "activity",
623
+ replace: true,
624
+ content: { a2ui_operations: [] },
625
+ });
626
+ expect(contents(core, "a")).not.toContainEqual(
627
+ expect.objectContaining({ toolCallId: "a2ui:surface" }),
628
+ );
629
+ if (snapshot === "reintroduce") {
630
+ emit(subscriber, {
631
+ type: "MESSAGES_SNAPSHOT",
632
+ messages: [
633
+ { id: "u", role: "user", content: "question" },
634
+ {
635
+ id: "a",
636
+ role: "assistant",
637
+ content: "Reconnected",
638
+ toolCalls: [
639
+ {
640
+ id: "a2ui:surface",
641
+ type: "function",
642
+ function: { name: "present", arguments: "{}" },
643
+ },
644
+ ],
645
+ },
646
+ ],
647
+ });
648
+ emit(subscriber, {
649
+ type: "TEXT_MESSAGE_CONTENT",
650
+ messageId: "a",
651
+ delta: " suffix",
652
+ });
653
+ expect(contents(core, "a")).toContainEqual(
654
+ expect.objectContaining({ toolCallId: "a2ui:surface" }),
655
+ );
656
+ }
657
+ emit(subscriber, { type: "RUN_FINISHED", runId: input.runId });
658
+ },
659
+ );
660
+ await resume(core, "u");
661
+ if (snapshot !== "reintroduce") {
662
+ expect(contents(core, "a")).toEqual([
663
+ expect.objectContaining({ toolCallId: "history", result: "kept" }),
664
+ { type: "text", text: "Reconnected" },
665
+ ]);
666
+ }
667
+ },
668
+ );
669
+
670
+ it("removes an earlier A2UI surface after replay moves the frame to B", async () => {
671
+ let aContentAfterRemoval: ThreadAssistantMessage["content"] | undefined;
672
+ let bContentAfterRemoval: ThreadAssistantMessage["content"] | undefined;
673
+ const surfaceContent = {
674
+ a2ui_operations: [
675
+ { version: "v0.9", createSurface: { surfaceId: "surface" } },
676
+ {
677
+ version: "v0.9",
678
+ updateComponents: {
679
+ surfaceId: "surface",
680
+ components: [{ id: "root", component: "Text", text: "Ready" }],
681
+ },
682
+ },
683
+ ],
684
+ };
685
+ const core = await loaded(
686
+ [
687
+ user("u"),
688
+ assistant("a", [{ type: "text", text: "old A" }]),
689
+ assistant("b", [{ type: "text", text: "old B" }]),
690
+ ],
691
+ async (input, subscriber) => {
692
+ text(subscriber, "a", "replayed A");
693
+ emit(subscriber, {
694
+ type: "ACTIVITY_SNAPSHOT",
695
+ activityType: "a2ui-surface",
696
+ messageId: "activity",
697
+ content: surfaceContent,
698
+ });
699
+ expect(contents(core, "a")).toContainEqual(
700
+ expect.objectContaining({ toolCallId: "a2ui:surface" }),
701
+ );
702
+
703
+ text(subscriber, "b", "fresh B");
704
+ emit(subscriber, {
705
+ type: "ACTIVITY_SNAPSHOT",
706
+ activityType: "a2ui-surface",
707
+ messageId: "activity",
708
+ content: surfaceContent,
709
+ });
710
+ expect(contents(core, "a")).toContainEqual(
711
+ expect.objectContaining({ toolCallId: "a2ui:surface" }),
712
+ );
713
+ expect(contents(core, "b")).not.toContainEqual(
714
+ expect.objectContaining({ toolCallId: "a2ui:surface" }),
715
+ );
716
+ emit(subscriber, {
717
+ type: "ACTIVITY_SNAPSHOT",
718
+ activityType: "a2ui-surface",
719
+ messageId: "activity",
720
+ replace: true,
721
+ content: { a2ui_operations: [] },
722
+ });
723
+ aContentAfterRemoval = contents(core, "a");
724
+ bContentAfterRemoval = contents(core, "b");
725
+ emit(subscriber, { type: "RUN_FINISHED", runId: input.runId });
726
+ },
727
+ );
728
+
729
+ await resume(core, "u");
730
+ expect(aContentAfterRemoval).not.toContainEqual(
731
+ expect.objectContaining({ toolCallId: "a2ui:surface" }),
732
+ );
733
+ expect(bContentAfterRemoval).toEqual([{ type: "text", text: "fresh B" }]);
734
+ expect(contents(core, "a")).toEqual([{ type: "text", text: "replayed A" }]);
735
+ expect(contents(core, "b")).toEqual([{ type: "text", text: "fresh B" }]);
736
+ });
737
+
738
+ it("continues ordered replay across a snapshot of the same selected turn", async () => {
739
+ const core = await loaded(
740
+ [
741
+ user("u"),
742
+ assistant("a", [{ type: "text", text: "old A" }]),
743
+ assistant("b", [{ type: "text", text: "old B" }]),
744
+ ],
745
+ async (input, subscriber) => {
746
+ text(subscriber, "a", "replayed A");
747
+ emit(subscriber, {
748
+ type: "MESSAGES_SNAPSHOT",
749
+ messages: [
750
+ { id: "u", role: "user", content: "question" },
751
+ { id: "a", role: "assistant", content: "replayed A" },
752
+ { id: "b", role: "assistant", content: "old B" },
753
+ ],
754
+ });
755
+ emit(subscriber, {
756
+ type: "TEXT_MESSAGE_CONTENT",
757
+ messageId: "a",
758
+ delta: " suffix",
759
+ });
760
+ text(subscriber, "b", "replayed B");
761
+ emit(subscriber, { type: "RUN_FINISHED", runId: input.runId });
762
+ },
763
+ );
764
+ await resume(core, "u");
765
+ expect(ids(core)).toEqual(["u", "a", "b"]);
766
+ expect(contents(core, "a")).toEqual([
767
+ { type: "text", text: "replayed A suffix" },
768
+ ]);
769
+ expect(contents(core, "b")).toEqual([{ type: "text", text: "replayed B" }]);
770
+ });
771
+
772
+ it.each([
773
+ ["same ID after complete assistant", "a", "complete"],
774
+ ["same ID after partial assistant", "a", "partial"],
775
+ ["fresh ID after complete assistant", "snapshot-answer", "complete"],
776
+ ["fresh ID after partial assistant", "snapshot-answer", "partial"],
777
+ ] as const)(
778
+ "uses a snapshot-only completion without appending an empty assistant (%s)",
779
+ async (_scenario, answerId, previousStatus) => {
780
+ const oldStatus: ThreadAssistantMessage["status"] =
781
+ previousStatus === "complete"
782
+ ? { type: "complete", reason: "unknown" }
783
+ : { type: "incomplete", reason: "error", error: "disconnected" };
784
+ const core = await loaded(
785
+ [
786
+ user("u"),
787
+ assistant(
788
+ "a",
789
+ [
790
+ {
791
+ type: "text",
792
+ text: previousStatus === "complete" ? "old answer" : "partial",
793
+ },
794
+ ],
795
+ oldStatus,
796
+ ),
797
+ ],
798
+ async (input, subscriber) => {
799
+ emit(subscriber, {
800
+ type: "MESSAGES_SNAPSHOT",
801
+ messages: [
802
+ { id: "u", role: "user", content: "question" },
803
+ {
804
+ id: answerId,
805
+ role: "assistant",
806
+ content: "snapshot answer",
807
+ },
808
+ ],
809
+ });
810
+ emit(subscriber, { type: "RUN_FINISHED", runId: input.runId });
811
+ },
812
+ );
813
+
814
+ await resume(core, "u");
815
+
816
+ const repository = core.getMessageRepository();
817
+ expect(ids(core)).toEqual(["u", answerId]);
818
+ expect(repository.headId).toBe(answerId);
819
+ expect(repository.messages).toHaveLength(answerId === "a" ? 2 : 3);
820
+ if (answerId !== "a") {
821
+ expect(
822
+ repository.messages.find(({ message }) => message.id === "a")
823
+ ?.message,
824
+ ).toMatchObject({
825
+ content: [
826
+ {
827
+ type: "text",
828
+ text: previousStatus === "complete" ? "old answer" : "partial",
829
+ },
830
+ ],
831
+ status: oldStatus,
832
+ });
833
+ }
834
+ expect(contents(core, answerId)).toEqual([
835
+ { type: "text", text: "snapshot answer" },
836
+ ]);
837
+ expect(core.getMessages().at(-1)).toMatchObject({
838
+ id: answerId,
839
+ status: { type: "complete" },
840
+ });
841
+ },
842
+ );
843
+
844
+ it("keeps a native interrupt after a snapshot-only assistant answer", async () => {
845
+ let attempts = 0;
846
+ const inputs: unknown[] = [];
847
+ const interrupt = {
848
+ id: "input-gate",
849
+ reason: "input_required",
850
+ message: "Provide a value",
851
+ };
852
+ const response = {
853
+ interruptId: "input-gate",
854
+ status: "resolved" as const,
855
+ payload: { answer: "42" },
856
+ };
857
+ const core = await loaded(
858
+ [
859
+ user("u"),
860
+ assistant("a", [{ type: "text", text: "partial answer" }], {
861
+ type: "incomplete",
862
+ reason: "error",
863
+ error: "disconnected",
864
+ }),
865
+ ],
866
+ async (input, subscriber) => {
867
+ attempts++;
868
+ inputs.push(input);
869
+ if (attempts > 1) {
870
+ text(subscriber, "continued", "input received");
871
+ emit(subscriber, { type: "RUN_FINISHED", runId: input.runId });
872
+ return;
873
+ }
874
+ emit(subscriber, {
875
+ type: "MESSAGES_SNAPSHOT",
876
+ messages: [
877
+ { id: "u", role: "user", content: "question" },
878
+ {
879
+ id: "snapshot-answer",
880
+ role: "assistant",
881
+ content: "snapshot answer",
882
+ },
883
+ ],
884
+ });
885
+ emit(subscriber, {
886
+ type: "RUN_FINISHED",
887
+ runId: input.runId,
888
+ outcome: { type: "interrupt", interrupts: [interrupt] },
889
+ });
890
+ },
891
+ );
892
+ const submit = vi.spyOn(core, "submitInterruptResponses");
893
+
894
+ await resume(core, "u");
895
+
896
+ expect(attempts).toBe(1);
897
+ expect(submit).not.toHaveBeenCalled();
898
+ expect(core.getPendingInterrupts()).toEqual({
899
+ messageId: "snapshot-answer",
900
+ interrupts: [interrupt],
901
+ });
902
+
903
+ await core.submitInterruptResponses([response]);
904
+ await vi.waitFor(() => expect(attempts).toBe(2));
905
+ await vi.waitFor(() => expect(core.isRunning()).toBe(false));
906
+
907
+ expect(submit).toHaveBeenCalledTimes(1);
908
+ expect(submit).toHaveBeenCalledWith([response]);
909
+ expect(inputs[1]).toMatchObject({ resume: [response] });
910
+ expect(core.getPendingInterrupts()).toBeNull();
911
+ });
912
+
913
+ it("keeps snapshot pending-call authority on an empty successful completion", async () => {
914
+ let attempts = 0;
915
+ const core = await loaded(
916
+ [user("u"), assistant("a", [{ type: "text", text: "partial" }])],
917
+ async (input, subscriber) => {
918
+ attempts++;
919
+ emit(subscriber, {
920
+ type: "MESSAGES_SNAPSHOT",
921
+ messages: [
922
+ { id: "u", role: "user", content: "question" },
923
+ {
924
+ id: "snapshot-answer",
925
+ role: "assistant",
926
+ content: "snapshot answer",
927
+ toolCalls: [
928
+ {
929
+ id: "x",
930
+ type: "function",
931
+ function: { name: "lookup", arguments: "{}" },
932
+ },
933
+ ],
934
+ },
935
+ ],
936
+ });
937
+ emit(subscriber, { type: "RUN_FINISHED", runId: input.runId });
938
+ },
939
+ );
940
+ await resume(core, "u");
941
+ expect(ids(core)).toEqual(["u", "snapshot-answer"]);
942
+ expect(core.getPendingToolCalls()).toEqual({
943
+ messageId: "snapshot-answer",
944
+ toolCallIds: ["x"],
945
+ });
946
+ expect(attempts).toBe(1);
947
+ });
948
+
949
+ it.each(["error", "cancelled"] as const)(
950
+ "preserves %s status for a snapshot-only assistant answer",
951
+ async (ending) => {
952
+ const core = await loaded(
953
+ [
954
+ user("u"),
955
+ assistant("a", [{ type: "text", text: "partial answer" }], {
956
+ type: "incomplete",
957
+ reason: "error",
958
+ error: "disconnected",
959
+ }),
960
+ ],
961
+ async (input, subscriber) => {
962
+ emit(subscriber, {
963
+ type: "MESSAGES_SNAPSHOT",
964
+ messages: [
965
+ { id: "u", role: "user", content: "question" },
966
+ {
967
+ id: "snapshot-answer",
968
+ role: "assistant",
969
+ content: "snapshot answer",
970
+ },
971
+ ],
972
+ });
973
+ if (ending === "error") {
974
+ emit(subscriber, {
975
+ type: "RUN_ERROR",
976
+ runId: input.runId,
977
+ message: "snapshot stream failed",
978
+ });
979
+ } else {
980
+ emit(subscriber, {
981
+ type: "RUN_FINISHED",
982
+ runId: input.runId,
983
+ outcome: { type: "cancelled" },
984
+ });
985
+ }
986
+ },
987
+ );
988
+
989
+ if (ending === "error") {
990
+ await expect(resume(core, "u")).rejects.toThrow(
991
+ "snapshot stream failed",
992
+ );
993
+ } else {
994
+ await resume(core, "u");
995
+ }
996
+
997
+ expect(ids(core)).toEqual(["u", "snapshot-answer"]);
998
+ expect(core.getMessageRepository().headId).toBe("snapshot-answer");
999
+ expect(contents(core, "snapshot-answer")).toEqual([
1000
+ { type: "text", text: "snapshot answer" },
1001
+ ]);
1002
+ expect(core.getMessages().at(-1)?.status).toMatchObject({
1003
+ type: "incomplete",
1004
+ reason: ending,
1005
+ ...(ending === "error" ? { error: "snapshot stream failed" } : {}),
1006
+ });
1007
+ },
1008
+ );
1009
+
1010
+ it("parents an unmatched replay frame after the assistant tail in a snapshot", async () => {
1011
+ const core = await loaded(
1012
+ [
1013
+ user("u"),
1014
+ assistant("a", [{ type: "text", text: "old A" }]),
1015
+ assistant("b", [{ type: "text", text: "old B" }]),
1016
+ ],
1017
+ async (input, subscriber) => {
1018
+ emit(subscriber, {
1019
+ type: "MESSAGES_SNAPSHOT",
1020
+ messages: [
1021
+ { id: "u", role: "user", content: "question" },
1022
+ { id: "a", role: "assistant", content: "snapshot A" },
1023
+ { id: "server-s", role: "assistant", content: "server S" },
1024
+ ],
1025
+ });
1026
+ text(subscriber, "c", "fresh C");
1027
+ emit(subscriber, { type: "RUN_FINISHED", runId: input.runId });
1028
+ },
1029
+ );
1030
+
1031
+ await resume(core, "u");
1032
+
1033
+ const repository = core.getMessageRepository();
1034
+ expect(ids(core)).toEqual(["u", "a", "server-s", "c"]);
1035
+ expect(repository.headId).toBe("c");
1036
+ expect(repository.messages).toEqual(
1037
+ expect.arrayContaining([
1038
+ expect.objectContaining({
1039
+ parentId: "a",
1040
+ message: expect.objectContaining({
1041
+ id: "server-s",
1042
+ content: [{ type: "text", text: "server S" }],
1043
+ }),
1044
+ }),
1045
+ expect.objectContaining({
1046
+ parentId: "server-s",
1047
+ message: expect.objectContaining({
1048
+ id: "c",
1049
+ content: [{ type: "text", text: "fresh C" }],
1050
+ }),
1051
+ }),
1052
+ ]),
1053
+ );
1054
+ expect(ids(core)).not.toContain("b");
1055
+ });
1056
+
1057
+ it.each(["error", "cancelled"] as const)(
1058
+ "uses a native child when %s arrives before the first assistant message ID",
1059
+ async (ending) => {
1060
+ const core = await loaded(
1061
+ [
1062
+ user("u"),
1063
+ assistant("a", [
1064
+ { type: "text", text: "retained" },
1065
+ call("done", { result: false }),
1066
+ ]),
1067
+ ],
1068
+ async (input, subscriber) => {
1069
+ if (ending === "error") throw new Error("before identity");
1070
+ emit(subscriber, {
1071
+ type: "RUN_FINISHED",
1072
+ runId: input.runId,
1073
+ outcome: { type: "cancelled" },
1074
+ });
1075
+ },
1076
+ );
1077
+ if (ending === "error")
1078
+ await expect(resume(core, "u")).rejects.toThrow("before identity");
1079
+ else await resume(core, "u");
1080
+ const repository = core.getMessageRepository();
1081
+ const oldA = repository.messages.find(
1082
+ ({ message }) => message.id === "a",
1083
+ )?.message;
1084
+ const newChild = repository.messages.find(
1085
+ ({ message, parentId }) =>
1086
+ parentId === "u" &&
1087
+ message.role === "assistant" &&
1088
+ message.id !== "a",
1089
+ );
1090
+ if (newChild?.message.role !== "assistant")
1091
+ throw new Error("missing native assistant child");
1092
+ expect(newChild.parentId).toBe("u");
1093
+ expect(newChild.message.status).toMatchObject({
1094
+ type: "incomplete",
1095
+ reason: ending,
1096
+ });
1097
+ expect(oldA).toMatchObject({
1098
+ id: "a",
1099
+ content: [
1100
+ { type: "text", text: "retained" },
1101
+ expect.objectContaining({ toolCallId: "done", result: false }),
1102
+ ],
1103
+ status: { type: "complete" },
1104
+ });
1105
+ expect(ids(core)).toEqual(["u", newChild.message.id]);
1106
+ },
1107
+ );
1108
+
1109
+ it.each(["unknown", "resolved-client", "retained-client"])(
1110
+ "keeps retained server calls non-actionable when producer pending list contains only %s",
1111
+ async (listed) => {
1112
+ let attempts = 0;
1113
+ const core = await loaded(
1114
+ [
1115
+ user("u"),
1116
+ assistant("a", [
1117
+ call("server-call"),
1118
+ call(listed === "retained-client" ? listed : "resolved-client", {
1119
+ result: listed === "retained-client" ? undefined : "done",
1120
+ }),
1121
+ ]),
1122
+ ],
1123
+ async (input, subscriber) => {
1124
+ attempts++;
1125
+ text(subscriber, "a", "replayed");
1126
+ emit(subscriber, {
1127
+ type: "TOOL_CALL_START",
1128
+ toolCallId: "server-call",
1129
+ toolCallName: "lookup",
1130
+ });
1131
+ emit(subscriber, {
1132
+ type: "TOOL_CALL_END",
1133
+ toolCallId: "server-call",
1134
+ });
1135
+ if (listed === "resolved-client") {
1136
+ emit(subscriber, {
1137
+ type: "TOOL_CALL_START",
1138
+ toolCallId: listed,
1139
+ toolCallName: "lookup",
1140
+ });
1141
+ emit(subscriber, { type: "TOOL_CALL_END", toolCallId: listed });
1142
+ emit(subscriber, {
1143
+ type: "TOOL_CALL_RESULT",
1144
+ toolCallId: listed,
1145
+ messageId: "result",
1146
+ content: "done",
1147
+ });
1148
+ }
1149
+ emit(subscriber, {
1150
+ type: "RUN_FINISHED",
1151
+ runId: input.runId,
1152
+ outcome: { type: "success", pendingToolCallIds: [listed] },
1153
+ });
1154
+ },
1155
+ );
1156
+ await resume(core, "u");
1157
+ expect(core.getPendingToolCalls()).toEqual(
1158
+ listed === "retained-client"
1159
+ ? { messageId: "a", toolCallIds: [listed] }
1160
+ : null,
1161
+ );
1162
+ expect(core.getMessages().at(-1)?.status).toMatchObject({
1163
+ type: listed === "retained-client" ? "requires-action" : "complete",
1164
+ });
1165
+ expect(contents(core, "a")).toContainEqual(
1166
+ expect.objectContaining({ toolCallId: "server-call" }),
1167
+ );
1168
+ expect(attempts).toBe(1);
1169
+ },
1170
+ );
1171
+ it("clears a retained call when replay success lists no pending calls", async () => {
1172
+ const core = await loaded(
1173
+ [
1174
+ user("u"),
1175
+ assistant("a", [call("x")], {
1176
+ type: "requires-action",
1177
+ reason: "tool-calls",
1178
+ }),
1179
+ ],
1180
+ async (input, subscriber) => {
1181
+ text(subscriber, "a", "replayed answer");
1182
+ emit(subscriber, {
1183
+ type: "RUN_FINISHED",
1184
+ runId: input.runId,
1185
+ outcome: { type: "success", pendingToolCallIds: [] },
1186
+ });
1187
+ },
1188
+ );
1189
+
1190
+ await resume(core, "u");
1191
+
1192
+ expect(core.getPendingToolCalls()).toBeNull();
1193
+ expect(core.getMessages().at(-1)).toMatchObject({
1194
+ id: "a",
1195
+ status: { type: "complete" },
1196
+ });
1197
+ });
1198
+
1199
+ it.each(["fork", "interrupt"] as const)(
1200
+ "revalidates a deferred tool continuation after the stream changes to %s",
1201
+ async (scenario) => {
1202
+ let release!: () => void;
1203
+ const gate = new Promise<void>((resolve) => {
1204
+ release = resolve;
1205
+ });
1206
+ let started!: () => void;
1207
+ const ready = new Promise<void>((resolve) => {
1208
+ started = resolve;
1209
+ });
1210
+ let attempts = 0;
1211
+ const core = await loaded(
1212
+ [
1213
+ user("u"),
1214
+ assistant("a", [{ type: "text", text: "original" }]),
1215
+ assistant("b", [call("waiting")], {
1216
+ type: "requires-action",
1217
+ reason: "tool-calls",
1218
+ }),
1219
+ ],
1220
+ async (input, subscriber) => {
1221
+ attempts++;
1222
+ if (attempts > 1) {
1223
+ text(subscriber, "unexpected", "wrong branch");
1224
+ emit(subscriber, { type: "RUN_FINISHED", runId: input.runId });
1225
+ return;
1226
+ }
1227
+ started();
1228
+ await gate;
1229
+ text(subscriber, "a", "replayed A");
1230
+ if (scenario === "fork") {
1231
+ text(subscriber, "fresh", "selected answer");
1232
+ emit(subscriber, { type: "RUN_FINISHED", runId: input.runId });
1233
+ } else {
1234
+ text(subscriber, "b", "replayed B");
1235
+ emit(subscriber, {
1236
+ type: "TOOL_CALL_START",
1237
+ toolCallId: "approval-call",
1238
+ toolCallName: "lookup",
1239
+ });
1240
+ emit(subscriber, {
1241
+ type: "TOOL_CALL_END",
1242
+ toolCallId: "approval-call",
1243
+ });
1244
+ emit(subscriber, {
1245
+ type: "RUN_FINISHED",
1246
+ runId: input.runId,
1247
+ outcome: {
1248
+ type: "interrupt",
1249
+ interrupts: [
1250
+ {
1251
+ id: "approval",
1252
+ reason: "tool_call",
1253
+ toolCallId: "approval-call",
1254
+ },
1255
+ ],
1256
+ },
1257
+ });
1258
+ }
1259
+ },
1260
+ );
1261
+ const running = resume(core, "u");
1262
+ await ready;
1263
+ core.addToolResult({
1264
+ messageId: "b",
1265
+ toolCallId: "waiting",
1266
+ toolName: "lookup",
1267
+ result: "done",
1268
+ isError: false,
1269
+ });
1270
+ release();
1271
+ await running;
1272
+ expect(attempts).toBe(1);
1273
+ if (scenario === "fork") {
1274
+ expect(ids(core)).toEqual(["u", "a", "fresh"]);
1275
+ expect(contents(core, "fresh")).toEqual([
1276
+ { type: "text", text: "selected answer" },
1277
+ ]);
1278
+ } else {
1279
+ expect(ids(core)).toEqual(["u", "a", "b"]);
1280
+ expect(
1281
+ core.getPendingInterrupts()?.interrupts.map((entry) => entry.id),
1282
+ ).toEqual(["approval"]);
1283
+ }
1284
+ const abandoned = core
1285
+ .getMessageRepository()
1286
+ .messages.find(({ message }) => message.id === "b");
1287
+ expect(abandoned?.message.content).toContainEqual(
1288
+ expect.objectContaining({ toolCallId: "waiting", result: "done" }),
1289
+ );
1290
+ },
1291
+ );
1292
+
1293
+ it.each(["first", "both"] as const)(
1294
+ "delivers a surviving earlier result when %s result owners complete before replay forks",
1295
+ async (completed) => {
1296
+ let release!: () => void;
1297
+ const gate = new Promise<void>((resolve) => {
1298
+ release = resolve;
1299
+ });
1300
+ let started!: () => void;
1301
+ const ready = new Promise<void>((resolve) => {
1302
+ started = resolve;
1303
+ });
1304
+ let attempts = 0;
1305
+ let delivered: readonly unknown[] | undefined;
1306
+ const core = await loaded(
1307
+ [
1308
+ user("u"),
1309
+ assistant("a", [call("call-a")], {
1310
+ type: "requires-action",
1311
+ reason: "tool-calls",
1312
+ }),
1313
+ assistant("b", [call("call-b")], {
1314
+ type: "requires-action",
1315
+ reason: "tool-calls",
1316
+ }),
1317
+ ],
1318
+ async (input, subscriber) => {
1319
+ attempts++;
1320
+ if (attempts > 1) {
1321
+ delivered = input.messages;
1322
+ text(subscriber, "continued", "delivered");
1323
+ emit(subscriber, { type: "RUN_FINISHED", runId: input.runId });
1324
+ return;
1325
+ }
1326
+ started();
1327
+ await gate;
1328
+ text(subscriber, "a", "selected A");
1329
+ text(subscriber, "fresh", "selected answer");
1330
+ emit(subscriber, { type: "RUN_FINISHED", runId: input.runId });
1331
+ },
1332
+ );
1333
+ const running = resume(core, "u");
1334
+ await ready;
1335
+ try {
1336
+ core.addToolResult({
1337
+ messageId: "a",
1338
+ toolCallId: "call-a",
1339
+ toolName: "lookup",
1340
+ result: "A result",
1341
+ isError: false,
1342
+ });
1343
+ if (completed === "both")
1344
+ core.addToolResult({
1345
+ messageId: "b",
1346
+ toolCallId: "call-b",
1347
+ toolName: "lookup",
1348
+ result: "B result",
1349
+ isError: false,
1350
+ });
1351
+ } finally {
1352
+ release();
1353
+ await running;
1354
+ }
1355
+ expect(attempts).toBe(2);
1356
+ expect(ids(core)).toEqual(["u", "a", "fresh", "continued"]);
1357
+ expect(delivered).toEqual(
1358
+ expect.arrayContaining([
1359
+ expect.objectContaining({
1360
+ role: "tool",
1361
+ toolCallId: "call-a",
1362
+ content: expect.stringContaining("A result"),
1363
+ }),
1364
+ ]),
1365
+ );
1366
+ expect(delivered).not.toEqual(
1367
+ expect.arrayContaining([
1368
+ expect.objectContaining({ role: "tool", toolCallId: "call-b" }),
1369
+ ]),
1370
+ );
1371
+ },
1372
+ );
1373
+
1374
+ it.each(["tool-only native branch", "matching-frame replay"] as const)(
1375
+ "keeps %s pending calls separate from excluded historical state",
1376
+ async (scenario) => {
1377
+ const hasMatchingFrame = scenario === "matching-frame replay";
1378
+ const historical = assistant("a", [call("x"), call("hidden")], {
1379
+ type: "requires-action",
1380
+ reason: "tool-calls",
1381
+ });
1382
+ const stored: ThreadAssistantMessage = {
1383
+ ...historical,
1384
+ metadata: {
1385
+ ...historical.metadata,
1386
+ custom: { agui: { pendingToolCallIds: ["x"] } },
1387
+ },
1388
+ };
1389
+ let attempts = 0;
1390
+ const core = await loaded(
1391
+ [
1392
+ user("u"),
1393
+ stored,
1394
+ ...(hasMatchingFrame
1395
+ ? [assistant("b", [{ type: "text", text: "old B" }])]
1396
+ : []),
1397
+ ],
1398
+ async (input, subscriber) => {
1399
+ attempts++;
1400
+ if (attempts === 1) {
1401
+ if (hasMatchingFrame) text(subscriber, "a", "replayed A");
1402
+ for (const toolCallId of ["x", "y"]) {
1403
+ emit(subscriber, {
1404
+ type: "TOOL_CALL_START",
1405
+ toolCallId,
1406
+ toolCallName: "lookup",
1407
+ });
1408
+ emit(subscriber, { type: "TOOL_CALL_END", toolCallId });
1409
+ }
1410
+ if (hasMatchingFrame) text(subscriber, "b", "replayed B");
1411
+ }
1412
+ emit(subscriber, { type: "RUN_FINISHED", runId: input.runId });
1413
+ },
1414
+ );
1415
+
1416
+ await resume(core, "u");
1417
+ const pending = core.getPendingToolCalls();
1418
+ if (!pending) throw new Error("missing pending calls");
1419
+ const repository = core.getMessageRepository();
1420
+ const owner = repository.messages.find(
1421
+ ({ message }) => message.id === pending.messageId,
1422
+ );
1423
+ expect(pending.toolCallIds).toEqual(["x", "y"]);
1424
+ expect(
1425
+ repository.messages.find(({ message }) => message.id === "a")?.message,
1426
+ ).toMatchObject({
1427
+ content: expect.arrayContaining([
1428
+ expect.objectContaining({ toolCallId: "hidden" }),
1429
+ ]),
1430
+ });
1431
+ if (hasMatchingFrame) {
1432
+ expect(pending.messageId).toBe("a");
1433
+ } else {
1434
+ expect(pending.messageId).not.toBe("a");
1435
+ expect(ids(core)).toEqual(["u", pending.messageId]);
1436
+ expect(owner?.parentId).toBe("u");
1437
+ expect(owner?.message).not.toMatchObject({
1438
+ content: expect.arrayContaining([
1439
+ expect.objectContaining({ toolCallId: "hidden" }),
1440
+ ]),
1441
+ });
1442
+ }
1443
+ core.addToolResult({
1444
+ messageId: pending.messageId,
1445
+ toolCallId: "x",
1446
+ toolName: "lookup",
1447
+ result: "x result",
1448
+ isError: false,
1449
+ });
1450
+ expect(attempts).toBe(1);
1451
+ expect(core.getPendingToolCalls()).toEqual({
1452
+ messageId: pending.messageId,
1453
+ toolCallIds: ["y"],
1454
+ });
1455
+ core.addToolResult({
1456
+ messageId: pending.messageId,
1457
+ toolCallId: "y",
1458
+ toolName: "lookup",
1459
+ result: "y result",
1460
+ isError: false,
1461
+ });
1462
+ await vi.waitFor(() => expect(attempts).toBe(2));
1463
+ },
1464
+ );
1465
+
1466
+ it("applies the producer pending-call list across earlier replay owners", async () => {
1467
+ let attempts = 0;
1468
+ let continuationMessages: readonly unknown[] = [];
1469
+ const core = await loaded(
1470
+ [
1471
+ user("u"),
1472
+ assistant("a", [call("x")], {
1473
+ type: "requires-action",
1474
+ reason: "tool-calls",
1475
+ }),
1476
+ assistant("b", [{ type: "text", text: "old B" }]),
1477
+ ],
1478
+ async (input, subscriber) => {
1479
+ attempts++;
1480
+ if (attempts > 1) {
1481
+ continuationMessages = input.messages ?? [];
1482
+ text(subscriber, "continued", "received y");
1483
+ emit(subscriber, { type: "RUN_FINISHED", runId: input.runId });
1484
+ return;
1485
+ }
1486
+ text(subscriber, "b", "replayed B");
1487
+ emit(subscriber, {
1488
+ type: "TOOL_CALL_START",
1489
+ toolCallId: "y",
1490
+ toolCallName: "lookup",
1491
+ });
1492
+ emit(subscriber, {
1493
+ type: "TOOL_CALL_ARGS",
1494
+ toolCallId: "y",
1495
+ delta: "{}",
1496
+ });
1497
+ emit(subscriber, { type: "TOOL_CALL_END", toolCallId: "y" });
1498
+ emit(subscriber, {
1499
+ type: "RUN_FINISHED",
1500
+ runId: input.runId,
1501
+ outcome: { type: "success", pendingToolCallIds: ["y"] },
1502
+ });
1503
+ },
1504
+ );
1505
+
1506
+ await resume(core, "u");
1507
+ const pendingAfterReplay = core.getPendingToolCalls();
1508
+ const xCallsInA = contents(core, "a").filter(
1509
+ (part) => part.type === "tool-call" && part.toolCallId === "x",
1510
+ );
1511
+
1512
+ core.addToolResult({
1513
+ messageId: "b",
1514
+ toolCallId: "y",
1515
+ toolName: "lookup",
1516
+ result: "y result",
1517
+ isError: false,
1518
+ });
1519
+ await vi.waitFor(() => expect(core.isRunning()).toBe(false));
1520
+
1521
+ const yResultsInB = contents(core, "b")
1522
+ .filter((part) => part.type === "tool-call" && part.toolCallId === "y")
1523
+ .map((part) => (part.type === "tool-call" ? part.result : undefined));
1524
+ const continuationToolResults = continuationMessages.filter(
1525
+ (message) =>
1526
+ typeof message === "object" &&
1527
+ message !== null &&
1528
+ "role" in message &&
1529
+ message.role === "tool",
1530
+ );
1531
+ expect({
1532
+ pendingAfterReplay,
1533
+ xCallsInA: xCallsInA.length,
1534
+ yResultsInB,
1535
+ attempts,
1536
+ continuationToolResults,
1537
+ }).toEqual({
1538
+ pendingAfterReplay: { messageId: "b", toolCallIds: ["y"] },
1539
+ xCallsInA: 1,
1540
+ yResultsInB: ["y result"],
1541
+ attempts: 2,
1542
+ continuationToolResults: [
1543
+ expect.objectContaining({
1544
+ role: "tool",
1545
+ toolCallId: "y",
1546
+ content: "y result",
1547
+ }),
1548
+ ],
1549
+ });
1550
+ });
1551
+
1552
+ it.each([
1553
+ ["tool-only native branch", null],
1554
+ ["matching retained B identity", "b"],
1555
+ ["new C native branch", "c"],
1556
+ ] as const)(
1557
+ "uses replay ownership only after a matching assistant ID arrives (%s)",
1558
+ async (_scenario, replayTextId) => {
1559
+ let attempts = 0;
1560
+ let continuationMessages: readonly unknown[] = [];
1561
+ const core = await loaded(
1562
+ [
1563
+ user("u"),
1564
+ assistant("a", [call("x")], {
1565
+ type: "requires-action",
1566
+ reason: "tool-calls",
1567
+ }),
1568
+ assistant("b", [{ type: "text", text: "old B" }]),
1569
+ ],
1570
+ async (input, subscriber) => {
1571
+ attempts++;
1572
+ if (attempts > 1) {
1573
+ continuationMessages = input.messages ?? [];
1574
+ text(subscriber, "continued", "received x");
1575
+ emit(subscriber, { type: "RUN_FINISHED", runId: input.runId });
1576
+ return;
1577
+ }
1578
+ emit(subscriber, {
1579
+ type: "TOOL_CALL_START",
1580
+ toolCallId: "x",
1581
+ toolCallName: "lookup",
1582
+ });
1583
+ emit(subscriber, {
1584
+ type: "TOOL_CALL_ARGS",
1585
+ toolCallId: "x",
1586
+ delta: "{}",
1587
+ });
1588
+ emit(subscriber, { type: "TOOL_CALL_END", toolCallId: "x" });
1589
+ if (replayTextId)
1590
+ text(
1591
+ subscriber,
1592
+ replayTextId,
1593
+ replayTextId === "c" ? "fresh C" : "fresh B",
1594
+ );
1595
+ emit(subscriber, { type: "RUN_FINISHED", runId: input.runId });
1596
+ },
1597
+ );
1598
+
1599
+ await resume(core, "u");
1600
+ const idsAfterReplay = ids(core);
1601
+ const repository = core.getMessageRepository();
1602
+ const headAfterReplay = repository.headId;
1603
+ const freshTextOwnerId = replayTextId ?? "b";
1604
+ const freshTextMessage = core
1605
+ .getMessages()
1606
+ .find((message) => message.id === freshTextOwnerId);
1607
+ const freshTextContent = freshTextMessage?.content;
1608
+ const freshTextParentId = core
1609
+ .getMessageRepository()
1610
+ .messages.find(
1611
+ ({ message }) => message.id === freshTextOwnerId,
1612
+ )?.parentId;
1613
+ const aMessageAfterReplay = core
1614
+ .getMessages()
1615
+ .find((message) => message.id === "a");
1616
+ const aContentAfterReplay =
1617
+ aMessageAfterReplay?.role === "assistant"
1618
+ ? aMessageAfterReplay.content
1619
+ : undefined;
1620
+ const xCallsInA =
1621
+ aContentAfterReplay?.filter(
1622
+ (part) => part.type === "tool-call" && part.toolCallId === "x",
1623
+ ) ?? [];
1624
+ const pendingAfterReplay = core.getPendingToolCalls();
1625
+ if (!pendingAfterReplay) throw new Error("missing pending call");
1626
+ const ownerId = pendingAfterReplay.messageId;
1627
+ const bMessageAfterReplay = core
1628
+ .getMessages()
1629
+ .find((message) => message.id === "b");
1630
+ const bContentAfterReplay =
1631
+ bMessageAfterReplay?.role === "assistant"
1632
+ ? bMessageAfterReplay.content
1633
+ : undefined;
1634
+
1635
+ if (replayTextId === "b") {
1636
+ expect({
1637
+ idsAfterReplay,
1638
+ headAfterReplay,
1639
+ freshTextOwnerId,
1640
+ freshTextMessageExists: freshTextMessage !== undefined,
1641
+ freshTextContent,
1642
+ freshTextParentId,
1643
+ xCallsInA: xCallsInA.length,
1644
+ bContentAfterReplay,
1645
+ pendingAfterReplay,
1646
+ }).toEqual({
1647
+ idsAfterReplay: ["u", "a", "b"],
1648
+ headAfterReplay: "b",
1649
+ freshTextOwnerId: "b",
1650
+ freshTextMessageExists: true,
1651
+ freshTextContent: [{ type: "text", text: "fresh B" }],
1652
+ freshTextParentId: "a",
1653
+ xCallsInA: 1,
1654
+ bContentAfterReplay: [{ type: "text", text: "fresh B" }],
1655
+ pendingAfterReplay: { messageId: "a", toolCallIds: ["x"] },
1656
+ });
1657
+ } else {
1658
+ const ownerEntry = repository.messages.find(
1659
+ ({ message }) => message.id === ownerId,
1660
+ );
1661
+ const ownerMessage = ownerEntry?.message;
1662
+ const ownerHasX =
1663
+ ownerMessage?.role === "assistant" &&
1664
+ ownerMessage.content.some(
1665
+ (part) => part.type === "tool-call" && part.toolCallId === "x",
1666
+ );
1667
+ const historicalA = repository.messages.find(
1668
+ ({ message }) => message.id === "a",
1669
+ )?.message;
1670
+ const historicalB = repository.messages.find(
1671
+ ({ message }) => message.id === "b",
1672
+ )?.message;
1673
+
1674
+ expect(idsAfterReplay).toEqual(["u", ownerId]);
1675
+ expect(headAfterReplay).toBe(ownerId);
1676
+ expect(ownerId).not.toBe("a");
1677
+ expect(ownerEntry?.parentId).toBe("u");
1678
+ expect(pendingAfterReplay).toEqual({
1679
+ messageId: ownerId,
1680
+ toolCallIds: ["x"],
1681
+ });
1682
+ expect(ownerHasX).toBe(true);
1683
+ expect(historicalA).toMatchObject({
1684
+ content: [expect.objectContaining({ toolCallId: "x", args: {} })],
1685
+ });
1686
+ expect(historicalB).toMatchObject({
1687
+ content: [{ type: "text", text: "old B" }],
1688
+ });
1689
+ if (replayTextId === "c") {
1690
+ expect(ownerId).toBe("c");
1691
+ expect(ownerMessage).toMatchObject({
1692
+ content: expect.arrayContaining([
1693
+ { type: "text", text: "fresh C" },
1694
+ ]),
1695
+ });
1696
+ } else {
1697
+ expect(freshTextMessage).toBeUndefined();
1698
+ }
1699
+ }
1700
+
1701
+ core.addToolResult({
1702
+ messageId: ownerId,
1703
+ toolCallId: "x",
1704
+ toolName: "lookup",
1705
+ result: "x result",
1706
+ isError: false,
1707
+ });
1708
+ await vi.waitFor(() => expect(core.isRunning()).toBe(false));
1709
+
1710
+ const xResultsInOwner = contents(core, ownerId)
1711
+ .filter((part) => part.type === "tool-call" && part.toolCallId === "x")
1712
+ .map((part) =>
1713
+ part.type === "tool-call"
1714
+ ? { toolCallId: part.toolCallId, result: part.result }
1715
+ : undefined,
1716
+ );
1717
+ const continuationParentId = core
1718
+ .getMessageRepository()
1719
+ .messages.at(-1)?.parentId;
1720
+ const continuationToolResults = continuationMessages.filter(
1721
+ (message) =>
1722
+ typeof message === "object" &&
1723
+ message !== null &&
1724
+ "role" in message &&
1725
+ message.role === "tool",
1726
+ );
1727
+ expect({
1728
+ xResultsInOwner,
1729
+ continuationParentId,
1730
+ attempts,
1731
+ continuationToolResults,
1732
+ }).toEqual({
1733
+ xResultsInOwner: [{ toolCallId: "x", result: "x result" }],
1734
+ continuationParentId: replayTextId === "b" ? "b" : ownerId,
1735
+ attempts: 2,
1736
+ continuationToolResults: [
1737
+ expect.objectContaining({
1738
+ role: "tool",
1739
+ toolCallId: "x",
1740
+ content: "x result",
1741
+ }),
1742
+ ],
1743
+ });
1744
+ },
1745
+ );
1746
+
1747
+ it.each([
1748
+ ["pending", { type: "requires-action", reason: "tool-calls" }],
1749
+ ["complete/read-only", { type: "complete", reason: "unknown" }],
1750
+ ] as const)(
1751
+ "keeps an anonymous tool update on B across later A and B text frames when B starts %s",
1752
+ async (_scenario, initialBStatus) => {
1753
+ let attempts = 0;
1754
+ let continuationMessages: readonly unknown[] = [];
1755
+ const core = await loaded(
1756
+ [
1757
+ user("u"),
1758
+ assistant("a", [{ type: "text", text: "old A" }]),
1759
+ assistant(
1760
+ "b",
1761
+ [call("y"), { type: "text", text: "old B" }],
1762
+ initialBStatus,
1763
+ ),
1764
+ ],
1765
+ async (input, subscriber) => {
1766
+ attempts++;
1767
+ if (attempts > 1) {
1768
+ continuationMessages = input.messages ?? [];
1769
+ text(subscriber, "continued", "received y");
1770
+ emit(subscriber, { type: "RUN_FINISHED", runId: input.runId });
1771
+ return;
1772
+ }
1773
+ streamToolCall(subscriber, "y", '{"query":"fresh y"}');
1774
+ text(subscriber, "a", "fresh A");
1775
+ text(subscriber, "b", "fresh B");
1776
+ emit(subscriber, {
1777
+ type: "RUN_FINISHED",
1778
+ runId: input.runId,
1779
+ outcome: { type: "success", pendingToolCallIds: ["y"] },
1780
+ });
1781
+ },
1782
+ );
1783
+
1784
+ await resume(core, "u");
1785
+ const yCallsInA = contents(core, "a").filter(
1786
+ (part) => part.type === "tool-call" && part.toolCallId === "y",
1787
+ );
1788
+ const yCallsInB = contents(core, "b")
1789
+ .filter((part) => part.type === "tool-call" && part.toolCallId === "y")
1790
+ .map((part) =>
1791
+ part.type === "tool-call"
1792
+ ? { toolCallId: part.toolCallId, args: part.args }
1793
+ : undefined,
1794
+ );
1795
+ expect({
1796
+ ids: ids(core),
1797
+ headId: core.getMessageRepository().headId,
1798
+ aContent: contents(core, "a"),
1799
+ yCallsInA: yCallsInA.length,
1800
+ bContent: contents(core, "b"),
1801
+ yCallsInB,
1802
+ pending: core.getPendingToolCalls(),
1803
+ }).toEqual({
1804
+ ids: ["u", "a", "b"],
1805
+ headId: "b",
1806
+ aContent: [{ type: "text", text: "fresh A" }],
1807
+ yCallsInA: 0,
1808
+ bContent: [
1809
+ expect.objectContaining({
1810
+ toolCallId: "y",
1811
+ args: { query: "fresh y" },
1812
+ }),
1813
+ { type: "text", text: "fresh B" },
1814
+ ],
1815
+ yCallsInB: [{ toolCallId: "y", args: { query: "fresh y" } }],
1816
+ pending: { messageId: "b", toolCallIds: ["y"] },
1817
+ });
1818
+
1819
+ core.addToolResult({
1820
+ messageId: "b",
1821
+ toolCallId: "y",
1822
+ toolName: "lookup",
1823
+ result: "y result",
1824
+ isError: false,
1825
+ });
1826
+ await vi.waitFor(() => expect(core.isRunning()).toBe(false));
1827
+ expect({
1828
+ attempts,
1829
+ continuationParentId: core.getMessageRepository().messages.at(-1)
1830
+ ?.parentId,
1831
+ continuationToolResults: toolResultCount(continuationMessages, "y"),
1832
+ }).toEqual({
1833
+ attempts: 2,
1834
+ continuationParentId: "b",
1835
+ continuationToolResults: 1,
1836
+ });
1837
+ },
1838
+ );
1839
+
1840
+ it("groups identity-less tool updates by their retained top-level owner", async () => {
1841
+ let attempts = 0;
1842
+ let continuationMessages: readonly unknown[] = [];
1843
+ const historyUpdate = vi.fn(
1844
+ async (
1845
+ _item: Parameters<NonNullable<ThreadHistoryAdapter["update"]>>[0],
1846
+ ) => {},
1847
+ );
1848
+ const core = await loaded(
1849
+ [
1850
+ user("u"),
1851
+ assistant("a", [call("x")], {
1852
+ type: "requires-action",
1853
+ reason: "tool-calls",
1854
+ }),
1855
+ assistant("b", [call("y"), { type: "text", text: "old B" }], {
1856
+ type: "requires-action",
1857
+ reason: "tool-calls",
1858
+ }),
1859
+ ],
1860
+ async (input, subscriber) => {
1861
+ attempts++;
1862
+ if (attempts > 1) {
1863
+ continuationMessages = input.messages ?? [];
1864
+ text(subscriber, "continued", "received y");
1865
+ emit(subscriber, { type: "RUN_FINISHED", runId: input.runId });
1866
+ return;
1867
+ }
1868
+ streamToolCall(subscriber, "x", '{"query":"fresh x"}');
1869
+ streamToolCall(subscriber, "y", '{"query":"fresh y"}');
1870
+ identifyAssistantFrame(subscriber, "b");
1871
+ emit(subscriber, {
1872
+ type: "RUN_FINISHED",
1873
+ runId: input.runId,
1874
+ outcome: { type: "success", pendingToolCallIds: ["y"] },
1875
+ });
1876
+ },
1877
+ undefined,
1878
+ historyUpdate,
1879
+ );
1880
+
1881
+ await resume(core, "u");
1882
+ const pendingAfterReplay = core.getPendingToolCalls();
1883
+ const headAfterReplay = core.getMessageRepository().headId;
1884
+ const parentIdsAfterReplay = core
1885
+ .getMessageRepository()
1886
+ .messages.map(({ message, parentId }) => [message.id, parentId]);
1887
+ const xCallsInA = contents(core, "a").flatMap((part) =>
1888
+ part.type === "tool-call" && part.toolCallId === "x"
1889
+ ? [{ toolCallId: part.toolCallId, args: part.args }]
1890
+ : [],
1891
+ );
1892
+ const yCallsInB = contents(core, "b").flatMap((part) =>
1893
+ part.type === "tool-call" && part.toolCallId === "y"
1894
+ ? [{ toolCallId: part.toolCallId, args: part.args }]
1895
+ : [],
1896
+ );
1897
+
1898
+ core.addToolResult({
1899
+ messageId: "b",
1900
+ toolCallId: "y",
1901
+ toolName: "lookup",
1902
+ result: "y result",
1903
+ isError: false,
1904
+ });
1905
+ await vi.waitFor(() => expect(core.isRunning()).toBe(false));
1906
+
1907
+ const historyUpdatedOwners = [
1908
+ ...new Set(historyUpdate.mock.calls.map(([item]) => item.message.id)),
1909
+ ].sort();
1910
+ expect({
1911
+ pendingAfterReplay,
1912
+ headAfterReplay,
1913
+ parentIdsAfterReplay,
1914
+ xCallsInA,
1915
+ yCallsInB,
1916
+ attempts,
1917
+ yResultCount: toolResultCount(continuationMessages, "y"),
1918
+ historyUpdatedOwners,
1919
+ }).toEqual({
1920
+ pendingAfterReplay: { messageId: "b", toolCallIds: ["y"] },
1921
+ headAfterReplay: "b",
1922
+ parentIdsAfterReplay: [
1923
+ ["u", null],
1924
+ ["a", "u"],
1925
+ ["b", "a"],
1926
+ ],
1927
+ xCallsInA: [{ toolCallId: "x", args: { query: "fresh x" } }],
1928
+ yCallsInB: [{ toolCallId: "y", args: { query: "fresh y" } }],
1929
+ attempts: 2,
1930
+ yResultCount: 1,
1931
+ historyUpdatedOwners: ["a", "b"],
1932
+ });
1933
+ expect(
1934
+ core
1935
+ .getMessageRepository()
1936
+ .messages.find(({ message }) => message.id === "continued")?.parentId,
1937
+ ).toBe("b");
1938
+ expect(continuationMessages).toEqual(
1939
+ expect.arrayContaining([
1940
+ expect.objectContaining({
1941
+ role: "tool",
1942
+ toolCallId: "y",
1943
+ content: "y result",
1944
+ }),
1945
+ ]),
1946
+ );
1947
+ });
1948
+
1949
+ it("updates an existing nested call in place during flat replay", async () => {
1950
+ let attempts = 0;
1951
+ let continuationMessages: readonly unknown[] = [];
1952
+ const nestedOwner = assistant("sub-a", [call("x")], {
1953
+ type: "requires-action",
1954
+ reason: "tool-calls",
1955
+ });
1956
+ const core = await loaded(
1957
+ [
1958
+ user("u"),
1959
+ assistant("a", [call("root", { messages: [nestedOwner] })], {
1960
+ type: "requires-action",
1961
+ reason: "tool-calls",
1962
+ }),
1963
+ assistant("b", [{ type: "text", text: "old B" }]),
1964
+ ],
1965
+ async (input, subscriber) => {
1966
+ attempts++;
1967
+ if (attempts > 1) {
1968
+ continuationMessages = input.messages ?? [];
1969
+ text(subscriber, "continued", "received x");
1970
+ emit(subscriber, { type: "RUN_FINISHED", runId: input.runId });
1971
+ return;
1972
+ }
1973
+ streamToolCall(subscriber, "x", '{"query":"fresh nested x"}');
1974
+ identifyAssistantFrame(subscriber, "a");
1975
+ emit(subscriber, {
1976
+ type: "RUN_FINISHED",
1977
+ runId: input.runId,
1978
+ outcome: { type: "success", pendingToolCallIds: ["x"] },
1979
+ });
1980
+ },
1981
+ );
1982
+
1983
+ await resume(core, "u");
1984
+ const rootCalls = contents(core, "a").filter(
1985
+ (part) => part.type === "tool-call" && part.toolCallId === "root",
1986
+ );
1987
+ const nestedCalls = rootCalls.flatMap((part) =>
1988
+ part.type === "tool-call"
1989
+ ? (part.messages ?? []).flatMap((message) =>
1990
+ message.role === "assistant"
1991
+ ? message.content.flatMap((nestedPart) =>
1992
+ nestedPart.type === "tool-call" &&
1993
+ nestedPart.toolCallId === "x"
1994
+ ? [
1995
+ {
1996
+ toolCallId: nestedPart.toolCallId,
1997
+ args: nestedPart.args,
1998
+ },
1999
+ ]
2000
+ : [],
2001
+ )
2002
+ : [],
2003
+ )
2004
+ : [],
2005
+ );
2006
+ const topLevelX = contents(core, "a").filter(
2007
+ (part) => part.type === "tool-call" && part.toolCallId === "x",
2008
+ );
2009
+ expect({
2010
+ ids: ids(core),
2011
+ headId: core.getMessageRepository().headId,
2012
+ rootCalls: rootCalls.length,
2013
+ nestedCalls,
2014
+ topLevelX: topLevelX.length,
2015
+ pending: core.getPendingToolCalls(),
2016
+ bContent: contents(core, "b"),
2017
+ }).toEqual({
2018
+ ids: ["u", "a", "b"],
2019
+ headId: "b",
2020
+ rootCalls: 1,
2021
+ nestedCalls: [{ toolCallId: "x", args: { query: "fresh nested x" } }],
2022
+ topLevelX: 0,
2023
+ pending: { messageId: "a", toolCallIds: ["x"] },
2024
+ bContent: [{ type: "text", text: "old B" }],
2025
+ });
2026
+
2027
+ core.addToolResult({
2028
+ messageId: "a",
2029
+ toolCallId: "x",
2030
+ toolName: "lookup",
2031
+ result: "nested x result",
2032
+ isError: false,
2033
+ });
2034
+ await vi.waitFor(() => expect(core.isRunning()).toBe(false));
2035
+ expect(attempts).toBe(2);
2036
+ expect(toolResultCount(continuationMessages, "x")).toBe(1);
2037
+ expect(continuationMessages).toEqual(
2038
+ expect.arrayContaining([
2039
+ expect.objectContaining({
2040
+ role: "tool",
2041
+ toolCallId: "x",
2042
+ content: "nested x result",
2043
+ }),
2044
+ ]),
2045
+ );
2046
+ });
2047
+
2048
+ it("cancels every touched owner when identity-less replay ends cancelled", async () => {
2049
+ let attempts = 0;
2050
+ const core = await loaded(
2051
+ [
2052
+ user("u"),
2053
+ assistant("a", [call("x")], {
2054
+ type: "requires-action",
2055
+ reason: "tool-calls",
2056
+ }),
2057
+ assistant("b", [call("y"), { type: "text", text: "old B" }], {
2058
+ type: "requires-action",
2059
+ reason: "tool-calls",
2060
+ }),
2061
+ ],
2062
+ async (input, subscriber) => {
2063
+ attempts++;
2064
+ streamToolCall(subscriber, "x", '{"query":"cancelled x"}');
2065
+ streamToolCall(subscriber, "y", '{"query":"cancelled y"}');
2066
+ identifyAssistantFrame(subscriber, "b");
2067
+ emit(subscriber, {
2068
+ type: "RUN_FINISHED",
2069
+ runId: input.runId,
2070
+ outcome: { type: "cancelled" },
2071
+ });
2072
+ },
2073
+ );
2074
+
2075
+ await resume(core, "u");
2076
+ const a = core.getMessages().find((message) => message.id === "a");
2077
+ const b = core.getMessages().find((message) => message.id === "b");
2078
+ expect({
2079
+ attempts,
2080
+ ids: ids(core),
2081
+ headId: core.getMessageRepository().headId,
2082
+ aStatus: a?.status,
2083
+ bStatus: b?.status,
2084
+ pending: core.getPendingToolCalls(),
2085
+ }).toEqual({
2086
+ attempts: 1,
2087
+ ids: ["u", "a", "b"],
2088
+ headId: "b",
2089
+ aStatus: { type: "incomplete", reason: "cancelled" },
2090
+ bStatus: { type: "incomplete", reason: "cancelled" },
2091
+ pending: null,
2092
+ });
2093
+ });
2094
+
2095
+ it.each([
2096
+ "single-owner",
2097
+ "cross-owner",
2098
+ "cross-owner-retained",
2099
+ "single-owner-late-text",
2100
+ ] as const)(
2101
+ "keeps a %s interrupt batch on one owner and the selected head on B",
2102
+ async (scope) => {
2103
+ let attempts = 0;
2104
+ const inputs: unknown[] = [];
2105
+ const xInterrupt = {
2106
+ id: "gate-x",
2107
+ reason: "tool_call" as const,
2108
+ toolCallId: "x",
2109
+ };
2110
+ const yInterrupt = {
2111
+ id: "gate-y",
2112
+ reason: "tool_call" as const,
2113
+ toolCallId: "y",
2114
+ };
2115
+ const isSingleOwner =
2116
+ scope === "single-owner" || scope === "single-owner-late-text";
2117
+ const interrupts = isSingleOwner
2118
+ ? [xInterrupt]
2119
+ : [xInterrupt, yInterrupt];
2120
+ const anchorId = isSingleOwner ? "a" : "b";
2121
+ const nonAnchorId = isSingleOwner ? "b" : "a";
2122
+ const core = await loaded(
2123
+ [
2124
+ user("u"),
2125
+ assistant("a", [call("x")], {
2126
+ type: "requires-action",
2127
+ reason: "tool-calls",
2128
+ }),
2129
+ assistant(
2130
+ "b",
2131
+ [call("y"), call("z"), { type: "text", text: "old B" }],
2132
+ { type: "requires-action", reason: "tool-calls" },
2133
+ ),
2134
+ ],
2135
+ async (input, subscriber) => {
2136
+ attempts++;
2137
+ inputs.push(input);
2138
+ if (attempts > 1) {
2139
+ emit(subscriber, { type: "RUN_FINISHED", runId: input.runId });
2140
+ return;
2141
+ }
2142
+ if (scope === "single-owner-late-text") {
2143
+ text(subscriber, "a", "fresh A");
2144
+ text(subscriber, "b", "fresh B");
2145
+ } else if (scope !== "cross-owner-retained") {
2146
+ streamToolCall(subscriber, "x", '{"query":"fresh x"}');
2147
+ streamToolCall(subscriber, "y", '{"query":"fresh y"}');
2148
+ }
2149
+ if (scope !== "single-owner-late-text")
2150
+ identifyAssistantFrame(subscriber, "b");
2151
+ emit(subscriber, {
2152
+ type: "RUN_FINISHED",
2153
+ runId: input.runId,
2154
+ outcome: { type: "interrupt", interrupts },
2155
+ });
2156
+ },
2157
+ );
2158
+
2159
+ await resume(core, "u");
2160
+ const pendingInterrupts = core.getPendingInterrupts();
2161
+ const interruptOwners = core
2162
+ .getMessages()
2163
+ .filter(
2164
+ (message) =>
2165
+ message.role === "assistant" &&
2166
+ message.status?.type === "requires-action" &&
2167
+ message.status.reason === "interrupt",
2168
+ );
2169
+ const nonAnchor = core
2170
+ .getMessages()
2171
+ .find((message) => message.id === nonAnchorId);
2172
+ const nonAnchorAgui = (
2173
+ nonAnchor?.metadata.custom as Record<string, unknown> | undefined
2174
+ )?.agui as Record<string, unknown> | undefined;
2175
+ const pendingToolsBeforeResponse = core.getPendingToolCalls();
2176
+
2177
+ const responses = interrupts.map(({ id }) => ({
2178
+ interruptId: id,
2179
+ status: "resolved" as const,
2180
+ }));
2181
+ const selectedHeadBeforeResponse = core.getMessageRepository().headId;
2182
+ if (scope === "single-owner-late-text") {
2183
+ expect(pendingInterrupts).toEqual({ messageId: anchorId, interrupts });
2184
+ await core.respondToToolApproval({
2185
+ approvalId: "gate-x",
2186
+ approved: true,
2187
+ });
2188
+ } else {
2189
+ await core.submitInterruptResponses(responses);
2190
+ }
2191
+ const continuationParentId = core
2192
+ .getMessageRepository()
2193
+ .messages.at(-1)?.parentId;
2194
+
2195
+ expect({
2196
+ pendingInterrupts,
2197
+ interruptOwnerIds: interruptOwners.map((message) => message.id),
2198
+ selectedHeadBeforeResponse,
2199
+ continuationParentId,
2200
+ nonAnchorStatus: nonAnchor?.status,
2201
+ nonAnchorInterrupts: nonAnchorAgui?.interrupts,
2202
+ nonAnchorPendingIds: nonAnchorAgui?.pendingToolCallIds ?? [],
2203
+ pendingToolsBeforeResponse,
2204
+ attempts,
2205
+ }).toEqual({
2206
+ pendingInterrupts: { messageId: anchorId, interrupts },
2207
+ interruptOwnerIds: [anchorId],
2208
+ selectedHeadBeforeResponse: "b",
2209
+ continuationParentId: "b",
2210
+ nonAnchorStatus: { type: "complete", reason: "unknown" },
2211
+ nonAnchorInterrupts: undefined,
2212
+ nonAnchorPendingIds: [],
2213
+ pendingToolsBeforeResponse: null,
2214
+ attempts: 2,
2215
+ });
2216
+ const expectedResume =
2217
+ scope === "single-owner-late-text"
2218
+ ? [
2219
+ {
2220
+ interruptId: "gate-x",
2221
+ status: "resolved",
2222
+ payload: { approved: true },
2223
+ },
2224
+ ]
2225
+ : responses;
2226
+ expect(inputs[1]).toMatchObject({ resume: expectedResume });
2227
+ if (scope === "single-owner-late-text") {
2228
+ expect(contents(core, "a")).toContainEqual({
2229
+ type: "text",
2230
+ text: "fresh A",
2231
+ });
2232
+ expect(contents(core, "b")).toContainEqual({
2233
+ type: "text",
2234
+ text: "fresh B",
2235
+ });
2236
+ }
2237
+ },
2238
+ );
2239
+
2240
+ it.each(["root", "nested"] as const)(
2241
+ "projects an interrupt onto a retained %s call that the stream does not re-emit",
2242
+ async (location) => {
2243
+ const inputs: unknown[] = [];
2244
+ const retained =
2245
+ location === "root"
2246
+ ? [call("x")]
2247
+ : [call("root", { messages: [assistant("sub", [call("x")])] })];
2248
+ const core = await loaded(
2249
+ [
2250
+ user("u"),
2251
+ assistant("a", retained, {
2252
+ type: "requires-action",
2253
+ reason: "tool-calls",
2254
+ }),
2255
+ ],
2256
+ async (input, subscriber) => {
2257
+ inputs.push(input);
2258
+ if (inputs.length === 1) {
2259
+ text(subscriber, "a", "replayed answer");
2260
+ emit(subscriber, {
2261
+ type: "RUN_FINISHED",
2262
+ runId: input.runId,
2263
+ outcome: {
2264
+ type: "interrupt",
2265
+ interrupts: [
2266
+ { id: "gate", reason: "tool_call", toolCallId: "x" },
2267
+ ],
2268
+ },
2269
+ });
2270
+ return;
2271
+ }
2272
+ emit(subscriber, { type: "RUN_FINISHED", runId: input.runId });
2273
+ },
2274
+ );
2275
+
2276
+ await resume(core, "u");
2277
+ expect(core.getPendingInterrupts()).toEqual({
2278
+ messageId: "a",
2279
+ interrupts: [{ id: "gate", reason: "tool_call", toolCallId: "x" }],
2280
+ });
2281
+ if (location === "root") {
2282
+ expect(contents(core, "a")).toContainEqual(
2283
+ expect.objectContaining({
2284
+ toolCallId: "x",
2285
+ approval: { id: "gate" },
2286
+ }),
2287
+ );
2288
+ } else {
2289
+ expect(contents(core, "a")).toContainEqual(
2290
+ expect.objectContaining({
2291
+ toolCallId: "root",
2292
+ messages: [
2293
+ expect.objectContaining({
2294
+ content: [
2295
+ expect.objectContaining({
2296
+ toolCallId: "x",
2297
+ approval: { id: "gate" },
2298
+ }),
2299
+ ],
2300
+ }),
2301
+ ],
2302
+ }),
2303
+ );
2304
+ }
2305
+ await core.respondToToolApproval({ approvalId: "gate", approved: true });
2306
+ expect(inputs).toHaveLength(2);
2307
+ expect(inputs[1]).toMatchObject({
2308
+ resume: [
2309
+ {
2310
+ interruptId: "gate",
2311
+ status: "resolved",
2312
+ payload: { approved: true },
2313
+ },
2314
+ ],
2315
+ });
2316
+ },
2317
+ );
2318
+
2319
+ it.each([
2320
+ ["gate", false],
2321
+ ["gate", true],
2322
+ ["old-gate", false],
2323
+ ["old-gate", true],
2324
+ ] as const)(
2325
+ "keeps a recorded approval decision only when replay reuses %s (re-emitted=%s)",
2326
+ async (previousGate, reemitted) => {
2327
+ let attempts = 0;
2328
+ const inputs: unknown[] = [];
2329
+ const core = await loaded(
2330
+ [
2331
+ user("u"),
2332
+ assistant(
2333
+ "a",
2334
+ [
2335
+ call("x", {
2336
+ approval: {
2337
+ id: previousGate,
2338
+ approved: true,
2339
+ reason: "approved before replay",
2340
+ },
2341
+ }),
2342
+ ],
2343
+ { type: "incomplete", reason: "error", error: "disconnected" },
2344
+ ),
2345
+ ],
2346
+ async (input, subscriber) => {
2347
+ attempts++;
2348
+ inputs.push(input);
2349
+ if (attempts > 1) {
2350
+ emit(subscriber, {
2351
+ type: "RUN_FINISHED",
2352
+ runId: input.runId,
2353
+ ...(attempts === 2
2354
+ ? {
2355
+ outcome: {
2356
+ type: "interrupt" as const,
2357
+ interrupts: [
2358
+ { id: "gate", reason: "tool_call", toolCallId: "x" },
2359
+ ],
2360
+ },
2361
+ }
2362
+ : {}),
2363
+ });
2364
+ return;
2365
+ }
2366
+ text(subscriber, "a", "replayed answer");
2367
+ if (reemitted) {
2368
+ emit(subscriber, {
2369
+ type: "TOOL_CALL_START",
2370
+ toolCallId: "x",
2371
+ toolCallName: "lookup",
2372
+ });
2373
+ emit(subscriber, { type: "TOOL_CALL_END", toolCallId: "x" });
2374
+ }
2375
+ emit(subscriber, {
2376
+ type: "RUN_FINISHED",
2377
+ runId: input.runId,
2378
+ outcome: {
2379
+ type: "interrupt",
2380
+ interrupts: [
2381
+ { id: "gate", reason: "tool_call", toolCallId: "x" },
2382
+ ],
2383
+ },
2384
+ });
2385
+ },
2386
+ );
2387
+
2388
+ await resume(core, "u");
2389
+ if (previousGate === "gate") {
2390
+ await vi.waitFor(() => expect(attempts).toBeGreaterThanOrEqual(2));
2391
+ await vi.waitFor(() => expect(core.isRunning()).toBe(false));
2392
+ expect(attempts).toBe(2);
2393
+ expect(inputs[1]).toMatchObject({
2394
+ resume: [
2395
+ {
2396
+ interruptId: "gate",
2397
+ status: "resolved",
2398
+ payload: { approved: true, reason: "approved before replay" },
2399
+ },
2400
+ ],
2401
+ });
2402
+ } else {
2403
+ expect(attempts).toBe(1);
2404
+ }
2405
+ const part = contents(core, "a").find(
2406
+ (item) => item.type === "tool-call" && item.toolCallId === "x",
2407
+ );
2408
+ if (part?.type !== "tool-call") throw new Error("missing retained call");
2409
+ expect(part.approval?.id).toBe("gate");
2410
+ expect(part.approval?.approved).toBe(
2411
+ previousGate === "gate" ? true : undefined,
2412
+ );
2413
+ expect(part.approval?.reason).toBe(
2414
+ previousGate === "gate" ? "approved before replay" : undefined,
2415
+ );
2416
+ },
2417
+ );
2418
+
2419
+ it("submits a retained denial once when replay returns the same approval gate", async () => {
2420
+ let attempts = 0;
2421
+ const inputs: unknown[] = [];
2422
+ const core = await loaded(
2423
+ [
2424
+ user("u"),
2425
+ assistant(
2426
+ "a",
2427
+ [
2428
+ call("x", {
2429
+ approval: {
2430
+ id: "gate",
2431
+ approved: false,
2432
+ reason: "declined before replay",
2433
+ },
2434
+ }),
2435
+ ],
2436
+ { type: "incomplete", reason: "error", error: "disconnected" },
2437
+ ),
2438
+ ],
2439
+ async (input, subscriber) => {
2440
+ attempts++;
2441
+ inputs.push(input);
2442
+ if (attempts === 1) text(subscriber, "a", "replayed answer");
2443
+ emit(subscriber, {
2444
+ type: "RUN_FINISHED",
2445
+ runId: input.runId,
2446
+ ...(attempts <= 2
2447
+ ? {
2448
+ outcome: {
2449
+ type: "interrupt" as const,
2450
+ interrupts: [
2451
+ { id: "gate", reason: "tool_call", toolCallId: "x" },
2452
+ ],
2453
+ },
2454
+ }
2455
+ : {}),
2456
+ });
2457
+ },
2458
+ );
2459
+
2460
+ await resume(core, "u");
2461
+ await vi.waitFor(() => expect(attempts).toBeGreaterThanOrEqual(2));
2462
+ await vi.waitFor(() => expect(core.isRunning()).toBe(false));
2463
+
2464
+ expect(attempts).toBe(2);
2465
+ expect(inputs[1]).toMatchObject({
2466
+ resume: [
2467
+ {
2468
+ interruptId: "gate",
2469
+ status: "resolved",
2470
+ payload: { approved: false, reason: "declined before replay" },
2471
+ },
2472
+ ],
2473
+ });
2474
+ });
2475
+
2476
+ it("does not apply a stored decision when the same gate ID moves to another tool", async () => {
2477
+ let attempts = 0;
2478
+ const core = await loaded(
2479
+ [
2480
+ user("u"),
2481
+ assistant(
2482
+ "a",
2483
+ [
2484
+ call("x", {
2485
+ approval: {
2486
+ id: "gate",
2487
+ approved: true,
2488
+ reason: "approved before replay",
2489
+ },
2490
+ }),
2491
+ call("y"),
2492
+ ],
2493
+ { type: "incomplete", reason: "error", error: "disconnected" },
2494
+ ),
2495
+ ],
2496
+ async (input, subscriber) => {
2497
+ attempts++;
2498
+ text(subscriber, "a", "replayed answer");
2499
+ emit(subscriber, {
2500
+ type: "TOOL_CALL_START",
2501
+ toolCallId: "y",
2502
+ toolCallName: "lookup",
2503
+ });
2504
+ emit(subscriber, { type: "TOOL_CALL_END", toolCallId: "y" });
2505
+ emit(subscriber, {
2506
+ type: "RUN_FINISHED",
2507
+ runId: input.runId,
2508
+ outcome: {
2509
+ type: "interrupt",
2510
+ interrupts: [{ id: "gate", reason: "tool_call", toolCallId: "y" }],
2511
+ },
2512
+ });
2513
+ },
2514
+ );
2515
+
2516
+ await resume(core, "u");
2517
+
2518
+ expect(attempts).toBe(1);
2519
+ expect(core.getPendingInterrupts()).toEqual({
2520
+ messageId: "a",
2521
+ interrupts: [{ id: "gate", reason: "tool_call", toolCallId: "y" }],
2522
+ });
2523
+ const y = contents(core, "a").find(
2524
+ (part) => part.type === "tool-call" && part.toolCallId === "y",
2525
+ );
2526
+ if (y?.type !== "tool-call") throw new Error("missing rebound call");
2527
+ expect(y.approval?.id).toBe("gate");
2528
+ expect(y.approval?.approved).toBeUndefined();
2529
+ });
2530
+
2531
+ it.each([true, false] as const)(
2532
+ "keeps a fresh approval gate pending when replay changes tool arguments (approved=%s)",
2533
+ async (approved) => {
2534
+ let attempts = 0;
2535
+ const core = await loaded(
2536
+ [
2537
+ user("u"),
2538
+ assistant(
2539
+ "a",
2540
+ [
2541
+ call("x", {
2542
+ args: { target: "old" },
2543
+ argsText: '{"target":"old"}',
2544
+ approval: {
2545
+ id: "gate",
2546
+ approved,
2547
+ reason: "decision for old arguments",
2548
+ },
2549
+ }),
2550
+ ],
2551
+ { type: "incomplete", reason: "error", error: "disconnected" },
2552
+ ),
2553
+ ],
2554
+ async (input, subscriber) => {
2555
+ attempts++;
2556
+ if (attempts > 1) {
2557
+ emit(subscriber, { type: "RUN_FINISHED", runId: input.runId });
2558
+ return;
2559
+ }
2560
+ text(subscriber, "a", "replayed answer");
2561
+ streamToolCall(subscriber, "x", '{"target":"new"}');
2562
+ emit(subscriber, {
2563
+ type: "RUN_FINISHED",
2564
+ runId: input.runId,
2565
+ outcome: {
2566
+ type: "interrupt",
2567
+ interrupts: [
2568
+ { id: "gate", reason: "tool_call", toolCallId: "x" },
2569
+ ],
2570
+ },
2571
+ });
2572
+ },
2573
+ );
2574
+ const submit = vi.spyOn(core, "submitInterruptResponses");
2575
+
2576
+ await resume(core, "u");
2577
+
2578
+ expect(attempts).toBe(1);
2579
+ expect(submit).not.toHaveBeenCalled();
2580
+ expect(core.getPendingInterrupts()).toEqual({
2581
+ messageId: "a",
2582
+ interrupts: [{ id: "gate", reason: "tool_call", toolCallId: "x" }],
2583
+ });
2584
+ const part = contents(core, "a").find(
2585
+ (item) => item.type === "tool-call" && item.toolCallId === "x",
2586
+ );
2587
+ if (part?.type !== "tool-call") throw new Error("missing replayed call");
2588
+ expect(part.args).toEqual({ target: "new" });
2589
+ expect(part.approval?.id).toBe("gate");
2590
+ expect(part.approval?.approved).toBeUndefined();
2591
+ expect(part.approval?.reason).toBeUndefined();
2592
+ },
2593
+ );
2594
+
2595
+ it("submits the original approval after matching replay arguments arrive in chunks", async () => {
2596
+ let attempts = 0;
2597
+ const inputs: unknown[] = [];
2598
+ const core = await loaded(
2599
+ [
2600
+ user("u"),
2601
+ assistant(
2602
+ "a",
2603
+ [
2604
+ call("x", {
2605
+ args: { target: "old" },
2606
+ argsText: '{"target":"old"}',
2607
+ approval: {
2608
+ id: "gate",
2609
+ approved: true,
2610
+ reason: "approved before replay",
2611
+ },
2612
+ }),
2613
+ ],
2614
+ { type: "incomplete", reason: "error", error: "disconnected" },
2615
+ ),
2616
+ ],
2617
+ async (input, subscriber) => {
2618
+ attempts++;
2619
+ inputs.push(input);
2620
+ if (attempts > 1) {
2621
+ text(subscriber, "continued", "approved call continued");
2622
+ emit(subscriber, { type: "RUN_FINISHED", runId: input.runId });
2623
+ return;
2624
+ }
2625
+ text(subscriber, "a", "replayed answer");
2626
+ emit(subscriber, {
2627
+ type: "TOOL_CALL_START",
2628
+ toolCallId: "x",
2629
+ toolCallName: "lookup",
2630
+ });
2631
+ emit(subscriber, {
2632
+ type: "TOOL_CALL_ARGS",
2633
+ toolCallId: "x",
2634
+ delta: '{"target":',
2635
+ });
2636
+ emit(subscriber, {
2637
+ type: "TOOL_CALL_ARGS",
2638
+ toolCallId: "x",
2639
+ delta: '"old"}',
2640
+ });
2641
+ emit(subscriber, { type: "TOOL_CALL_END", toolCallId: "x" });
2642
+ emit(subscriber, {
2643
+ type: "RUN_FINISHED",
2644
+ runId: input.runId,
2645
+ outcome: {
2646
+ type: "interrupt",
2647
+ interrupts: [{ id: "gate", reason: "tool_call", toolCallId: "x" }],
2648
+ },
2649
+ });
2650
+ },
2651
+ );
2652
+ const submit = vi.spyOn(core, "submitInterruptResponses");
2653
+
2654
+ await resume(core, "u");
2655
+ await vi.waitFor(() => expect(attempts).toBe(2));
2656
+ await vi.waitFor(() => expect(core.isRunning()).toBe(false));
2657
+
2658
+ expect(submit).toHaveBeenCalledTimes(1);
2659
+ expect(inputs[1]).toMatchObject({
2660
+ resume: [
2661
+ {
2662
+ interruptId: "gate",
2663
+ status: "resolved",
2664
+ payload: { approved: true, reason: "approved before replay" },
2665
+ },
2666
+ ],
2667
+ });
2668
+ expect(core.getPendingInterrupts()).toBeNull();
2669
+ });
2670
+
2671
+ it.each(["expired", "malformed"] as const)(
2672
+ "does not resubmit a stored decision for a %s replayed gate",
2673
+ async (expiry) => {
2674
+ const expiresAt =
2675
+ expiry === "expired"
2676
+ ? new Date(Date.now() - 60_000).toISOString()
2677
+ : "not-a-date";
2678
+ let attempts = 0;
2679
+ const onError = vi.fn((_error: Error) => {});
2680
+ const core = await loaded(
2681
+ [
2682
+ user("u"),
2683
+ assistant(
2684
+ "a",
2685
+ [
2686
+ call("x", {
2687
+ approval: {
2688
+ id: "gate",
2689
+ approved: true,
2690
+ reason: "approved before replay",
2691
+ },
2692
+ }),
2693
+ ],
2694
+ { type: "incomplete", reason: "error", error: "disconnected" },
2695
+ ),
2696
+ ],
2697
+ async (input, subscriber) => {
2698
+ attempts++;
2699
+ text(subscriber, "a", "replayed answer");
2700
+ emit(subscriber, {
2701
+ type: "RUN_FINISHED",
2702
+ runId: input.runId,
2703
+ outcome: {
2704
+ type: "interrupt",
2705
+ interrupts: [
2706
+ {
2707
+ id: "gate",
2708
+ reason: "tool_call",
2709
+ toolCallId: "x",
2710
+ expiresAt,
2711
+ },
2712
+ ],
2713
+ },
2714
+ });
2715
+ },
2716
+ undefined,
2717
+ undefined,
2718
+ onError,
2719
+ );
2720
+
2721
+ await expect(resume(core, "u")).rejects.toThrow(
2722
+ expiry === "expired" ? /expired at/ : /malformed expiresAt/,
2723
+ );
2724
+ expect(attempts).toBe(1);
2725
+ expect(onError).toHaveBeenCalledTimes(1);
2726
+ expect(onError).toHaveBeenCalledWith(expect.any(Error));
2727
+ },
2728
+ );
2729
+
2730
+ it.each(["error", "cancelled"] as const)(
2731
+ "does not resubmit a stored decision when replay ends %s",
2732
+ async (ending) => {
2733
+ let attempts = 0;
2734
+ const core = await loaded(
2735
+ [
2736
+ user("u"),
2737
+ assistant(
2738
+ "a",
2739
+ [
2740
+ call("x", {
2741
+ approval: {
2742
+ id: "gate",
2743
+ approved: true,
2744
+ reason: "approved before replay",
2745
+ },
2746
+ }),
2747
+ ],
2748
+ { type: "incomplete", reason: "error", error: "disconnected" },
2749
+ ),
2750
+ ],
2751
+ async (input, subscriber) => {
2752
+ attempts++;
2753
+ text(subscriber, "a", "replayed answer");
2754
+ if (ending === "error") throw new Error("replay failed");
2755
+ emit(subscriber, {
2756
+ type: "RUN_FINISHED",
2757
+ runId: input.runId,
2758
+ outcome: { type: "cancelled" },
2759
+ });
2760
+ },
2761
+ );
2762
+
2763
+ if (ending === "error")
2764
+ await expect(resume(core, "u")).rejects.toThrow("replay failed");
2765
+ else await resume(core, "u");
2766
+
2767
+ expect(attempts).toBe(1);
2768
+ },
2769
+ );
2770
+
2771
+ it("does not submit a replayed decision after its active run is cancelled", async () => {
2772
+ let release!: () => void;
2773
+ const drain = new Promise<void>((resolve) => {
2774
+ release = resolve;
2775
+ });
2776
+ let atInterrupt!: () => void;
2777
+ const interruptReached = new Promise<void>((resolve) => {
2778
+ atInterrupt = resolve;
2779
+ });
2780
+ let attempts = 0;
2781
+ const core = await loaded(
2782
+ [
2783
+ user("u"),
2784
+ assistant(
2785
+ "a",
2786
+ [
2787
+ call("x", {
2788
+ approval: {
2789
+ id: "gate",
2790
+ approved: true,
2791
+ reason: "approved before replay",
2792
+ },
2793
+ }),
2794
+ ],
2795
+ { type: "incomplete", reason: "error", error: "disconnected" },
2796
+ ),
2797
+ ],
2798
+ async (input, subscriber) => {
2799
+ attempts++;
2800
+ text(subscriber, "a", "replayed answer");
2801
+ emit(subscriber, {
2802
+ type: "RUN_FINISHED",
2803
+ runId: input.runId,
2804
+ outcome: {
2805
+ type: "interrupt",
2806
+ interrupts: [{ id: "gate", reason: "tool_call", toolCallId: "x" }],
2807
+ },
2808
+ });
2809
+ atInterrupt();
2810
+ await drain;
2811
+ },
2812
+ );
2813
+
2814
+ const running = resume(core, "u");
2815
+ await interruptReached;
2816
+ await core.cancel();
2817
+ release();
2818
+ await running;
2819
+
2820
+ expect(attempts).toBe(1);
2821
+ });
2822
+
2823
+ it("does not auto-submit after reset advances the history generation at run finish", async () => {
2824
+ let attempts = 0;
2825
+ let reset = false;
2826
+ const core = await loaded(
2827
+ [
2828
+ user("u"),
2829
+ assistant(
2830
+ "a",
2831
+ [
2832
+ call("x", {
2833
+ approval: {
2834
+ id: "gate",
2835
+ approved: true,
2836
+ reason: "approved before replay",
2837
+ },
2838
+ }),
2839
+ ],
2840
+ { type: "incomplete", reason: "error", error: "disconnected" },
2841
+ ),
2842
+ ],
2843
+ async (input, subscriber) => {
2844
+ attempts++;
2845
+ text(subscriber, "a", "replayed answer");
2846
+ emit(subscriber, {
2847
+ type: "RUN_FINISHED",
2848
+ runId: input.runId,
2849
+ outcome: {
2850
+ type: "interrupt",
2851
+ interrupts: [{ id: "gate", reason: "tool_call", toolCallId: "x" }],
2852
+ },
2853
+ });
2854
+ },
2855
+ );
2856
+ const unsubscribe = core.subscribe(() => {
2857
+ if (
2858
+ reset ||
2859
+ attempts !== 1 ||
2860
+ core.getSnapshot().isRunning ||
2861
+ !core.getPendingInterrupts()
2862
+ )
2863
+ return;
2864
+ reset = true;
2865
+ core.resetThreadState();
2866
+ });
2867
+
2868
+ try {
2869
+ await resume(core, "u");
2870
+ } finally {
2871
+ unsubscribe();
2872
+ }
2873
+
2874
+ expect(reset).toBe(true);
2875
+ expect(attempts).toBe(1);
2876
+ });
2877
+
2878
+ it("lets a newer native interrupt response own the run instead of auto-submitting the old decision", async () => {
2879
+ let attempts = 0;
2880
+ let submitted = false;
2881
+ let nativeResponse: Promise<void> | undefined;
2882
+ const inputs: unknown[] = [];
2883
+ const core = await loaded(
2884
+ [
2885
+ user("u"),
2886
+ assistant(
2887
+ "a",
2888
+ [
2889
+ call("x", {
2890
+ approval: {
2891
+ id: "gate",
2892
+ approved: true,
2893
+ reason: "approved before replay",
2894
+ },
2895
+ }),
2896
+ ],
2897
+ { type: "incomplete", reason: "error", error: "disconnected" },
2898
+ ),
2899
+ ],
2900
+ async (input, subscriber) => {
2901
+ attempts++;
2902
+ inputs.push(input);
2903
+ if (attempts === 1) {
2904
+ text(subscriber, "a", "replayed answer");
2905
+ emit(subscriber, {
2906
+ type: "RUN_FINISHED",
2907
+ runId: input.runId,
2908
+ outcome: {
2909
+ type: "interrupt",
2910
+ interrupts: [
2911
+ { id: "gate", reason: "tool_call", toolCallId: "x" },
2912
+ ],
2913
+ },
2914
+ });
2915
+ return;
2916
+ }
2917
+ emit(subscriber, { type: "RUN_FINISHED", runId: input.runId });
2918
+ },
2919
+ );
2920
+ const unsubscribe = core.subscribe(() => {
2921
+ if (
2922
+ submitted ||
2923
+ attempts !== 1 ||
2924
+ core.getSnapshot().isRunning ||
2925
+ !core.getPendingInterrupts()
2926
+ )
2927
+ return;
2928
+ submitted = true;
2929
+ nativeResponse = core.submitInterruptResponses([
2930
+ { interruptId: "gate", status: "cancelled" },
2931
+ ]);
2932
+ });
2933
+
2934
+ try {
2935
+ await resume(core, "u");
2936
+ await nativeResponse;
2937
+ } finally {
2938
+ unsubscribe();
2939
+ }
2940
+
2941
+ expect(submitted).toBe(true);
2942
+ expect(attempts).toBe(2);
2943
+ expect(inputs[1]).toMatchObject({
2944
+ resume: [{ interruptId: "gate", status: "cancelled" }],
2945
+ });
2946
+ });
2947
+
2948
+ it.each(["error", "cancelled"] as const)(
2949
+ "clears a retained unanswered approval when replay ends %s",
2950
+ async (ending) => {
2951
+ const historical = assistant(
2952
+ "a",
2953
+ [call("x", { approval: { id: "old-gate" } })],
2954
+ { type: "incomplete", reason: "error", error: "previous disconnect" },
2955
+ );
2956
+ const stored: ThreadAssistantMessage = {
2957
+ ...historical,
2958
+ metadata: {
2959
+ ...historical.metadata,
2960
+ custom: {
2961
+ agui: {
2962
+ interrupts: [
2963
+ { id: "old-gate", reason: "tool_call", toolCallId: "x" },
2964
+ ],
2965
+ },
2966
+ },
2967
+ },
2968
+ };
2969
+ const core = await loaded(
2970
+ [user("u"), stored],
2971
+ async (input, subscriber) => {
2972
+ text(subscriber, "a", "replayed answer");
2973
+ if (ending === "error") throw new Error("disconnected");
2974
+ emit(subscriber, {
2975
+ type: "RUN_FINISHED",
2976
+ runId: input.runId,
2977
+ outcome: { type: "cancelled" },
2978
+ });
2979
+ },
2980
+ );
2981
+
2982
+ if (ending === "error")
2983
+ await expect(resume(core, "u")).rejects.toThrow("disconnected");
2984
+ else await resume(core, "u");
2985
+ expect(core.getPendingInterrupts()).toBeNull();
2986
+ const part = contents(core, "a").find(
2987
+ (item) => item.type === "tool-call" && item.toolCallId === "x",
2988
+ );
2989
+ if (part?.type !== "tool-call") throw new Error("missing retained call");
2990
+ expect(part.approval).toBeUndefined();
2991
+ },
2992
+ );
2993
+
2994
+ it.each(["raw-results", "frontend-results"] as const)(
2995
+ "keeps an excluded historical call non-actionable on a second replay after %s",
2996
+ async (delivery) => {
2997
+ const historical = assistant("a", [call("hidden"), call("x")], {
2998
+ type: "requires-action",
2999
+ reason: "tool-calls",
3000
+ });
3001
+ const stored: ThreadAssistantMessage = {
3002
+ ...historical,
3003
+ metadata: {
3004
+ ...historical.metadata,
3005
+ custom: { agui: { pendingToolCallIds: ["x"] } },
3006
+ },
3007
+ };
3008
+ let attempts = 0;
3009
+ const core = await loaded(
3010
+ [user("u"), stored],
3011
+ async (input, subscriber) => {
3012
+ attempts++;
3013
+ if (attempts === 1) {
3014
+ for (const toolCallId of ["x", "y"]) {
3015
+ emit(subscriber, {
3016
+ type: "TOOL_CALL_START",
3017
+ toolCallId,
3018
+ toolCallName: "lookup",
3019
+ });
3020
+ emit(subscriber, { type: "TOOL_CALL_END", toolCallId });
3021
+ if (delivery === "raw-results") {
3022
+ emit(subscriber, {
3023
+ type: "TOOL_CALL_RESULT",
3024
+ toolCallId,
3025
+ messageId: `result-${toolCallId}`,
3026
+ content: `${toolCallId} result`,
3027
+ });
3028
+ }
3029
+ }
3030
+ identifyAssistantFrame(subscriber, "a");
3031
+ } else {
3032
+ if (attempts === 2 && delivery === "frontend-results")
3033
+ text(subscriber, "continued", "continued");
3034
+ else text(subscriber, "a", "replayed again");
3035
+ }
3036
+ emit(subscriber, { type: "RUN_FINISHED", runId: input.runId });
3037
+ },
3038
+ );
3039
+
3040
+ await resume(core, "u");
3041
+ if (delivery === "frontend-results") {
3042
+ expect(core.getPendingToolCalls()).toEqual({
3043
+ messageId: "a",
3044
+ toolCallIds: ["x", "y"],
3045
+ });
3046
+ for (const toolCallId of ["x", "y"]) {
3047
+ core.addToolResult({
3048
+ messageId: "a",
3049
+ toolCallId,
3050
+ toolName: "lookup",
3051
+ result: `${toolCallId} result`,
3052
+ isError: false,
3053
+ });
3054
+ }
3055
+ await vi.waitFor(() => expect(core.isRunning()).toBe(false));
3056
+ expect(attempts).toBe(2);
3057
+ } else {
3058
+ expect(attempts).toBe(1);
3059
+ }
3060
+ expect(
3061
+ core.getMessages().find((message) => message.id === "a")?.status,
3062
+ ).toMatchObject({ type: "complete" });
3063
+ const hidden = contents(core, "a").find(
3064
+ (part) => part.type === "tool-call" && part.toolCallId === "hidden",
3065
+ );
3066
+ if (hidden?.type !== "tool-call")
3067
+ throw new Error("missing retained call");
3068
+ expect(hidden.result).toBeUndefined();
3069
+ expect(core.getPendingToolCalls()).toBeNull();
3070
+
3071
+ await resume(core, "u");
3072
+ expect(
3073
+ core.getMessages().find((message) => message.id === "a")?.status,
3074
+ ).toMatchObject({ type: "complete" });
3075
+ expect(core.getPendingToolCalls()).toBeNull();
3076
+ expect(attempts).toBe(delivery === "raw-results" ? 2 : 3);
3077
+ },
3078
+ );
3079
+
3080
+ it.each(["omitted", "raw", "listed"] as const)(
3081
+ "admits an incomplete historical call only with current %s authority",
3082
+ async (authority) => {
3083
+ let attempts = 0;
3084
+ const core = await loaded(
3085
+ [
3086
+ user("u"),
3087
+ assistant("a", [call("x")], {
3088
+ type: "incomplete",
3089
+ reason: "error",
3090
+ error: "previous disconnect",
3091
+ }),
3092
+ ],
3093
+ async (input, subscriber) => {
3094
+ attempts++;
3095
+ text(subscriber, "a", "replayed answer");
3096
+ if (authority === "raw") {
3097
+ emit(subscriber, {
3098
+ type: "TOOL_CALL_START",
3099
+ toolCallId: "x",
3100
+ toolCallName: "lookup",
3101
+ });
3102
+ emit(subscriber, { type: "TOOL_CALL_END", toolCallId: "x" });
3103
+ }
3104
+ emit(subscriber, {
3105
+ type: "RUN_FINISHED",
3106
+ runId: input.runId,
3107
+ ...(authority === "listed"
3108
+ ? { outcome: { type: "success", pendingToolCallIds: ["x"] } }
3109
+ : {}),
3110
+ });
3111
+ },
3112
+ );
3113
+
3114
+ await resume(core, "u");
3115
+ expect(contents(core, "a")).toContainEqual(
3116
+ expect.objectContaining({ toolCallId: "x" }),
3117
+ );
3118
+ expect(
3119
+ core.getMessages().find((message) => message.id === "a")?.status,
3120
+ ).toMatchObject({
3121
+ type: authority === "omitted" ? "complete" : "requires-action",
3122
+ });
3123
+ expect(core.getPendingToolCalls()).toEqual(
3124
+ authority === "omitted" ? null : { messageId: "a", toolCallIds: ["x"] },
3125
+ );
3126
+ expect(attempts).toBe(1);
3127
+ },
3128
+ );
3129
+
3130
+ it("settles a held message using snapshot admission rather than its earlier open calls", async () => {
3131
+ let attempts = 0;
3132
+ const core = await loaded(
3133
+ [
3134
+ user("u"),
3135
+ assistant("a", [call("x")], {
3136
+ type: "requires-action",
3137
+ reason: "tool-calls",
3138
+ }),
3139
+ assistant("b", [{ type: "text", text: "old B" }]),
3140
+ ],
3141
+ async (input, subscriber) => {
3142
+ attempts++;
3143
+ text(subscriber, "a", "replayed A");
3144
+ emit(subscriber, {
3145
+ type: "TOOL_CALL_START",
3146
+ toolCallId: "x",
3147
+ toolCallName: "lookup",
3148
+ });
3149
+ emit(subscriber, { type: "TOOL_CALL_END", toolCallId: "x" });
3150
+ text(subscriber, "b", "replayed B");
3151
+ emit(subscriber, {
3152
+ type: "MESSAGES_SNAPSHOT",
3153
+ messages: [
3154
+ { id: "u", role: "user", content: "u" },
3155
+ {
3156
+ id: "a",
3157
+ role: "assistant",
3158
+ content: "replayed A",
3159
+ toolCalls: ["x", "y"].map((id) => ({
3160
+ id,
3161
+ type: "function",
3162
+ function: { name: "lookup", arguments: "{}" },
3163
+ })),
3164
+ metadata: { custom: { agui: { pendingToolCallIds: ["y"] } } },
3165
+ },
3166
+ { id: "b", role: "assistant", content: "replayed B" },
3167
+ ],
3168
+ });
3169
+ emit(subscriber, { type: "RUN_FINISHED", runId: input.runId });
3170
+ },
3171
+ );
3172
+
3173
+ await resume(core, "u");
3174
+ expect(contents(core, "a")).toEqual(
3175
+ expect.arrayContaining([
3176
+ expect.objectContaining({ toolCallId: "x" }),
3177
+ expect.objectContaining({ toolCallId: "y" }),
3178
+ ]),
3179
+ );
3180
+ expect(core.getPendingToolCalls()).toEqual({
3181
+ messageId: "a",
3182
+ toolCallIds: ["y"],
3183
+ });
3184
+ expect(attempts).toBe(1);
3185
+ });
3186
+
3187
+ it("lets a final producer list expand admission for a held message", async () => {
3188
+ const historical = assistant("a", [call("x"), call("y")], {
3189
+ type: "requires-action",
3190
+ reason: "tool-calls",
3191
+ });
3192
+ const stored: ThreadAssistantMessage = {
3193
+ ...historical,
3194
+ metadata: {
3195
+ ...historical.metadata,
3196
+ custom: { agui: { pendingToolCallIds: ["x"] } },
3197
+ },
3198
+ };
3199
+ let attempts = 0;
3200
+ const core = await loaded(
3201
+ [user("u"), stored, assistant("b", [{ type: "text", text: "old B" }])],
3202
+ async (input, subscriber) => {
3203
+ attempts++;
3204
+ text(subscriber, "a", "replayed A");
3205
+ text(subscriber, "b", "replayed B");
3206
+ emit(subscriber, {
3207
+ type: "RUN_FINISHED",
3208
+ runId: input.runId,
3209
+ outcome: { type: "success", pendingToolCallIds: ["y"] },
3210
+ });
3211
+ },
3212
+ );
3213
+
3214
+ await resume(core, "u");
3215
+ expect(contents(core, "a")).toEqual(
3216
+ expect.arrayContaining([
3217
+ expect.objectContaining({ toolCallId: "x" }),
3218
+ expect.objectContaining({ toolCallId: "y" }),
3219
+ ]),
3220
+ );
3221
+ expect(core.getPendingToolCalls()).toEqual({
3222
+ messageId: "a",
3223
+ toolCallIds: ["y"],
3224
+ });
3225
+ expect(attempts).toBe(1);
3226
+ });
3227
+
3228
+ it.each(["complete", "incomplete"] as const)(
3229
+ "admits a retained call from initially %s A when the final producer lists it",
3230
+ async (initial) => {
3231
+ let attempts = 0;
3232
+ const core = await loaded(
3233
+ [
3234
+ user("u"),
3235
+ assistant(
3236
+ "a",
3237
+ [call("y")],
3238
+ initial === "complete"
3239
+ ? { type: "complete", reason: "stop" }
3240
+ : { type: "incomplete", reason: "error", error: "old failure" },
3241
+ ),
3242
+ assistant("b", [{ type: "text", text: "old B" }]),
3243
+ ],
3244
+ async (input, subscriber) => {
3245
+ attempts++;
3246
+ text(subscriber, "a", "replayed A");
3247
+ text(subscriber, "b", "replayed B");
3248
+ emit(subscriber, {
3249
+ type: "RUN_FINISHED",
3250
+ runId: input.runId,
3251
+ outcome: { type: "success", pendingToolCallIds: ["y"] },
3252
+ });
3253
+ },
3254
+ );
3255
+
3256
+ await resume(core, "u");
3257
+ expect(contents(core, "a")).toContainEqual(
3258
+ expect.objectContaining({ toolCallId: "y" }),
3259
+ );
3260
+ expect(core.getPendingToolCalls()).toEqual({
3261
+ messageId: "a",
3262
+ toolCallIds: ["y"],
3263
+ });
3264
+ expect(attempts).toBe(1);
3265
+ },
3266
+ );
3267
+
3268
+ it("does not continue a normal run after the server resolves a held call", async () => {
3269
+ let attempts = 0;
3270
+ const core = await loaded([user("u")], async (input, subscriber) => {
3271
+ attempts++;
3272
+ if (attempts > 1) {
3273
+ emit(subscriber, { type: "RUN_FINISHED", runId: input.runId });
3274
+ return;
3275
+ }
3276
+ text(subscriber, "a", "answer A");
3277
+ emit(subscriber, {
3278
+ type: "TOOL_CALL_START",
3279
+ toolCallId: "x",
3280
+ toolCallName: "lookup",
3281
+ });
3282
+ emit(subscriber, { type: "TOOL_CALL_END", toolCallId: "x" });
3283
+ text(subscriber, "b", "answer B");
3284
+ emit(subscriber, {
3285
+ type: "TOOL_CALL_RESULT",
3286
+ toolCallId: "x",
3287
+ messageId: "result-x",
3288
+ content: "server result",
3289
+ });
3290
+ emit(subscriber, {
3291
+ type: "RUN_FINISHED",
3292
+ runId: input.runId,
3293
+ outcome: { type: "success", pendingToolCallIds: ["x"] },
3294
+ });
3295
+ });
3296
+
3297
+ await resume(core, "u");
3298
+ expect(contents(core, "a")).toContainEqual(
3299
+ expect.objectContaining({ toolCallId: "x", result: "server result" }),
3300
+ );
3301
+ expect(
3302
+ core.getMessages().find((message) => message.id === "a")?.status,
3303
+ ).toMatchObject({ type: "complete" });
3304
+ expect(core.getPendingToolCalls()).toBeNull();
3305
+ expect(attempts).toBe(1);
3306
+ });
3307
+
3308
+ it("does not leave a resolved call pending after a normal append collides with an off-branch ID", async () => {
3309
+ let attempts = 0;
3310
+ const core = await loaded(
3311
+ [
3312
+ user("u"),
3313
+ assistant("off-branch", [{ type: "text", text: "old off branch" }]),
3314
+ assistant("selected", [{ type: "text", text: "selected branch" }]),
3315
+ ],
3316
+ async (input, subscriber) => {
3317
+ attempts++;
3318
+ if (attempts > 1) {
3319
+ emit(subscriber, { type: "RUN_FINISHED", runId: input.runId });
3320
+ return;
3321
+ }
3322
+ text(subscriber, "fresh-a", "answer A");
3323
+ streamToolCall(subscriber, "t", "{}");
3324
+ text(subscriber, "off-branch", "answer B");
3325
+ emit(subscriber, {
3326
+ type: "TOOL_CALL_RESULT",
3327
+ toolCallId: "t",
3328
+ messageId: "result-t",
3329
+ content: "server result",
3330
+ });
3331
+ emit(subscriber, {
3332
+ type: "RUN_FINISHED",
3333
+ runId: input.runId,
3334
+ outcome: { type: "success", pendingToolCallIds: ["t"] },
3335
+ });
3336
+ },
3337
+ [null, "u", "u"],
3338
+ );
3339
+
3340
+ await core.append({
3341
+ role: "user",
3342
+ content: [{ type: "text", text: "new prompt" }],
3343
+ attachments: [],
3344
+ createdAt: new Date(1),
3345
+ metadata: { custom: {} },
3346
+ parentId: core.getMessageRepository().headId ?? null,
3347
+ sourceId: null,
3348
+ runConfig: {},
3349
+ startRun: true,
3350
+ });
3351
+
3352
+ const repository = core.getMessageRepository().messages;
3353
+ expect(
3354
+ repository.find(({ message }) => message.id === "off-branch")?.message,
3355
+ ).toMatchObject({ content: [{ type: "text", text: "old off branch" }] });
3356
+ expect(
3357
+ repository.find(({ message }) => message.id === "fresh-a")?.message,
3358
+ ).toMatchObject({
3359
+ content: expect.arrayContaining([
3360
+ expect.objectContaining({ toolCallId: "t", result: "server result" }),
3361
+ ]),
3362
+ });
3363
+ expect(core.getPendingToolCalls()).toBeNull();
3364
+ expect(attempts).toBe(1);
3365
+ });
3366
+
3367
+ it.each(["frontend", "server"] as const)(
3368
+ "continues a running replay only for a %s result on its retained call",
3369
+ async (source) => {
3370
+ let attempts = 0;
3371
+ let delivered: readonly unknown[] | undefined;
3372
+ let core!: AgUiThreadController;
3373
+ core = await loaded(
3374
+ [
3375
+ user("u"),
3376
+ assistant("a", [call("x")], {
3377
+ type: "requires-action",
3378
+ reason: "tool-calls",
3379
+ }),
3380
+ ],
3381
+ async (input, subscriber) => {
3382
+ attempts++;
3383
+ emit(subscriber, { type: "RUN_STARTED", runId: input.runId });
3384
+ if (attempts > 1) {
3385
+ delivered = input.messages;
3386
+ text(subscriber, "continued", "result delivered");
3387
+ emit(subscriber, { type: "RUN_FINISHED", runId: input.runId });
3388
+ return;
3389
+ }
3390
+ text(subscriber, "a", "replayed A");
3391
+ if (source === "frontend") {
3392
+ core.addToolResult({
3393
+ messageId: "a",
3394
+ toolCallId: "x",
3395
+ toolName: "lookup",
3396
+ result: "frontend result",
3397
+ isError: false,
3398
+ });
3399
+ } else {
3400
+ emit(subscriber, {
3401
+ type: "TOOL_CALL_RESULT",
3402
+ toolCallId: "x",
3403
+ messageId: "result-x",
3404
+ content: "server result",
3405
+ });
3406
+ }
3407
+ emit(subscriber, {
3408
+ type: "RUN_FINISHED",
3409
+ runId: input.runId,
3410
+ outcome: { type: "success", pendingToolCallIds: ["x"] },
3411
+ });
3412
+ },
3413
+ );
3414
+
3415
+ await resume(core, "u");
3416
+ expect(contents(core, "a")).toContainEqual(
3417
+ expect.objectContaining({
3418
+ toolCallId: "x",
3419
+ result: source === "frontend" ? "frontend result" : "server result",
3420
+ }),
3421
+ );
3422
+ expect(core.getPendingToolCalls()).toBeNull();
3423
+ if (source === "frontend") {
3424
+ await vi.waitFor(() => expect(attempts).toBe(2));
3425
+ expect(delivered).toEqual(
3426
+ expect.arrayContaining([
3427
+ expect.objectContaining({
3428
+ role: "tool",
3429
+ toolCallId: "x",
3430
+ content: expect.stringContaining("frontend result"),
3431
+ }),
3432
+ ]),
3433
+ );
3434
+ } else {
3435
+ expect(attempts).toBe(1);
3436
+ }
3437
+ },
3438
+ );
3439
+
3440
+ it.each(["server-event", "snapshot-ack", "snapshot-missing"] as const)(
3441
+ "continues a pre-ID frontend result only when %s has not acknowledged it",
3442
+ async (ack) => {
3443
+ let attempts = 0;
3444
+ let delivered: readonly unknown[] | undefined;
3445
+ let core!: AgUiThreadController;
3446
+ core = await loaded(
3447
+ [
3448
+ user("u"),
3449
+ assistant("a", [call("x")], {
3450
+ type: "requires-action",
3451
+ reason: "tool-calls",
3452
+ }),
3453
+ ],
3454
+ async (input, subscriber) => {
3455
+ attempts++;
3456
+ emit(subscriber, { type: "RUN_STARTED", runId: input.runId });
3457
+ if (attempts > 1) {
3458
+ delivered = input.messages;
3459
+ text(subscriber, "continued", "result delivered");
3460
+ emit(subscriber, { type: "RUN_FINISHED", runId: input.runId });
3461
+ return;
3462
+ }
3463
+ core.addToolResult({
3464
+ messageId: "a",
3465
+ toolCallId: "x",
3466
+ toolName: "lookup",
3467
+ result: "frontend x",
3468
+ isError: false,
3469
+ });
3470
+ if (ack === "server-event") {
3471
+ emit(subscriber, {
3472
+ type: "TOOL_CALL_RESULT",
3473
+ toolCallId: "x",
3474
+ messageId: "result-x",
3475
+ content: "frontend x",
3476
+ });
3477
+ } else {
3478
+ emit(subscriber, {
3479
+ type: "MESSAGES_SNAPSHOT",
3480
+ messages: [
3481
+ { id: "u", role: "user", content: "u" },
3482
+ {
3483
+ id: "a",
3484
+ role: "assistant",
3485
+ content: "",
3486
+ toolCalls: [
3487
+ {
3488
+ id: "x",
3489
+ type: "function",
3490
+ function: { name: "lookup", arguments: "{}" },
3491
+ },
3492
+ ],
3493
+ },
3494
+ ...(ack === "snapshot-ack"
3495
+ ? [
3496
+ {
3497
+ id: "result-x",
3498
+ role: "tool",
3499
+ tool_call_id: "x",
3500
+ content: "frontend x",
3501
+ },
3502
+ ]
3503
+ : []),
3504
+ ],
3505
+ });
3506
+ }
3507
+ text(subscriber, "a", "replayed A");
3508
+ emit(subscriber, {
3509
+ type: "RUN_FINISHED",
3510
+ runId: input.runId,
3511
+ outcome: { type: "success", pendingToolCallIds: ["x"] },
3512
+ });
3513
+ },
3514
+ );
3515
+
3516
+ await resume(core, "u");
3517
+ expect(contents(core, "a")).toContainEqual(
3518
+ expect.objectContaining({ toolCallId: "x", result: "frontend x" }),
3519
+ );
3520
+ expect(core.getPendingToolCalls()).toBeNull();
3521
+ if (ack === "snapshot-missing") {
3522
+ await vi.waitFor(() => expect(attempts).toBe(2));
3523
+ expect(delivered).toEqual(
3524
+ expect.arrayContaining([
3525
+ expect.objectContaining({
3526
+ role: "tool",
3527
+ toolCallId: "x",
3528
+ content: expect.stringContaining("frontend x"),
3529
+ }),
3530
+ ]),
3531
+ );
3532
+ } else {
3533
+ expect(attempts).toBe(1);
3534
+ }
3535
+ },
3536
+ );
3537
+
3538
+ it("keeps the unacknowledged result when a pre-ID pair has only one server echo", async () => {
3539
+ let attempts = 0;
3540
+ let delivered: readonly unknown[] | undefined;
3541
+ let core!: AgUiThreadController;
3542
+ core = await loaded(
3543
+ [
3544
+ user("u"),
3545
+ assistant("a", [call("x"), call("y")], {
3546
+ type: "requires-action",
3547
+ reason: "tool-calls",
3548
+ }),
3549
+ ],
3550
+ async (input, subscriber) => {
3551
+ attempts++;
3552
+ emit(subscriber, { type: "RUN_STARTED", runId: input.runId });
3553
+ if (attempts > 1) {
3554
+ delivered = input.messages;
3555
+ text(subscriber, "continued", "result delivered");
3556
+ emit(subscriber, { type: "RUN_FINISHED", runId: input.runId });
3557
+ return;
3558
+ }
3559
+ for (const toolCallId of ["x", "y"]) {
3560
+ core.addToolResult({
3561
+ messageId: "a",
3562
+ toolCallId,
3563
+ toolName: "lookup",
3564
+ result: `frontend ${toolCallId}`,
3565
+ isError: false,
3566
+ });
3567
+ }
3568
+ emit(subscriber, {
3569
+ type: "TOOL_CALL_RESULT",
3570
+ toolCallId: "x",
3571
+ messageId: "result-x",
3572
+ content: "frontend x",
3573
+ });
3574
+ text(subscriber, "a", "replayed A");
3575
+ emit(subscriber, {
3576
+ type: "RUN_FINISHED",
3577
+ runId: input.runId,
3578
+ outcome: { type: "success", pendingToolCallIds: ["x", "y"] },
3579
+ });
3580
+ },
3581
+ );
3582
+
3583
+ await resume(core, "u");
3584
+ expect(core.getPendingToolCalls()).toBeNull();
3585
+ await vi.waitFor(() => expect(attempts).toBe(2));
3586
+ expect(delivered).toEqual(
3587
+ expect.arrayContaining([
3588
+ expect.objectContaining({
3589
+ role: "tool",
3590
+ toolCallId: "y",
3591
+ content: expect.stringContaining("frontend y"),
3592
+ }),
3593
+ ]),
3594
+ );
3595
+ });
3596
+
3597
+ it.each([
3598
+ ["user", "u", false, 2],
3599
+ ["assistant", "a", true, 1],
3600
+ ] as const)(
3601
+ "continues a stored result only when the %s-parent request omitted it",
3602
+ async (_parentKind, parentId, requestIncludesResult, expectedAttempts) => {
3603
+ let attempts = 0;
3604
+ const requests: (readonly unknown[])[] = [];
3605
+ const core = await loaded(
3606
+ [user("u"), assistant("a", [call("x", { result: "stored x" })])],
3607
+ async (input, subscriber) => {
3608
+ attempts++;
3609
+ requests.push(input.messages ?? []);
3610
+ if (attempts === 1) {
3611
+ text(subscriber, "a", "replayed A");
3612
+ emit(subscriber, {
3613
+ type: "TOOL_CALL_START",
3614
+ toolCallId: "x",
3615
+ toolCallName: "lookup",
3616
+ });
3617
+ emit(subscriber, { type: "TOOL_CALL_END", toolCallId: "x" });
3618
+ emit(subscriber, {
3619
+ type: "RUN_FINISHED",
3620
+ runId: input.runId,
3621
+ outcome: { type: "success", pendingToolCallIds: ["x"] },
3622
+ });
3623
+ return;
3624
+ }
3625
+ text(subscriber, "continued", "stored result delivered");
3626
+ emit(subscriber, { type: "RUN_FINISHED", runId: input.runId });
3627
+ },
3628
+ );
3629
+
3630
+ await resume(core, parentId);
3631
+
3632
+ if (requestIncludesResult) {
3633
+ expect(toolResultCount(requests[0] ?? [], "x")).toBe(1);
3634
+ } else {
3635
+ expect(toolResultCount(requests[0] ?? [], "x")).toBe(0);
3636
+ }
3637
+ if (expectedAttempts === 2) {
3638
+ await vi.waitFor(() => expect(attempts).toBe(2));
3639
+ expect(toolResultCount(requests[1] ?? [], "x")).toBe(1);
3640
+ }
3641
+ expect(attempts).toBe(expectedAttempts);
3642
+ expect(core.getPendingToolCalls()).toBeNull();
3643
+ },
3644
+ );
3645
+
3646
+ it("delivers an eligible result after replay advances from A to B without re-emitting its call", async () => {
3647
+ const originalA = assistant("a", [call("x"), call("hidden")], {
3648
+ type: "requires-action",
3649
+ reason: "tool-calls",
3650
+ });
3651
+ const storedA: ThreadAssistantMessage = {
3652
+ ...originalA,
3653
+ metadata: {
3654
+ ...originalA.metadata,
3655
+ custom: { agui: { pendingToolCallIds: ["x"] } },
3656
+ },
3657
+ };
3658
+ let attempts = 0;
3659
+ let delivered: readonly unknown[] = [];
3660
+ let core!: AgUiThreadController;
3661
+ core = await loaded(
3662
+ [user("u"), storedA, assistant("b", [{ type: "text", text: "old B" }])],
3663
+ async (input, subscriber) => {
3664
+ attempts++;
3665
+ if (attempts > 1) {
3666
+ delivered = input.messages ?? [];
3667
+ text(subscriber, "continued", "eligible result delivered");
3668
+ emit(subscriber, { type: "RUN_FINISHED", runId: input.runId });
3669
+ return;
3670
+ }
3671
+
3672
+ expect(core.getPendingToolCalls()).toEqual({
3673
+ messageId: "a",
3674
+ toolCallIds: ["x"],
3675
+ });
3676
+ core.addToolResult({
3677
+ messageId: "a",
3678
+ toolCallId: "x",
3679
+ toolName: "lookup",
3680
+ result: "frontend x",
3681
+ isError: false,
3682
+ });
3683
+ expect(core.getPendingToolCalls()).toBeNull();
3684
+ text(subscriber, "a", "replayed A");
3685
+ text(subscriber, "b", "replayed B");
3686
+ emit(subscriber, {
3687
+ type: "RUN_FINISHED",
3688
+ runId: input.runId,
3689
+ outcome: { type: "success", pendingToolCallIds: ["x"] },
3690
+ });
3691
+ },
3692
+ );
3693
+
3694
+ await resume(core, "u");
3695
+ await vi.waitFor(() => expect(attempts).toBe(2));
3696
+
3697
+ expect(ids(core)).toEqual(["u", "a", "b", "continued"]);
3698
+ expect(contents(core, "a")).toContainEqual(
3699
+ expect.objectContaining({ toolCallId: "x", result: "frontend x" }),
3700
+ );
3701
+ const hidden = contents(core, "a").find(
3702
+ (part) => part.type === "tool-call" && part.toolCallId === "hidden",
3703
+ );
3704
+ if (hidden?.type !== "tool-call") throw new Error("missing hidden call");
3705
+ expect(hidden.result).toBeUndefined();
3706
+ expect(contents(core, "b")).toEqual([{ type: "text", text: "replayed B" }]);
3707
+ expect(toolResultCount(delivered, "x")).toBe(1);
3708
+ expect(toolResultCount(delivered, "hidden")).toBe(0);
3709
+ expect(core.getPendingToolCalls()).toBeNull();
3710
+ });
3711
+
3712
+ it("answers an interrupt on retained A once while preserving later B in the selected turn", async () => {
3713
+ const inputs: unknown[] = [];
3714
+ let attempts = 0;
3715
+ const core = await loaded(
3716
+ [
3717
+ user("u"),
3718
+ assistant("a", [call("x")], {
3719
+ type: "requires-action",
3720
+ reason: "tool-calls",
3721
+ }),
3722
+ assistant("b", [{ type: "text", text: "old B" }]),
3723
+ ],
3724
+ async (input, subscriber) => {
3725
+ attempts++;
3726
+ inputs.push(input);
3727
+ if (attempts === 1) {
3728
+ text(subscriber, "a", "replayed A");
3729
+ emit(subscriber, {
3730
+ type: "RUN_FINISHED",
3731
+ runId: input.runId,
3732
+ outcome: {
3733
+ type: "interrupt",
3734
+ interrupts: [
3735
+ { id: "gate", reason: "tool_call", toolCallId: "x" },
3736
+ ],
3737
+ },
3738
+ });
3739
+ return;
3740
+ }
3741
+ text(subscriber, "continued", "approval resumed");
3742
+ emit(subscriber, { type: "RUN_FINISHED", runId: input.runId });
3743
+ },
3744
+ );
3745
+
3746
+ await resume(core, "u");
3747
+
3748
+ expect(core.getPendingInterrupts()).toEqual({
3749
+ messageId: "a",
3750
+ interrupts: [{ id: "gate", reason: "tool_call", toolCallId: "x" }],
3751
+ });
3752
+ expect(ids(core)).toEqual(["u", "a", "b"]);
3753
+ await core.respondToToolApproval({ approvalId: "gate", approved: true });
3754
+
3755
+ expect(attempts).toBe(2);
3756
+ expect(inputs).toHaveLength(2);
3757
+ expect(inputs[1]).toMatchObject({
3758
+ resume: [
3759
+ {
3760
+ interruptId: "gate",
3761
+ status: "resolved",
3762
+ payload: { approved: true },
3763
+ },
3764
+ ],
3765
+ });
3766
+ expect(ids(core)).toEqual(["u", "a", "b", "continued"]);
3767
+ expect(contents(core, "b")).toEqual([{ type: "text", text: "old B" }]);
3768
+ });
3769
+
3770
+ it.each([
3771
+ ["after replaying A and B", true, true],
3772
+ ["without terminal admission after replaying A and B", false, true],
3773
+ ["after replaying only B", true, false],
3774
+ ] as const)(
3775
+ "delivers a stored result %s only when x is terminally admitted",
3776
+ async (_scenario, admitted, replayA) => {
3777
+ let attempts = 0;
3778
+ const requests: (readonly unknown[])[] = [];
3779
+ const core = await loaded(
3780
+ [
3781
+ user("u"),
3782
+ assistant("a", [call("x", { result: "stored x" })]),
3783
+ assistant("b", [{ type: "text", text: "old B" }]),
3784
+ ],
3785
+ async (input, subscriber) => {
3786
+ attempts++;
3787
+ requests.push(input.messages ?? []);
3788
+ if (attempts > 1) {
3789
+ text(subscriber, "continued", "stored x delivered");
3790
+ emit(subscriber, { type: "RUN_FINISHED", runId: input.runId });
3791
+ return;
3792
+ }
3793
+
3794
+ if (replayA) text(subscriber, "a", "replayed A");
3795
+ text(subscriber, "b", "replayed B");
3796
+ emit(subscriber, {
3797
+ type: "RUN_FINISHED",
3798
+ runId: input.runId,
3799
+ ...(admitted
3800
+ ? { outcome: { type: "success", pendingToolCallIds: ["x"] } }
3801
+ : {}),
3802
+ });
3803
+ },
3804
+ );
3805
+
3806
+ await resume(core, "u");
3807
+ expect(toolResultCount(requests[0] ?? [], "x")).toBe(0);
3808
+ if (admitted) {
3809
+ await vi.waitFor(() => expect(attempts).toBe(2));
3810
+ expect(toolResultCount(requests[1] ?? [], "x")).toBe(1);
3811
+ } else {
3812
+ expect(attempts).toBe(1);
3813
+ expect(requests).toHaveLength(1);
3814
+ expect(toolResultCount(requests.flat(), "x")).toBe(0);
3815
+ }
3816
+ expect(contents(core, "a")).toContainEqual(
3817
+ expect.objectContaining({ toolCallId: "x", result: "stored x" }),
3818
+ );
3819
+ expect(contents(core, "b")).toEqual([
3820
+ { type: "text", text: "replayed B" },
3821
+ ]);
3822
+ expect(core.getPendingToolCalls()).toBeNull();
3823
+ },
3824
+ );
3825
+
3826
+ it.each([
3827
+ ["same-gate approval", "gate", true, 2],
3828
+ ["same-gate denial", "gate", false, 2],
3829
+ ["changed-gate control", "old-gate", true, 1],
3830
+ ] as const)(
3831
+ "preserves only the matching recorded decision across a replay snapshot: %s",
3832
+ async (_scenario, previousGate, approved, expectedAttempts) => {
3833
+ let attempts = 0;
3834
+ const inputs: unknown[] = [];
3835
+ let snapshotApproval: unknown;
3836
+ const core = await loaded(
3837
+ [
3838
+ user("u"),
3839
+ assistant(
3840
+ "a",
3841
+ [
3842
+ call("x", {
3843
+ approval: {
3844
+ id: previousGate,
3845
+ approved,
3846
+ reason: "recorded decision",
3847
+ },
3848
+ }),
3849
+ ],
3850
+ { type: "incomplete", reason: "error", error: "disconnected" },
3851
+ ),
3852
+ ],
3853
+ async (input, subscriber) => {
3854
+ attempts++;
3855
+ inputs.push(input);
3856
+ if (attempts > 1) {
3857
+ text(subscriber, "continued", "approval sent");
3858
+ emit(subscriber, { type: "RUN_FINISHED", runId: input.runId });
3859
+ return;
3860
+ }
3861
+
3862
+ text(subscriber, "a", "replayed answer");
3863
+ emit(subscriber, {
3864
+ type: "MESSAGES_SNAPSHOT",
3865
+ messages: [
3866
+ { id: "u", role: "user", content: "u" },
3867
+ {
3868
+ id: "a",
3869
+ role: "assistant",
3870
+ content: "snapshot answer",
3871
+ toolCalls: [
3872
+ {
3873
+ id: "x",
3874
+ type: "function",
3875
+ function: { name: "lookup", arguments: "{}" },
3876
+ },
3877
+ ],
3878
+ metadata: {
3879
+ custom: {
3880
+ agui: {
3881
+ interrupts: [
3882
+ { id: "gate", reason: "tool_call", toolCallId: "x" },
3883
+ ],
3884
+ },
3885
+ },
3886
+ },
3887
+ },
3888
+ ],
3889
+ });
3890
+ const part = contents(core, "a").find(
3891
+ (item) => item.type === "tool-call" && item.toolCallId === "x",
3892
+ );
3893
+ snapshotApproval =
3894
+ part?.type === "tool-call" ? part.approval : undefined;
3895
+ emit(subscriber, {
3896
+ type: "RUN_FINISHED",
3897
+ runId: input.runId,
3898
+ outcome: {
3899
+ type: "interrupt",
3900
+ interrupts: [
3901
+ { id: "gate", reason: "tool_call", toolCallId: "x" },
3902
+ ],
3903
+ },
3904
+ });
3905
+ },
3906
+ );
3907
+
3908
+ await resume(core, "u");
3909
+ if (expectedAttempts === 2) {
3910
+ await vi.waitFor(() => expect(attempts).toBe(2));
3911
+ expect(snapshotApproval).toMatchObject({
3912
+ id: "gate",
3913
+ approved,
3914
+ reason: "recorded decision",
3915
+ });
3916
+ expect(inputs[1]).toMatchObject({
3917
+ resume: [
3918
+ {
3919
+ interruptId: "gate",
3920
+ status: "resolved",
3921
+ payload: { approved, reason: "recorded decision" },
3922
+ },
3923
+ ],
3924
+ });
3925
+ } else {
3926
+ expect(attempts).toBe(1);
3927
+ expect(snapshotApproval).toMatchObject({ id: "gate" });
3928
+ expect(snapshotApproval).not.toHaveProperty("approved");
3929
+ }
3930
+ },
3931
+ );
3932
+ });