@assistant-ui/react-ag-ui 0.0.57 → 0.0.59

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 (46) hide show
  1. package/README.md +16 -0
  2. package/dist/index.d.ts +2 -2
  3. package/dist/runtime/AgUiThreadRuntimeCore.d.ts +19 -8
  4. package/dist/runtime/AgUiThreadRuntimeCore.d.ts.map +1 -1
  5. package/dist/runtime/AgUiThreadRuntimeCore.js +118 -86
  6. package/dist/runtime/AgUiThreadRuntimeCore.js.map +1 -1
  7. package/dist/runtime/adapter/conversions.d.ts.map +1 -1
  8. package/dist/runtime/adapter/conversions.js +34 -11
  9. package/dist/runtime/adapter/conversions.js.map +1 -1
  10. package/dist/runtime/adapter/run-aggregator.d.ts +13 -3
  11. package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
  12. package/dist/runtime/adapter/run-aggregator.js +385 -119
  13. package/dist/runtime/adapter/run-aggregator.js.map +1 -1
  14. package/dist/runtime/adapter/subscriber.d.ts +9 -0
  15. package/dist/runtime/adapter/subscriber.d.ts.map +1 -1
  16. package/dist/runtime/adapter/subscriber.js +4 -1
  17. package/dist/runtime/adapter/subscriber.js.map +1 -1
  18. package/dist/runtime/adapter/tool-approval.d.ts +3 -1
  19. package/dist/runtime/adapter/tool-approval.d.ts.map +1 -1
  20. package/dist/runtime/adapter/tool-approval.js +11 -12
  21. package/dist/runtime/adapter/tool-approval.js.map +1 -1
  22. package/dist/runtime/event-parser.d.ts.map +1 -1
  23. package/dist/runtime/event-parser.js +176 -45
  24. package/dist/runtime/event-parser.js.map +1 -1
  25. package/dist/runtime/types.d.ts +67 -1
  26. package/dist/runtime/types.d.ts.map +1 -1
  27. package/dist/useAgUiRuntime.d.ts.map +1 -1
  28. package/dist/useAgUiRuntime.js +15 -5
  29. package/dist/useAgUiRuntime.js.map +1 -1
  30. package/package.json +9 -9
  31. package/src/index.ts +1 -0
  32. package/src/runtime/AgUiThreadRuntimeCore.test.ts +18 -0
  33. package/src/runtime/AgUiThreadRuntimeCore.ts +204 -120
  34. package/src/runtime/adapter/conversions.ts +87 -17
  35. package/src/runtime/adapter/run-aggregator.approval.test.ts +128 -0
  36. package/src/runtime/adapter/run-aggregator.ts +595 -139
  37. package/src/runtime/adapter/subscriber.ts +10 -1
  38. package/src/runtime/adapter/tool-approval.test.ts +75 -0
  39. package/src/runtime/adapter/tool-approval.ts +17 -12
  40. package/src/runtime/event-parser.ts +216 -36
  41. package/src/runtime/types.ts +96 -12
  42. package/src/useAgUiRuntime.approval.test.tsx +34 -24
  43. package/src/useAgUiRuntime.queue.test.tsx +54 -0
  44. package/src/useAgUiRuntime.thread-switch.test.tsx +199 -0
  45. package/src/useAgUiRuntime.toolDeferral.test.tsx +186 -0
  46. package/src/useAgUiRuntime.ts +15 -3
