@assistant-ui/react-ag-ui 0.0.71 → 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 (52) hide show
  1. package/README.md +1 -1
  2. package/dist/runtime/{AgUiThreadRuntimeCore.d.ts → AgUiThreadController.d.ts} +6 -12
  3. package/dist/runtime/AgUiThreadController.d.ts.map +1 -0
  4. package/dist/runtime/{AgUiThreadRuntimeCore.js → AgUiThreadController.js} +246 -159
  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 +3 -5
  11. package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
  12. package/dist/runtime/adapter/run-aggregator.js +47 -61
  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 +11 -35
  30. package/dist/runtime/types.d.ts.map +1 -1
  31. package/dist/runtime/types.js.map +1 -1
  32. package/dist/useAgUiRuntime.js +2 -2
  33. package/dist/useAgUiRuntime.js.map +1 -1
  34. package/package.json +12 -11
  35. package/src/runtime/{AgUiThreadRuntimeCore.interrupt-cancel.test.ts → AgUiThreadController.interrupt-cancel.test.ts} +4 -4
  36. package/src/runtime/{AgUiThreadRuntimeCore.test.ts → AgUiThreadController.test.ts} +35 -6
  37. package/src/runtime/{AgUiThreadRuntimeCore.ts → AgUiThreadController.ts} +356 -171
  38. package/src/runtime/adapter/conversions.content.test.ts +37 -5
  39. package/src/runtime/adapter/conversions.tools.test.ts +2 -1
  40. package/src/runtime/adapter/conversions.ts +235 -58
  41. package/src/runtime/adapter/run-aggregator.ts +79 -114
  42. package/src/runtime/adapter/subscriber.ts +1 -44
  43. package/src/runtime/agUiThreadState.test.ts +205 -0
  44. package/src/runtime/agUiThreadState.ts +130 -0
  45. package/src/runtime/event-parser.ts +40 -58
  46. package/src/runtime/mcp-tool-result.ts +16 -2
  47. package/src/runtime/types.ts +16 -28
  48. package/src/useAgUiRuntime.fast-refresh.test.tsx +3 -3
  49. package/src/useAgUiRuntime.thread-switch.test.tsx +4 -4
  50. package/src/useAgUiRuntime.ts +3 -3
  51. package/dist/runtime/AgUiThreadRuntimeCore.d.ts.map +0 -1
  52. 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
 
@@ -155,7 +195,7 @@ const mergeAgUiNamespace = (
155
195
  return merged;
156
196
  };
157
197
 
158
- export class AgUiThreadRuntimeCore {
198
+ export class AgUiThreadController {
159
199
  private agent: AbstractAgent;
160
200
  private logger: Logger;
161
201
  private showThinking: boolean;
@@ -194,27 +234,18 @@ export class AgUiThreadRuntimeCore {
194
234
  };
195
235
  },
196
236
  });
197
- private isRunningFlag = false;
237
+ private state: AgUiThreadState = createAgUiThreadState();
198
238
  private abortController: AbortController | null = null;
199
239
  // The agent that started the active run. updateOptions can swap this.agent
200
240
  // mid-run, and cancelling has to reach the agent holding the live request.
201
241
  private activeRunAgent: AbstractAgent | null = null;
202
- private stateSnapshot: ReadonlyJSONValue | undefined;
203
242
  private history: ThreadHistoryAdapter | undefined;
204
- private lastRunConfig: RunConfig | undefined;
205
243
  private readonly assistantHistoryParents = new Map<string, string | null>();
206
244
  private readonly snapshotHistoryIds = new Set<string>();
207
245
  private readonly persistedHistoryIds = new Set<string>();
208
- private readonly historyWrites = new Map<string, Promise<void>>();
209
- private _isLoading = false;
246
+ private readonly historyWrites = createHistoryWriteChain();
210
247
  private _historyImportPromise: Promise<void> | undefined;
211
- private historyMutationGeneration = 0;
212
248
  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
249
 
