@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
@@ -24,6 +24,7 @@ import type {
24
24
  } from "@assistant-ui/core";
25
25
  import {
26
26
  appendToolInteraction,
27
+ createHistoryWriteChain,
27
28
  createMessageRepositorySession,
28
29
  invokeUserCallback,
29
30
  iterateToolCallParts,
@@ -37,8 +38,14 @@ import type {
37
38
  RunAgentResult,
38
39
  } from "@ag-ui/client";
39
40
  import jsonpatch, { type Operation } from "fast-json-patch";
41
+ import {
42
+ createAgUiThreadState,
43
+ reduceAgUiThreadState,
44
+ type AgUiThreadAction,
45
+ type AgUiThreadState,
46
+ } from "./agUiThreadState";
40
47
  import type { Logger } from "./logger";
41
- import { readMcpAppResourceUri } from "./mcp-tool-result";
48
+ import { readMcpAppResourceUri, toolContentText } from "./mcp-tool-result";
42
49
  import type {
43
50
  AgUiCustomMetadata,
44
51
  AgUiEvent,
@@ -60,9 +67,16 @@ import {
60
67
  toAgUiTools,
61
68
  } from "./adapter/conversions";
62
69
  import { createAgUiSubscriber } from "./adapter/subscriber";
70
+ import {
71
+ createReplayContentMerger,
72
+ groupReplayToolContent,
73
+ indexReplayToolOwners,
74
+ preserveToolResult,
75
+ } from "./adapter/replay-content";
63
76
  import {
64
77
  buildToolApprovalResume,
65
78
  projectAgUiToolApprovals,
79
+ preserveToolApprovalDecision,
66
80
  withSettledToolApprovals,
67
81
  withToolApprovalDecision,
68
82
  } from "./adapter/tool-approval";
@@ -85,6 +99,97 @@ const isResolvedToolCall = (
85
99
  ): boolean =>
86
100
  part.type === "tool-call" && "result" in part && part.result !== undefined;
87
101
 
102
+ const hasResolvedToolCalls = (message: ThreadMessage): boolean => {
103
+ if (message.role !== "assistant") return false;
104
+ for (const part of iterateToolCallParts(message.content)) {
105
+ if (isResolvedToolCall(part)) return true;
106
+ }
107
+ return false;
108
+ };
109
+
110
+ const listedPendingToolCallIds = (
111
+ assistant: Pick<ThreadAssistantMessage, "metadata">,
112
+ ): ReadonlySet<string> | undefined => {
113
+ const ids = (
114
+ assistant.metadata.custom[AG_UI_METADATA_NAMESPACE] as
115
+ | AgUiCustomMetadata
116
+ | undefined
117
+ )?.pendingToolCallIds;
118
+ return ids === undefined ? undefined : new Set(ids);
119
+ };
120
+
121
+ type ReplayContext = {
122
+ // Immutable history reference: partial argument frames cannot redefine consent.
123
+ readonly originalContent: ThreadAssistantMessage["content"];
124
+ accountedResultIds: ReadonlySet<string>;
125
+ admittedToolCallIds: ReadonlySet<string>;
126
+ merge: ReturnType<typeof createReplayContentMerger>;
127
+ };
128
+
129
+ type SnapshotMessageReconciler = (
130
+ snapshot: ThreadAssistantMessage,
131
+ previous: ThreadAssistantMessage | undefined,
132
+ ) => ThreadAssistantMessage;
133
+
134
+ const withoutPendingToolCallIds = (
135
+ metadata: ThreadAssistantMessage["metadata"],
136
+ ): ThreadAssistantMessage["metadata"] => {
137
+ const namespace = metadata.custom[AG_UI_METADATA_NAMESPACE] as
138
+ | AgUiCustomMetadata
139
+ | undefined;
140
+ if (!namespace || !("pendingToolCallIds" in namespace)) return metadata;
141
+ const { pendingToolCallIds: _pendingToolCallIds, ...remainingNamespace } =
142
+ namespace;
143
+ const { [AG_UI_METADATA_NAMESPACE]: _namespace, ...remainingCustom } =
144
+ metadata.custom;
145
+ return {
146
+ ...metadata,
147
+ custom: {
148
+ ...remainingCustom,
149
+ ...(Object.keys(remainingNamespace).length > 0
150
+ ? { [AG_UI_METADATA_NAMESPACE]: remainingNamespace }
151
+ : {}),
152
+ },
153
+ };
154
+ };
155
+
156
+ const withPendingToolCallIds = (
157
+ metadata: ThreadAssistantMessage["metadata"],
158
+ ids: Iterable<string>,
159
+ ): ThreadAssistantMessage["metadata"] => ({
160
+ ...metadata,
161
+ custom: {
162
+ ...metadata.custom,
163
+ [AG_UI_METADATA_NAMESPACE]: {
164
+ ...(metadata.custom[AG_UI_METADATA_NAMESPACE] as
165
+ | AgUiCustomMetadata
166
+ | undefined),
167
+ pendingToolCallIds: [...ids],
168
+ },
169
+ },
170
+ });
171
+
172
+ const actionableToolCallIds = (
173
+ message: ThreadMessage | undefined,
174
+ ): ReadonlySet<string> => {
175
+ if (
176
+ message?.role !== "assistant" ||
177
+ message.status?.type !== "requires-action" ||
178
+ message.status.reason !== "tool-calls"
179
+ )
180
+ return new Set();
181
+ const permitted = listedPendingToolCallIds(message);
182
+ return new Set(
183
+ Array.from(iterateToolCallParts(message.content))
184
+ .filter(
185
+ (part) =>
186
+ !isResolvedToolCall(part) &&
187
+ (!permitted || permitted.has(part.toolCallId)),
188
+ )
189
+ .map((part) => part.toolCallId),
190
+ );
191
+ };
192
+
88
193
  const isActivityPart = (part: ThreadAssistantMessage["content"][number]) =>
89
194
  part.type === "data" && part.name.startsWith("agui-activity/");
90
195
 
@@ -155,7 +260,7 @@ const mergeAgUiNamespace = (
155
260
  return merged;
156
261
  };
157
262
 
158
- export class AgUiThreadRuntimeCore {
263
+ export class AgUiThreadController {
159
264
  private agent: AbstractAgent;
160
265
  private logger: Logger;
161
266
  private showThinking: boolean;
@@ -194,27 +299,18 @@ export class AgUiThreadRuntimeCore {
194
299
  };
195
300
  },
196
301
  });
197
- private isRunningFlag = false;
302
+ private state: AgUiThreadState = createAgUiThreadState();
198
303
  private abortController: AbortController | null = null;
199
304
  // The agent that started the active run. updateOptions can swap this.agent
200
305
  // mid-run, and cancelling has to reach the agent holding the live request.
201
306
  private activeRunAgent: AbstractAgent | null = null;
202
- private stateSnapshot: ReadonlyJSONValue | undefined;
203
307
  private history: ThreadHistoryAdapter | undefined;
204
- private lastRunConfig: RunConfig | undefined;
205
308
  private readonly assistantHistoryParents = new Map<string, string | null>();
206
309
  private readonly snapshotHistoryIds = new Set<string>();
207
310
  private readonly persistedHistoryIds = new Set<string>();
208
- private readonly historyWrites = new Map<string, Promise<void>>();
209
- private _isLoading = false;
311
+ private readonly historyWrites = createHistoryWriteChain();
210
312
  private _historyImportPromise: Promise<void> | undefined;
211
- private historyMutationGeneration = 0;
212
313
  private _loadPromise: Promise<void> | undefined;
213
- private _loadRequested = false;
214
- private pendingResume: { owner: AbortController; messageId: string } | null =
215
- null;
216
- private pendingA2uiResumeOwner: AbortController | null = null;
217
- private pendingA2uiAction: Record<string, unknown> | undefined;
218
314
 
219
315
  constructor(options: CoreOptions) {
220
316
  this.agent = options.agent;
@@ -237,12 +333,16 @@ export class AgUiThreadRuntimeCore {
237
333
 
238
334
  getSnapshot = () =>
239
335
  (this.snapshot ??= {
240
- isLoading: this._isLoading,
241
- isRunning: this.isRunningFlag,
336
+ isLoading: this.state.isLoading,
337
+ isRunning: this.state.isRunning,
242
338
  messageRepository: this.session.export(),
243
- state: this.stateSnapshot,
339
+ state: this.state.stateSnapshot,
244
340
  });
245
341
 
342
+ private dispatch(action: AgUiThreadAction): void {
343
+ this.state = reduceAgUiThreadState(this.state, action);
344
+ }
345
+
246
346
  private publishUpdate(): void {
247
347
  this.snapshot = undefined;
248
348
  for (const listener of this.listeners) listener();
@@ -261,7 +361,7 @@ export class AgUiThreadRuntimeCore {
261
361
  const previousHistory = this.history;
262
362
  this.history = options.history;
263
363
  if (
264
- this._loadRequested &&
364
+ this.state.loadRequested &&
265
365
  !this._loadPromise &&
266
366
  !previousHistory &&
267
367
  options.history &&
@@ -291,19 +391,19 @@ export class AgUiThreadRuntimeCore {
291
391
  }
292
392
 
293
393
  getState(): ReadonlyJSONValue | undefined {
294
- return this.stateSnapshot;
394
+ return this.state.stateSnapshot;
295
395
  }
296
396
 
297
397
  isRunning(): boolean {
298
- return this.isRunningFlag;
398
+ return this.state.isRunning;
299
399
  }
300
400
 
301
401
  get isLoading(): boolean {
302
- return this._isLoading;
402
+ return this.state.isLoading;
303
403
  }
304
404
 
305
405
  __internal_load(): Promise<void> {
306
- this._loadRequested = true;
406
+ this.dispatch({ type: "loadRequested" });
307
407
  if (this._loadPromise) return this._loadPromise;
308
408
  if (!this.history) return Promise.resolve();
309
409
 
@@ -326,7 +426,7 @@ export class AgUiThreadRuntimeCore {
326
426
  return repo;
327
427
  });
328
428
 
329
- this._isLoading = true;
429
+ this.dispatch({ type: "loadStarted" });
330
430
  this._historyImportPromise = promise.then(
331
431
  () => {
332
432
  this._historyImportPromise = undefined;
@@ -343,7 +443,7 @@ export class AgUiThreadRuntimeCore {
343
443
  const resumeStream = this.history?.resume?.bind(this.history);
344
444
  await this.startRun(
345
445
  parentId,
346
- this.lastRunConfig,
446
+ this.state.lastRunConfig,
347
447
  undefined,
348
448
  resumeStream,
349
449
  );
@@ -354,7 +454,9 @@ export class AgUiThreadRuntimeCore {
354
454
  this.reportError(error);
355
455
  })
356
456
  .finally(() => {
357
- this._isLoading = false;
457
+ this.dispatch({
458
+ type: "loadFinished",
459
+ });
358
460
  this.publishUpdate();
359
461
  });
360
462
 
@@ -365,10 +467,10 @@ export class AgUiThreadRuntimeCore {
365
467
  private waitForHistoryImport(): Promise<boolean> | undefined {
366
468
  const historyImport = this._historyImportPromise;
367
469
  if (!historyImport) return undefined;
368
- const generation = this.historyMutationGeneration;
470
+ const generation = this.state.historyMutationGeneration;
369
471
  return historyImport.then(
370
472
  () =>
371
- generation === this.historyMutationGeneration &&
473
+ generation === this.state.historyMutationGeneration &&
372
474
  !this.isThreadSwitching?.(),
373
475
  );
374
476
  }
@@ -376,10 +478,10 @@ export class AgUiThreadRuntimeCore {
376
478
  private deferUntilHistoryImported(callback: () => void): boolean {
377
479
  const historyImport = this._historyImportPromise;
378
480
  if (!historyImport) return false;
379
- const generation = this.historyMutationGeneration;
481
+ const generation = this.state.historyMutationGeneration;
380
482
  void historyImport.then(() => {
381
483
  if (
382
- generation !== this.historyMutationGeneration ||
484
+ generation !== this.state.historyMutationGeneration ||
383
485
  this.isThreadSwitching?.()
384
486
  ) {
385
487
  return;
@@ -397,10 +499,10 @@ export class AgUiThreadRuntimeCore {
397
499
  const historyImport = this._historyImportPromise;
398
500
  if (historyImport) {
399
501
  const wasAtTail = message.parentId === this.session.headId;
400
- const generation = this.historyMutationGeneration;
502
+ const generation = this.state.historyMutationGeneration;
401
503
  await historyImport;
402
504
  if (
403
- generation !== this.historyMutationGeneration ||
505
+ generation !== this.state.historyMutationGeneration ||
404
506
  this.isThreadSwitching?.()
405
507
  )
406
508
  return;
@@ -434,10 +536,10 @@ export class AgUiThreadRuntimeCore {
434
536
 
435
537
  private maybeAutoCancelPendingToolCalls(): void {
436
538
  if (this.autoCancelPendingToolCalls === false) return;
437
- const pending = this.getPendingToolCalls();
438
- if (!pending) return;
439
- this.cancelUnresolvedToolCalls(pending.messageId);
440
- this.maybeCompleteAfterToolResults(pending.messageId);
539
+ for (const assistant of this.getPendingToolCallMessages()) {
540
+ this.cancelUnresolvedToolCalls(assistant.id);
541
+ this.maybeCompleteAfterToolResults(assistant.id);
542
+ }
441
543
  }
442
544
 
443
545
  private appendEntry(message: AppendMessage): string {
@@ -471,7 +573,9 @@ export class AgUiThreadRuntimeCore {
471
573
  }
472
574
 
473
575
  async cancel(): Promise<void> {
474
- this.historyMutationGeneration++;
576
+ this.dispatch({
577
+ type: "historyMutationAdvanced",
578
+ });
475
579
  this.abortActiveRun();
476
580
  }
477
581
 
@@ -518,9 +622,10 @@ export class AgUiThreadRuntimeCore {
518
622
  this.assertNoPendingInterrupts();
519
623
  await this.startRun(
520
624
  config.parentId,
521
- config.runConfig ?? this.lastRunConfig,
625
+ config.runConfig ?? this.state.lastRunConfig,
522
626
  undefined,
523
627
  config.stream,
628
+ true,
524
629
  );
525
630
  }
526
631
 
@@ -541,7 +646,7 @@ export class AgUiThreadRuntimeCore {
541
646
  try {
542
647
  await this.startRun(
543
648
  parentId,
544
- this.lastRunConfig,
649
+ this.state.lastRunConfig,
545
650
  undefined,
546
651
  resumeStream,
547
652
  );
@@ -560,17 +665,32 @@ export class AgUiThreadRuntimeCore {
560
665
  private findRequiresActionAssistant(
561
666
  reason: "interrupt" | "tool-calls",
562
667
  ): ThreadAssistantMessage | null {
563
- const assistant = this.getMessages().findLast(
564
- (message) => message.role === "assistant",
565
- ) as ThreadAssistantMessage | undefined;
566
- if (
567
- !assistant ||
568
- assistant.status?.type !== "requires-action" ||
569
- assistant.status.reason !== reason
570
- ) {
571
- return null;
668
+ return (
669
+ this.getTrailingAssistantTurn().findLast(
670
+ (message) =>
671
+ message.status?.type === "requires-action" &&
672
+ message.status.reason === reason,
673
+ ) ?? null
674
+ );
675
+ }
676
+
677
+ private getTrailingAssistantTurn(): ThreadAssistantMessage[] {
678
+ const messages = this.getMessages();
679
+ const turn: ThreadAssistantMessage[] = [];
680
+ for (let index = messages.length - 1; index >= 0; index--) {
681
+ const message = messages[index];
682
+ if (message?.role !== "assistant") break;
683
+ turn.unshift(message as ThreadAssistantMessage);
572
684
  }
573
- return assistant;
685
+ return turn;
686
+ }
687
+
688
+ private getPendingToolCallMessages(): ThreadAssistantMessage[] {
689
+ return this.getTrailingAssistantTurn().filter(
690
+ (message) =>
691
+ message.status?.type === "requires-action" &&
692
+ message.status.reason === "tool-calls",
693
+ );
574
694
  }
575
695
 
576
696
  getPendingInterrupts(): {
@@ -592,15 +712,21 @@ export class AgUiThreadRuntimeCore {
592
712
  messageId: string;
593
713
  toolCallIds: string[];
594
714
  } | null {
595
- const assistant = this.findRequiresActionAssistant("tool-calls");
596
- if (!assistant) return null;
597
715
  const toolCallIds: string[] = [];
598
- for (const part of iterateToolCallParts(assistant.content)) {
599
- if (isResolvedToolCall(part)) continue;
600
- toolCallIds.push(part.toolCallId);
716
+ let messageId: string | undefined;
717
+ for (const assistant of this.getPendingToolCallMessages()) {
718
+ const listed = listedPendingToolCallIds(assistant);
719
+ for (const part of iterateToolCallParts(assistant.content)) {
720
+ if (
721
+ isResolvedToolCall(part) ||
722
+ (listed && !listed.has(part.toolCallId))
723
+ )
724
+ continue;
725
+ messageId ??= assistant.id;
726
+ toolCallIds.push(part.toolCallId);
727
+ }
601
728
  }
602
- if (toolCallIds.length === 0) return null;
603
- return { messageId: assistant.id, toolCallIds };
729
+ return messageId === undefined ? null : { messageId, toolCallIds };
604
730
  }
605
731
 
606
732
  async submitInterruptResponses(
@@ -632,6 +758,7 @@ export class AgUiThreadRuntimeCore {
632
758
  this.assertInterruptsAnswerable(
633
759
  "submitInterruptResponses",
634
760
  pending.interrupts,
761
+ responsesById,
635
762
  );
636
763
 
637
764
  await this.resumeWithResponses(
@@ -650,8 +777,9 @@ export class AgUiThreadRuntimeCore {
650
777
  messageId: string,
651
778
  resume: AgUiResumeEntry[],
652
779
  ): Promise<void> {
780
+ const parentId = this.session.headId ?? messageId;
653
781
  this.clearPendingInterrupts(messageId, resume);
654
- await this.startRun(messageId, this.lastRunConfig, {
782
+ await this.startRun(parentId, this.state.lastRunConfig, {
655
783
  entries: resume,
656
784
  interruptedMessageId: messageId,
657
785
  });
@@ -679,6 +807,7 @@ export class AgUiThreadRuntimeCore {
679
807
  private assertInterruptsAnswerable(
680
808
  method: "submitInterruptResponses" | "respondToToolApproval",
681
809
  interrupts: readonly AgUiInterrupt[],
810
+ responsesById?: ReadonlyMap<string, AgUiResumeEntry>,
682
811
  ): void {
683
812
  const now = Date.now();
684
813
  for (const interrupt of interrupts) {
@@ -689,13 +818,16 @@ export class AgUiThreadRuntimeCore {
689
818
  `[agui] ${method}: interrupt ${interrupt.id} has malformed expiresAt "${interrupt.expiresAt}"`,
690
819
  );
691
820
  }
692
- if (expiry <= now) {
821
+ if (
822
+ expiry <= now &&
823
+ responsesById?.get(interrupt.id)?.status !== "cancelled"
824
+ ) {
693
825
  throw new Error(
694
826
  `[agui] ${method}: interrupt ${interrupt.id} expired at ${interrupt.expiresAt}`,
695
827
  );
696
828
  }
697
829
  }
698
- if (this.isRunningFlag) {
830
+ if (this.state.isRunning) {
699
831
  throw new Error(`[agui] ${method}: a run is already in progress`);
700
832
  }
701
833
  }
@@ -789,11 +921,13 @@ export class AgUiThreadRuntimeCore {
789
921
  "[agui] steerAway: responses are only valid for pending interrupts",
790
922
  );
791
923
  }
792
- if (this.isRunningFlag) {
924
+ if (this.state.isRunning) {
793
925
  throw new Error("[agui] steerAway: a run is already in progress");
794
926
  }
795
- this.cancelUnresolvedToolCalls(pendingTools.messageId);
796
- this.maybeCompleteAfterToolResults(pendingTools.messageId);
927
+ for (const assistant of this.getPendingToolCallMessages()) {
928
+ this.cancelUnresolvedToolCalls(assistant.id);
929
+ this.maybeCompleteAfterToolResults(assistant.id);
930
+ }
797
931
  const normalized = this.toAppendMessage(message);
798
932
  const threadMessageId = this.appendEntry(normalized);
799
933
  await this.startRun(threadMessageId, normalized.runConfig);
@@ -810,7 +944,7 @@ export class AgUiThreadRuntimeCore {
810
944
 
811
945
  const resume = this.resolveSteerAwayResume(pending.interrupts, responses);
812
946
 
813
- if (this.isRunningFlag) {
947
+ if (this.state.isRunning) {
814
948
  throw new Error("[agui] steerAway: a run is already in progress");
815
949
  }
816
950
 
@@ -1067,7 +1201,7 @@ export class AgUiThreadRuntimeCore {
1067
1201
  return;
1068
1202
  }
1069
1203
 
1070
- await this.chainHistoryWrite(sessionMessageId, () =>
1204
+ await this.historyWrites.chain(sessionMessageId, () =>
1071
1205
  history.update!({ parentId: item.parentId, message }),
1072
1206
  );
1073
1207
  }
@@ -1090,11 +1224,17 @@ export class AgUiThreadRuntimeCore {
1090
1224
  if (!("timestamp" in userAction)) {
1091
1225
  userAction.timestamp = new Date().toISOString();
1092
1226
  }
1093
- this.pendingA2uiAction = userAction;
1227
+ this.dispatch({
1228
+ type: "a2uiActionDeferred",
1229
+ action: userAction,
1230
+ });
1094
1231
 
1095
1232
  const owner = this.abortController;
1096
1233
  if (owner) {
1097
- this.pendingA2uiResumeOwner = owner;
1234
+ this.dispatch({
1235
+ type: "a2uiResumeDeferred",
1236
+ owner,
1237
+ });
1098
1238
  return;
1099
1239
  }
1100
1240
  this.startResumeRun(parentId);
@@ -1103,17 +1243,41 @@ export class AgUiThreadRuntimeCore {
1103
1243
  // The continuation fires whether the frontend result lands before
1104
1244
  // RUN_FINISHED (the status flips to requires-action only later, while the
1105
1245
  // run is still draining) or after it.
1106
- private maybeResumeAfterToolResults(messageId: string): void {
1246
+ private maybeResumeAfterToolResults(
1247
+ messageId: string,
1248
+ accountedResultIds?: ReadonlySet<string>,
1249
+ ): void {
1107
1250
  if (!this.maybeCompleteAfterToolResults(messageId)) return;
1108
-
1251
+ if (accountedResultIds) {
1252
+ const message = this.session.tryGetMessage(messageId)?.message;
1253
+ if (
1254
+ message?.role !== "assistant" ||
1255
+ !Array.from(iterateToolCallParts(message.content)).some(
1256
+ (part) =>
1257
+ isResolvedToolCall(part) &&
1258
+ !accountedResultIds.has(part.toolCallId),
1259
+ )
1260
+ )
1261
+ return;
1262
+ }
1263
+ if (this.getPendingInterrupts()) return;
1264
+ const turn = this.getTrailingAssistantTurn();
1265
+ if (!turn.some((message) => message.id === messageId)) return;
1109
1266
  const owner = this.abortController;
1110
1267
  if (owner) {
1111
1268
  // A run is still draining (RUN_FINISHED arrived but the stream has not
1112
1269
  // closed). Defer until startRun's tail so we never start two runs.
1113
- this.pendingResume = { owner, messageId };
1270
+ this.dispatch({
1271
+ type: "resumeDeferred",
1272
+ owner,
1273
+ messageId,
1274
+ });
1114
1275
  return;
1115
1276
  }
1116
- this.startResumeRun(messageId);
1277
+ if (this.getPendingToolCalls()) return;
1278
+ const headId = turn.at(-1)?.id;
1279
+ if (headId === undefined) return;
1280
+ this.startResumeRun(headId);
1117
1281
  }
1118
1282
 
1119
1283
  private maybeCompleteAfterToolResults(messageId: string): boolean {
@@ -1127,7 +1291,9 @@ export class AgUiThreadRuntimeCore {
1127
1291
  return false;
1128
1292
  }
1129
1293
  let allResolved = true;
1294
+ const listed = listedPendingToolCallIds(assistant);
1130
1295
  for (const part of iterateToolCallParts(assistant.content)) {
1296
+ if (listed && !listed.has(part.toolCallId)) continue;
1131
1297
  if (!isResolvedToolCall(part)) {
1132
1298
  allResolved = false;
1133
1299
  break;
@@ -1140,6 +1306,7 @@ export class AgUiThreadRuntimeCore {
1140
1306
  ? {
1141
1307
  ...(current as ThreadAssistantMessage),
1142
1308
  status: { type: "complete" as const, reason: "unknown" as const },
1309
+ metadata: withoutPendingToolCallIds(current.metadata),
1143
1310
  }
1144
1311
  : current,
1145
1312
  );
@@ -1150,7 +1317,7 @@ export class AgUiThreadRuntimeCore {
1150
1317
  }
1151
1318
 
1152
1319
  private startResumeRun(messageId: string): void {
1153
- void this.startRun(messageId, this.lastRunConfig).catch((error) => {
1320
+ void this.startRun(messageId, this.state.lastRunConfig).catch((error) => {
1154
1321
  this.reportError(error);
1155
1322
  });
1156
1323
  }
@@ -1167,8 +1334,9 @@ export class AgUiThreadRuntimeCore {
1167
1334
  metadata: { ...message.metadata, modality },
1168
1335
  } as ThreadMessage;
1169
1336
  });
1170
- this.pendingA2uiResumeOwner = null;
1171
- this.pendingA2uiAction = undefined;
1337
+ this.dispatch({
1338
+ type: "a2uiContinuationsCleared",
1339
+ });
1172
1340
  this.assistantHistoryParents.clear();
1173
1341
 
1174
1342
  if (messages.length === 0) {
@@ -1178,6 +1346,19 @@ export class AgUiThreadRuntimeCore {
1178
1346
  let lastAppliedId: string | null = null;
1179
1347
  let hardReplace = false;
1180
1348
  const seen = new Set<string>();
1349
+ const retainToolResults = (
1350
+ existing: ThreadMessage | undefined,
1351
+ message: ThreadMessage,
1352
+ ): ThreadMessage =>
1353
+ existing?.role === "assistant" && message.role === "assistant"
1354
+ ? {
1355
+ ...message,
1356
+ content: this.preserveToolResults(
1357
+ existing.content,
1358
+ message.content,
1359
+ ),
1360
+ }
1361
+ : message;
1181
1362
 
1182
1363
  for (const message of messages) {
1183
1364
  if (seen.has(message.id)) continue;
@@ -1187,12 +1368,21 @@ export class AgUiThreadRuntimeCore {
1187
1368
  hardReplace = true;
1188
1369
  break;
1189
1370
  }
1190
- this.session.addOrUpdateMessage(expectedParentId, message);
1371
+ this.session.addOrUpdateMessage(
1372
+ expectedParentId,
1373
+ retainToolResults(existing?.message, message),
1374
+ );
1191
1375
  expectedParentId = message.id;
1192
1376
  lastAppliedId = message.id;
1193
1377
  }
1194
1378
 
1195
1379
  if (hardReplace) {
1380
+ const previous = new Map(
1381
+ this.getMessageRepository().messages.map(({ message }) => [
1382
+ message.id,
1383
+ message,
1384
+ ]),
1385
+ );
1196
1386
  this.session.clear();
1197
1387
  expectedParentId = null;
1198
1388
  lastAppliedId = null;
@@ -1200,35 +1390,57 @@ export class AgUiThreadRuntimeCore {
1200
1390
  for (const message of messages) {
1201
1391
  if (seen.has(message.id)) continue;
1202
1392
  seen.add(message.id);
1203
- this.session.addOrUpdateMessage(expectedParentId, message);
1393
+ this.session.addOrUpdateMessage(
1394
+ expectedParentId,
1395
+ retainToolResults(previous.get(message.id), message),
1396
+ );
1204
1397
  expectedParentId = message.id;
1205
1398
  lastAppliedId = message.id;
1206
1399
  }
1207
1400
  }
1208
1401
 
1209
1402
  this.session.resetHead(lastAppliedId);
1403
+ for (const messageId of seen) {
1404
+ this.maybeCompleteAfterToolResults(messageId);
1405
+ }
1210
1406
  }
1211
1407
 
1212
1408
  this.snapshotHistoryIds.clear();
1213
1409
  for (const { message } of this.getMessageRepository().messages) {
1214
1410
  this.snapshotHistoryIds.add(message.id);
1215
1411
  }
1216
- // MESSAGES_SNAPSHOT re-appends the active assistant, so a parked
1217
- // continuation whose target survived the snapshot is still answerable.
1218
- // Membership is the head branch, not the repository: a soft merge leaves
1219
- // the messages it dropped behind as off-branch nodes.
1220
- const resumeTarget = this.pendingResume?.messageId;
1221
- if (
1222
- resumeTarget !== undefined &&
1223
- !this.session.getMessages().some((message) => message.id === resumeTarget)
1224
- ) {
1225
- this.pendingResume = null;
1412
+ // A soft merge leaves dropped messages off branch; only selected
1413
+ // messages with resolved tool calls can still own a continuation.
1414
+ const pendingResume = this.state.pendingResume;
1415
+ if (pendingResume) {
1416
+ const selectedResultOwnerIds = new Set(
1417
+ this.session
1418
+ .getMessages()
1419
+ .filter(hasResolvedToolCalls)
1420
+ .map((message) => message.id),
1421
+ );
1422
+ const retained = pendingResume.messageIds.filter((id) =>
1423
+ selectedResultOwnerIds.has(id),
1424
+ );
1425
+ if (retained.length !== pendingResume.messageIds.length) {
1426
+ this.dispatch({ type: "resumeCleared" });
1427
+ for (const messageId of retained) {
1428
+ this.dispatch({
1429
+ type: "resumeDeferred",
1430
+ owner: pendingResume.owner,
1431
+ messageId,
1432
+ });
1433
+ }
1434
+ }
1226
1435
  }
1227
1436
  this.publishUpdate();
1228
1437
  }
1229
1438
 
1230
1439
  loadExternalState(state: ReadonlyJSONValue): void {
1231
- this.stateSnapshot = state;
1440
+ this.dispatch({
1441
+ type: "stateSnapshot",
1442
+ snapshot: state,
1443
+ });
1232
1444
  this.publishUpdate();
1233
1445
  }
1234
1446
 
@@ -1237,25 +1449,28 @@ export class AgUiThreadRuntimeCore {
1237
1449
  | ReadonlyJSONValue
1238
1450
  | ((prev: ReadonlyJSONValue | undefined) => ReadonlyJSONValue),
1239
1451
  ): void {
1240
- this.stateSnapshot =
1241
- typeof next === "function" ? next(this.stateSnapshot) : next;
1452
+ const snapshot =
1453
+ typeof next === "function" ? next(this.state.stateSnapshot) : next;
1454
+ this.dispatch({
1455
+ type: "stateSnapshot",
1456
+ snapshot,
1457
+ });
1242
1458
  this.publishUpdate();
1243
1459
  }
1244
1460
 
1245
1461
  resetState(): void {
1246
- this.stateSnapshot = undefined;
1462
+ this.dispatch({ type: "resetState" });
1247
1463
  this.publishUpdate();
1248
1464
  }
1249
1465
 
1250
1466
  resetThreadState(): void {
1251
- this.historyMutationGeneration++;
1467
+ this.dispatch({
1468
+ type: "historyMutationAdvanced",
1469
+ });
1252
1470
  const controller = this.abortController;
1253
1471
  const activeRunAgent = this.activeRunAgent;
1254
1472
 
1255
- this.stateSnapshot = undefined;
1256
- this.pendingResume = null;
1257
- this.pendingA2uiResumeOwner = null;
1258
- this.pendingA2uiAction = undefined;
1473
+ this.dispatch({ type: "resetThread" });
1259
1474
 
1260
1475
  if (controller) {
1261
1476
  this.abortController = null;
@@ -1278,6 +1493,7 @@ export class AgUiThreadRuntimeCore {
1278
1493
  runConfig?: RunConfig,
1279
1494
  resume?: ResumeDispatch,
1280
1495
  resumeStream?: ResumeStream,
1496
+ replayExistingMessages = false,
1281
1497
  ): Promise<void> {
1282
1498
  if (this.isThreadSwitching?.()) return;
1283
1499
  // A default AG-UI run supersedes the active run; the hook's opt-in message
@@ -1285,8 +1501,12 @@ export class AgUiThreadRuntimeCore {
1285
1501
  // its message; this covers the entry points that start a run without one.
1286
1502
  if (!this.supersedeActiveRun()) return;
1287
1503
 
1504
+ const generation = this.state.historyMutationGeneration;
1288
1505
  const normalizedRunConfig = runConfig ?? {};
1289
- this.lastRunConfig = normalizedRunConfig;
1506
+ this.dispatch({
1507
+ type: "runConfigRecorded",
1508
+ runConfig: normalizedRunConfig,
1509
+ });
1290
1510
  const parent =
1291
1511
  parentId === null ? undefined : this.session.tryGetMessage(parentId);
1292
1512
  const shouldEagerlyInsertAssistant =
@@ -1301,12 +1521,62 @@ export class AgUiThreadRuntimeCore {
1301
1521
  const runStartMessageIds = new Set(
1302
1522
  this.getMessageRepository().messages.map(({ message }) => message.id),
1303
1523
  );
1524
+ const selectedMessages = this.session.getMessages();
1525
+ const parentIndex = selectedMessages.findIndex(
1526
+ (message) => message.id === parentId,
1527
+ );
1528
+ let replayStart = parentIndex + 1;
1529
+ if (selectedMessages[parentIndex]?.role === "assistant") {
1530
+ replayStart = parentIndex;
1531
+ while (
1532
+ replayStart > 0 &&
1533
+ selectedMessages[replayStart - 1]?.role === "assistant"
1534
+ )
1535
+ replayStart--;
1536
+ }
1537
+ const replayMessageIds: string[] = [];
1538
+ if (replayExistingMessages && !resumeStream && parentIndex >= 0) {
1539
+ for (const message of selectedMessages.slice(replayStart)) {
1540
+ if (message.role !== "assistant") break;
1541
+ replayMessageIds.push(message.id);
1542
+ }
1543
+ }
1544
+ // Only request-delivered and producer-observed results suppress continuation.
1545
+ const accountedResultIds = new Set(
1546
+ historicalMessages.flatMap((message) =>
1547
+ message.role === "assistant"
1548
+ ? Array.from(iterateToolCallParts(message.content))
1549
+ .filter(isResolvedToolCall)
1550
+ .map((part) => part.toolCallId)
1551
+ : [],
1552
+ ),
1553
+ );
1554
+ const candidateContexts = new Map<string, ReplayContext>();
1555
+ for (const message of selectedMessages) {
1556
+ if (
1557
+ message.role !== "assistant" ||
1558
+ !replayMessageIds.includes(message.id)
1559
+ )
1560
+ continue;
1561
+ candidateContexts.set(message.id, {
1562
+ originalContent: message.content,
1563
+ accountedResultIds,
1564
+ admittedToolCallIds: actionableToolCallIds(message),
1565
+ merge: createReplayContentMerger(),
1566
+ });
1567
+ }
1568
+ let replayContexts = new Map<string, ReplayContext>();
1569
+ let lastReplayedIndex = -1;
1570
+ let replayEnded = replayMessageIds.length === 0;
1571
+ const replayTail = replayMessageIds.at(-1);
1304
1572
  const joinStrategy = this.joinStrategy;
1305
1573
  const hasRunUserBoundary = parent?.message.role === "user" && !resume;
1306
1574
 
1307
1575
  let pendingError: Error | null = null;
1308
1576
  const assistantParentId = parent ? parentId : this.session.headId;
1309
1577
  let assistantMessageId: string | undefined;
1578
+ let snapshotAssistantId: string | undefined;
1579
+ let replayInterruptMessageId: string | undefined;
1310
1580
  // A snapshot the preserve gate declines still evicts the in-flight
1311
1581
  // assistant; recreating under the cached id on the next content-bearing
1312
1582
  // emit keeps both the stream and the message identity. Status-only emits,
@@ -1315,6 +1585,10 @@ export class AgUiThreadRuntimeCore {
1315
1585
  // resurrecting for custom-event data would leave a trailing data-only
1316
1586
  // duplicate.
1317
1587
  let assistantCollided = false;
1588
+ const heldMessages = new Map<string, Set<string>>();
1589
+ let frameOwnerIds = new Set<string>();
1590
+ let restrictedPendingToolCallIds: ReadonlySet<string> | undefined;
1591
+ let settlingRun = false;
1318
1592
  const ensureAssistant = (allowRecreate = false): string => {
1319
1593
  const cached = assistantMessageId;
1320
1594
  if (cached !== undefined && this.session.tryGetMessage(cached))
@@ -1324,10 +1598,16 @@ export class AgUiThreadRuntimeCore {
1324
1598
  }
1325
1599
  const repositoryHeadId = this.session.headId;
1326
1600
  const shouldUseSelectedParent =
1327
- cached === undefined ||
1328
- (shouldEagerlyInsertAssistant &&
1329
- (repositoryHeadId === null ||
1330
- runStartMessageIds.has(repositoryHeadId)));
1601
+ replayContexts.size === 0 &&
1602
+ (cached === undefined ||
1603
+ (shouldEagerlyInsertAssistant &&
1604
+ (repositoryHeadId === null ||
1605
+ runStartMessageIds.has(repositoryHeadId)))) &&
1606
+ // Replay candidates defer the placeholder; honor snapshots that advanced
1607
+ // the head before the producer identified its first assistant message.
1608
+ (replayMessageIds.length === 0 ||
1609
+ repositoryHeadId === null ||
1610
+ runStartMessageIds.has(repositoryHeadId));
1331
1611
  // Branch runs keep their selected parent unless the snapshot advanced to
1332
1612
  // a message introduced during this run.
1333
1613
  const parentId =
@@ -1342,35 +1622,348 @@ export class AgUiThreadRuntimeCore {
1342
1622
  return created;
1343
1623
  };
1344
1624
 
1345
- if (shouldEagerlyInsertAssistant) ensureAssistant();
1625
+ if (shouldEagerlyInsertAssistant && replayEnded) ensureAssistant();
1626
+
1627
+ const settleHeldMessages = (terminalStatus: MessageStatus): string[] => {
1628
+ const pendingMessages: string[] = [];
1629
+ const producerPending = restrictedPendingToolCallIds;
1630
+ const canWaitForTools =
1631
+ terminalStatus.type === "complete" ||
1632
+ terminalStatus.type === "requires-action";
1633
+ if (producerPending && canWaitForTools) {
1634
+ for (const message of this.getTrailingAssistantTurn()) {
1635
+ const replay = replayContexts.get(message.id);
1636
+ if (
1637
+ message.id === assistantMessageId ||
1638
+ heldMessages.has(message.id) ||
1639
+ !replay ||
1640
+ (message.status?.type === "requires-action" &&
1641
+ message.status.reason === "interrupt")
1642
+ )
1643
+ continue;
1644
+ if (
1645
+ replay.admittedToolCallIds.size > 0 ||
1646
+ actionableToolCallIds(message).size > 0 ||
1647
+ Array.from(iterateToolCallParts(message.content)).some(
1648
+ (part) =>
1649
+ producerPending.has(part.toolCallId) &&
1650
+ !accountedResultIds.has(part.toolCallId),
1651
+ )
1652
+ )
1653
+ heldMessages.set(message.id, new Set());
1654
+ }
1655
+ }
1656
+ for (const [messageId, openToolCallIds] of heldMessages) {
1657
+ const existing = this.session.tryGetMessage(messageId);
1658
+ if (existing)
1659
+ this.markPendingAssistantHistory(messageId, existing.parentId);
1660
+ let requiresToolResult = false;
1661
+ const updated = this.session.updateMessage(messageId, (message) => {
1662
+ if (message.role !== "assistant") return message;
1663
+ const assistant = message as ThreadAssistantMessage;
1664
+ const pendingToolCallIds = producerPending
1665
+ ? Array.from(iterateToolCallParts(assistant.content))
1666
+ .filter(
1667
+ (part) =>
1668
+ producerPending.has(part.toolCallId) &&
1669
+ (!isResolvedToolCall(part) ||
1670
+ openToolCallIds.has(part.toolCallId) ||
1671
+ (replayContexts.has(messageId) &&
1672
+ !accountedResultIds.has(part.toolCallId))),
1673
+ )
1674
+ .map((part) => part.toolCallId)
1675
+ : Array.from(openToolCallIds);
1676
+ const replay = replayContexts.get(messageId);
1677
+ if (replay) replay.admittedToolCallIds = new Set(pendingToolCallIds);
1678
+ const status: MessageStatus = canWaitForTools
1679
+ ? pendingToolCallIds.length > 0
1680
+ ? { type: "requires-action", reason: "tool-calls" }
1681
+ : { type: "complete", reason: "unknown" }
1682
+ : terminalStatus;
1683
+ requiresToolResult = status.type === "requires-action";
1684
+ const metadata = withoutPendingToolCallIds(assistant.metadata);
1685
+ return {
1686
+ ...assistant,
1687
+ status,
1688
+ metadata:
1689
+ status.type === "requires-action" &&
1690
+ (restrictedPendingToolCallIds || replayContexts.has(messageId))
1691
+ ? withPendingToolCallIds(metadata, pendingToolCallIds)
1692
+ : metadata,
1693
+ };
1694
+ });
1695
+ if (updated) {
1696
+ this.publishUpdate();
1697
+ this.persistAssistantHistory(messageId);
1698
+ if (requiresToolResult) {
1699
+ pendingMessages.push(messageId);
1700
+ }
1701
+ }
1702
+ }
1703
+ heldMessages.clear();
1704
+ return pendingMessages;
1705
+ };
1706
+
1707
+ const accountProducerResults = (
1708
+ content: ThreadAssistantMessage["content"],
1709
+ ) => {
1710
+ for (const part of iterateToolCallParts(content))
1711
+ if (isResolvedToolCall(part)) accountedResultIds.add(part.toolCallId);
1712
+ };
1713
+ const reconcileReplaySnapshot: SnapshotMessageReconciler = (
1714
+ snapshot,
1715
+ previous,
1716
+ ) => {
1717
+ // Observe the producer before preserving any local frontend results.
1718
+ accountProducerResults(snapshot.content);
1719
+ const replay = candidateContexts.get(snapshot.id);
1720
+ if (!replay) return snapshot;
1721
+ replay.admittedToolCallIds = actionableToolCallIds(snapshot);
1722
+ if (heldMessages.has(snapshot.id))
1723
+ heldMessages.set(snapshot.id, new Set(replay.admittedToolCallIds));
1724
+ if (!replayContexts.has(snapshot.id)) return snapshot;
1725
+ return previous
1726
+ ? {
1727
+ ...snapshot,
1728
+ content: this.preserveToolResults(
1729
+ previous.content,
1730
+ snapshot.content,
1731
+ replay.originalContent,
1732
+ ),
1733
+ }
1734
+ : snapshot;
1735
+ };
1346
1736
 
1347
1737
  const applyUpdate = (update: ChatModelRunResult) => {
1738
+ if (
1739
+ !assistantCollided &&
1740
+ replayContexts.size &&
1741
+ update.content !== undefined
1742
+ )
1743
+ accountProducerResults(
1744
+ update.content as ThreadAssistantMessage["content"],
1745
+ );
1746
+ let ownerParts:
1747
+ | Map<string, ThreadAssistantMessage["content"][number][]>
1748
+ | undefined;
1749
+ let contentOwnerIds: Set<string> | undefined;
1348
1750
  const hasStreamContent =
1349
1751
  Array.isArray(update.content) &&
1350
1752
  update.content.some((part) => part.type !== "data");
1753
+ if (!replayEnded && assistantMessageId === undefined) {
1754
+ if (!update.status || update.status.type === "running") return;
1755
+ // No matching producer identity arrived: retain native regeneration.
1756
+ replayEnded = true;
1757
+ if (
1758
+ replayContexts.size === 0 &&
1759
+ snapshotAssistantId !== undefined &&
1760
+ this.session.headId === snapshotAssistantId &&
1761
+ !hasStreamContent
1762
+ ) {
1763
+ if (update.status.type !== "complete") {
1764
+ this.updateAssistantMessage(snapshotAssistantId, {
1765
+ status: update.status,
1766
+ ...(update.metadata ? { metadata: update.metadata } : {}),
1767
+ });
1768
+ }
1769
+ return;
1770
+ }
1771
+ }
1772
+ const streamMessageId = assistantMessageId;
1773
+ let primaryMessageId = streamMessageId;
1774
+ const terminalStatus = update.status;
1775
+ const replayInterrupt =
1776
+ replayContexts.size > 0 &&
1777
+ terminalStatus?.type === "requires-action" &&
1778
+ terminalStatus.reason === "interrupt";
1779
+ if (
1780
+ streamMessageId !== undefined &&
1781
+ !assistantCollided &&
1782
+ replayContexts.size > 0 &&
1783
+ (update.content !== undefined || replayInterrupt)
1784
+ ) {
1785
+ const owners = this.getTrailingAssistantTurn().filter((message) =>
1786
+ replayContexts.has(message.id),
1787
+ );
1788
+ // Buffered calls keep their owners when a later text frame arrives.
1789
+ const grouped =
1790
+ update.content !== undefined
1791
+ ? groupReplayToolContent(owners, update.content, streamMessageId)
1792
+ : new Map([[streamMessageId, [...(update.content ?? [])]]]);
1793
+ if (update.content !== undefined) {
1794
+ const currentOwnerIds = new Set(grouped.keys());
1795
+ for (const ownerId of frameOwnerIds)
1796
+ grouped.set(ownerId, grouped.get(ownerId) ?? []);
1797
+ contentOwnerIds = new Set(grouped.keys());
1798
+ frameOwnerIds = currentOwnerIds;
1799
+ }
1800
+ ownerParts = grouped;
1801
+ if (replayInterrupt) {
1802
+ const interrupts = (
1803
+ update.metadata?.custom?.[AG_UI_METADATA_NAMESPACE] as
1804
+ | AgUiCustomMetadata
1805
+ | undefined
1806
+ )?.interrupts;
1807
+ const approvals = projectAgUiToolApprovals(interrupts);
1808
+ for (const owner of owners) {
1809
+ if (
1810
+ Array.from(iterateToolCallParts(owner.content)).some((part) =>
1811
+ approvals.has(part.toolCallId),
1812
+ )
1813
+ )
1814
+ grouped.set(owner.id, grouped.get(owner.id) ?? []);
1815
+ }
1816
+ const anchor = owners.find(
1817
+ (message) =>
1818
+ projectAgUiToolApprovals(
1819
+ interrupts,
1820
+ new Set(
1821
+ Array.from(
1822
+ iterateToolCallParts([
1823
+ ...message.content,
1824
+ ...(grouped.get(message.id) ?? []),
1825
+ ]),
1826
+ ).map((part) => part.toolCallId),
1827
+ ),
1828
+ ).size > 0,
1829
+ );
1830
+ if (anchor) {
1831
+ primaryMessageId = anchor.id;
1832
+ replayInterruptMessageId = anchor.id;
1833
+ grouped.set(anchor.id, grouped.get(anchor.id) ?? []);
1834
+ }
1835
+ }
1836
+ }
1837
+ const pendingMessages =
1838
+ settlingRun && terminalStatus && terminalStatus.type !== "running"
1839
+ ? settleHeldMessages(terminalStatus)
1840
+ : [];
1841
+ const frameMessageId = ensureAssistant(hasStreamContent);
1842
+ const messageId = primaryMessageId ?? frameMessageId;
1843
+ let primaryUpdate = update;
1844
+ if (ownerParts) {
1845
+ const interrupt =
1846
+ terminalStatus?.type === "requires-action" &&
1847
+ terminalStatus.reason === "interrupt";
1848
+ const ownerStatus: MessageStatus | undefined =
1849
+ terminalStatus?.type === "requires-action"
1850
+ ? { type: "complete", reason: "unknown" }
1851
+ : terminalStatus;
1852
+ for (const [ownerId, content] of ownerParts) {
1853
+ const existing = this.session.tryGetMessage(ownerId);
1854
+ if (existing)
1855
+ this.markPendingAssistantHistory(ownerId, existing.parentId);
1856
+ if (ownerId === messageId) continue;
1857
+ this.updateAssistantMessage(
1858
+ ownerId,
1859
+ {
1860
+ ...(contentOwnerIds?.has(ownerId) ? { content } : {}),
1861
+ ...(ownerStatus ? { status: ownerStatus } : {}),
1862
+ },
1863
+ replayContexts.get(ownerId),
1864
+ interrupt ? new Set<string>() : restrictedPendingToolCallIds,
1865
+ );
1866
+ }
1867
+ const { content: _content, ...controlUpdate } = update;
1868
+ const content = ownerParts.get(messageId) ?? [];
1869
+ primaryUpdate = {
1870
+ ...controlUpdate,
1871
+ ...(contentOwnerIds?.has(messageId) ? { content } : {}),
1872
+ ...(ownerStatus && !interrupt && replayContexts.has(messageId)
1873
+ ? { status: ownerStatus }
1874
+ : {}),
1875
+ };
1876
+ }
1351
1877
  const resolved = this.updateAssistantMessage(
1352
- ensureAssistant(hasStreamContent),
1353
- update,
1878
+ messageId,
1879
+ primaryUpdate,
1880
+ replayContexts.get(messageId),
1881
+ restrictedPendingToolCallIds,
1354
1882
  );
1355
- if (resolved !== assistantMessageId) {
1883
+ if (messageId === frameMessageId && resolved !== assistantMessageId) {
1356
1884
  assistantMessageId = resolved;
1357
1885
  }
1886
+ for (const messageId of pendingMessages) {
1887
+ this.maybeResumeAfterToolResults(
1888
+ messageId,
1889
+ replayMessageIds.includes(messageId) ? accountedResultIds : undefined,
1890
+ );
1891
+ }
1358
1892
  };
1359
1893
 
1360
1894
  const adoptServerMessageId = (
1361
1895
  serverId: string,
1362
1896
  startNewMessage: boolean,
1897
+ openToolCallIds: string[] = [],
1363
1898
  ) => {
1899
+ const existing = this.session.tryGetMessage(serverId);
1900
+ const replayIndex = replayMessageIds.indexOf(serverId);
1901
+ const adoptsReplay =
1902
+ !replayEnded &&
1903
+ !assistantCollided &&
1904
+ replayIndex > lastReplayedIndex &&
1905
+ existing?.message.role === "assistant" &&
1906
+ this.session.getMessages().some((message) => message.id === serverId);
1907
+ if (adoptsReplay && replayContexts.size === 0) {
1908
+ replayContexts = candidateContexts;
1909
+ const selected = this.session.getMessages();
1910
+ let tailId = serverId;
1911
+ for (const message of selected.slice(
1912
+ selected.findIndex((message) => message.id === serverId) + 1,
1913
+ )) {
1914
+ if (message.role !== "assistant") break;
1915
+ tailId = message.id;
1916
+ }
1917
+ this.session.selectHead(tailId);
1918
+ }
1364
1919
  if (startNewMessage && assistantMessageId !== undefined) {
1365
- applyUpdate({ status: { type: "complete", reason: "unknown" } });
1920
+ let hasReplayCallsToHold = false;
1921
+ const replay = replayContexts.get(assistantMessageId);
1922
+ if (replay) {
1923
+ const current =
1924
+ this.session.tryGetMessage(assistantMessageId)?.message;
1925
+ if (current?.role === "assistant") {
1926
+ const retainedPending = Array.from(
1927
+ iterateToolCallParts(current.content),
1928
+ ).filter(
1929
+ (part) =>
1930
+ !isResolvedToolCall(part) ||
1931
+ (replay.admittedToolCallIds.has(part.toolCallId) &&
1932
+ !accountedResultIds.has(part.toolCallId)),
1933
+ );
1934
+ hasReplayCallsToHold = retainedPending.length > 0;
1935
+ openToolCallIds = [
1936
+ ...new Set([
1937
+ ...openToolCallIds,
1938
+ ...retainedPending
1939
+ .filter((part) =>
1940
+ replay.admittedToolCallIds.has(part.toolCallId),
1941
+ )
1942
+ .map((part) => part.toolCallId),
1943
+ ]),
1944
+ ];
1945
+ }
1946
+ }
1947
+ if (openToolCallIds.length > 0 || hasReplayCallsToHold) {
1948
+ heldMessages.set(assistantMessageId, new Set(openToolCallIds));
1949
+ } else {
1950
+ applyUpdate({ status: { type: "complete", reason: "unknown" } });
1951
+ }
1366
1952
  const previousId = assistantMessageId;
1367
1953
  assistantMessageId = undefined;
1368
- if (this.session.tryGetMessage(previousId)) {
1954
+ if (!adoptsReplay && this.session.tryGetMessage(previousId)) {
1369
1955
  const created = this.insertAssistantPlaceholder(previousId);
1370
1956
  assistantMessageId = created;
1371
1957
  this.markPendingAssistantHistory(created, previousId);
1372
1958
  }
1373
1959
  }
1960
+ if (adoptsReplay) {
1961
+ assistantMessageId = serverId;
1962
+ lastReplayedIndex = replayIndex;
1963
+ this.markPendingAssistantHistory(serverId, existing.parentId);
1964
+ return;
1965
+ }
1966
+ replayEnded = true;
1374
1967
  const placeholder = ensureAssistant(true);
1375
1968
  if (placeholder === serverId) return;
1376
1969
  const reassigned = this.reassignAssistantId(placeholder, serverId);
@@ -1404,22 +1997,85 @@ export class AgUiThreadRuntimeCore {
1404
1997
  onServerMessageId: (serverId) => {
1405
1998
  adoptServerMessageId(serverId, false);
1406
1999
  },
1407
- onTextMessageStart: (serverId) => adoptServerMessageId(serverId, true),
2000
+ onTextMessageStart: (serverId, openToolCallIds) => {
2001
+ const owners = this.getTrailingAssistantTurn().filter((message) =>
2002
+ replayContexts.has(message.id),
2003
+ );
2004
+ const toolOwners = indexReplayToolOwners(owners);
2005
+ const frameOpenIds = openToolCallIds.filter((id) => {
2006
+ const ownerId = toolOwners.get(id);
2007
+ if (!ownerId || ownerId === assistantMessageId || assistantCollided)
2008
+ return true;
2009
+ const held = heldMessages.get(ownerId) ?? new Set<string>();
2010
+ held.add(id);
2011
+ heldMessages.set(ownerId, held);
2012
+ return false;
2013
+ });
2014
+ // The native accumulator just reset; prior frame contributions are
2015
+ // retained history, while removals within this frame remain authoritative.
2016
+ for (const ownerId of frameOwnerIds) {
2017
+ const replay = replayContexts.get(ownerId);
2018
+ if (replay) replay.merge = createReplayContentMerger();
2019
+ }
2020
+ frameOwnerIds.clear();
2021
+ adoptServerMessageId(serverId, true, frameOpenIds);
2022
+ },
1408
2023
  });
1409
2024
  let runFinished = false;
1410
2025
  const dispatch = (event: AgUiEvent) => {
1411
2026
  if (this.abortController !== abortController) return;
1412
- if (event.type === "RUN_FINISHED") runFinished = true;
1413
- const nextAssistantMessageId = this.handleEvent(
1414
- aggregator,
1415
- event,
1416
- assistantMessageId,
1417
- joinStrategy,
1418
- runStartMessageIds,
1419
- hasRunUserBoundary,
1420
- );
1421
- if (event.type === "MESSAGES_SNAPSHOT") {
1422
- assistantMessageId = nextAssistantMessageId;
2027
+ if (event.type === "RUN_FINISHED") {
2028
+ runFinished = true;
2029
+ if (event.outcome?.type === "interrupt")
2030
+ replayInterruptMessageId = assistantMessageId ?? replayTail;
2031
+ const ids =
2032
+ event.outcome?.type === "success"
2033
+ ? event.outcome.pendingToolCallIds
2034
+ : undefined;
2035
+ restrictedPendingToolCallIds =
2036
+ ids === undefined ? undefined : new Set(ids);
2037
+ }
2038
+ if (event.type === "TOOL_CALL_RESULT") {
2039
+ if (!assistantCollided && candidateContexts.size)
2040
+ accountedResultIds.add(event.toolCallId);
2041
+ for (const openToolCallIds of heldMessages.values()) {
2042
+ openToolCallIds.delete(event.toolCallId);
2043
+ }
2044
+ }
2045
+ settlingRun =
2046
+ event.type === "RUN_FINISHED" ||
2047
+ event.type === "RUN_ERROR" ||
2048
+ event.type === "RUN_CANCELLED";
2049
+ const beforeSnapshot =
2050
+ event.type === "MESSAGES_SNAPSHOT" ? this.session.getMessages() : [];
2051
+ try {
2052
+ const nextAssistantMessageId = this.handleEvent(
2053
+ aggregator,
2054
+ event,
2055
+ assistantMessageId,
2056
+ joinStrategy,
2057
+ runStartMessageIds,
2058
+ hasRunUserBoundary,
2059
+ candidateContexts.size ? reconcileReplaySnapshot : undefined,
2060
+ );
2061
+ if (event.type === "MESSAGES_SNAPSHOT") {
2062
+ for (const message of beforeSnapshot) {
2063
+ const context = replayContexts.get(message.id);
2064
+ const current = this.session.tryGetMessage(message.id)?.message;
2065
+ if (context && current?.content !== message.content) {
2066
+ context.merge = createReplayContentMerger();
2067
+ frameOwnerIds.delete(message.id);
2068
+ }
2069
+ }
2070
+ assistantMessageId = nextAssistantMessageId;
2071
+ if (!replayEnded && assistantMessageId === undefined) {
2072
+ const tail = this.session.getMessages().at(-1);
2073
+ snapshotAssistantId =
2074
+ tail?.role === "assistant" ? tail.id : undefined;
2075
+ }
2076
+ }
2077
+ } finally {
2078
+ settlingRun = false;
1423
2079
  }
1424
2080
  };
1425
2081
 
@@ -1482,9 +2138,9 @@ export class AgUiThreadRuntimeCore {
1482
2138
  },
1483
2139
  });
1484
2140
  try {
1485
- (runAgentInstance as any).messages = input.messages;
1486
- (runAgentInstance as any).threadId = input.threadId;
1487
- (runAgentInstance as any).state = input.state ?? null;
2141
+ runAgentInstance.messages = input.messages;
2142
+ runAgentInstance.threadId = input.threadId;
2143
+ runAgentInstance.state = input.state ?? null;
1488
2144
  } catch {
1489
2145
  // ignore
1490
2146
  }
@@ -1512,21 +2168,78 @@ export class AgUiThreadRuntimeCore {
1512
2168
  throw err;
1513
2169
  }
1514
2170
 
2171
+ const pending = replayInterruptMessageId
2172
+ ? this.getPendingInterrupts()
2173
+ : null;
2174
+ if (
2175
+ pending &&
2176
+ pending.messageId === replayInterruptMessageId &&
2177
+ replayContexts.has(pending.messageId) &&
2178
+ !abortSignal.aborted &&
2179
+ this.abortController === null &&
2180
+ generation === this.state.historyMutationGeneration &&
2181
+ !this.isThreadSwitching?.()
2182
+ ) {
2183
+ const message = this.session.tryGetMessage(pending.messageId)?.message;
2184
+ const responses =
2185
+ message?.role === "assistant"
2186
+ ? buildToolApprovalResume(message.content, pending.interrupts)
2187
+ : null;
2188
+ if (responses) {
2189
+ this.clearDeferredContinuations(abortController);
2190
+ try {
2191
+ await this.submitInterruptResponses(responses);
2192
+ } catch (error) {
2193
+ const err = error instanceof Error ? error : new Error(String(error));
2194
+ if (!this.reportedErrors.has(err)) {
2195
+ this.reportError(err);
2196
+ this.reportedErrors.add(err);
2197
+ }
2198
+ throw err;
2199
+ }
2200
+ return;
2201
+ }
2202
+ }
2203
+
1515
2204
  // A tool result that landed before the run settled deferred its
1516
2205
  // continuation here so a second run never overlaps the first.
1517
- if (this.pendingResume?.owner === abortController) {
1518
- const { messageId } = this.pendingResume;
1519
- this.pendingResume = null;
1520
- if (!abortSignal.aborted) {
1521
- this.startResumeRun(messageId);
2206
+ if (this.state.pendingResume?.owner === abortController) {
2207
+ const { messageIds } = this.state.pendingResume;
2208
+ this.dispatch({ type: "resumeCleared" });
2209
+ if (
2210
+ !abortSignal.aborted &&
2211
+ !this.getPendingInterrupts() &&
2212
+ !this.getPendingToolCalls()
2213
+ ) {
2214
+ const turn = this.getTrailingAssistantTurn();
2215
+ if (
2216
+ turn.some((message) => {
2217
+ if (!messageIds.includes(message.id)) return false;
2218
+ const replay = replayContexts.get(message.id);
2219
+ if (!replay) return hasResolvedToolCalls(message);
2220
+ return Array.from(iterateToolCallParts(message.content)).some(
2221
+ (part) =>
2222
+ isResolvedToolCall(part) &&
2223
+ replay.admittedToolCallIds.has(part.toolCallId) &&
2224
+ !accountedResultIds.has(part.toolCallId),
2225
+ );
2226
+ })
2227
+ ) {
2228
+ const headId = turn.at(-1)?.id;
2229
+ if (headId !== undefined) this.startResumeRun(headId);
2230
+ }
1522
2231
  }
1523
2232
  }
1524
2233
 
1525
- if (this.pendingA2uiResumeOwner === abortController) {
1526
- this.pendingA2uiResumeOwner = null;
1527
- if (!abortSignal.aborted && this.pendingA2uiAction !== undefined) {
2234
+ if (this.state.pendingA2uiResumeOwner === abortController) {
2235
+ this.dispatch({
2236
+ type: "a2uiResumeCleared",
2237
+ });
2238
+ if (!abortSignal.aborted && this.state.pendingA2uiAction !== undefined) {
1528
2239
  if (this.getPendingInterrupts()) {
1529
- this.pendingA2uiAction = undefined;
2240
+ this.dispatch({
2241
+ type: "a2uiActionCleared",
2242
+ });
1530
2243
  this.logger.debug(
1531
2244
  "[agui] sendA2uiAction: pending interrupts, dropping action",
1532
2245
  );
@@ -1537,13 +2250,17 @@ export class AgUiThreadRuntimeCore {
1537
2250
  if (parentId !== null) {
1538
2251
  this.startResumeRun(parentId);
1539
2252
  } else {
1540
- this.pendingA2uiAction = undefined;
2253
+ this.dispatch({
2254
+ type: "a2uiActionCleared",
2255
+ });
1541
2256
  this.logger.debug(
1542
2257
  "[agui] sendA2uiAction: no messages to resume, dropping action",
1543
2258
  );
1544
2259
  }
1545
2260
  } else {
1546
- this.pendingA2uiAction = undefined;
2261
+ this.dispatch({
2262
+ type: "a2uiActionCleared",
2263
+ });
1547
2264
  }
1548
2265
  }
1549
2266
  }
@@ -1562,8 +2279,9 @@ export class AgUiThreadRuntimeCore {
1562
2279
  getAssistantMessageId: () => string | undefined;
1563
2280
  },
1564
2281
  ): Promise<Error | undefined> {
1565
- this.pendingA2uiAction = undefined;
1566
- this.pendingA2uiResumeOwner = null;
2282
+ this.dispatch({
2283
+ type: "a2uiContinuationsCleared",
2284
+ });
1567
2285
  const assistantId = ctx.ensureAssistant();
1568
2286
  const currentId = () => ctx.getAssistantMessageId() ?? assistantId;
1569
2287
  const options: ChatModelRunOptions = {
@@ -1614,8 +2332,10 @@ export class AgUiThreadRuntimeCore {
1614
2332
  historyMessages: readonly ThreadMessage[] | undefined,
1615
2333
  resume?: ResumeDispatch,
1616
2334
  ) {
1617
- const pendingA2uiAction = this.pendingA2uiAction;
1618
- this.pendingA2uiAction = undefined;
2335
+ const pendingA2uiAction = this.state.pendingA2uiAction;
2336
+ this.dispatch({
2337
+ type: "a2uiActionCleared",
2338
+ });
1619
2339
  const threadId = this.agent.threadId || "main";
1620
2340
  const messages = toAgUiMessages(
1621
2341
  this.selectRunMessages(
@@ -1635,7 +2355,7 @@ export class AgUiThreadRuntimeCore {
1635
2355
  const input = {
1636
2356
  threadId,
1637
2357
  runId,
1638
- state: this.stateSnapshot ?? null,
2358
+ state: this.state.stateSnapshot ?? null,
1639
2359
  messages,
1640
2360
  tools: toAgUiTools(context?.tools),
1641
2361
  context: [
@@ -1678,17 +2398,20 @@ export class AgUiThreadRuntimeCore {
1678
2398
  }
1679
2399
 
1680
2400
  private clearDeferredContinuations(owner: AbortController): void {
1681
- if (this.pendingResume?.owner === owner) {
1682
- this.pendingResume = null;
2401
+ if (this.state.pendingResume?.owner === owner) {
2402
+ this.dispatch({ type: "resumeCleared" });
1683
2403
  }
1684
- if (this.pendingA2uiResumeOwner === owner) {
1685
- this.pendingA2uiResumeOwner = null;
1686
- this.pendingA2uiAction = undefined;
2404
+ if (this.state.pendingA2uiResumeOwner === owner) {
2405
+ this.dispatch({
2406
+ type: "a2uiContinuationsCleared",
2407
+ });
1687
2408
  }
1688
2409
  }
1689
2410
 
1690
2411
  private setRunning(running: boolean) {
1691
- this.isRunningFlag = running;
2412
+ this.dispatch({
2413
+ type: running ? "runStarted" : "runFinished",
2414
+ });
1692
2415
  this.publishUpdate();
1693
2416
  }
1694
2417
 
@@ -1710,7 +2433,7 @@ export class AgUiThreadRuntimeCore {
1710
2433
  status: { type: "running" },
1711
2434
  content: [],
1712
2435
  metadata: {
1713
- unstable_state: this.stateSnapshot ?? null,
2436
+ unstable_state: this.state.stateSnapshot ?? null,
1714
2437
  unstable_annotations: [],
1715
2438
  unstable_data: [],
1716
2439
  steps: [],
@@ -1768,22 +2491,18 @@ export class AgUiThreadRuntimeCore {
1768
2491
  // An in-flight write completes under the id it was started with, so the
1769
2492
  // rename moves the chain entry and transfers the completion mark; without
1770
2493
  // this the resolve records the dead id and the live one appends again.
1771
- const pendingWrite = this.historyWrites.get(oldId);
1772
- if (pendingWrite) {
1773
- this.historyWrites.delete(oldId);
1774
- if (!collidesWithExisting) {
1775
- this.historyWrites.set(newId, pendingWrite);
1776
- const settle = () => {
1777
- if (this.historyWrites.get(newId) === pendingWrite) {
1778
- this.historyWrites.delete(newId);
1779
- }
1780
- };
1781
- void pendingWrite.then(() => {
2494
+ const pendingWrite = this.historyWrites.move(
2495
+ oldId,
2496
+ collidesWithExisting ? undefined : newId,
2497
+ );
2498
+ if (pendingWrite && !collidesWithExisting) {
2499
+ void pendingWrite.then(
2500
+ () => {
1782
2501
  this.persistedHistoryIds.delete(oldId);
1783
2502
  this.persistedHistoryIds.add(newId);
1784
- settle();
1785
- }, settle);
1786
- }
2503
+ },
2504
+ () => {},
2505
+ );
1787
2506
  }
1788
2507
 
1789
2508
  this.publishUpdate();
@@ -1793,22 +2512,113 @@ export class AgUiThreadRuntimeCore {
1793
2512
  private updateAssistantMessage(
1794
2513
  messageId: string,
1795
2514
  update: ChatModelRunResult,
2515
+ replay?: ReplayContext,
2516
+ pendingToolCallIds?: ReadonlySet<string>,
1796
2517
  ): string {
1797
2518
  let latestStatus: MessageStatus | undefined;
1798
2519
  const touched = this.session.updateMessage(messageId, (message) => {
1799
2520
  if (message.role !== "assistant") return message;
1800
2521
  const assistant = message as ThreadAssistantMessage;
1801
- const metadata = update.metadata
2522
+ const mergedMetadata = update.metadata
1802
2523
  ? this.mergeAssistantMetadata(assistant.metadata, update.metadata)
1803
2524
  : assistant.metadata;
1804
2525
  latestStatus = update.status ?? assistant.status;
1805
- const content =
2526
+ let content =
1806
2527
  update.content !== undefined
1807
- ? this.preserveToolResults(
1808
- assistant.content,
1809
- update.content as ThreadAssistantMessage["content"],
1810
- )
2528
+ ? replay
2529
+ ? replay.merge(
2530
+ assistant.content,
2531
+ update.content as ThreadAssistantMessage["content"],
2532
+ )
2533
+ : this.preserveToolResults(
2534
+ assistant.content,
2535
+ update.content as ThreadAssistantMessage["content"],
2536
+ )
1811
2537
  : assistant.content;
2538
+ const calls = replay ? Array.from(iterateToolCallParts(content)) : [];
2539
+ // A retained historical call needs existing or current stream authority.
2540
+ const pending =
2541
+ pendingToolCallIds ??
2542
+ (replay
2543
+ ? new Set([
2544
+ ...replay.admittedToolCallIds,
2545
+ ...Array.from(iterateToolCallParts(update.content ?? []))
2546
+ .filter((part) => !isResolvedToolCall(part))
2547
+ .map((part) => part.toolCallId),
2548
+ ])
2549
+ : undefined);
2550
+ if (replay && latestStatus?.type !== "running" && pending)
2551
+ replay.admittedToolCallIds = pending;
2552
+ if (replay) {
2553
+ // Project gates against the content the user can actually answer.
2554
+ const interrupts =
2555
+ latestStatus?.type === "requires-action" &&
2556
+ latestStatus.reason === "interrupt"
2557
+ ? (
2558
+ mergedMetadata.custom[AG_UI_METADATA_NAMESPACE] as
2559
+ | AgUiCustomMetadata
2560
+ | undefined
2561
+ )?.interrupts
2562
+ : undefined;
2563
+ const approvals = projectAgUiToolApprovals(
2564
+ interrupts,
2565
+ new Set(calls.map((part) => part.toolCallId)),
2566
+ );
2567
+ const originalCalls = new Map(
2568
+ Array.from(iterateToolCallParts(replay.originalContent), (part) => [
2569
+ part.toolCallId,
2570
+ part,
2571
+ ]),
2572
+ );
2573
+ content = mapToolCallPartsDeep(content, (part) => {
2574
+ const approval = approvals.get(part.toolCallId);
2575
+ if (approval)
2576
+ return {
2577
+ ...part,
2578
+ approval:
2579
+ preserveToolApprovalDecision(
2580
+ originalCalls.get(part.toolCallId),
2581
+ { ...part, approval },
2582
+ ) ?? approval,
2583
+ };
2584
+ if (
2585
+ !part.approval ||
2586
+ part.approval.approved !== undefined ||
2587
+ part.approval.resolution
2588
+ )
2589
+ return part;
2590
+ const { approval: _approval, ...withoutApproval } = part;
2591
+ return withoutApproval;
2592
+ }).content;
2593
+ }
2594
+ if (replay && latestStatus?.type === "complete") {
2595
+ const listed =
2596
+ pending ??
2597
+ listedPendingToolCallIds({
2598
+ ...assistant,
2599
+ metadata: mergedMetadata,
2600
+ });
2601
+ if (
2602
+ calls.some(
2603
+ (part) =>
2604
+ (!isResolvedToolCall(part) ||
2605
+ !replay.accountedResultIds.has(part.toolCallId)) &&
2606
+ (!listed || listed.has(part.toolCallId)),
2607
+ )
2608
+ )
2609
+ latestStatus = { type: "requires-action", reason: "tool-calls" };
2610
+ }
2611
+ const admission =
2612
+ pending && replay
2613
+ ? withPendingToolCallIds(mergedMetadata, pending)
2614
+ : mergedMetadata;
2615
+ const metadata =
2616
+ latestStatus?.type === "requires-action" &&
2617
+ latestStatus.reason === "tool-calls"
2618
+ ? admission
2619
+ : replay && latestStatus?.type === "running"
2620
+ ? mergedMetadata
2621
+ : withoutPendingToolCallIds(admission);
1812
2622
  return {
1813
2623
  ...assistant,
1814
2624
  content,
@@ -1831,7 +2641,10 @@ export class AgUiThreadRuntimeCore {
1831
2641
  if (this.isPersistableStatus(latestStatus)) {
1832
2642
  this.persistAssistantHistory(resolvedMessageId);
1833
2643
  }
1834
- this.maybeResumeAfterToolResults(resolvedMessageId);
2644
+ this.maybeResumeAfterToolResults(
2645
+ resolvedMessageId,
2646
+ replay?.accountedResultIds,
2647
+ );
1835
2648
  return resolvedMessageId;
1836
2649
  }
1837
2650
 
@@ -1842,6 +2655,7 @@ export class AgUiThreadRuntimeCore {
1842
2655
  private preserveToolResults(
1843
2656
  previous: ThreadAssistantMessage["content"],
1844
2657
  next: ThreadAssistantMessage["content"],
2658
+ decisionSource?: ThreadAssistantMessage["content"],
1845
2659
  ): ThreadAssistantMessage["content"] {
1846
2660
  const preserved = new Map<string, ToolCallMessagePart>();
1847
2661
  for (const part of iterateToolCallParts(previous)) {
@@ -1849,25 +2663,27 @@ export class AgUiThreadRuntimeCore {
1849
2663
  preserved.set(part.toolCallId, part);
1850
2664
  }
1851
2665
  }
1852
- if (preserved.size === 0) {
2666
+ const decisions = new Map(
2667
+ Array.from(iterateToolCallParts(decisionSource ?? []))
2668
+ .filter((part) => part.approval?.approved !== undefined)
2669
+ .map((part) => [part.toolCallId, part]),
2670
+ );
2671
+ if (preserved.size === 0 && decisions.size === 0) {
1853
2672
  return this.preserveToolInteractions(previous, next);
1854
2673
  }
1855
2674
 
1856
2675
  const { content: merged, changed } = mapToolCallPartsDeep(next, (part) => {
1857
2676
  const prior = preserved.get(part.toolCallId);
1858
- if (!prior) return part;
1859
- if (!isResolvedToolCall(part) && isResolvedToolCall(prior)) {
1860
- return {
1861
- ...part,
1862
- result: prior.result,
1863
- ...(prior.artifact !== undefined ? { artifact: prior.artifact } : {}),
1864
- ...(prior.isError !== undefined ? { isError: prior.isError } : {}),
1865
- ...(prior.modelContent !== undefined
1866
- ? { modelContent: prior.modelContent }
1867
- : {}),
1868
- };
2677
+ const result = prior ? preserveToolResult(prior, part) : part;
2678
+ if (result.approval && decisions.has(part.toolCallId)) {
2679
+ const approval = preserveToolApprovalDecision(
2680
+ decisions.get(part.toolCallId),
2681
+ result,
2682
+ );
2683
+ if (approval && approval !== result.approval)
2684
+ return { ...result, approval };
1869
2685
  }
1870
- return part;
2686
+ return result;
1871
2687
  });
1872
2688
  return this.preserveToolInteractions(previous, changed ? merged : next);
1873
2689
  }
@@ -2054,24 +2870,30 @@ export class AgUiThreadRuntimeCore {
2054
2870
  joinStrategy: UseAgUiRuntimeOptions["joinStrategy"],
2055
2871
  runStartMessageIds: ReadonlySet<string>,
2056
2872
  hasRunUserBoundary: boolean,
2873
+ reconcileSnapshot?: SnapshotMessageReconciler,
2057
2874
  ): string | undefined {
2058
2875
  switch (event.type) {
2059
2876
  case "STATE_SNAPSHOT": {
2060
- this.stateSnapshot = event.snapshot as ReadonlyJSONValue;
2877
+ this.dispatch({
2878
+ type: "stateSnapshot",
2879
+ snapshot: event.snapshot as ReadonlyJSONValue,
2880
+ });
2061
2881
  this.updateActiveAssistantState(activeAssistantId);
2062
2882
  break;
2063
2883
  }
2064
2884
  case "STATE_DELTA": {
2065
2885
  if (event.delta.length === 0) break;
2066
2886
  try {
2067
- const state = this.stateSnapshot ?? {};
2068
2887
  const result = jsonpatch.applyPatch(
2069
- state,
2888
+ this.state.stateSnapshot ?? {},
2070
2889
  event.delta as Operation[],
2071
2890
  /* validateOperation */ true,
2072
2891
  /* mutateDocument */ false,
2073
2892
  );
2074
- this.stateSnapshot = result.newDocument as ReadonlyJSONValue;
2893
+ this.dispatch({
2894
+ type: "stateSnapshot",
2895
+ snapshot: result.newDocument as ReadonlyJSONValue,
2896
+ });
2075
2897
  this.updateActiveAssistantState(activeAssistantId);
2076
2898
  } catch (error) {
2077
2899
  this.logger.error?.("[agui] failed to apply state delta", error);
@@ -2093,6 +2915,7 @@ export class AgUiThreadRuntimeCore {
2093
2915
  joinStrategy,
2094
2916
  runStartMessageIds,
2095
2917
  hasRunUserBoundary,
2918
+ reconcileSnapshot,
2096
2919
  );
2097
2920
  const headId = this.session.headId;
2098
2921
  if (hasActiveText && headId !== previousHeadId) {
@@ -2156,7 +2979,7 @@ export class AgUiThreadRuntimeCore {
2156
2979
  ...assistant,
2157
2980
  metadata: {
2158
2981
  ...assistant.metadata,
2159
- unstable_state: this.stateSnapshot ?? null,
2982
+ unstable_state: this.state.stateSnapshot ?? null,
2160
2983
  },
2161
2984
  };
2162
2985
  });
@@ -2172,6 +2995,7 @@ export class AgUiThreadRuntimeCore {
2172
2995
  if (message.role !== "assistant") return message;
2173
2996
  const assistant = message as ThreadAssistantMessage;
2174
2997
  const mcpAppUri = readMcpAppResourceUri(event.mcpResult?._meta);
2998
+ const modelText = toolContentText(event.content);
2175
2999
  let matchedToolCall = false;
2176
3000
  const { content } = mapToolCallPartsDeep(assistant.content, (part) => {
2177
3001
  if (part.toolCallId !== event.toolCallId) return part;
@@ -2188,11 +3012,9 @@ export class AgUiThreadRuntimeCore {
2188
3012
  if (snapshotResultApplied) {
2189
3013
  return {
2190
3014
  ...part,
2191
- ...(part.modelContent === undefined && event.content
3015
+ ...(part.modelContent === undefined
2192
3016
  ? {
2193
- modelContent: [
2194
- { type: "text" as const, text: event.content },
2195
- ],
3017
+ modelContent: [{ type: "text" as const, text: modelText }],
2196
3018
  }
2197
3019
  : {}),
2198
3020
  ...(part.isError === undefined
@@ -2207,12 +3029,18 @@ export class AgUiThreadRuntimeCore {
2207
3029
  : {}),
2208
3030
  };
2209
3031
  }
3032
+ const { modelContent: _modelContent, ...partWithoutModelContent } =
3033
+ part;
2210
3034
  return {
2211
- ...part,
3035
+ ...(event.mcpResult === undefined && Array.isArray(event.content)
3036
+ ? partWithoutModelContent
3037
+ : part),
2212
3038
  result: (event.mcpResult ??
2213
- tryParseJSON(event.content ?? "")) as ReadonlyJSONValue,
3039
+ (Array.isArray(event.content)
3040
+ ? event.content
3041
+ : tryParseJSON(event.content))) as ReadonlyJSONValue,
2214
3042
  ...(event.mcpResult !== undefined
2215
- ? { modelContent: [{ type: "text" as const, text: event.content }] }
3043
+ ? { modelContent: [{ type: "text" as const, text: modelText }] }
2216
3044
  : {}),
2217
3045
  ...(typeof event.mcpResult?.isError === "boolean"
2218
3046
  ? { isError: event.mcpResult.isError }
@@ -2305,6 +3133,7 @@ export class AgUiThreadRuntimeCore {
2305
3133
  joinStrategy: UseAgUiRuntimeOptions["joinStrategy"],
2306
3134
  runStartMessageIds: ReadonlySet<string>,
2307
3135
  hasRunUserBoundary: boolean,
3136
+ reconcileSnapshot?: SnapshotMessageReconciler,
2308
3137
  ) {
2309
3138
  try {
2310
3139
  const activeAssistantItem = activeAssistantId
@@ -2344,19 +3173,22 @@ export class AgUiThreadRuntimeCore {
2344
3173
  const existing = this.session.tryGetMessage(
2345
3174
  convertedMessage.id,
2346
3175
  )?.message;
2347
- if (
2348
- convertedMessage.role === "assistant" &&
2349
- existing?.role === "assistant"
2350
- ) {
2351
- converted[index] = {
2352
- ...convertedMessage,
2353
- content: this.mergeSnapshotAssistantContent(
2354
- existing.content,
2355
- convertedMessage.content,
2356
- snapshotHasReasoning,
2357
- snapshotHasActivity,
2358
- ),
2359
- };
3176
+ if (convertedMessage.role === "assistant") {
3177
+ const previous =
3178
+ existing?.role === "assistant" ? existing : undefined;
3179
+ const snapshot =
3180
+ reconcileSnapshot?.(convertedMessage, previous) ?? convertedMessage;
3181
+ converted[index] = previous
3182
+ ? {
3183
+ ...snapshot,
3184
+ content: this.mergeSnapshotAssistantContent(
3185
+ previous.content,
3186
+ snapshot.content,
3187
+ snapshotHasReasoning,
3188
+ snapshotHasActivity,
3189
+ ),
3190
+ }
3191
+ : snapshot;
2360
3192
  }
2361
3193
  }
2362
3194
  if (joinStrategy === "concat-content") {
@@ -2531,7 +3363,7 @@ export class AgUiThreadRuntimeCore {
2531
3363
  (wasPersisted || this.snapshotHistoryIds.has(messageId));
2532
3364
 
2533
3365
  if (shouldUpdate) {
2534
- const write = this.chainHistoryWrite(messageId, () =>
3366
+ const write = this.historyWrites.chain(messageId, () =>
2535
3367
  update.call(history, { parentId, message }),
2536
3368
  );
2537
3369
  this.assistantHistoryParents.delete(messageId);
@@ -2540,7 +3372,7 @@ export class AgUiThreadRuntimeCore {
2540
3372
  this.persistedHistoryIds.add(messageId);
2541
3373
  },
2542
3374
  (error) => {
2543
- const pending = this.historyWrites.get(messageId);
3375
+ const pending = this.historyWrites.pending(messageId);
2544
3376
  if (pending === undefined || pending === write) {
2545
3377
  this.assistantHistoryParents.set(messageId, parentId);
2546
3378
  }
@@ -2561,7 +3393,7 @@ export class AgUiThreadRuntimeCore {
2561
3393
  void write.then(
2562
3394
  () => {},
2563
3395
  (error) => {
2564
- const pending = this.historyWrites.get(messageId);
3396
+ const pending = this.historyWrites.pending(messageId);
2565
3397
  if (pending === undefined || pending === write) {
2566
3398
  this.assistantHistoryParents.set(messageId, parentId);
2567
3399
  }
@@ -2575,11 +3407,11 @@ export class AgUiThreadRuntimeCore {
2575
3407
  message: ThreadMessage,
2576
3408
  ): Promise<void> | undefined {
2577
3409
  if (!this.history || this.persistedHistoryIds.has(message.id)) return;
2578
- const pending = this.historyWrites.get(message.id);
3410
+ const pending = this.historyWrites.pending(message.id);
2579
3411
  if (pending) return pending;
2580
3412
 
2581
3413
  const append = this.history.append.bind(this.history);
2582
- const write = this.chainHistoryWrite(message.id, () =>
3414
+ const write = this.historyWrites.chain(message.id, () =>
2583
3415
  append({ parentId, message }),
2584
3416
  );
2585
3417
  void write.then(
@@ -2590,35 +3422,4 @@ export class AgUiThreadRuntimeCore {
2590
3422
  );
2591
3423
  return write;
2592
3424
  }
2593
-
2594
- private chainHistoryWrite(
2595
- id: string,
2596
- write: () => Promise<void>,
2597
- ): Promise<void> {
2598
- const pending = this.historyWrites.get(id);
2599
- let next: Promise<void>;
2600
- if (pending) {
2601
- next = pending.then(write, write);
2602
- } else {
2603
- try {
2604
- next = Promise.resolve(write());
2605
- } catch (error) {
2606
- next = Promise.reject(error);
2607
- }
2608
- }
2609
- this.historyWrites.set(id, next);
2610
- void next.then(
2611
- () => {
2612
- if (this.historyWrites.get(id) === next) {
2613
- this.historyWrites.delete(id);
2614
- }
2615
- },
2616
- () => {
2617
- if (this.historyWrites.get(id) === next) {
2618
- this.historyWrites.delete(id);
2619
- }
2620
- },
2621
- );
2622
- return next;
2623
- }
2624
3425
  }