@@ -23,6 +23,8 @@ import type {
23
23
  import {
24
24
  createMessageRepositorySession,
25
25
  invokeUserCallback,
26
+ iterateToolCallParts,
27
+ mapToolCallPartsDeep,
26
28
  } from "@assistant-ui/core/internal";
27
29
  import type {
28
30
  AbstractAgent,
@@ -33,7 +35,12 @@ import type {
33
35
  import jsonpatch, { type Operation } from "fast-json-patch";
34
36
  import type { Logger } from "./logger";
35
37
  import { readMcpAppResourceUri } from "./mcp-tool-result";
36
- import type { AgUiEvent, AgUiInterrupt, AgUiResumeEntry } from "./types";
38
+ import type {
39
+ AgUiEvent,
40
+ AgUiInterrupt,
41
+ AgUiResumeEntry,
42
+ AgUiResumeTranscript,
43
+ } from "./types";
37
44
  import type { ReadonlyJSONValue } from "assistant-stream/utils";
38
45
  import {
39
46
  AG_UI_METADATA_NAMESPACE,
@@ -74,8 +81,6 @@ const isResolvedToolCall = (
74
81
  ): boolean =>
75
82
  part.type === "tool-call" && "result" in part && part.result !== undefined;
76
83
 
77
- const symbolResumeShim = Symbol("agui-resume-shim");
78
-
79
84
  type RunConfig = NonNullable<AppendMessage["runConfig"]>;
80
85
  type ResumeStream = (
81
86
  options: ChatModelRunOptions,
@@ -87,10 +92,21 @@ type ResumeRunConfig = {
87
92
  stream?: ResumeStream;
88
93
  };
89
94
 
95
+ /**
96
+ * A resume submission, pairing the entries that answer the open interrupts with
97
+ * the assistant message they were opened on. That message anchors the resume
98
+ * transcript: under `resumeTranscript: "appended"` only what follows it is sent.
99
+ */
100
+ type ResumeDispatch = {
101
+ entries: AgUiResumeEntry[];
102
+ interruptedMessageId: string;
103
+ };
104
+
90
105
  type CoreOptions = {
91
106
  agent: AbstractAgent;
92
107
  logger: Logger;
93
108
  showThinking: boolean;
109
+ resumeTranscript?: AgUiResumeTranscript | undefined;
94
110
  autoCancelPendingToolCalls?: boolean | undefined;
95
111
  onError?: (error: Error) => void;
96
112
  onCancel?: () => void;
@@ -135,6 +151,7 @@ export class AgUiThreadRuntimeCore {
135
151
  private agent: AbstractAgent;
136
152
  private logger: Logger;
137
153
  private showThinking: boolean;
154
+ private resumeTranscript: AgUiResumeTranscript;
138
155
  private autoCancelPendingToolCalls: boolean | undefined;
139
156
  private onError: ((error: Error) => void) | undefined;
140
157
  private onCancel: (() => void) | undefined;
@@ -164,7 +181,6 @@ export class AgUiThreadRuntimeCore {
164
181
  // mid-run, and cancelling has to reach the agent holding the live request.
165
182
  private activeRunAgent: AbstractAgent | null = null;
166
183
  private stateSnapshot: ReadonlyJSONValue | undefined;
167
- private pendingError: Error | null = null;
168
184
  private history: ThreadHistoryAdapter | undefined;
169
185
  private lastRunConfig: RunConfig | undefined;
170
186
  private readonly assistantHistoryParents = new Map<string, string | null>();
@@ -174,33 +190,33 @@ export class AgUiThreadRuntimeCore {
174
190
  private _isLoading = false;
175
191
  private _loadPromise: Promise<void> | undefined;
176
192
  private _loadRequested = false;
177
- private pendingResumeMessageId: string | null = null;
178
- private pendingA2uiResume = false;
193
+ private pendingResume: { owner: AbortController; messageId: string } | null =
194
+ null;
195
+ private pendingA2uiResumeOwner: AbortController | null = null;
179
196
  private pendingA2uiAction: Record<string, unknown> | undefined;
180
197
 
181
198
  constructor(options: CoreOptions) {
182
199
  this.agent = options.agent;
183
200
  this.logger = options.logger;
184
201
  this.showThinking = options.showThinking;
202
+ this.resumeTranscript = options.resumeTranscript ?? "full";
185
203
  this.autoCancelPendingToolCalls = options.autoCancelPendingToolCalls;
186
204
  this.onError = options.onError;
187
205
  this.onCancel = options.onCancel;
188
206
  this.history = options.history;
189
207
  this.notifyUpdate = options.notifyUpdate;
190
- this.installResumeShim();
191
208
  }
192
209
 
193
210
  updateOptions(options: Omit<CoreOptions, "notifyUpdate">) {
194
211
  this.agent = options.agent;
195
212
  this.logger = options.logger;
196
213
  this.showThinking = options.showThinking;
214
+ this.resumeTranscript = options.resumeTranscript ?? "full";
197
215
  this.autoCancelPendingToolCalls = options.autoCancelPendingToolCalls;
198
216
  this.onError = options.onError;
199
217
  this.onCancel = options.onCancel;
200
218
  const previousHistory = this.history;
201
219
  this.history = options.history;
202
- this.installResumeShim();
203
-
204
220
  if (
205
221
  this._loadRequested &&
206
222
  !this._loadPromise &&
@@ -299,12 +315,17 @@ export class AgUiThreadRuntimeCore {
299
315
 
300
316
  async append(message: AppendMessage): Promise<void> {
301
317
  const startRun = message.startRun ?? message.role === "user";
318
+ let ownsThread = true;
302
319
  if (startRun) {
303
320
  this.assertNoPendingInterrupts();
304
321
  this.maybeAutoCancelPendingToolCalls();
322
+ // Before the message is linked: callers parent it on the in-flight
323
+ // assistant, and settling that assistant reassigns its optimistic id,
324
+ // which would evict it and reparent this message onto a branch.
325
+ ownsThread = this.supersedeActiveRun();
305
326
  }
306
327
  const threadMessageId = this.appendEntry(message);
307
- if (!startRun) return;
328
+ if (!startRun || !ownsThread) return;
308
329
  await this.startRun(threadMessageId, message.runConfig);
309
330
  }
310
331
 
@@ -347,19 +368,46 @@ export class AgUiThreadRuntimeCore {
347
368
  }
348
369
 
349
370
  async cancel(): Promise<void> {
350
- if (!this.abortController) return;
351
- // Before the local abort, whose listener runs onCancel synchronously: a
352
- // callback that starts another run replaces the agent's controller, and
353
- // aborting afterwards would kill that replacement and leave this run live.
354
- // The local abort is unconditional because abortRun is a user subclass's
355
- // code, and a throw there would otherwise strand the thread as running.
371
+ this.abortActiveRun();
372
+ }
373
+
374
+ // abortRun is what stops an upstream agent: AbstractAgent.runAgent declares
375
+ // two parameters and HttpAgent binds its request to a controller of its own,
376
+ // so the run options object reaches only subclasses that read it. The local
377
+ // abort comes last and is unconditional: its listener runs onCancel
378
+ // synchronously, so a callback that starts another run must keep the
379
+ // controller it installed, and a throw from a subclass's abortRun would
380
+ // otherwise strand the thread as running.
381
+ private abortActiveRun(): void {
382
+ const controller = this.abortController;
383
+ if (!controller) return;
356
384
  try {
357
385
  (this.activeRunAgent ?? this.agent).abortRun();
358
386
  } finally {
359
- this.abortController.abort();
387
+ controller.abort();
360
388
  }
361
389
  }
362
390
 
391
+ // Starting a run supersedes the active one, and reports whether the caller
392
+ // still owns the thread afterwards: the local abort runs onCancel
393
+ // synchronously, so a callback that starts its own run keeps it. abortRun is a
394
+ // subclass's code, and a throw there must not abandon the run being started,
395
+ // unlike cancel(), where the failed operation is the caller's own. Calling
396
+ // this twice for one run is harmless, because the second call finds no active
397
+ // run to supersede.
398
+ private supersedeActiveRun(): boolean {
399
+ try {
400
+ this.abortActiveRun();
401
+ } catch (error) {
402
+ this.logger.error?.("[agui] agent abortRun failed", error);
403
+ }
404
+ if (this.abortController === null) return true;
405
+ this.logger.debug?.(
406
+ "[agui] onCancel started a replacement run; dropping the superseding send",
407
+ );
408
+ return false;
409
+ }
410
+
363
411
  async resume(config: ResumeRunConfig): Promise<void> {
364
412
  this.assertNoPendingInterrupts();
365
413
  await this.startRun(
@@ -439,8 +487,7 @@ export class AgUiThreadRuntimeCore {
439
487
  const assistant = this.findRequiresActionAssistant("tool-calls");
440
488
  if (!assistant) return null;
441
489
  const toolCallIds: string[] = [];
442
- for (const part of assistant.content) {
443
- if (part.type !== "tool-call") continue;
490
+ for (const part of iterateToolCallParts(assistant.content)) {
444
491
  if (isResolvedToolCall(part)) continue;
445
492
  toolCallIds.push(part.toolCallId);
446
493
  }
@@ -494,13 +541,16 @@ export class AgUiThreadRuntimeCore {
494
541
  resume: AgUiResumeEntry[],
495
542
  ): Promise<void> {
496
543
  this.clearPendingInterrupts(messageId, resume);
497
- await this.startRun(messageId, this.lastRunConfig, resume);
544
+ await this.startRun(messageId, this.lastRunConfig, {
545
+ entries: resume,
546
+ interruptedMessageId: messageId,
547
+ });
498
548
  }
499
549
 
500
550
  /**
501
- * The core seams that reach `respondToToolApproval` discard the promise they
502
- * receive, so a rejected decision would otherwise surface only as an
503
- * unhandled rejection. A failure raised by the resumed run itself was already
551
+ * A rejected decision reaches the caller through the approval seam, and
552
+ * `onError` in addition, because a consumer watching only the runtime would
553
+ * otherwise miss it. A failure raised by the resumed run itself was already
504
554
  * reported by `startRun` before it rethrew, so reporting it here again would
505
555
  * give the consumer two notifications for one failure.
506
556
  */
@@ -557,9 +607,9 @@ export class AgUiThreadRuntimeCore {
557
607
  const gated = projectAgUiToolApprovals(
558
608
  pending.interrupts,
559
609
  new Set(
560
- (gatedMessage?.content ?? [])
561
- .filter((part) => part.type === "tool-call")
562
- .map((part) => part.toolCallId),
610
+ Array.from(iterateToolCallParts(gatedMessage?.content ?? [])).map(
611
+ (part) => part.toolCallId,
612
+ ),
563
613
  ),
564
614
  );
565
615
  const isGated = [...gated.values()].some(
@@ -653,7 +703,10 @@ export class AgUiThreadRuntimeCore {
653
703
  const normalized = this.toAppendMessage(message);
654
704
  this.clearPendingInterrupts(pending.messageId, resume);
655
705
  const threadMessageId = this.appendEntry(normalized);
656
- await this.startRun(threadMessageId, normalized.runConfig, resume);
706
+ await this.startRun(threadMessageId, normalized.runConfig, {
707
+ entries: resume,
708
+ interruptedMessageId: pending.messageId,
709
+ });
657
710
  }
658
711
 
659
712
  private resolveSteerAwayResume(
@@ -722,7 +775,8 @@ export class AgUiThreadRuntimeCore {
722
775
  }
723
776
  return {
724
777
  createdAt: message.createdAt ?? new Date(),
725
- parentId: message.parentId ?? this.session.headId,
778
+ parentId:
779
+ message.parentId === undefined ? this.session.headId : message.parentId,
726
780
  sourceId: message.sourceId ?? null,
727
781
  role: message.role ?? "user",
728
782
  content: message.content,
@@ -774,9 +828,8 @@ export class AgUiThreadRuntimeCore {
774
828
  for (let index = messages.length - 1; index >= 0; index--) {
775
829
  const message = messages[index];
776
830
  if (!message || message.role !== "assistant") continue;
777
- for (const part of message.content) {
778
- if (part.type !== "tool-call" || part.toolCallId !== toolCallId)
779
- continue;
831
+ for (const part of iterateToolCallParts(message.content)) {
832
+ if (part.toolCallId !== toolCallId) continue;
780
833
  if (!isResolvedToolCall(part)) {
781
834
  return message.id;
782
835
  }
@@ -790,8 +843,8 @@ export class AgUiThreadRuntimeCore {
790
843
  const updated = this.session.updateMessage(messageId, (message) => {
791
844
  if (message.role !== "assistant") return message;
792
845
  const assistant = message as ThreadAssistantMessage;
793
- const content = assistant.content.map((part) => {
794
- if (part.type !== "tool-call" || isResolvedToolCall(part)) return part;
846
+ const { content } = mapToolCallPartsDeep(assistant.content, (part) => {
847
+ if (isResolvedToolCall(part)) return part;
795
848
  return {
796
849
  ...part,
797
850
  result: { error: "Tool call cancelled by user" },
@@ -804,13 +857,19 @@ export class AgUiThreadRuntimeCore {
804
857
  }
805
858
 
806
859
  addToolResult(options: AddToolResultOptions): void {
807
- const updated = this.session.updateMessage(options.messageId, (message) => {
860
+ // Core's ToolInvocationTracker resolves a nested call to the nested
861
+ // subagent message's id, which is not a session message; re-anchor on the
862
+ // top-level message that owns the tree so the update can land.
863
+ const sessionMessageId = this.session.tryGetMessage(options.messageId)
864
+ ? options.messageId
865
+ : this.findMessageIdForToolCall(options.toolCallId);
866
+ if (sessionMessageId === undefined) return;
867
+ const updated = this.session.updateMessage(sessionMessageId, (message) => {
808
868
  if (message.role !== "assistant") return message;
809
869
  const assistant = message as ThreadAssistantMessage;
810
870
  let matchedToolCall = false;
811
- const content = assistant.content.map((part) => {
812
- if (part.type !== "tool-call" || part.toolCallId !== options.toolCallId)
813
- return part;
871
+ const { content } = mapToolCallPartsDeep(assistant.content, (part) => {
872
+ if (part.toolCallId !== options.toolCallId) return part;
814
873
  matchedToolCall = true;
815
874
  return {
816
875
  ...part,
@@ -825,7 +884,7 @@ export class AgUiThreadRuntimeCore {
825
884
 
826
885
  if (!updated) return;
827
886
  this.notifyUpdate();
828
- this.maybeResumeAfterToolResults(options.messageId);
887
+ this.maybeResumeAfterToolResults(sessionMessageId);
829
888
  }
830
889
 
831
890
  sendA2uiAction(action: Record<string, unknown>): void {
@@ -846,8 +905,9 @@ export class AgUiThreadRuntimeCore {
846
905
  }
847
906
  this.pendingA2uiAction = userAction;
848
907
 
849
- if (this.isRunningFlag) {
850
- this.pendingA2uiResume = true;
908
+ const owner = this.abortController;
909
+ if (owner) {
910
+ this.pendingA2uiResumeOwner = owner;
851
911
  return;
852
912
  }
853
913
  this.startResumeRun(parentId);
@@ -859,10 +919,11 @@ export class AgUiThreadRuntimeCore {
859
919
  private maybeResumeAfterToolResults(messageId: string): void {
860
920
  if (!this.maybeCompleteAfterToolResults(messageId)) return;
861
921
 
862
- if (this.isRunningFlag) {
922
+ const owner = this.abortController;
923
+ if (owner) {
863
924
  // A run is still draining (RUN_FINISHED arrived but the stream has not
864
925
  // closed). Defer until startRun's tail so we never start two runs.
865
- this.pendingResumeMessageId = messageId;
926
+ this.pendingResume = { owner, messageId };
866
927
  return;
867
928
  }
868
929
  this.startResumeRun(messageId);
@@ -878,9 +939,13 @@ export class AgUiThreadRuntimeCore {
878
939
  ) {
879
940
  return false;
880
941
  }
881
- const allResolved = assistant.content.every(
882
- (part) => part.type !== "tool-call" || isResolvedToolCall(part),
883
- );
942
+ let allResolved = true;
943
+ for (const part of iterateToolCallParts(assistant.content)) {
944
+ if (!isResolvedToolCall(part)) {
945
+ allResolved = false;
946
+ break;
947
+ }
948
+ }
884
949
  if (!allResolved) return false;
885
950
 
886
951
  const updated = this.session.updateMessage(messageId, (current) =>
@@ -908,7 +973,7 @@ export class AgUiThreadRuntimeCore {
908
973
  }
909
974
 
910
975
  applyExternalMessages(messages: readonly ThreadMessage[]): void {
911
- this.pendingA2uiResume = false;
976
+ this.pendingA2uiResumeOwner = null;
912
977
  this.pendingA2uiAction = undefined;
913
978
  this.assistantHistoryParents.clear();
914
979
 
@@ -954,6 +1019,17 @@ export class AgUiThreadRuntimeCore {
954
1019
  for (const { message } of this.getMessageRepository().messages) {
955
1020
  this.snapshotHistoryIds.add(message.id);
956
1021
  }
1022
+ // MESSAGES_SNAPSHOT re-appends the active assistant, so a parked
1023
+ // continuation whose target survived the snapshot is still answerable.
1024
+ // Membership is the head branch, not the repository: a soft merge leaves
1025
+ // the messages it dropped behind as off-branch nodes.
1026
+ const resumeTarget = this.pendingResume?.messageId;
1027
+ if (
1028
+ resumeTarget !== undefined &&
1029
+ !this.session.getMessages().some((message) => message.id === resumeTarget)
1030
+ ) {
1031
+ this.pendingResume = null;
1032
+ }
957
1033
  this.notifyUpdate();
958
1034
  }
959
1035
 
@@ -980,9 +1056,14 @@ export class AgUiThreadRuntimeCore {
980
1056
  private async startRun(
981
1057
  parentId: string | null,
982
1058
  runConfig?: RunConfig,
983
- resume?: AgUiResumeEntry[],
1059
+ resume?: ResumeDispatch,
984
1060
  resumeStream?: ResumeStream,
985
1061
  ): Promise<void> {
1062
+ // A default AG-UI run supersedes the active run; the hook's opt-in message
1063
+ // queue serializes sends instead. append supersedes earlier, before it links
1064
+ // its message; this covers the entry points that start a run without one.
1065
+ if (!this.supersedeActiveRun()) return;
1066
+
986
1067
  const normalizedRunConfig = runConfig ?? {};
987
1068
  this.lastRunConfig = normalizedRunConfig;
988
1069
  const parent =
@@ -1000,7 +1081,7 @@ export class AgUiThreadRuntimeCore {
1000
1081
  this.getMessageRepository().messages.map(({ message }) => message.id),
1001
1082
  );
1002
1083
 
1003
- this.pendingError = null;
1084
+ let pendingError: Error | null = null;
1004
1085
  const assistantParentId = parent ? parentId : this.session.headId;
1005
1086
  let assistantMessageId: string | undefined;
1006
1087
  // A snapshot the preserve gate declines still evicts the in-flight
@@ -1126,16 +1207,17 @@ export class AgUiThreadRuntimeCore {
1126
1207
  // Cancel flips only the status; an aggregator RUN_CANCELLED would emit an empty snapshot and wipe the replayed content.
1127
1208
  cancelRun = () =>
1128
1209
  applyUpdate({ status: { type: "incomplete", reason: "cancelled" } });
1129
- await this.consumeResumeStream(resumeStream, {
1130
- runConfig: normalizedRunConfig,
1131
- threadId: this.agent.threadId || "main",
1132
- parentId: assistantParentId,
1133
- historicalMessages,
1134
- abortSignal,
1135
- ensureAssistant,
1136
- applyUpdate,
1137
- getAssistantMessageId: () => assistantMessageId,
1138
- });
1210
+ pendingError =
1211
+ (await this.consumeResumeStream(resumeStream, {
1212
+ runConfig: normalizedRunConfig,
1213
+ threadId: this.agent.threadId || "main",
1214
+ parentId: assistantParentId,
1215
+ historicalMessages,
1216
+ abortSignal,
1217
+ ensureAssistant,
1218
+ applyUpdate,
1219
+ getAssistantMessageId: () => assistantMessageId,
1220
+ })) ?? null;
1139
1221
  } else {
1140
1222
  const runId = generateId();
1141
1223
  aggregator.handle({ type: "RUN_STARTED", runId });
@@ -1151,7 +1233,7 @@ export class AgUiThreadRuntimeCore {
1151
1233
  logger: this.logger,
1152
1234
  onRunFailed: (error) => {
1153
1235
  if (abortSignal.aborted) return;
1154
- this.pendingError = error;
1236
+ pendingError = error;
1155
1237
  invokeRuntimeCallback("onError", this.onError, error);
1156
1238
  },
1157
1239
  });
@@ -1171,36 +1253,31 @@ export class AgUiThreadRuntimeCore {
1171
1253
  const err = error instanceof Error ? error : new Error(String(error));
1172
1254
  dispatch({ type: "RUN_ERROR", message: err.message });
1173
1255
  invokeRuntimeCallback("onError", this.onError, err);
1174
- this.pendingError = this.pendingError ?? err;
1256
+ pendingError ??= err;
1175
1257
  }
1176
1258
  } finally {
1177
1259
  this.finishRun(abortController);
1178
1260
  }
1179
1261
 
1180
- if (this.pendingError) {
1181
- const err = this.pendingError;
1262
+ if (pendingError) {
1263
+ const err = pendingError;
1182
1264
  this.reportedErrors.add(err);
1183
- this.pendingError = null;
1184
- this.pendingResumeMessageId = null;
1185
- this.pendingA2uiResume = false;
1186
- this.pendingA2uiAction = undefined;
1265
+ this.clearDeferredContinuations(abortController);
1187
1266
  throw err;
1188
1267
  }
1189
1268
 
1190
1269
  // A tool result that landed before the run settled deferred its
1191
1270
  // continuation here so a second run never overlaps the first.
1192
- if (this.pendingResumeMessageId !== null) {
1193
- const resumeMessageId = this.pendingResumeMessageId;
1194
- this.pendingResumeMessageId = null;
1271
+ if (this.pendingResume?.owner === abortController) {
1272
+ const { messageId } = this.pendingResume;
1273
+ this.pendingResume = null;
1195
1274
  if (!abortSignal.aborted) {
1196
- this.startResumeRun(resumeMessageId);
1197
- } else {
1198
- this.pendingA2uiAction = undefined;
1275
+ this.startResumeRun(messageId);
1199
1276
  }
1200
1277
  }
1201
1278
 
1202
- if (this.pendingA2uiResume) {
1203
- this.pendingA2uiResume = false;
1279
+ if (this.pendingA2uiResumeOwner === abortController) {
1280
+ this.pendingA2uiResumeOwner = null;
1204
1281
  if (!abortSignal.aborted && this.pendingA2uiAction !== undefined) {
1205
1282
  if (this.getPendingInterrupts()) {
1206
1283
  this.pendingA2uiAction = undefined;
@@ -1238,9 +1315,9 @@ export class AgUiThreadRuntimeCore {
1238
1315
  applyUpdate: (update: ChatModelRunResult) => void;
1239
1316
  getAssistantMessageId: () => string | undefined;
1240
1317
  },
1241
- ): Promise<void> {
1318
+ ): Promise<Error | undefined> {
1242
1319
  this.pendingA2uiAction = undefined;
1243
- this.pendingA2uiResume = false;
1320
+ this.pendingA2uiResumeOwner = null;
1244
1321
  const assistantId = ctx.ensureAssistant();
1245
1322
  const currentId = () => ctx.getAssistantMessageId() ?? assistantId;
1246
1323
  const options: ChatModelRunOptions = {
@@ -1264,36 +1341,39 @@ export class AgUiThreadRuntimeCore {
1264
1341
 
1265
1342
  try {
1266
1343
  for await (const result of stream(options)) {
1267
- if (ctx.abortSignal.aborted) return;
1344
+ if (ctx.abortSignal.aborted) return undefined;
1268
1345
  ctx.applyUpdate(result);
1269
1346
  }
1270
1347
  } catch (error) {
1271
- if (ctx.abortSignal.aborted) return;
1348
+ if (ctx.abortSignal.aborted) return undefined;
1272
1349
  const err = error instanceof Error ? error : new Error(String(error));
1273
1350
  ctx.applyUpdate({
1274
1351
  status: { type: "incomplete", reason: "error", error: err.message },
1275
1352
  });
1276
1353
  invokeRuntimeCallback("onError", this.onError, err);
1277
- this.pendingError = this.pendingError ?? err;
1278
- return;
1354
+ return err;
1279
1355
  }
1280
1356
 
1281
- if (ctx.abortSignal.aborted) return;
1357
+ if (ctx.abortSignal.aborted) return undefined;
1282
1358
  const current = this.session.tryGetMessage(currentId())?.message;
1283
1359
  if (!current || current.status?.type === "running") {
1284
1360
  ctx.applyUpdate({ status: { type: "complete", reason: "unknown" } });
1285
1361
  }
1362
+ return undefined;
1286
1363
  }
1287
1364
 
1288
1365
  private buildRunInput(
1289
1366
  runId: string,
1290
1367
  runConfig: RunConfig | undefined,
1291
1368
  historyMessages: readonly ThreadMessage[] | undefined,
1292
- resume?: AgUiResumeEntry[],
1369
+ resume?: ResumeDispatch,
1293
1370
  ) {
1294
1371
  const threadId = this.agent.threadId || "main";
1295
1372
  const messages = toAgUiMessages(
1296
- historyMessages ?? this.session.getMessages(),
1373
+ this.selectRunMessages(
1374
+ historyMessages ?? this.session.getMessages(),
1375
+ resume,
1376
+ ),
1297
1377
  );
1298
1378
  const context = this.runtime?.thread.getModelContext();
1299
1379
  const input = {
@@ -1313,31 +1393,40 @@ export class AgUiThreadRuntimeCore {
1313
1393
  ? { a2uiAction: { userAction: this.pendingA2uiAction } }
1314
1394
  : {}),
1315
1395
  },
1316
- ...(resume !== undefined ? { resume } : {}),
1396
+ ...(resume !== undefined ? { resume: resume.entries } : {}),
1317
1397
  };
1318
1398
  this.pendingA2uiAction = undefined;
1319
1399
  return input;
1320
1400
  }
1321
1401
 
1322
- private installResumeShim(): void {
1323
- const agent = this.agent as any;
1324
- if (agent[symbolResumeShim]) return;
1325
- agent[symbolResumeShim] = true;
1326
- const onInstance = Object.hasOwn(agent, "prepareRunAgentInput");
1327
- const original = onInstance
1328
- ? agent.prepareRunAgentInput
1329
- : Object.getPrototypeOf(agent)?.prepareRunAgentInput;
1330
- if (typeof original !== "function") return;
1331
- agent.prepareRunAgentInput = function (
1332
- this: unknown,
1333
- params: { resume?: unknown } | undefined,
1334
- ) {
1335
- const input = original.call(this, params);
1336
- if (params?.resume !== undefined && input && typeof input === "object") {
1337
- return { ...(input as object), resume: params.resume };
1338
- }
1339
- return input;
1340
- };
1402
+ /**
1403
+ * An anchor the thread no longer carries falls back to the whole transcript.
1404
+ * The anchor is missing exactly when a snapshot import replaced the messages
1405
+ * this resume was raised against, and a host that expects a delta tolerates
1406
+ * being told more than it knows, while one rebuilding the run from the
1407
+ * transcript cannot recover from being told less.
1408
+ */
1409
+ private selectRunMessages(
1410
+ messages: readonly ThreadMessage[],
1411
+ resume: ResumeDispatch | undefined,
1412
+ ): readonly ThreadMessage[] {
1413
+ if (resume === undefined || this.resumeTranscript !== "appended")
1414
+ return messages;
1415
+ const anchor = messages.findIndex(
1416
+ (message) => message.id === resume.interruptedMessageId,
1417
+ );
1418
+ if (anchor === -1) return messages;
1419
+ return messages.slice(anchor + 1);
1420
+ }
1421
+
1422
+ private clearDeferredContinuations(owner: AbortController): void {
1423
+ if (this.pendingResume?.owner === owner) {
1424
+ this.pendingResume = null;
1425
+ }
1426
+ if (this.pendingA2uiResumeOwner === owner) {
1427
+ this.pendingA2uiResumeOwner = null;
1428
+ this.pendingA2uiAction = undefined;
1429
+ }
1341
1430
  }
1342
1431
 
1343
1432
  private setRunning(running: boolean) {
@@ -1346,10 +1435,9 @@ export class AgUiThreadRuntimeCore {
1346
1435
  }
1347
1436
 
1348
1437
  private finishRun(controller: AbortController | null) {
1349
- if (this.abortController === controller) {
1350
- this.abortController = null;
1351
- this.activeRunAgent = null;
1352
- }
1438
+ if (this.abortController !== controller) return;
1439
+ this.abortController = null;
1440
+ this.activeRunAgent = null;
1353
1441
  this.setRunning(false);
1354
1442
  }
1355
1443
 
@@ -1498,19 +1586,17 @@ export class AgUiThreadRuntimeCore {
1498
1586
  next: ThreadAssistantMessage["content"],
1499
1587
  ): ThreadAssistantMessage["content"] {
1500
1588
  const resolved = new Map<string, ToolCallMessagePart>();
1501
- for (const part of previous) {
1502
- if (part.type === "tool-call" && isResolvedToolCall(part)) {
1589
+ for (const part of iterateToolCallParts(previous)) {
1590
+ if (isResolvedToolCall(part)) {
1503
1591
  resolved.set(part.toolCallId, part);
1504
1592
  }
1505
1593
  }
1506
1594
  if (resolved.size === 0) return next;
1507
1595
 
1508
- let changed = false;
1509
- const merged = next.map((part) => {
1510
- if (part.type !== "tool-call" || isResolvedToolCall(part)) return part;
1596
+ const { content: merged, changed } = mapToolCallPartsDeep(next, (part) => {
1597
+ if (isResolvedToolCall(part)) return part;
1511
1598
  const prior = resolved.get(part.toolCallId);
1512
1599
  if (!prior) return part;
1513
- changed = true;
1514
1600
  return {
1515
1601
  ...part,
1516
1602
  result: prior.result,
@@ -1518,7 +1604,7 @@ export class AgUiThreadRuntimeCore {
1518
1604
  ...(prior.isError !== undefined ? { isError: prior.isError } : {}),
1519
1605
  };
1520
1606
  });
1521
- return changed ? (merged as ThreadAssistantMessage["content"]) : next;
1607
+ return changed ? merged : next;
1522
1608
  }
1523
1609
 
1524
1610
  private mergeAssistantMetadata(
@@ -1623,9 +1709,8 @@ export class AgUiThreadRuntimeCore {
1623
1709
  const assistant = message as ThreadAssistantMessage;
1624
1710
  const mcpAppUri = readMcpAppResourceUri(event.mcpResult?._meta);
1625
1711
  let matchedToolCall = false;
1626
- const content = assistant.content.map((part) => {
1627
- if (part.type !== "tool-call" || part.toolCallId !== event.toolCallId)
1628
- return part;
1712
+ const { content } = mapToolCallPartsDeep(assistant.content, (part) => {
1713
+ if (part.toolCallId !== event.toolCallId) return part;
1629
1714
  matchedToolCall = true;
1630
1715
  // An applied activity snapshot owns part.result; a later result only
1631
1716
  // fills what is missing, mirroring the aggregator's finishToolCall.
@@ -1709,9 +1794,8 @@ export class AgUiThreadRuntimeCore {
1709
1794
  if (message.role !== "assistant") return message;
1710
1795
  const assistant = message as ThreadAssistantMessage;
1711
1796
  let matchedToolCall = false;
1712
- const content = assistant.content.map((part) => {
1713
- if (part.type !== "tool-call" || part.toolCallId !== toolCallId)
1714
- return part;
1797
+ const { content } = mapToolCallPartsDeep(assistant.content, (part) => {
1798
+ if (part.toolCallId !== toolCallId) return part;
1715
1799
  matchedToolCall = true;
1716
1800
  return {
1717
1801
  ...part,