@assistant-ui/react-ag-ui 0.0.70 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +1 -1
  2. package/dist/runtime/{AgUiThreadRuntimeCore.d.ts → AgUiThreadController.d.ts} +8 -12
  3. package/dist/runtime/AgUiThreadController.d.ts.map +1 -0
  4. package/dist/runtime/{AgUiThreadRuntimeCore.js → AgUiThreadController.js} +302 -162
  5. package/dist/runtime/AgUiThreadController.js.map +1 -0
  6. package/dist/runtime/adapter/conversions.d.ts +4 -4
  7. package/dist/runtime/adapter/conversions.d.ts.map +1 -1
  8. package/dist/runtime/adapter/conversions.js +111 -33
  9. package/dist/runtime/adapter/conversions.js.map +1 -1
  10. package/dist/runtime/adapter/run-aggregator.d.ts +6 -5
  11. package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
  12. package/dist/runtime/adapter/run-aggregator.js +50 -62
  13. package/dist/runtime/adapter/run-aggregator.js.map +1 -1
  14. package/dist/runtime/adapter/subscriber.d.ts +0 -24
  15. package/dist/runtime/adapter/subscriber.d.ts.map +1 -1
  16. package/dist/runtime/adapter/subscriber.js +1 -22
  17. package/dist/runtime/adapter/subscriber.js.map +1 -1
  18. package/dist/runtime/agUiThreadState.d.ts +60 -0
  19. package/dist/runtime/agUiThreadState.d.ts.map +1 -0
  20. package/dist/runtime/agUiThreadState.js +95 -0
  21. package/dist/runtime/agUiThreadState.js.map +1 -0
  22. package/dist/runtime/event-parser.d.ts.map +1 -1
  23. package/dist/runtime/event-parser.js +45 -48
  24. package/dist/runtime/event-parser.js.map +1 -1
  25. package/dist/runtime/mcp-tool-result.d.ts +3 -1
  26. package/dist/runtime/mcp-tool-result.d.ts.map +1 -1
  27. package/dist/runtime/mcp-tool-result.js +6 -3
  28. package/dist/runtime/mcp-tool-result.js.map +1 -1
  29. package/dist/runtime/types.d.ts +13 -35
  30. package/dist/runtime/types.d.ts.map +1 -1
  31. package/dist/runtime/types.js.map +1 -1
  32. package/dist/useAgUiRuntime.d.ts.map +1 -1
  33. package/dist/useAgUiRuntime.js +4 -2
  34. package/dist/useAgUiRuntime.js.map +1 -1
  35. package/package.json +13 -12
  36. package/src/runtime/{AgUiThreadRuntimeCore.interrupt-cancel.test.ts → AgUiThreadController.interrupt-cancel.test.ts} +4 -4
  37. package/src/runtime/{AgUiThreadRuntimeCore.test.ts → AgUiThreadController.test.ts} +35 -6
  38. package/src/runtime/{AgUiThreadRuntimeCore.ts → AgUiThreadController.ts} +471 -169
  39. package/src/runtime/adapter/conversions.content.test.ts +37 -5
  40. package/src/runtime/adapter/conversions.tools.test.ts +2 -1
  41. package/src/runtime/adapter/conversions.ts +235 -58
  42. package/src/runtime/adapter/run-aggregator.ts +84 -114
  43. package/src/runtime/adapter/subscriber.ts +1 -44
  44. package/src/runtime/agUiThreadState.test.ts +205 -0
  45. package/src/runtime/agUiThreadState.ts +130 -0
  46. package/src/runtime/event-parser.ts +40 -58
  47. package/src/runtime/mcp-tool-result.ts +16 -2
  48. package/src/runtime/types.ts +18 -28
  49. package/src/useAgUiRuntime.external-store.test.tsx +39 -0
  50. package/src/useAgUiRuntime.fast-refresh.test.tsx +3 -3
  51. package/src/useAgUiRuntime.thread-switch.test.tsx +4 -4
  52. package/src/useAgUiRuntime.ts +5 -3
  53. package/dist/runtime/AgUiThreadRuntimeCore.d.ts.map +0 -1
  54. 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,
@@ -85,6 +92,39 @@ const isResolvedToolCall = (
85
92
  ): boolean =>
86
93
  part.type === "tool-call" && "result" in part && part.result !== undefined;
87
94
 
95
+ const listedPendingToolCallIds = (
96
+ assistant: ThreadAssistantMessage,
97
+ ): ReadonlySet<string> | undefined => {
98
+ const ids = (
99
+ assistant.metadata.custom[AG_UI_METADATA_NAMESPACE] as
100
+ | AgUiCustomMetadata
101
+ | undefined
102
+ )?.pendingToolCallIds;
103
+ return ids?.length ? new Set(ids) : undefined;
104
+ };
105
+
106
+ const withoutPendingToolCallIds = (
107
+ metadata: ThreadAssistantMessage["metadata"],
108
+ ): ThreadAssistantMessage["metadata"] => {
109
+ const namespace = metadata.custom[AG_UI_METADATA_NAMESPACE] as
110
+ | AgUiCustomMetadata
111
+ | undefined;
112
+ if (!namespace || !("pendingToolCallIds" in namespace)) return metadata;
113
+ const { pendingToolCallIds: _pendingToolCallIds, ...remainingNamespace } =
114
+ namespace;
115
+ const { [AG_UI_METADATA_NAMESPACE]: _namespace, ...remainingCustom } =
116
+ metadata.custom;
117
+ return {
118
+ ...metadata,
119
+ custom: {
120
+ ...remainingCustom,
121
+ ...(Object.keys(remainingNamespace).length > 0
122
+ ? { [AG_UI_METADATA_NAMESPACE]: remainingNamespace }
123
+ : {}),
124
+ },
125
+ };
126
+ };
127
+
88
128
  const isActivityPart = (part: ThreadAssistantMessage["content"][number]) =>
89
129
  part.type === "data" && part.name.startsWith("agui-activity/");
90
130
 
@@ -112,6 +152,7 @@ type CoreOptions = {
112
152
  agent: AbstractAgent;
113
153
  logger: Logger;
114
154
  showThinking: boolean;
155
+ joinStrategy?: UseAgUiRuntimeOptions["joinStrategy"];
115
156
  getRunContext?: UseAgUiRuntimeOptions["getRunContext"];
116
157
  resumeTranscript?: AgUiResumeTranscript | undefined;
117
158
  autoCancelPendingToolCalls?: boolean | undefined;
@@ -154,10 +195,11 @@ const mergeAgUiNamespace = (
154
195
  return merged;
155
196
  };
156
197
 
157
- export class AgUiThreadRuntimeCore {
198
+ export class AgUiThreadController {
158
199
  private agent: AbstractAgent;
159
200
  private logger: Logger;
160
201
  private showThinking: boolean;
202
+ private joinStrategy: UseAgUiRuntimeOptions["joinStrategy"];
161
203
  private getRunContext: UseAgUiRuntimeOptions["getRunContext"];
162
204
  private resumeTranscript: AgUiResumeTranscript;
163
205
  private autoCancelPendingToolCalls: boolean | undefined;
@@ -192,32 +234,24 @@ export class AgUiThreadRuntimeCore {
192
234
  };
193
235
  },
194
236
  });
195
- private isRunningFlag = false;
237
+ private state: AgUiThreadState = createAgUiThreadState();
196
238
  private abortController: AbortController | null = null;
197
239
  // The agent that started the active run. updateOptions can swap this.agent