219
250
  constructor(options: CoreOptions) {
220
251
  this.agent = options.agent;
@@ -237,12 +268,16 @@ export class AgUiThreadRuntimeCore {
237
268
 
238
269
  getSnapshot = () =>
239
270
  (this.snapshot ??= {
240
- isLoading: this._isLoading,
241
- isRunning: this.isRunningFlag,
271
+ isLoading: this.state.isLoading,
272
+ isRunning: this.state.isRunning,
242
273
  messageRepository: this.session.export(),
243
- state: this.stateSnapshot,
274
+ state: this.state.stateSnapshot,
244
275
  });
245
276
 
277
+ private dispatch(action: AgUiThreadAction): void {
278
+ this.state = reduceAgUiThreadState(this.state, action);
279
+ }
280
+
246
281
  private publishUpdate(): void {
247
282
  this.snapshot = undefined;
248
283
  for (const listener of this.listeners) listener();
@@ -261,7 +296,7 @@ export class AgUiThreadRuntimeCore {
261
296
  const previousHistory = this.history;
262
297
  this.history = options.history;
263
298
  if (
264
- this._loadRequested &&
299
+ this.state.loadRequested &&
265
300
  !this._loadPromise &&
266
301
  !previousHistory &&
267
302
  options.history &&
@@ -291,19 +326,19 @@ export class AgUiThreadRuntimeCore {
291
326
  }
292
327
 
293
328
  getState(): ReadonlyJSONValue | undefined {
294
- return this.stateSnapshot;
329
+ return this.state.stateSnapshot;
295
330
  }
296
331
 
297
332
  isRunning(): boolean {
298
- return this.isRunningFlag;
333
+ return this.state.isRunning;
299
334
  }
300
335
 
301
336
  get isLoading(): boolean {
302
- return this._isLoading;
337
+ return this.state.isLoading;
303
338
  }
304
339
 
305
340
  __internal_load(): Promise<void> {
306
- this._loadRequested = true;
341
+ this.dispatch({ type: "loadRequested" });
307
342
  if (this._loadPromise) return this._loadPromise;
308
343
  if (!this.history) return Promise.resolve();
309
344
 
@@ -326,7 +361,7 @@ export class AgUiThreadRuntimeCore {
326
361
  return repo;
327
362
  });
328
363
 
329
- this._isLoading = true;
364
+ this.dispatch({ type: "loadStarted" });
330
365
  this._historyImportPromise = promise.then(
331
366
  () => {
332
367
  this._historyImportPromise = undefined;
@@ -343,7 +378,7 @@ export class AgUiThreadRuntimeCore {
343
378
  const resumeStream = this.history?.resume?.bind(this.history);
344
379
  await this.startRun(
345
380
  parentId,
346
- this.lastRunConfig,
381
+ this.state.lastRunConfig,
347
382
  undefined,
348
383
  resumeStream,
349
384
  );
@@ -354,7 +389,9 @@ export class AgUiThreadRuntimeCore {
354
389
  this.reportError(error);
355
390
  })
356
391
  .finally(() => {
357
- this._isLoading = false;
392
+ this.dispatch({
393
+ type: "loadFinished",
394
+ });
358
395
  this.publishUpdate();
359
396
  });
360
397
 
@@ -365,10 +402,10 @@ export class AgUiThreadRuntimeCore {
365
402
  private waitForHistoryImport(): Promise<boolean> | undefined {
366
403
  const historyImport = this._historyImportPromise;
367
404
  if (!historyImport) return undefined;
368
- const generation = this.historyMutationGeneration;
405
+ const generation = this.state.historyMutationGeneration;
369
406
  return historyImport.then(
370
407
  () =>
371
- generation === this.historyMutationGeneration &&
408
+ generation === this.state.historyMutationGeneration &&
372
409
  !this.isThreadSwitching?.(),
373
410
  );
374
411
  }
@@ -376,10 +413,10 @@ export class AgUiThreadRuntimeCore {
376
413
  private deferUntilHistoryImported(callback: () => void): boolean {
377
414
  const historyImport = this._historyImportPromise;
378
415
  if (!historyImport) return false;
379
- const generation = this.historyMutationGeneration;
416
+ const generation = this.state.historyMutationGeneration;
380
417
  void historyImport.then(() => {
381
418
  if (
382
- generation !== this.historyMutationGeneration ||
419
+ generation !== this.state.historyMutationGeneration ||
383
420
  this.isThreadSwitching?.()
384
421
  ) {
385
422
  return;
@@ -397,10 +434,10 @@ export class AgUiThreadRuntimeCore {
397
434
  const historyImport = this._historyImportPromise;
398
435
  if (historyImport) {
399
436
  const wasAtTail = message.parentId === this.session.headId;
400
- const generation = this.historyMutationGeneration;
437
+ const generation = this.state.historyMutationGeneration;
401
438
  await historyImport;
402
439
  if (
403
- generation !== this.historyMutationGeneration ||
440
+ generation !== this.state.historyMutationGeneration ||
404
441
  this.isThreadSwitching?.()
405
442
  )
406
443
  return;
@@ -434,10 +471,10 @@ export class AgUiThreadRuntimeCore {
434
471
 
435
472
  private maybeAutoCancelPendingToolCalls(): void {
436
473
  if (this.autoCancelPendingToolCalls === false) return;
437
- const pending = this.getPendingToolCalls();
438
- if (!pending) return;
439
- this.cancelUnresolvedToolCalls(pending.messageId);
440
- this.maybeCompleteAfterToolResults(pending.messageId);
474
+ for (const assistant of this.getPendingToolCallMessages()) {
475
+ this.cancelUnresolvedToolCalls(assistant.id);
476
+ this.maybeCompleteAfterToolResults(assistant.id);
477
+ }
441
478
  }
442
479
 
443
480
  private appendEntry(message: AppendMessage): string {
@@ -471,7 +508,9 @@ export class AgUiThreadRuntimeCore {
471
508
  }
472
509
 
473
510
  async cancel(): Promise<void> {
474
- this.historyMutationGeneration++;
511
+ this.dispatch({
512
+ type: "historyMutationAdvanced",
513
+ });
475
514
  this.abortActiveRun();
476
515
  }
477
516
 
@@ -518,7 +557,7 @@ export class AgUiThreadRuntimeCore {
518
557
  this.assertNoPendingInterrupts();
519
558
  await this.startRun(
520
559
  config.parentId,
521
- config.runConfig ?? this.lastRunConfig,
560
+ config.runConfig ?? this.state.lastRunConfig,
522
561
  undefined,
523
562
  config.stream,
524
563
  );
@@ -541,7 +580,7 @@ export class AgUiThreadRuntimeCore {
541
580
  try {
542
581
  await this.startRun(
543
582
  parentId,
544
- this.lastRunConfig,
583
+ this.state.lastRunConfig,
545
584
  undefined,
546
585
  resumeStream,
547
586
  );
@@ -573,6 +612,25 @@ export class AgUiThreadRuntimeCore {
573
612
  return assistant;
574
613
  }
575
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
+
576
634
  getPendingInterrupts(): {
577
635
  messageId: string;
578
636
  interrupts: readonly AgUiInterrupt[];
@@ -592,15 +650,21 @@ export class AgUiThreadRuntimeCore {
592
650
  messageId: string;
593
651
  toolCallIds: string[];
594
652
  } | null {
595
- const assistant = this.findRequiresActionAssistant("tool-calls");
596
- if (!assistant) return null;
597
653
  const toolCallIds: string[] = [];
598
- for (const part of iterateToolCallParts(assistant.content)) {
599
- if (isResolvedToolCall(part)) continue;
600
- 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
+ }
601
666
  }
602
- if (toolCallIds.length === 0) return null;
603
- return { messageId: assistant.id, toolCallIds };
667
+ return messageId === undefined ? null : { messageId, toolCallIds };
604
668
  }
605
669
 
606
670
  async submitInterruptResponses(
@@ -632,6 +696,7 @@ export class AgUiThreadRuntimeCore {
632
696
  this.assertInterruptsAnswerable(
633
697
  "submitInterruptResponses",
634
698
  pending.interrupts,
699
+ responsesById,
635
700
  );
636
701
 
637
702
  await this.resumeWithResponses(
@@ -651,7 +716,7 @@ export class AgUiThreadRuntimeCore {
651
716
  resume: AgUiResumeEntry[],
652
717
  ): Promise<void> {
653
718
  this.clearPendingInterrupts(messageId, resume);
654
- await this.startRun(messageId, this.lastRunConfig, {
719
+ await this.startRun(messageId, this.state.lastRunConfig, {
655
720
  entries: resume,
656
721
  interruptedMessageId: messageId,
657
722
  });
@@ -679,6 +744,7 @@ export class AgUiThreadRuntimeCore {
679
744
  private assertInterruptsAnswerable(
680
745
  method: "submitInterruptResponses" | "respondToToolApproval",
681
746
  interrupts: readonly AgUiInterrupt[],
747
+ responsesById?: ReadonlyMap<string, AgUiResumeEntry>,
682
748
  ): void {
683
749
  const now = Date.now();
684
750
  for (const interrupt of interrupts) {
@@ -689,13 +755,16 @@ export class AgUiThreadRuntimeCore {
689
755
  `[agui] ${method}: interrupt ${interrupt.id} has malformed expiresAt "${interrupt.expiresAt}"`,
690
756
  );
691
757
  }
692
- if (expiry <= now) {
758
+ if (
759
+ expiry <= now &&
760
+ responsesById?.get(interrupt.id)?.status !== "cancelled"
761
+ ) {
693
762
  throw new Error(
694
763
  `[agui] ${method}: interrupt ${interrupt.id} expired at ${interrupt.expiresAt}`,
695
764
  );
696
765
  }
697
766
  }
698
- if (this.isRunningFlag) {
767
+ if (this.state.isRunning) {
699
768
  throw new Error(`[agui] ${method}: a run is already in progress`);
700
769
  }
701
770
  }
@@ -789,11 +858,13 @@ export class AgUiThreadRuntimeCore {
789
858
  "[agui] steerAway: responses are only valid for pending interrupts",
790
859
  );
791
860
  }
792
- if (this.isRunningFlag) {
861
+ if (this.state.isRunning) {
793
862
  throw new Error("[agui] steerAway: a run is already in progress");
794
863
  }
795
- this.cancelUnresolvedToolCalls(pendingTools.messageId);
796
- this.maybeCompleteAfterToolResults(pendingTools.messageId);
864
+ for (const assistant of this.getPendingToolCallMessages()) {
865
+ this.cancelUnresolvedToolCalls(assistant.id);
866
+ this.maybeCompleteAfterToolResults(assistant.id);
867
+ }
797
868
  const normalized = this.toAppendMessage(message);
798
869
  const threadMessageId = this.appendEntry(normalized);
799
870
  await this.startRun(threadMessageId, normalized.runConfig);
@@ -810,7 +881,7 @@ export class AgUiThreadRuntimeCore {
810
881
 
811
882
  const resume = this.resolveSteerAwayResume(pending.interrupts, responses);
812
883
 
813
- if (this.isRunningFlag) {
884
+ if (this.state.isRunning) {
814
885
  throw new Error("[agui] steerAway: a run is already in progress");
815
886
  }
816
887
 
@@ -1067,7 +1138,7 @@ export class AgUiThreadRuntimeCore {
1067
1138
  return;
1068
1139
  }
1069
1140
 
1070
- await this.chainHistoryWrite(sessionMessageId, () =>
1141
+ await this.historyWrites.chain(sessionMessageId, () =>
1071
1142
  history.update!({ parentId: item.parentId, message }),
1072
1143
  );
1073
1144
  }
@@ -1090,11 +1161,17 @@ export class AgUiThreadRuntimeCore {
1090
1161
  if (!("timestamp" in userAction)) {
1091
1162
  userAction.timestamp = new Date().toISOString();
1092
1163
  }
1093
- this.pendingA2uiAction = userAction;
1164
+ this.dispatch({
1165
+ type: "a2uiActionDeferred",
1166
+ action: userAction,
1167
+ });
1094
1168
 
1095
1169
  const owner = this.abortController;
1096
1170
  if (owner) {
1097
- this.pendingA2uiResumeOwner = owner;
1171
+ this.dispatch({
1172
+ type: "a2uiResumeDeferred",
1173
+ owner,
1174
+ });
1098
1175
  return;
1099
1176
  }
1100
1177
  this.startResumeRun(parentId);
@@ -1105,15 +1182,25 @@ export class AgUiThreadRuntimeCore {
1105
1182
  // run is still draining) or after it.
1106
1183
  private maybeResumeAfterToolResults(messageId: string): void {
1107
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;
1108
1191
 
1109
1192
  const owner = this.abortController;
1110
1193
  if (owner) {
1111
1194
  // A run is still draining (RUN_FINISHED arrived but the stream has not
1112
1195
  // closed). Defer until startRun's tail so we never start two runs.
1113
- this.pendingResume = { owner, messageId };
1196
+ this.dispatch({
1197
+ type: "resumeDeferred",
1198
+ owner,
1199
+ messageId: headId,
1200
+ });
1114
1201
  return;
1115
1202
  }
1116
- this.startResumeRun(messageId);
1203
+ this.startResumeRun(headId);
1117
1204
  }
1118
1205
 
1119
1206
  private maybeCompleteAfterToolResults(messageId: string): boolean {
@@ -1127,7 +1214,9 @@ export class AgUiThreadRuntimeCore {
1127
1214
  return false;
1128
1215
  }
1129
1216
  let allResolved = true;
1217
+ const listed = listedPendingToolCallIds(assistant);
1130
1218
  for (const part of iterateToolCallParts(assistant.content)) {
1219
+ if (listed && !listed.has(part.toolCallId)) continue;
1131
1220
  if (!isResolvedToolCall(part)) {
1132
1221
  allResolved = false;
1133
1222
  break;
@@ -1140,6 +1229,7 @@ export class AgUiThreadRuntimeCore {
1140
1229
  ? {
1141
1230
  ...(current as ThreadAssistantMessage),
1142
1231
  status: { type: "complete" as const, reason: "unknown" as const },
1232
+ metadata: withoutPendingToolCallIds(current.metadata),
1143
1233
  }
1144
1234
  : current,
1145
1235
  );
@@ -1150,7 +1240,7 @@ export class AgUiThreadRuntimeCore {
1150
1240
  }
1151
1241
 
1152
1242
  private startResumeRun(messageId: string): void {
1153
- void this.startRun(messageId, this.lastRunConfig).catch((error) => {
1243
+ void this.startRun(messageId, this.state.lastRunConfig).catch((error) => {
1154
1244
  this.reportError(error);
1155
1245
  });
1156
1246
  }
@@ -1167,8 +1257,9 @@ export class AgUiThreadRuntimeCore {
1167
1257
  metadata: { ...message.metadata, modality },
1168
1258
  } as ThreadMessage;
1169
1259
  });
1170
- this.pendingA2uiResumeOwner = null;
1171
- this.pendingA2uiAction = undefined;
1260
+ this.dispatch({
1261
+ type: "a2uiContinuationsCleared",
1262
+ });
1172
1263
  this.assistantHistoryParents.clear();
1173
1264
 
1174
1265
  if (messages.length === 0) {
@@ -1217,18 +1308,21 @@ export class AgUiThreadRuntimeCore {
1217
1308
  // continuation whose target survived the snapshot is still answerable.
1218
1309
  // Membership is the head branch, not the repository: a soft merge leaves
1219
1310
  // the messages it dropped behind as off-branch nodes.
1220
- const resumeTarget = this.pendingResume?.messageId;
1311
+ const resumeTarget = this.state.pendingResume?.messageId;
1221
1312
  if (
1222
1313
  resumeTarget !== undefined &&
1223
1314
  !this.session.getMessages().some((message) => message.id === resumeTarget)
1224
1315
  ) {
1225
- this.pendingResume = null;
1316
+ this.dispatch({ type: "resumeCleared" });
1226
1317
  }
1227
1318
  this.publishUpdate();
1228
1319
  }
1229
1320
 
1230
1321
  loadExternalState(state: ReadonlyJSONValue): void {
1231
- this.stateSnapshot = state;
1322
+ this.dispatch({
1323
+ type: "stateSnapshot",
1324
+ snapshot: state,
1325
+ });
1232
1326
  this.publishUpdate();
1233
1327
  }
1234
1328
 
@@ -1237,25 +1331,28 @@ export class AgUiThreadRuntimeCore {
1237
1331
  | ReadonlyJSONValue
1238
1332
  | ((prev: ReadonlyJSONValue | undefined) => ReadonlyJSONValue),
1239
1333
  ): void {
1240
- this.stateSnapshot =
1241
- 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
+ });
1242
1340
  this.publishUpdate();
1243
1341
  }
1244
1342
 
1245
1343
  resetState(): void {
1246
- this.stateSnapshot = undefined;
1344
+ this.dispatch({ type: "resetState" });
1247
1345
  this.publishUpdate();
1248
1346
  }
1249
1347
 
1250
1348
  resetThreadState(): void {
1251
- this.historyMutationGeneration++;
1349
+ this.dispatch({
1350
+ type: "historyMutationAdvanced",
1351
+ });
1252
1352
  const controller = this.abortController;
1253
1353
  const activeRunAgent = this.activeRunAgent;
1254
1354
 
1255
- this.stateSnapshot = undefined;
1256
- this.pendingResume = null;
1257
- this.pendingA2uiResumeOwner = null;
1258
- this.pendingA2uiAction = undefined;
1355
+ this.dispatch({ type: "resetThread" });
1259
1356
 
1260
1357
  if (controller) {
1261
1358
  this.abortController = null;
@@ -1286,7 +1383,10 @@ export class AgUiThreadRuntimeCore {
1286
1383
  if (!this.supersedeActiveRun()) return;
1287
1384
 
1288
1385
  const normalizedRunConfig = runConfig ?? {};
1289
- this.lastRunConfig = normalizedRunConfig;
1386
+ this.dispatch({
1387
+ type: "runConfigRecorded",
1388
+ runConfig: normalizedRunConfig,
1389
+ });
1290
1390
  const parent =
1291
1391
  parentId === null ? undefined : this.session.tryGetMessage(parentId);
1292
1392
  const shouldEagerlyInsertAssistant =
@@ -1315,6 +1415,9 @@ export class AgUiThreadRuntimeCore {
1315
1415
  // resurrecting for custom-event data would leave a trailing data-only
1316
1416
  // duplicate.
1317
1417
  let assistantCollided = false;
1418
+ const heldMessages = new Map<string, Set<string>>();
1419
+ let restrictedPendingToolCallIds: ReadonlySet<string> | undefined;
1420
+ let settlingRun = false;
1318
1421
  const ensureAssistant = (allowRecreate = false): string => {
1319
1422
  const cached = assistantMessageId;
1320
1423
  if (cached !== undefined && this.session.tryGetMessage(cached))
@@ -1344,7 +1447,66 @@ export class AgUiThreadRuntimeCore {
1344
1447
 
1345
1448
  if (shouldEagerlyInsertAssistant) ensureAssistant();
1346
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
+
1347
1504
  const applyUpdate = (update: ChatModelRunResult) => {
1505
+ const terminalStatus = update.status;
1506
+ const pendingMessages =
1507
+ settlingRun && terminalStatus && terminalStatus.type !== "running"
1508
+ ? settleHeldMessages(terminalStatus)
1509
+ : [];
1348
1510
  const hasStreamContent =
1349
1511
  Array.isArray(update.content) &&
1350
1512
  update.content.some((part) => part.type !== "data");
@@ -1355,14 +1517,22 @@ export class AgUiThreadRuntimeCore {
1355
1517
  if (resolved !== assistantMessageId) {
1356
1518
  assistantMessageId = resolved;
1357
1519
  }
1520
+ for (const messageId of pendingMessages) {
1521
+ this.maybeResumeAfterToolResults(messageId);
1522
+ }
1358
1523
  };
1359
1524
 
1360
1525
  const adoptServerMessageId = (
1361
1526
  serverId: string,
1362
1527
  startNewMessage: boolean,
1528
+ openToolCallIds: string[] = [],
1363
1529
  ) => {
1364
1530
  if (startNewMessage && assistantMessageId !== undefined) {
1365
- 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
+ }
1366
1536
  const previousId = assistantMessageId;
1367
1537
  assistantMessageId = undefined;
1368
1538
  if (this.session.tryGetMessage(previousId)) {
@@ -1404,22 +1574,43 @@ export class AgUiThreadRuntimeCore {
1404
1574
  onServerMessageId: (serverId) => {
1405
1575
  adoptServerMessageId(serverId, false);
1406
1576
  },
1407
- onTextMessageStart: (serverId) => adoptServerMessageId(serverId, true),
1577
+ onTextMessageStart: (serverId, openToolCallIds) =>
1578
+ adoptServerMessageId(serverId, true, openToolCallIds),
1408
1579
  });
1409
1580
  let runFinished = false;
1410
1581
  const dispatch = (event: AgUiEvent) => {
1411
1582
  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;
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;
1423
1614
  }
1424
1615
  };
1425
1616
 
@@ -1482,9 +1673,9 @@ export class AgUiThreadRuntimeCore {
1482
1673
  },
1483
1674
  });
1484
1675
  try {
1485
- (runAgentInstance as any).messages = input.messages;
1486
- (runAgentInstance as any).threadId = input.threadId;
1487
- (runAgentInstance as any).state = input.state ?? null;
1676
+ runAgentInstance.messages = input.messages;
1677
+ runAgentInstance.threadId = input.threadId;
1678
+ runAgentInstance.state = input.state ?? null;
1488
1679
  } catch {
1489
1680
  // ignore
1490
1681
  }
@@ -1514,19 +1705,23 @@ export class AgUiThreadRuntimeCore {
1514
1705
 
1515
1706
  // A tool result that landed before the run settled deferred its
1516
1707
  // 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;
1708
+ if (this.state.pendingResume?.owner === abortController) {
1709
+ const { messageId } = this.state.pendingResume;
1710
+ this.dispatch({ type: "resumeCleared" });
1520
1711
  if (!abortSignal.aborted) {
1521
1712
  this.startResumeRun(messageId);
1522
1713
  }
1523
1714
  }
1524
1715
 
1525
- if (this.pendingA2uiResumeOwner === abortController) {
1526
- this.pendingA2uiResumeOwner = null;
1527
- 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) {
1528
1721
  if (this.getPendingInterrupts()) {
1529
- this.pendingA2uiAction = undefined;
1722
+ this.dispatch({
1723
+ type: "a2uiActionCleared",
1724
+ });
1530
1725
  this.logger.debug(
1531
1726
  "[agui] sendA2uiAction: pending interrupts, dropping action",
1532
1727
  );
@@ -1537,13 +1732,17 @@ export class AgUiThreadRuntimeCore {
1537
1732
  if (parentId !== null) {
1538
1733
  this.startResumeRun(parentId);
1539
1734
  } else {
1540
- this.pendingA2uiAction = undefined;
1735
+ this.dispatch({
1736
+ type: "a2uiActionCleared",
1737
+ });
1541
1738
  this.logger.debug(
1542
1739
  "[agui] sendA2uiAction: no messages to resume, dropping action",
1543
1740
  );
1544
1741
  }
1545
1742
  } else {
1546
- this.pendingA2uiAction = undefined;
1743
+ this.dispatch({
1744
+ type: "a2uiActionCleared",
1745
+ });
1547
1746
  }
1548
1747
  }
1549
1748
  }
@@ -1562,8 +1761,9 @@ export class AgUiThreadRuntimeCore {
1562
1761
  getAssistantMessageId: () => string | undefined;
1563
1762
  },
1564
1763
  ): Promise<Error | undefined> {
1565
- this.pendingA2uiAction = undefined;
1566
- this.pendingA2uiResumeOwner = null;
1764
+ this.dispatch({
1765
+ type: "a2uiContinuationsCleared",
1766
+ });
1567
1767
  const assistantId = ctx.ensureAssistant();
1568
1768
  const currentId = () => ctx.getAssistantMessageId() ?? assistantId;
1569
1769
  const options: ChatModelRunOptions = {
@@ -1614,8 +1814,10 @@ export class AgUiThreadRuntimeCore {
1614
1814
  historyMessages: readonly ThreadMessage[] | undefined,
1615
1815
  resume?: ResumeDispatch,
1616
1816
  ) {
1617
- const pendingA2uiAction = this.pendingA2uiAction;
1618
- this.pendingA2uiAction = undefined;
1817
+ const pendingA2uiAction = this.state.pendingA2uiAction;
1818
+ this.dispatch({
1819
+ type: "a2uiActionCleared",
1820
+ });
1619
1821
  const threadId = this.agent.threadId || "main";
1620
1822
  const messages = toAgUiMessages(
1621
1823
  this.selectRunMessages(
@@ -1635,7 +1837,7 @@ export class AgUiThreadRuntimeCore {
1635
1837
  const input = {
1636
1838
  threadId,
1637
1839
  runId,
1638
- state: this.stateSnapshot ?? null,
1840
+ state: this.state.stateSnapshot ?? null,
1639
1841
  messages,
1640
1842
  tools: toAgUiTools(context?.tools),
1641
1843
  context: [
@@ -1678,17 +1880,20 @@ export class AgUiThreadRuntimeCore {
1678
1880
  }
1679
1881
 
1680
1882
  private clearDeferredContinuations(owner: AbortController): void {
1681
- if (this.pendingResume?.owner === owner) {
1682
- this.pendingResume = null;
1883
+ if (this.state.pendingResume?.owner === owner) {
1884
+ this.dispatch({ type: "resumeCleared" });
1683
1885
  }
1684
- if (this.pendingA2uiResumeOwner === owner) {
1685
- this.pendingA2uiResumeOwner = null;
1686
- this.pendingA2uiAction = undefined;
1886
+ if (this.state.pendingA2uiResumeOwner === owner) {
1887
+ this.dispatch({
1888
+ type: "a2uiContinuationsCleared",
1889
+ });
1687
1890
  }
1688
1891
  }
1689
1892
 
1690
1893
  private setRunning(running: boolean) {
1691
- this.isRunningFlag = running;
1894
+ this.dispatch({
1895
+ type: running ? "runStarted" : "runFinished",
1896
+ });
1692
1897
  this.publishUpdate();
1693
1898
  }
1694
1899
 
@@ -1710,7 +1915,7 @@ export class AgUiThreadRuntimeCore {
1710
1915
  status: { type: "running" },
1711
1916
  content: [],
1712
1917
  metadata: {
1713
- unstable_state: this.stateSnapshot ?? null,
1918
+ unstable_state: this.state.stateSnapshot ?? null,
1714
1919
  unstable_annotations: [],
1715
1920
  unstable_data: [],
1716
1921
  steps: [],
@@ -1768,22 +1973,18 @@ export class AgUiThreadRuntimeCore {
1768
1973
  // An in-flight write completes under the id it was started with, so the
1769
1974
  // rename moves the chain entry and transfers the completion mark; without
1770
1975
  // 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(() => {
1976
+ const pendingWrite = this.historyWrites.move(
1977
+ oldId,
1978
+ collidesWithExisting ? undefined : newId,
1979
+ );
1980
+ if (pendingWrite && !collidesWithExisting) {
1981
+ void pendingWrite.then(
1982
+ () => {
1782
1983
  this.persistedHistoryIds.delete(oldId);
1783
1984
  this.persistedHistoryIds.add(newId);
1784
- settle();
1785
- }, settle);
1786
- }
1985
+ },
1986
+ () => {},
1987
+ );
1787
1988
  }
1788
1989
 
1789
1990
  this.publishUpdate();
@@ -1798,10 +1999,15 @@ export class AgUiThreadRuntimeCore {
1798
1999
  const touched = this.session.updateMessage(messageId, (message) => {
1799
2000
  if (message.role !== "assistant") return message;
1800
2001
  const assistant = message as ThreadAssistantMessage;
1801
- const metadata = update.metadata
2002
+ const mergedMetadata = update.metadata
1802
2003
  ? this.mergeAssistantMetadata(assistant.metadata, update.metadata)
1803
2004
  : assistant.metadata;
1804
2005
  latestStatus = update.status ?? assistant.status;
2006
+ const metadata =
2007
+ latestStatus?.type === "requires-action" &&
2008
+ latestStatus.reason === "tool-calls"
2009
+ ? mergedMetadata
2010
+ : withoutPendingToolCallIds(mergedMetadata);
1805
2011
  const content =
1806
2012
  update.content !== undefined
1807
2013
  ? this.preserveToolResults(
@@ -2057,21 +2263,26 @@ export class AgUiThreadRuntimeCore {
2057
2263
  ): string | undefined {
2058
2264
  switch (event.type) {
2059
2265
  case "STATE_SNAPSHOT": {
2060
- this.stateSnapshot = event.snapshot as ReadonlyJSONValue;
2266
+ this.dispatch({
2267
+ type: "stateSnapshot",
2268
+ snapshot: event.snapshot as ReadonlyJSONValue,
2269
+ });
2061
2270
  this.updateActiveAssistantState(activeAssistantId);
2062
2271
  break;
2063
2272
  }
2064
2273
  case "STATE_DELTA": {
2065
2274
  if (event.delta.length === 0) break;
2066
2275
  try {
2067
- const state = this.stateSnapshot ?? {};
2068
2276
  const result = jsonpatch.applyPatch(
2069
- state,
2277
+ this.state.stateSnapshot ?? {},
2070
2278
  event.delta as Operation[],
2071
2279
  /* validateOperation */ true,
2072
2280
  /* mutateDocument */ false,
2073
2281
  );
2074
- this.stateSnapshot = result.newDocument as ReadonlyJSONValue;
2282
+ this.dispatch({
2283
+ type: "stateSnapshot",
2284
+ snapshot: result.newDocument as ReadonlyJSONValue,
2285
+ });
2075
2286
  this.updateActiveAssistantState(activeAssistantId);
2076
2287
  } catch (error) {
2077
2288
  this.logger.error?.("[agui] failed to apply state delta", error);
@@ -2156,7 +2367,7 @@ export class AgUiThreadRuntimeCore {
2156
2367
  ...assistant,
2157
2368
  metadata: {
2158
2369
  ...assistant.metadata,
2159
- unstable_state: this.stateSnapshot ?? null,
2370
+ unstable_state: this.state.stateSnapshot ?? null,
2160
2371
  },
2161
2372
  };
2162
2373
  });
@@ -2172,6 +2383,7 @@ export class AgUiThreadRuntimeCore {
2172
2383
  if (message.role !== "assistant") return message;
2173
2384
  const assistant = message as ThreadAssistantMessage;
2174
2385
  const mcpAppUri = readMcpAppResourceUri(event.mcpResult?._meta);
2386
+ const modelText = toolContentText(event.content);
2175
2387
  let matchedToolCall = false;
2176
2388
  const { content } = mapToolCallPartsDeep(assistant.content, (part) => {
2177
2389
  if (part.toolCallId !== event.toolCallId) return part;
@@ -2188,11 +2400,9 @@ export class AgUiThreadRuntimeCore {
2188
2400
  if (snapshotResultApplied) {
2189
2401
  return {
2190
2402
  ...part,
2191
- ...(part.modelContent === undefined && event.content
2403
+ ...(part.modelContent === undefined
2192
2404
  ? {
2193
- modelContent: [
2194
- { type: "text" as const, text: event.content },
2195
- ],
2405
+ modelContent: [{ type: "text" as const, text: modelText }],
2196
2406
  }
2197
2407
  : {}),
2198
2408
  ...(part.isError === undefined
@@ -2207,12 +2417,18 @@ export class AgUiThreadRuntimeCore {
2207
2417
  : {}),
2208
2418
  };
2209
2419
  }
2420
+ const { modelContent: _modelContent, ...partWithoutModelContent } =
2421
+ part;
2210
2422
  return {
2211
- ...part,
2423
+ ...(event.mcpResult === undefined && Array.isArray(event.content)
2424
+ ? partWithoutModelContent
2425
+ : part),
2212
2426
  result: (event.mcpResult ??
2213
- tryParseJSON(event.content ?? "")) as ReadonlyJSONValue,
2427
+ (Array.isArray(event.content)
2428
+ ? event.content
2429
+ : tryParseJSON(event.content))) as ReadonlyJSONValue,
2214
2430
  ...(event.mcpResult !== undefined
2215
- ? { modelContent: [{ type: "text" as const, text: event.content }] }
2431
+ ? { modelContent: [{ type: "text" as const, text: modelText }] }
2216
2432
  : {}),
2217
2433
  ...(typeof event.mcpResult?.isError === "boolean"
2218
2434
  ? { isError: event.mcpResult.isError }
@@ -2531,7 +2747,7 @@ export class AgUiThreadRuntimeCore {
2531
2747
  (wasPersisted || this.snapshotHistoryIds.has(messageId));
2532
2748
 
2533
2749
  if (shouldUpdate) {
2534
- const write = this.chainHistoryWrite(messageId, () =>
2750
+ const write = this.historyWrites.chain(messageId, () =>
2535
2751
  update.call(history, { parentId, message }),
2536
2752
  );
2537
2753
  this.assistantHistoryParents.delete(messageId);
@@ -2540,7 +2756,7 @@ export class AgUiThreadRuntimeCore {
2540
2756
  this.persistedHistoryIds.add(messageId);
2541
2757
  },
2542
2758
  (error) => {
2543
- const pending = this.historyWrites.get(messageId);
2759
+ const pending = this.historyWrites.pending(messageId);
2544
2760
  if (pending === undefined || pending === write) {
2545
2761
  this.assistantHistoryParents.set(messageId, parentId);
2546
2762
  }
@@ -2561,7 +2777,7 @@ export class AgUiThreadRuntimeCore {
2561
2777
  void write.then(
2562
2778
  () => {},
2563
2779
  (error) => {
2564
- const pending = this.historyWrites.get(messageId);
2780
+ const pending = this.historyWrites.pending(messageId);
2565
2781
  if (pending === undefined || pending === write) {
2566
2782
  this.assistantHistoryParents.set(messageId, parentId);
2567
2783
  }
@@ -2575,11 +2791,11 @@ export class AgUiThreadRuntimeCore {
2575
2791
  message: ThreadMessage,
2576
2792
  ): Promise<void> | undefined {
2577
2793
  if (!this.history || this.persistedHistoryIds.has(message.id)) return;
2578
- const pending = this.historyWrites.get(message.id);
2794
+ const pending = this.historyWrites.pending(message.id);
2579
2795
  if (pending) return pending;
2580
2796
 
2581
2797
  const append = this.history.append.bind(this.history);
2582
- const write = this.chainHistoryWrite(message.id, () =>
2798
+ const write = this.historyWrites.chain(message.id, () =>
2583
2799
  append({ parentId, message }),
2584
2800
  );
2585
2801
  void write.then(
@@ -2590,35 +2806,4 @@ export class AgUiThreadRuntimeCore {
2590
2806
  );
2591
2807
  return write;
2592
2808
  }
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
2809
  }