198
240
  // mid-run, and cancelling has to reach the agent holding the live request.
199
241
  private activeRunAgent: AbstractAgent | null = null;
200
- private stateSnapshot: ReadonlyJSONValue | undefined;
201
242
  private history: ThreadHistoryAdapter | undefined;
202
- private lastRunConfig: RunConfig | undefined;
203
243
  private readonly assistantHistoryParents = new Map<string, string | null>();
204
244
  private readonly snapshotHistoryIds = new Set<string>();
205
245
  private readonly persistedHistoryIds = new Set<string>();
206
- private readonly historyWrites = new Map<string, Promise<void>>();
207
- private _isLoading = false;
246
+ private readonly historyWrites = createHistoryWriteChain();
208
247
  private _historyImportPromise: Promise<void> | undefined;
209
- private historyMutationGeneration = 0;
210
248
  private _loadPromise: Promise<void> | undefined;
211
- private _loadRequested = false;
212
- private pendingResume: { owner: AbortController; messageId: string } | null =
213
- null;
214
- private pendingA2uiResumeOwner: AbortController | null = null;
215
- private pendingA2uiAction: Record<string, unknown> | undefined;
216
249
 
217
250
  constructor(options: CoreOptions) {
218
251
  this.agent = options.agent;
219
252
  this.logger = options.logger;
220
253
  this.showThinking = options.showThinking;
254
+ this.joinStrategy = options.joinStrategy;
221
255
  this.getRunContext = options.getRunContext;
222
256
  this.resumeTranscript = options.resumeTranscript ?? "full";
223
257
  this.autoCancelPendingToolCalls = options.autoCancelPendingToolCalls;
@@ -234,12 +268,16 @@ export class AgUiThreadRuntimeCore {
234
268
 
235
269
  getSnapshot = () =>
236
270
  (this.snapshot ??= {
237
- isLoading: this._isLoading,
238
- isRunning: this.isRunningFlag,
271
+ isLoading: this.state.isLoading,
272
+ isRunning: this.state.isRunning,
239
273
  messageRepository: this.session.export(),
240
- state: this.stateSnapshot,
274
+ state: this.state.stateSnapshot,
241
275
  });
242
276
 
277
+ private dispatch(action: AgUiThreadAction): void {
278
+ this.state = reduceAgUiThreadState(this.state, action);
279
+ }
280
+
243
281
  private publishUpdate(): void {
244
282
  this.snapshot = undefined;
245
283
  for (const listener of this.listeners) listener();
@@ -249,6 +287,7 @@ export class AgUiThreadRuntimeCore {
249
287
  this.agent = options.agent;
250
288
  this.logger = options.logger;
251
289
  this.showThinking = options.showThinking;
290
+ this.joinStrategy = options.joinStrategy;
252
291
  this.getRunContext = options.getRunContext;
253
292
  this.resumeTranscript = options.resumeTranscript ?? "full";
254
293
  this.autoCancelPendingToolCalls = options.autoCancelPendingToolCalls;
@@ -257,7 +296,7 @@ export class AgUiThreadRuntimeCore {
257
296
  const previousHistory = this.history;
258
297
  this.history = options.history;
259
298
  if (
260
- this._loadRequested &&
299
+ this.state.loadRequested &&
261
300
  !this._loadPromise &&
262
301
  !previousHistory &&
263
302
  options.history &&
@@ -287,19 +326,19 @@ export class AgUiThreadRuntimeCore {
287
326
  }
288
327
 
289
328
  getState(): ReadonlyJSONValue | undefined {
290
- return this.stateSnapshot;
329
+ return this.state.stateSnapshot;
291
330
  }
292
331
 
293
332
  isRunning(): boolean {
294
- return this.isRunningFlag;
333
+ return this.state.isRunning;
295
334
  }
296
335
 
297
336
  get isLoading(): boolean {
298
- return this._isLoading;
337
+ return this.state.isLoading;
299
338
  }
300
339
 
301
340
  __internal_load(): Promise<void> {
302
- this._loadRequested = true;
341
+ this.dispatch({ type: "loadRequested" });
303
342
  if (this._loadPromise) return this._loadPromise;
304
343
  if (!this.history) return Promise.resolve();
305
344
 
@@ -322,7 +361,7 @@ export class AgUiThreadRuntimeCore {
322
361
  return repo;
323
362
  });
324
363
 
325
- this._isLoading = true;
364
+ this.dispatch({ type: "loadStarted" });
326
365
  this._historyImportPromise = promise.then(
327
366
  () => {
328
367
  this._historyImportPromise = undefined;
@@ -339,7 +378,7 @@ export class AgUiThreadRuntimeCore {
339
378
  const resumeStream = this.history?.resume?.bind(this.history);
340
379
  await this.startRun(
341
380
  parentId,
342
- this.lastRunConfig,
381
+ this.state.lastRunConfig,
343
382
  undefined,
344
383
  resumeStream,
345
384
  );
@@ -350,7 +389,9 @@ export class AgUiThreadRuntimeCore {
350
389
  this.reportError(error);
351
390
  })
352
391
  .finally(() => {
353
- this._isLoading = false;
392
+ this.dispatch({
393
+ type: "loadFinished",
394
+ });
354
395
  this.publishUpdate();
355
396
  });
356
397
 
@@ -361,10 +402,10 @@ export class AgUiThreadRuntimeCore {
361
402
  private waitForHistoryImport(): Promise<boolean> | undefined {
362
403
  const historyImport = this._historyImportPromise;
363
404
  if (!historyImport) return undefined;
364
- const generation = this.historyMutationGeneration;
405
+ const generation = this.state.historyMutationGeneration;
365
406
  return historyImport.then(
366
407
  () =>
367
- generation === this.historyMutationGeneration &&
408
+ generation === this.state.historyMutationGeneration &&
368
409
  !this.isThreadSwitching?.(),
369
410
  );
370
411
  }
@@ -372,10 +413,10 @@ export class AgUiThreadRuntimeCore {
372
413
  private deferUntilHistoryImported(callback: () => void): boolean {
373
414
  const historyImport = this._historyImportPromise;
374
415
  if (!historyImport) return false;
375
- const generation = this.historyMutationGeneration;
416
+ const generation = this.state.historyMutationGeneration;
376
417
  void historyImport.then(() => {
377
418
  if (
378
- generation !== this.historyMutationGeneration ||
419
+ generation !== this.state.historyMutationGeneration ||
379
420
  this.isThreadSwitching?.()
380
421
  ) {
381
422
  return;
@@ -393,10 +434,10 @@ export class AgUiThreadRuntimeCore {
393
434
  const historyImport = this._historyImportPromise;
394
435
  if (historyImport) {
395
436
  const wasAtTail = message.parentId === this.session.headId;
396
- const generation = this.historyMutationGeneration;
437
+ const generation = this.state.historyMutationGeneration;
397
438
  await historyImport;
398
439
  if (
399
- generation !== this.historyMutationGeneration ||
440
+ generation !== this.state.historyMutationGeneration ||
400
441
  this.isThreadSwitching?.()
401
442
  )
402
443
  return;
@@ -430,10 +471,10 @@ export class AgUiThreadRuntimeCore {
430
471
 
431
472
  private maybeAutoCancelPendingToolCalls(): void {
432
473
  if (this.autoCancelPendingToolCalls === false) return;
433
- const pending = this.getPendingToolCalls();
434
- if (!pending) return;
435
- this.cancelUnresolvedToolCalls(pending.messageId);
436
- this.maybeCompleteAfterToolResults(pending.messageId);
474
+ for (const assistant of this.getPendingToolCallMessages()) {
475
+ this.cancelUnresolvedToolCalls(assistant.id);
476
+ this.maybeCompleteAfterToolResults(assistant.id);
477
+ }
437
478
  }
438
479
 
439
480
  private appendEntry(message: AppendMessage): string {
@@ -467,7 +508,9 @@ export class AgUiThreadRuntimeCore {
467
508
  }
468
509
 
469
510
  async cancel(): Promise<void> {
470
- this.historyMutationGeneration++;
511
+ this.dispatch({
512
+ type: "historyMutationAdvanced",
513
+ });
471
514
  this.abortActiveRun();
472
515
  }
473
516
 
@@ -514,7 +557,7 @@ export class AgUiThreadRuntimeCore {
514
557
  this.assertNoPendingInterrupts();
515
558
  await this.startRun(
516
559
  config.parentId,
517
- config.runConfig ?? this.lastRunConfig,
560
+ config.runConfig ?? this.state.lastRunConfig,
518
561
  undefined,
519
562
  config.stream,
520
563
  );
@@ -537,7 +580,7 @@ export class AgUiThreadRuntimeCore {
537
580
  try {
538
581
  await this.startRun(
539
582
  parentId,
540
- this.lastRunConfig,
583
+ this.state.lastRunConfig,
541
584
  undefined,
542
585
  resumeStream,
543
586
  );
@@ -569,6 +612,25 @@ export class AgUiThreadRuntimeCore {
569
612
  return assistant;
570
613
  }
571
614
 
615
+ private getTrailingAssistantTurn(): ThreadAssistantMessage[] {
616
+ const messages = this.getMessages();
617
+ const turn: ThreadAssistantMessage[] = [];
618
+ for (let index = messages.length - 1; index >= 0; index--) {
619
+ const message = messages[index];
620
+ if (message?.role !== "assistant") break;
621
+ turn.unshift(message as ThreadAssistantMessage);
622
+ }
623
+ return turn;
624
+ }
625
+
626
+ private getPendingToolCallMessages(): ThreadAssistantMessage[] {
627
+ return this.getTrailingAssistantTurn().filter(
628
+ (message) =>
629
+ message.status?.type === "requires-action" &&
630
+ message.status.reason === "tool-calls",
631
+ );
632
+ }
633
+
572
634
  getPendingInterrupts(): {
573
635
  messageId: string;
574
636
  interrupts: readonly AgUiInterrupt[];
@@ -588,15 +650,21 @@ export class AgUiThreadRuntimeCore {
588
650
  messageId: string;
589
651
  toolCallIds: string[];
590
652
  } | null {
591
- const assistant = this.findRequiresActionAssistant("tool-calls");
592
- if (!assistant) return null;
593
653
  const toolCallIds: string[] = [];
594
- for (const part of iterateToolCallParts(assistant.content)) {
595
- if (isResolvedToolCall(part)) continue;
596
- toolCallIds.push(part.toolCallId);
654
+ let messageId: string | undefined;
655
+ for (const assistant of this.getPendingToolCallMessages()) {
656
+ const listed = listedPendingToolCallIds(assistant);
657
+ for (const part of iterateToolCallParts(assistant.content)) {
658
+ if (
659
+ isResolvedToolCall(part) ||
660
+ (listed && !listed.has(part.toolCallId))
661
+ )
662
+ continue;
663
+ messageId ??= assistant.id;
664
+ toolCallIds.push(part.toolCallId);
665
+ }
597
666
  }
598
- if (toolCallIds.length === 0) return null;
599
- return { messageId: assistant.id, toolCallIds };
667
+ return messageId === undefined ? null : { messageId, toolCallIds };
600
668
  }
601
669
 
602
670
  async submitInterruptResponses(
@@ -628,6 +696,7 @@ export class AgUiThreadRuntimeCore {
628
696
  this.assertInterruptsAnswerable(
629
697
  "submitInterruptResponses",
630
698
  pending.interrupts,
699
+ responsesById,
631
700
  );
632
701
 
633
702
  await this.resumeWithResponses(
@@ -647,7 +716,7 @@ export class AgUiThreadRuntimeCore {
647
716
  resume: AgUiResumeEntry[],
648
717
  ): Promise<void> {
649
718
  this.clearPendingInterrupts(messageId, resume);
650
- await this.startRun(messageId, this.lastRunConfig, {
719
+ await this.startRun(messageId, this.state.lastRunConfig, {
651
720
  entries: resume,
652
721
  interruptedMessageId: messageId,
653
722
  });
@@ -675,6 +744,7 @@ export class AgUiThreadRuntimeCore {
675
744
  private assertInterruptsAnswerable(
676
745
  method: "submitInterruptResponses" | "respondToToolApproval",
677
746
  interrupts: readonly AgUiInterrupt[],
747
+ responsesById?: ReadonlyMap<string, AgUiResumeEntry>,
678
748
  ): void {
679
749
  const now = Date.now();
680
750
  for (const interrupt of interrupts) {
@@ -685,13 +755,16 @@ export class AgUiThreadRuntimeCore {
685
755
  `[agui] ${method}: interrupt ${interrupt.id} has malformed expiresAt "${interrupt.expiresAt}"`,
686
756
  );
687
757
  }
688
- if (expiry <= now) {
758
+ if (
759
+ expiry <= now &&
760
+ responsesById?.get(interrupt.id)?.status !== "cancelled"
761
+ ) {
689
762
  throw new Error(
690
763
  `[agui] ${method}: interrupt ${interrupt.id} expired at ${interrupt.expiresAt}`,
691
764
  );
692
765
  }
693
766
  }
694
- if (this.isRunningFlag) {
767
+ if (this.state.isRunning) {
695
768
  throw new Error(`[agui] ${method}: a run is already in progress`);
696
769
  }
697
770
  }
@@ -785,11 +858,13 @@ export class AgUiThreadRuntimeCore {
785
858
  "[agui] steerAway: responses are only valid for pending interrupts",
786
859
  );
787
860
  }
788
- if (this.isRunningFlag) {
861
+ if (this.state.isRunning) {
789
862
  throw new Error("[agui] steerAway: a run is already in progress");
790
863
  }
791
- this.cancelUnresolvedToolCalls(pendingTools.messageId);
792
- this.maybeCompleteAfterToolResults(pendingTools.messageId);
864
+ for (const assistant of this.getPendingToolCallMessages()) {
865
+ this.cancelUnresolvedToolCalls(assistant.id);
866
+ this.maybeCompleteAfterToolResults(assistant.id);
867
+ }
793
868
  const normalized = this.toAppendMessage(message);
794
869
  const threadMessageId = this.appendEntry(normalized);
795
870
  await this.startRun(threadMessageId, normalized.runConfig);
@@ -806,7 +881,7 @@ export class AgUiThreadRuntimeCore {
806
881
 
807
882
  const resume = this.resolveSteerAwayResume(pending.interrupts, responses);
808
883
 
809
- if (this.isRunningFlag) {
884
+ if (this.state.isRunning) {
810
885
  throw new Error("[agui] steerAway: a run is already in progress");
811
886
  }
812
887
 
@@ -1063,7 +1138,7 @@ export class AgUiThreadRuntimeCore {
1063
1138
  return;
1064
1139
  }
1065
1140
 
1066
- await this.chainHistoryWrite(sessionMessageId, () =>
1141
+ await this.historyWrites.chain(sessionMessageId, () =>
1067
1142
  history.update!({ parentId: item.parentId, message }),
1068
1143
  );
1069
1144
  }
@@ -1086,11 +1161,17 @@ export class AgUiThreadRuntimeCore {
1086
1161
  if (!("timestamp" in userAction)) {
1087
1162
  userAction.timestamp = new Date().toISOString();
1088
1163
  }
1089
- this.pendingA2uiAction = userAction;
1164
+ this.dispatch({
1165
+ type: "a2uiActionDeferred",
1166
+ action: userAction,
1167
+ });
1090
1168
 
1091
1169
  const owner = this.abortController;
1092
1170
  if (owner) {
1093
- this.pendingA2uiResumeOwner = owner;
1171
+ this.dispatch({
1172
+ type: "a2uiResumeDeferred",
1173
+ owner,
1174
+ });
1094
1175
  return;
1095
1176
  }
1096
1177
  this.startResumeRun(parentId);
@@ -1101,15 +1182,25 @@ export class AgUiThreadRuntimeCore {
1101
1182
  // run is still draining) or after it.
1102
1183
  private maybeResumeAfterToolResults(messageId: string): void {
1103
1184
  if (!this.maybeCompleteAfterToolResults(messageId)) return;
1185
+ if (this.getPendingInterrupts()) return;
1186
+ const turn = this.getTrailingAssistantTurn();
1187
+ if (!turn.some((message) => message.id === messageId)) return;
1188
+ if (this.getPendingToolCalls()) return;
1189
+ const headId = turn.at(-1)?.id;
1190
+ if (headId === undefined) return;
1104
1191
 
1105
1192
  const owner = this.abortController;
1106
1193
  if (owner) {
1107
1194
  // A run is still draining (RUN_FINISHED arrived but the stream has not
1108
1195
  // closed). Defer until startRun's tail so we never start two runs.
1109
- this.pendingResume = { owner, messageId };
1196
+ this.dispatch({
1197
+ type: "resumeDeferred",
1198
+ owner,
1199
+ messageId: headId,
1200
+ });
1110
1201
  return;
1111
1202
  }
1112
- this.startResumeRun(messageId);
1203
+ this.startResumeRun(headId);
1113
1204
  }
1114
1205
 
1115
1206
  private maybeCompleteAfterToolResults(messageId: string): boolean {
@@ -1123,7 +1214,9 @@ export class AgUiThreadRuntimeCore {
1123
1214
  return false;
1124
1215
  }
1125
1216
  let allResolved = true;
1217
+ const listed = listedPendingToolCallIds(assistant);
1126
1218
  for (const part of iterateToolCallParts(assistant.content)) {
1219
+ if (listed && !listed.has(part.toolCallId)) continue;
1127
1220
  if (!isResolvedToolCall(part)) {
1128
1221
  allResolved = false;
1129
1222
  break;
@@ -1136,6 +1229,7 @@ export class AgUiThreadRuntimeCore {
1136
1229
  ? {
1137
1230
  ...(current as ThreadAssistantMessage),
1138
1231
  status: { type: "complete" as const, reason: "unknown" as const },
1232
+ metadata: withoutPendingToolCallIds(current.metadata),
1139
1233
  }
1140
1234
  : current,
1141
1235
  );
@@ -1146,7 +1240,7 @@ export class AgUiThreadRuntimeCore {
1146
1240
  }
1147
1241
 
1148
1242
  private startResumeRun(messageId: string): void {
1149
- void this.startRun(messageId, this.lastRunConfig).catch((error) => {
1243
+ void this.startRun(messageId, this.state.lastRunConfig).catch((error) => {
1150
1244
  this.reportError(error);
1151
1245
  });
1152
1246
  }
@@ -1163,8 +1257,9 @@ export class AgUiThreadRuntimeCore {
1163
1257
  metadata: { ...message.metadata, modality },
1164
1258
  } as ThreadMessage;
1165
1259
  });
1166
- this.pendingA2uiResumeOwner = null;
1167
- this.pendingA2uiAction = undefined;
1260
+ this.dispatch({
1261
+ type: "a2uiContinuationsCleared",
1262
+ });
1168
1263
  this.assistantHistoryParents.clear();
1169
1264
 
1170
1265
  if (messages.length === 0) {
@@ -1213,18 +1308,21 @@ export class AgUiThreadRuntimeCore {
1213
1308
  // continuation whose target survived the snapshot is still answerable.
1214
1309
  // Membership is the head branch, not the repository: a soft merge leaves
1215
1310
  // the messages it dropped behind as off-branch nodes.
1216
- const resumeTarget = this.pendingResume?.messageId;
1311
+ const resumeTarget = this.state.pendingResume?.messageId;
1217
1312
  if (
1218
1313
  resumeTarget !== undefined &&
1219
1314
  !this.session.getMessages().some((message) => message.id === resumeTarget)
1220
1315
  ) {
1221
- this.pendingResume = null;
1316
+ this.dispatch({ type: "resumeCleared" });
1222
1317
  }
1223
1318
  this.publishUpdate();
1224
1319
  }
1225
1320
 
1226
1321
  loadExternalState(state: ReadonlyJSONValue): void {
1227
- this.stateSnapshot = state;
1322
+ this.dispatch({
1323
+ type: "stateSnapshot",
1324
+ snapshot: state,
1325
+ });
1228
1326
  this.publishUpdate();
1229
1327
  }
1230
1328
 
@@ -1233,25 +1331,28 @@ export class AgUiThreadRuntimeCore {
1233
1331
  | ReadonlyJSONValue
1234
1332
  | ((prev: ReadonlyJSONValue | undefined) => ReadonlyJSONValue),
1235
1333
  ): void {
1236
- this.stateSnapshot =
1237
- typeof next === "function" ? next(this.stateSnapshot) : next;
1334
+ const snapshot =
1335
+ typeof next === "function" ? next(this.state.stateSnapshot) : next;
1336
+ this.dispatch({
1337
+ type: "stateSnapshot",
1338
+ snapshot,
1339
+ });
1238
1340
  this.publishUpdate();
1239
1341
  }
1240
1342
 
1241
1343
  resetState(): void {
1242
- this.stateSnapshot = undefined;
1344
+ this.dispatch({ type: "resetState" });
1243
1345
  this.publishUpdate();
1244
1346
  }
1245
1347
 
1246
1348
  resetThreadState(): void {
1247
- this.historyMutationGeneration++;
1349
+ this.dispatch({
1350
+ type: "historyMutationAdvanced",
1351
+ });
1248
1352
  const controller = this.abortController;
1249
1353
  const activeRunAgent = this.activeRunAgent;
1250
1354
 
1251
- this.stateSnapshot = undefined;
1252
- this.pendingResume = null;
1253
- this.pendingA2uiResumeOwner = null;
1254
- this.pendingA2uiAction = undefined;
1355
+ this.dispatch({ type: "resetThread" });
1255
1356
 
1256
1357
  if (controller) {
1257
1358
  this.abortController = null;
@@ -1282,7 +1383,10 @@ export class AgUiThreadRuntimeCore {
1282
1383
  if (!this.supersedeActiveRun()) return;
1283
1384
 
1284
1385
  const normalizedRunConfig = runConfig ?? {};
1285
- this.lastRunConfig = normalizedRunConfig;
1386
+ this.dispatch({
1387
+ type: "runConfigRecorded",
1388
+ runConfig: normalizedRunConfig,
1389
+ });
1286
1390
  const parent =
1287
1391
  parentId === null ? undefined : this.session.tryGetMessage(parentId);
1288
1392
  const shouldEagerlyInsertAssistant =
@@ -1297,6 +1401,8 @@ export class AgUiThreadRuntimeCore {
1297
1401
  const runStartMessageIds = new Set(
1298
1402
  this.getMessageRepository().messages.map(({ message }) => message.id),
1299
1403
  );
1404
+ const joinStrategy = this.joinStrategy;
1405
+ const hasRunUserBoundary = parent?.message.role === "user" && !resume;
1300
1406
 
1301
1407
  let pendingError: Error | null = null;
1302
1408
  const assistantParentId = parent ? parentId : this.session.headId;
@@ -1309,6 +1415,9 @@ export class AgUiThreadRuntimeCore {
1309
1415
  // resurrecting for custom-event data would leave a trailing data-only
1310
1416
  // duplicate.
1311
1417
  let assistantCollided = false;
1418
+ const heldMessages = new Map<string, Set<string>>();
1419
+ let restrictedPendingToolCallIds: ReadonlySet<string> | undefined;
1420
+ let settlingRun = false;
1312
1421
  const ensureAssistant = (allowRecreate = false): string => {
1313
1422
  const cached = assistantMessageId;
1314
1423
  if (cached !== undefined && this.session.tryGetMessage(cached))
@@ -1338,7 +1447,66 @@ export class AgUiThreadRuntimeCore {
1338
1447
 
1339
1448
  if (shouldEagerlyInsertAssistant) ensureAssistant();
1340
1449
 
1450
+ const settleHeldMessages = (terminalStatus: MessageStatus): string[] => {
1451
+ const pendingMessages: string[] = [];
1452
+ for (const [messageId, openToolCallIds] of heldMessages) {
1453
+ let requiresToolResult = false;
1454
+ const updated = this.session.updateMessage(messageId, (message) => {
1455
+ if (message.role !== "assistant") return message;
1456
+ const assistant = message as ThreadAssistantMessage;
1457
+ const pendingToolCallIds = Array.from(openToolCallIds).filter(
1458
+ (id) =>
1459
+ !restrictedPendingToolCallIds ||
1460
+ restrictedPendingToolCallIds.has(id),
1461
+ );
1462
+ const canWaitForTools =
1463
+ terminalStatus.type === "complete" ||
1464
+ terminalStatus.type === "requires-action";
1465
+ const status: MessageStatus = canWaitForTools
1466
+ ? pendingToolCallIds.length > 0
1467
+ ? { type: "requires-action", reason: "tool-calls" }
1468
+ : { type: "complete", reason: "unknown" }
1469
+ : terminalStatus;
1470
+ requiresToolResult = status.type === "requires-action";
1471
+ const metadata = withoutPendingToolCallIds(assistant.metadata);
1472
+ return {
1473
+ ...assistant,
1474
+ status,
1475
+ metadata:
1476
+ status.type === "requires-action" && restrictedPendingToolCallIds
1477
+ ? {
1478
+ ...metadata,
1479
+ custom: {
1480
+ ...metadata.custom,
1481
+ [AG_UI_METADATA_NAMESPACE]: {
1482
+ ...(metadata.custom[AG_UI_METADATA_NAMESPACE] as
1483
+ | AgUiCustomMetadata
1484
+ | undefined),
1485
+ pendingToolCallIds,
1486
+ },
1487
+ },
1488
+ }
1489
+ : metadata,
1490
+ };
1491
+ });
1492
+ if (updated) {
1493
+ this.publishUpdate();
1494
+ this.persistAssistantHistory(messageId);
1495
+ if (requiresToolResult) {
1496
+ pendingMessages.push(messageId);
1497
+ }
1498
+ }
1499
+ }
1500
+ heldMessages.clear();
1501
+ return pendingMessages;
1502
+ };
1503
+
1341
1504
  const applyUpdate = (update: ChatModelRunResult) => {
1505
+ const terminalStatus = update.status;
1506
+ const pendingMessages =
1507
+ settlingRun && terminalStatus && terminalStatus.type !== "running"
1508
+ ? settleHeldMessages(terminalStatus)
1509
+ : [];
1342
1510
  const hasStreamContent =
1343
1511
  Array.isArray(update.content) &&
1344
1512
  update.content.some((part) => part.type !== "data");
@@ -1349,14 +1517,22 @@ export class AgUiThreadRuntimeCore {
1349
1517
  if (resolved !== assistantMessageId) {
1350
1518
  assistantMessageId = resolved;
1351
1519
  }
1520
+ for (const messageId of pendingMessages) {
1521
+ this.maybeResumeAfterToolResults(messageId);
1522
+ }
1352
1523
  };
1353
1524
 
1354
1525
  const adoptServerMessageId = (
1355
1526
  serverId: string,
1356
1527
  startNewMessage: boolean,
1528
+ openToolCallIds: string[] = [],
1357
1529
  ) => {
1358
1530
  if (startNewMessage && assistantMessageId !== undefined) {
1359
- applyUpdate({ status: { type: "complete", reason: "unknown" } });
1531
+ if (openToolCallIds.length > 0) {
1532
+ heldMessages.set(assistantMessageId, new Set(openToolCallIds));
1533
+ } else {
1534
+ applyUpdate({ status: { type: "complete", reason: "unknown" } });
1535
+ }
1360
1536
  const previousId = assistantMessageId;
1361
1537
  assistantMessageId = undefined;
1362
1538
  if (this.session.tryGetMessage(previousId)) {
@@ -1392,24 +1568,49 @@ export class AgUiThreadRuntimeCore {
1392
1568
  const abortSignal = abortController.signal;
1393
1569
  const aggregator = new RunAggregator({
1394
1570
  showThinking: this.showThinking,
1571
+ joinStrategy,
1395
1572
  logger: this.logger,
1396
1573
  emit: applyUpdate,
1397
1574
  onServerMessageId: (serverId) => {
1398
1575
  adoptServerMessageId(serverId, false);
1399
1576
  },
1400
- onTextMessageStart: (serverId) => adoptServerMessageId(serverId, true),
1577
+ onTextMessageStart: (serverId, openToolCallIds) =>
1578
+ adoptServerMessageId(serverId, true, openToolCallIds),
1401
1579
  });
1402
1580
  let runFinished = false;
1403
1581
  const dispatch = (event: AgUiEvent) => {
1404
1582
  if (this.abortController !== abortController) return;
1405
- if (event.type === "RUN_FINISHED") runFinished = true;
1406
- const nextAssistantMessageId = this.handleEvent(
1407
- aggregator,
1408
- event,
1409
- assistantMessageId,
1410
- );
1411
- if (event.type === "MESSAGES_SNAPSHOT") {
1412
- assistantMessageId = nextAssistantMessageId;
1583
+ if (event.type === "RUN_FINISHED") {
1584
+ runFinished = true;
1585
+ const ids =
1586
+ event.outcome?.type === "success"
1587
+ ? event.outcome.pendingToolCallIds
1588
+ : undefined;
1589
+ restrictedPendingToolCallIds = ids?.length ? new Set(ids) : undefined;
1590
+ }
1591
+ if (event.type === "TOOL_CALL_RESULT") {
1592
+ for (const openToolCallIds of heldMessages.values()) {
1593
+ openToolCallIds.delete(event.toolCallId);
1594
+ }
1595
+ }
1596
+ settlingRun =
1597
+ event.type === "RUN_FINISHED" ||
1598
+ event.type === "RUN_ERROR" ||
1599
+ event.type === "RUN_CANCELLED";
1600
+ try {
1601
+ const nextAssistantMessageId = this.handleEvent(
1602
+ aggregator,
1603
+ event,
1604
+ assistantMessageId,
1605
+ joinStrategy,
1606
+ runStartMessageIds,
1607
+ hasRunUserBoundary,
1608
+ );
1609
+ if (event.type === "MESSAGES_SNAPSHOT") {
1610
+ assistantMessageId = nextAssistantMessageId;
1611
+ }
1612
+ } finally {
1613
+ settlingRun = false;
1413
1614
  }
1414
1615
  };
1415
1616
 
@@ -1472,9 +1673,9 @@ export class AgUiThreadRuntimeCore {
1472
1673
  },
1473
1674
  });
1474
1675
  try {
1475
- (runAgentInstance as any).messages = input.messages;
1476
- (runAgentInstance as any).threadId = input.threadId;
1477
- (runAgentInstance as any).state = input.state ?? null;
1676
+ runAgentInstance.messages = input.messages;
1677
+ runAgentInstance.threadId = input.threadId;
1678
+ runAgentInstance.state = input.state ?? null;
1478
1679
  } catch {
1479
1680
  // ignore
1480
1681
  }
@@ -1504,19 +1705,23 @@ export class AgUiThreadRuntimeCore {
1504
1705
 
1505
1706
  // A tool result that landed before the run settled deferred its
1506
1707
  // continuation here so a second run never overlaps the first.
1507
- if (this.pendingResume?.owner === abortController) {
1508
- const { messageId } = this.pendingResume;
1509
- this.pendingResume = null;
1708
+ if (this.state.pendingResume?.owner === abortController) {
1709
+ const { messageId } = this.state.pendingResume;
1710
+ this.dispatch({ type: "resumeCleared" });
1510
1711
  if (!abortSignal.aborted) {
1511
1712
  this.startResumeRun(messageId);
1512
1713
  }
1513
1714
  }
1514
1715
 
1515
- if (this.pendingA2uiResumeOwner === abortController) {
1516
- this.pendingA2uiResumeOwner = null;
1517
- if (!abortSignal.aborted && this.pendingA2uiAction !== undefined) {
1716
+ if (this.state.pendingA2uiResumeOwner === abortController) {
1717
+ this.dispatch({
1718
+ type: "a2uiResumeCleared",
1719
+ });
1720
+ if (!abortSignal.aborted && this.state.pendingA2uiAction !== undefined) {
1518
1721
  if (this.getPendingInterrupts()) {
1519
- this.pendingA2uiAction = undefined;
1722
+ this.dispatch({
1723
+ type: "a2uiActionCleared",
1724
+ });
1520
1725
  this.logger.debug(
1521
1726
  "[agui] sendA2uiAction: pending interrupts, dropping action",
1522
1727
  );
@@ -1527,13 +1732,17 @@ export class AgUiThreadRuntimeCore {
1527
1732
  if (parentId !== null) {
1528
1733
  this.startResumeRun(parentId);
1529
1734
  } else {
1530
- this.pendingA2uiAction = undefined;
1735
+ this.dispatch({
1736
+ type: "a2uiActionCleared",
1737
+ });
1531
1738
  this.logger.debug(
1532
1739
  "[agui] sendA2uiAction: no messages to resume, dropping action",
1533
1740
  );
1534
1741
  }
1535
1742
  } else {
1536
- this.pendingA2uiAction = undefined;
1743
+ this.dispatch({
1744
+ type: "a2uiActionCleared",
1745
+ });
1537
1746
  }
1538
1747
  }
1539
1748
  }
@@ -1552,8 +1761,9 @@ export class AgUiThreadRuntimeCore {
1552
1761
  getAssistantMessageId: () => string | undefined;
1553
1762
  },
1554
1763
  ): Promise<Error | undefined> {
1555
- this.pendingA2uiAction = undefined;
1556
- this.pendingA2uiResumeOwner = null;
1764
+ this.dispatch({
1765
+ type: "a2uiContinuationsCleared",
1766
+ });
1557
1767
  const assistantId = ctx.ensureAssistant();
1558
1768
  const currentId = () => ctx.getAssistantMessageId() ?? assistantId;
1559
1769
  const options: ChatModelRunOptions = {
@@ -1604,8 +1814,10 @@ export class AgUiThreadRuntimeCore {
1604
1814
  historyMessages: readonly ThreadMessage[] | undefined,
1605
1815
  resume?: ResumeDispatch,
1606
1816
  ) {
1607
- const pendingA2uiAction = this.pendingA2uiAction;
1608
- this.pendingA2uiAction = undefined;
1817
+ const pendingA2uiAction = this.state.pendingA2uiAction;
1818
+ this.dispatch({
1819
+ type: "a2uiActionCleared",
1820
+ });
1609
1821
  const threadId = this.agent.threadId || "main";
1610
1822
  const messages = toAgUiMessages(
1611
1823
  this.selectRunMessages(
@@ -1625,7 +1837,7 @@ export class AgUiThreadRuntimeCore {
1625
1837
  const input = {
1626
1838
  threadId,
1627
1839
  runId,
1628
- state: this.stateSnapshot ?? null,
1840
+ state: this.state.stateSnapshot ?? null,
1629
1841
  messages,
1630
1842
  tools: toAgUiTools(context?.tools),
1631
1843
  context: [
@@ -1668,17 +1880,20 @@ export class AgUiThreadRuntimeCore {
1668
1880
  }
1669
1881
 
1670
1882
  private clearDeferredContinuations(owner: AbortController): void {
1671
- if (this.pendingResume?.owner === owner) {
1672
- this.pendingResume = null;
1883
+ if (this.state.pendingResume?.owner === owner) {
1884
+ this.dispatch({ type: "resumeCleared" });
1673
1885
  }
1674
- if (this.pendingA2uiResumeOwner === owner) {
1675
- this.pendingA2uiResumeOwner = null;
1676
- this.pendingA2uiAction = undefined;
1886
+ if (this.state.pendingA2uiResumeOwner === owner) {
1887
+ this.dispatch({
1888
+ type: "a2uiContinuationsCleared",
1889
+ });
1677
1890
  }
1678
1891
  }
1679
1892
 
1680
1893
  private setRunning(running: boolean) {
1681
- this.isRunningFlag = running;
1894
+ this.dispatch({
1895
+ type: running ? "runStarted" : "runFinished",
1896
+ });
1682
1897
  this.publishUpdate();
1683
1898
  }
1684
1899
 
@@ -1700,7 +1915,7 @@ export class AgUiThreadRuntimeCore {
1700
1915
  status: { type: "running" },
1701
1916
  content: [],
1702
1917
  metadata: {
1703
- unstable_state: this.stateSnapshot ?? null,
1918
+ unstable_state: this.state.stateSnapshot ?? null,
1704
1919
  unstable_annotations: [],
1705
1920
  unstable_data: [],
1706
1921
  steps: [],
@@ -1758,22 +1973,18 @@ export class AgUiThreadRuntimeCore {
1758
1973
  // An in-flight write completes under the id it was started with, so the
1759
1974
  // rename moves the chain entry and transfers the completion mark; without
1760
1975
  // this the resolve records the dead id and the live one appends again.
1761
- const pendingWrite = this.historyWrites.get(oldId);
1762
- if (pendingWrite) {
1763
- this.historyWrites.delete(oldId);
1764
- if (!collidesWithExisting) {
1765
- this.historyWrites.set(newId, pendingWrite);
1766
- const settle = () => {
1767
- if (this.historyWrites.get(newId) === pendingWrite) {
1768
- this.historyWrites.delete(newId);
1769
- }
1770
- };
1771
- void pendingWrite.then(() => {
1976
+ const pendingWrite = this.historyWrites.move(
1977
+ oldId,
1978
+ collidesWithExisting ? undefined : newId,
1979
+ );
1980
+ if (pendingWrite && !collidesWithExisting) {
1981
+ void pendingWrite.then(
1982
+ () => {
1772
1983
  this.persistedHistoryIds.delete(oldId);
1773
1984
  this.persistedHistoryIds.add(newId);
1774
- settle();
1775
- }, settle);
1776
- }
1985
+ },
1986
+ () => {},
1987
+ );
1777
1988
  }
1778
1989
 
1779
1990
  this.publishUpdate();
@@ -1788,10 +1999,15 @@ export class AgUiThreadRuntimeCore {
1788
1999
  const touched = this.session.updateMessage(messageId, (message) => {
1789
2000
  if (message.role !== "assistant") return message;
1790
2001
  const assistant = message as ThreadAssistantMessage;
1791
- const metadata = update.metadata
2002
+ const mergedMetadata = update.metadata
1792
2003
  ? this.mergeAssistantMetadata(assistant.metadata, update.metadata)
1793
2004
  : assistant.metadata;
1794
2005
  latestStatus = update.status ?? assistant.status;
2006
+ const metadata =
2007
+ latestStatus?.type === "requires-action" &&
2008
+ latestStatus.reason === "tool-calls"
2009
+ ? mergedMetadata
2010
+ : withoutPendingToolCallIds(mergedMetadata);
1795
2011
  const content =
1796
2012
  update.content !== undefined
1797
2013
  ? this.preserveToolResults(
@@ -2041,24 +2257,32 @@ export class AgUiThreadRuntimeCore {
2041
2257
  aggregator: RunAggregator,
2042
2258
  event: AgUiEvent,
2043
2259
  activeAssistantId: string | undefined,
2260
+ joinStrategy: UseAgUiRuntimeOptions["joinStrategy"],
2261
+ runStartMessageIds: ReadonlySet<string>,
2262
+ hasRunUserBoundary: boolean,
2044
2263
  ): string | undefined {
2045
2264
  switch (event.type) {
2046
2265
  case "STATE_SNAPSHOT": {
2047
- this.stateSnapshot = event.snapshot as ReadonlyJSONValue;
2266
+ this.dispatch({
2267
+ type: "stateSnapshot",
2268
+ snapshot: event.snapshot as ReadonlyJSONValue,
2269
+ });
2048
2270
  this.updateActiveAssistantState(activeAssistantId);
2049
2271
  break;
2050
2272
  }
2051
2273
  case "STATE_DELTA": {
2052
2274
  if (event.delta.length === 0) break;
2053
2275
  try {
2054
- const state = this.stateSnapshot ?? {};
2055
2276
  const result = jsonpatch.applyPatch(
2056
- state,
2277
+ this.state.stateSnapshot ?? {},
2057
2278
  event.delta as Operation[],
2058
2279
  /* validateOperation */ true,
2059
2280
  /* mutateDocument */ false,
2060
2281
  );
2061
- this.stateSnapshot = result.newDocument as ReadonlyJSONValue;
2282
+ this.dispatch({
2283
+ type: "stateSnapshot",
2284
+ snapshot: result.newDocument as ReadonlyJSONValue,
2285
+ });
2062
2286
  this.updateActiveAssistantState(activeAssistantId);
2063
2287
  } catch (error) {
2064
2288
  this.logger.error?.("[agui] failed to apply state delta", error);
@@ -2074,7 +2298,13 @@ export class AgUiThreadRuntimeCore {
2074
2298
  const hasActiveText =
2075
2299
  activeAssistant?.role === "assistant" &&
2076
2300
  activeAssistant.content.some((part) => part.type === "text");
2077
- this.importMessagesSnapshot(event.messages, activeAssistantId);
2301
+ this.importMessagesSnapshot(
2302
+ event.messages,
2303
+ activeAssistantId,
2304
+ joinStrategy,
2305
+ runStartMessageIds,
2306
+ hasRunUserBoundary,
2307
+ );
2078
2308
  const headId = this.session.headId;
2079
2309
  if (hasActiveText && headId !== previousHeadId) {
2080
2310
  const head =
@@ -2137,7 +2367,7 @@ export class AgUiThreadRuntimeCore {
2137
2367
  ...assistant,
2138
2368
  metadata: {
2139
2369
  ...assistant.metadata,
2140
- unstable_state: this.stateSnapshot ?? null,
2370
+ unstable_state: this.state.stateSnapshot ?? null,
2141
2371
  },
2142
2372
  };
2143
2373
  });
@@ -2153,6 +2383,7 @@ export class AgUiThreadRuntimeCore {
2153
2383
  if (message.role !== "assistant") return message;
2154
2384
  const assistant = message as ThreadAssistantMessage;
2155
2385
  const mcpAppUri = readMcpAppResourceUri(event.mcpResult?._meta);
2386
+ const modelText = toolContentText(event.content);
2156
2387
  let matchedToolCall = false;
2157
2388
  const { content } = mapToolCallPartsDeep(assistant.content, (part) => {
2158
2389
  if (part.toolCallId !== event.toolCallId) return part;
@@ -2169,11 +2400,9 @@ export class AgUiThreadRuntimeCore {
2169
2400
  if (snapshotResultApplied) {
2170
2401
  return {
2171
2402
  ...part,
2172
- ...(part.modelContent === undefined && event.content
2403
+ ...(part.modelContent === undefined
2173
2404
  ? {
2174
- modelContent: [
2175
- { type: "text" as const, text: event.content },
2176
- ],
2405
+ modelContent: [{ type: "text" as const, text: modelText }],
2177
2406
  }
2178
2407
  : {}),
2179
2408
  ...(part.isError === undefined
@@ -2188,12 +2417,18 @@ export class AgUiThreadRuntimeCore {
2188
2417
  : {}),
2189
2418
  };
2190
2419
  }
2420
+ const { modelContent: _modelContent, ...partWithoutModelContent } =
2421
+ part;
2191
2422
  return {
2192
- ...part,
2423
+ ...(event.mcpResult === undefined && Array.isArray(event.content)
2424
+ ? partWithoutModelContent
2425
+ : part),
2193
2426
  result: (event.mcpResult ??
2194
- tryParseJSON(event.content ?? "")) as ReadonlyJSONValue,
2427
+ (Array.isArray(event.content)
2428
+ ? event.content
2429
+ : tryParseJSON(event.content))) as ReadonlyJSONValue,
2195
2430
  ...(event.mcpResult !== undefined
2196
- ? { modelContent: [{ type: "text" as const, text: event.content }] }
2431
+ ? { modelContent: [{ type: "text" as const, text: modelText }] }
2197
2432
  : {}),
2198
2433
  ...(typeof event.mcpResult?.isError === "boolean"
2199
2434
  ? { isError: event.mcpResult.isError }
@@ -2283,6 +2518,9 @@ export class AgUiThreadRuntimeCore {
2283
2518
  private importMessagesSnapshot(
2284
2519
  rawMessages: readonly unknown[],
2285
2520
  activeAssistantId: string | undefined,
2521
+ joinStrategy: UseAgUiRuntimeOptions["joinStrategy"],
2522
+ runStartMessageIds: ReadonlySet<string>,
2523
+ hasRunUserBoundary: boolean,
2286
2524
  ) {
2287
2525
  try {
2288
2526
  const activeAssistantItem = activeAssistantId
@@ -2337,6 +2575,101 @@ export class AgUiThreadRuntimeCore {
2337
2575
  };
2338
2576
  }
2339
2577
  }
2578
+ if (joinStrategy === "concat-content") {
2579
+ const activeIndex = converted.findIndex(
2580
+ (message) => message.id === activeAssistantId,
2581
+ );
2582
+ const lastKnownIndex = converted.findLastIndex((message) =>
2583
+ runStartMessageIds.has(message.id),
2584
+ );
2585
+ const lastUserIndex = hasRunUserBoundary
2586
+ ? converted.findLastIndex((message) => message.role === "user")
2587
+ : -1;
2588
+ const startIndex =
2589
+ activeIndex >= 0
2590
+ ? activeIndex
2591
+ : lastKnownIndex >= 0 || lastUserIndex >= 0
2592
+ ? Math.max(lastKnownIndex, lastUserIndex) + 1
2593
+ : -1;
2594
+ if (startIndex >= 0 && converted[startIndex]?.role === "assistant") {
2595
+ let endIndex = startIndex + 1;
2596
+ while (converted[endIndex]?.role === "assistant") endIndex++;
2597
+ const runMessages = converted.slice(
2598
+ startIndex,
2599
+ endIndex,
2600
+ ) as ThreadAssistantMessage[];
2601
+ const includesStreamedPrefix =
2602
+ activeIndex < 0 &&
2603
+ activeAssistant?.content.some((part) => part.type !== "data");
2604
+ if (includesStreamedPrefix && activeAssistant) {
2605
+ runMessages.unshift(activeAssistant);
2606
+ }
2607
+ if (runMessages.length > 1) {
2608
+ const [first, ...rest] = runMessages;
2609
+ let content = runMessages.flatMap((message) => message.content);
2610
+ if (includesStreamedPrefix) {
2611
+ content = [...first!.content];
2612
+ let cursor = 0;
2613
+ const keyOf = (part: (typeof content)[number]) =>
2614
+ part.type === "tool-call"
2615
+ ? `tool:${part.toolCallId}`
2616
+ : part.type === "text" || part.type === "reasoning"
2617
+ ? `${part.type}:${part.text}`
2618
+ : undefined;
2619
+ for (const message of rest) {
2620
+ for (const part of message.content) {
2621
+ const key = keyOf(part);
2622
+ const match =
2623
+ key === undefined
2624
+ ? -1
2625
+ : content.findIndex(
2626
+ (previous, index) =>
2627
+ index >= cursor && keyOf(previous) === key,
2628
+ );
2629
+ if (match < 0) {
2630
+ content.push(part);
2631
+ cursor = content.length;
2632
+ } else {
2633
+ if (part.type === "tool-call") {
2634
+ content[match] = this.preserveToolResults(
2635
+ [content[match]!],
2636
+ [part],
2637
+ )[0]!;
2638
+ }
2639
+ cursor = match + 1;
2640
+ }
2641
+ }
2642
+ }
2643
+ }
2644
+ converted.splice(startIndex, endIndex - startIndex, {
2645
+ ...first!,
2646
+ content,
2647
+ status: rest.at(-1)!.status,
2648
+ metadata: rest.reduce(
2649
+ (metadata, message) => ({
2650
+ ...metadata,
2651
+ ...message.metadata,
2652
+ unstable_state: message.metadata.unstable_state,
2653
+ unstable_annotations: [
2654
+ ...metadata.unstable_annotations,
2655
+ ...message.metadata.unstable_annotations,
2656
+ ],
2657
+ unstable_data: [
2658
+ ...metadata.unstable_data,
2659
+ ...message.metadata.unstable_data,
2660
+ ],
2661
+ steps: [...metadata.steps, ...message.metadata.steps],
2662
+ custom: mergeAgUiNamespace(
2663
+ metadata.custom,
2664
+ message.metadata.custom,
2665
+ ),
2666
+ }),
2667
+ first!.metadata,
2668
+ ),
2669
+ });
2670
+ }
2671
+ }
2672
+ }
2340
2673
  const snapshotContainsActiveAssistant = converted.some(
2341
2674
  (message) => message.id === activeAssistant?.id,
2342
2675
  );
@@ -2414,7 +2747,7 @@ export class AgUiThreadRuntimeCore {
2414
2747
  (wasPersisted || this.snapshotHistoryIds.has(messageId));
2415
2748
 
2416
2749
  if (shouldUpdate) {
2417
- const write = this.chainHistoryWrite(messageId, () =>
2750
+ const write = this.historyWrites.chain(messageId, () =>
2418
2751
  update.call(history, { parentId, message }),
2419
2752
  );
2420
2753
  this.assistantHistoryParents.delete(messageId);
@@ -2423,7 +2756,7 @@ export class AgUiThreadRuntimeCore {
2423
2756
  this.persistedHistoryIds.add(messageId);
2424
2757
  },
2425
2758
  (error) => {
2426
- const pending = this.historyWrites.get(messageId);
2759
+ const pending = this.historyWrites.pending(messageId);
2427
2760
  if (pending === undefined || pending === write) {
2428
2761
  this.assistantHistoryParents.set(messageId, parentId);
2429
2762
  }
@@ -2444,7 +2777,7 @@ export class AgUiThreadRuntimeCore {
2444
2777
  void write.then(
2445
2778
  () => {},
2446
2779
  (error) => {
2447
- const pending = this.historyWrites.get(messageId);
2780
+ const pending = this.historyWrites.pending(messageId);
2448
2781
  if (pending === undefined || pending === write) {
2449
2782
  this.assistantHistoryParents.set(messageId, parentId);
2450
2783
  }
@@ -2458,11 +2791,11 @@ export class AgUiThreadRuntimeCore {
2458
2791
  message: ThreadMessage,
2459
2792
  ): Promise<void> | undefined {
2460
2793
  if (!this.history || this.persistedHistoryIds.has(message.id)) return;
2461
- const pending = this.historyWrites.get(message.id);
2794
+ const pending = this.historyWrites.pending(message.id);
2462
2795
  if (pending) return pending;
2463
2796
 
2464
2797
  const append = this.history.append.bind(this.history);
2465
- const write = this.chainHistoryWrite(message.id, () =>
2798
+ const write = this.historyWrites.chain(message.id, () =>
2466
2799
  append({ parentId, message }),
2467
2800
  );
2468
2801
  void write.then(
@@ -2473,35 +2806,4 @@ export class AgUiThreadRuntimeCore {
2473
2806
  );
2474
2807
  return write;
2475
2808
  }
2476
-
2477
- private chainHistoryWrite(
2478
- id: string,
2479
- write: () => Promise<void>,
2480
- ): Promise<void> {
2481
- const pending = this.historyWrites.get(id);
2482
- let next: Promise<void>;
2483
- if (pending) {
2484
- next = pending.then(write, write);
2485
- } else {
2486
- try {
2487
- next = Promise.resolve(write());
2488
- } catch (error) {
2489
- next = Promise.reject(error);
2490
- }
2491
- }
2492
- this.historyWrites.set(id, next);
2493
- void next.then(
2494
- () => {
2495
- if (this.historyWrites.get(id) === next) {
2496
- this.historyWrites.delete(id);
2497
- }
2498
- },
2499
- () => {
2500
- if (this.historyWrites.get(id) === next) {
2501
- this.historyWrites.delete(id);
2502
- }
2503
- },
2504
- );
2505
- return next;
2506
- }
2507
2809
  }