@assistant-ui/react-ag-ui 0.0.58 → 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.
@@ -35,7 +35,12 @@ import type {
35
35
  import jsonpatch, { type Operation } from "fast-json-patch";
36
36
  import type { Logger } from "./logger";
37
37
  import { readMcpAppResourceUri } from "./mcp-tool-result";
38
- import type { AgUiEvent, AgUiInterrupt, AgUiResumeEntry } from "./types";
38
+ import type {
39
+ AgUiEvent,
40
+ AgUiInterrupt,
41
+ AgUiResumeEntry,
42
+ AgUiResumeTranscript,
43
+ } from "./types";
39
44
  import type { ReadonlyJSONValue } from "assistant-stream/utils";
40
45
  import {
41
46
  AG_UI_METADATA_NAMESPACE,
@@ -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,14 +190,16 @@ 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;
@@ -193,6 +211,7 @@ export class AgUiThreadRuntimeCore {
193
211
  this.agent = options.agent;
194
212
  this.logger = options.logger;
195
213
  this.showThinking = options.showThinking;
214
+ this.resumeTranscript = options.resumeTranscript ?? "full";
196
215
  this.autoCancelPendingToolCalls = options.autoCancelPendingToolCalls;
197
216
  this.onError = options.onError;
198
217
  this.onCancel = options.onCancel;
@@ -296,12 +315,17 @@ export class AgUiThreadRuntimeCore {
296
315
 
297
316
  async append(message: AppendMessage): Promise<void> {
298
317
  const startRun = message.startRun ?? message.role === "user";
318
+ let ownsThread = true;
299
319
  if (startRun) {
300
320
  this.assertNoPendingInterrupts();
301
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();
302
326
  }
303
327
  const threadMessageId = this.appendEntry(message);
304
- if (!startRun) return;
328
+ if (!startRun || !ownsThread) return;
305
329
  await this.startRun(threadMessageId, message.runConfig);
306
330
  }
307
331
 
@@ -344,19 +368,46 @@ export class AgUiThreadRuntimeCore {
344
368
  }
345
369
 
346
370
  async cancel(): Promise<void> {
347
- if (!this.abortController) return;
348
- // Before the local abort, whose listener runs onCancel synchronously: a
349
- // callback that starts another run replaces the agent's controller, and
350
- // aborting afterwards would kill that replacement and leave this run live.
351
- // The local abort is unconditional because abortRun is a user subclass's
352
- // 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;
353
384
  try {
354
385
  (this.activeRunAgent ?? this.agent).abortRun();
355
386
  } finally {
356
- this.abortController.abort();
387
+ controller.abort();
357
388
  }
358
389
  }
359
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
+
360
411
  async resume(config: ResumeRunConfig): Promise<void> {
361
412
  this.assertNoPendingInterrupts();
362
413
  await this.startRun(
@@ -490,7 +541,10 @@ export class AgUiThreadRuntimeCore {
490
541
  resume: AgUiResumeEntry[],
491
542
  ): Promise<void> {
492
543
  this.clearPendingInterrupts(messageId, resume);
493
- await this.startRun(messageId, this.lastRunConfig, resume);
544
+ await this.startRun(messageId, this.lastRunConfig, {
545
+ entries: resume,
546
+ interruptedMessageId: messageId,
547
+ });
494
548
  }
495
549
 
496
550
  /**
@@ -649,7 +703,10 @@ export class AgUiThreadRuntimeCore {
649
703
  const normalized = this.toAppendMessage(message);
650
704
  this.clearPendingInterrupts(pending.messageId, resume);
651
705
  const threadMessageId = this.appendEntry(normalized);
652
- await this.startRun(threadMessageId, normalized.runConfig, resume);
706
+ await this.startRun(threadMessageId, normalized.runConfig, {
707
+ entries: resume,
708
+ interruptedMessageId: pending.messageId,
709
+ });
653
710
  }
654
711
 
655
712
  private resolveSteerAwayResume(
@@ -718,7 +775,8 @@ export class AgUiThreadRuntimeCore {
718
775
  }
719
776
  return {
720
777
  createdAt: message.createdAt ?? new Date(),
721
- parentId: message.parentId ?? this.session.headId,
778
+ parentId:
779
+ message.parentId === undefined ? this.session.headId : message.parentId,
722
780
  sourceId: message.sourceId ?? null,
723
781
  role: message.role ?? "user",
724
782
  content: message.content,
@@ -847,8 +905,9 @@ export class AgUiThreadRuntimeCore {
847
905
  }
848
906
  this.pendingA2uiAction = userAction;
849
907
 
850
- if (this.isRunningFlag) {
851
- this.pendingA2uiResume = true;
908
+ const owner = this.abortController;
909
+ if (owner) {
910
+ this.pendingA2uiResumeOwner = owner;
852
911
  return;
853
912
  }
854
913
  this.startResumeRun(parentId);
@@ -860,10 +919,11 @@ export class AgUiThreadRuntimeCore {
860
919
  private maybeResumeAfterToolResults(messageId: string): void {
861
920
  if (!this.maybeCompleteAfterToolResults(messageId)) return;
862
921
 
863
- if (this.isRunningFlag) {
922
+ const owner = this.abortController;
923
+ if (owner) {
864
924
  // A run is still draining (RUN_FINISHED arrived but the stream has not
865
925
  // closed). Defer until startRun's tail so we never start two runs.
866
- this.pendingResumeMessageId = messageId;
926
+ this.pendingResume = { owner, messageId };
867
927
  return;
868
928
  }
869
929
  this.startResumeRun(messageId);
@@ -913,7 +973,7 @@ export class AgUiThreadRuntimeCore {
913
973
  }
914
974
 
915
975
  applyExternalMessages(messages: readonly ThreadMessage[]): void {
916
- this.pendingA2uiResume = false;
976
+ this.pendingA2uiResumeOwner = null;
917
977
  this.pendingA2uiAction = undefined;
918
978
  this.assistantHistoryParents.clear();
919
979
 
@@ -959,6 +1019,17 @@ export class AgUiThreadRuntimeCore {
959
1019
  for (const { message } of this.getMessageRepository().messages) {
960
1020
  this.snapshotHistoryIds.add(message.id);
961
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
+ }
962
1033
  this.notifyUpdate();
963
1034
  }
964
1035
 
@@ -985,9 +1056,14 @@ export class AgUiThreadRuntimeCore {
985
1056
  private async startRun(
986
1057
  parentId: string | null,
987
1058
  runConfig?: RunConfig,
988
- resume?: AgUiResumeEntry[],
1059
+ resume?: ResumeDispatch,
989
1060
  resumeStream?: ResumeStream,
990
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
+
991
1067
  const normalizedRunConfig = runConfig ?? {};
992
1068
  this.lastRunConfig = normalizedRunConfig;
993
1069
  const parent =
@@ -1005,7 +1081,7 @@ export class AgUiThreadRuntimeCore {
1005
1081
  this.getMessageRepository().messages.map(({ message }) => message.id),
1006
1082
  );
1007
1083
 
1008
- this.pendingError = null;
1084
+ let pendingError: Error | null = null;
1009
1085
  const assistantParentId = parent ? parentId : this.session.headId;
1010
1086
  let assistantMessageId: string | undefined;
1011
1087
  // A snapshot the preserve gate declines still evicts the in-flight
@@ -1131,16 +1207,17 @@ export class AgUiThreadRuntimeCore {
1131
1207
  // Cancel flips only the status; an aggregator RUN_CANCELLED would emit an empty snapshot and wipe the replayed content.
1132
1208
  cancelRun = () =>
1133
1209
  applyUpdate({ status: { type: "incomplete", reason: "cancelled" } });
1134
- await this.consumeResumeStream(resumeStream, {
1135
- runConfig: normalizedRunConfig,
1136
- threadId: this.agent.threadId || "main",
1137
- parentId: assistantParentId,
1138
- historicalMessages,
1139
- abortSignal,
1140
- ensureAssistant,
1141
- applyUpdate,
1142
- getAssistantMessageId: () => assistantMessageId,
1143
- });
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;
1144
1221
  } else {
1145
1222
  const runId = generateId();
1146
1223
  aggregator.handle({ type: "RUN_STARTED", runId });
@@ -1156,7 +1233,7 @@ export class AgUiThreadRuntimeCore {
1156
1233
  logger: this.logger,
1157
1234
  onRunFailed: (error) => {
1158
1235
  if (abortSignal.aborted) return;
1159
- this.pendingError = error;
1236
+ pendingError = error;
1160
1237
  invokeRuntimeCallback("onError", this.onError, error);
1161
1238
  },
1162
1239
  });
@@ -1176,36 +1253,31 @@ export class AgUiThreadRuntimeCore {
1176
1253
  const err = error instanceof Error ? error : new Error(String(error));
1177
1254
  dispatch({ type: "RUN_ERROR", message: err.message });
1178
1255
  invokeRuntimeCallback("onError", this.onError, err);
1179
- this.pendingError = this.pendingError ?? err;
1256
+ pendingError ??= err;
1180
1257
  }
1181
1258
  } finally {
1182
1259
  this.finishRun(abortController);
1183
1260
  }
1184
1261
 
1185
- if (this.pendingError) {
1186
- const err = this.pendingError;
1262
+ if (pendingError) {
1263
+ const err = pendingError;
1187
1264
  this.reportedErrors.add(err);
1188
- this.pendingError = null;
1189
- this.pendingResumeMessageId = null;
1190
- this.pendingA2uiResume = false;
1191
- this.pendingA2uiAction = undefined;
1265
+ this.clearDeferredContinuations(abortController);
1192
1266
  throw err;
1193
1267
  }
1194
1268
 
1195
1269
  // A tool result that landed before the run settled deferred its
1196
1270
  // continuation here so a second run never overlaps the first.
1197
- if (this.pendingResumeMessageId !== null) {
1198
- const resumeMessageId = this.pendingResumeMessageId;
1199
- this.pendingResumeMessageId = null;
1271
+ if (this.pendingResume?.owner === abortController) {
1272
+ const { messageId } = this.pendingResume;
1273
+ this.pendingResume = null;
1200
1274
  if (!abortSignal.aborted) {
1201
- this.startResumeRun(resumeMessageId);
1202
- } else {
1203
- this.pendingA2uiAction = undefined;
1275
+ this.startResumeRun(messageId);
1204
1276
  }
1205
1277
  }
1206
1278
 
1207
- if (this.pendingA2uiResume) {
1208
- this.pendingA2uiResume = false;
1279
+ if (this.pendingA2uiResumeOwner === abortController) {
1280
+ this.pendingA2uiResumeOwner = null;
1209
1281
  if (!abortSignal.aborted && this.pendingA2uiAction !== undefined) {
1210
1282
  if (this.getPendingInterrupts()) {
1211
1283
  this.pendingA2uiAction = undefined;
@@ -1243,9 +1315,9 @@ export class AgUiThreadRuntimeCore {
1243
1315
  applyUpdate: (update: ChatModelRunResult) => void;
1244
1316
  getAssistantMessageId: () => string | undefined;
1245
1317
  },
1246
- ): Promise<void> {
1318
+ ): Promise<Error | undefined> {
1247
1319
  this.pendingA2uiAction = undefined;
1248
- this.pendingA2uiResume = false;
1320
+ this.pendingA2uiResumeOwner = null;
1249
1321
  const assistantId = ctx.ensureAssistant();
1250
1322
  const currentId = () => ctx.getAssistantMessageId() ?? assistantId;
1251
1323
  const options: ChatModelRunOptions = {
@@ -1269,36 +1341,39 @@ export class AgUiThreadRuntimeCore {
1269
1341
 
1270
1342
  try {
1271
1343
  for await (const result of stream(options)) {
1272
- if (ctx.abortSignal.aborted) return;
1344
+ if (ctx.abortSignal.aborted) return undefined;
1273
1345
  ctx.applyUpdate(result);
1274
1346
  }
1275
1347
  } catch (error) {
1276
- if (ctx.abortSignal.aborted) return;
1348
+ if (ctx.abortSignal.aborted) return undefined;
1277
1349
  const err = error instanceof Error ? error : new Error(String(error));
1278
1350
  ctx.applyUpdate({
1279
1351
  status: { type: "incomplete", reason: "error", error: err.message },
1280
1352
  });
1281
1353
  invokeRuntimeCallback("onError", this.onError, err);
1282
- this.pendingError = this.pendingError ?? err;
1283
- return;
1354
+ return err;
1284
1355
  }
1285
1356
 
1286
- if (ctx.abortSignal.aborted) return;
1357
+ if (ctx.abortSignal.aborted) return undefined;
1287
1358
  const current = this.session.tryGetMessage(currentId())?.message;
1288
1359
  if (!current || current.status?.type === "running") {
1289
1360
  ctx.applyUpdate({ status: { type: "complete", reason: "unknown" } });
1290
1361
  }
1362
+ return undefined;
1291
1363
  }
1292
1364
 
1293
1365
  private buildRunInput(
1294
1366
  runId: string,
1295
1367
  runConfig: RunConfig | undefined,
1296
1368
  historyMessages: readonly ThreadMessage[] | undefined,
1297
- resume?: AgUiResumeEntry[],
1369
+ resume?: ResumeDispatch,
1298
1370
  ) {
1299
1371
  const threadId = this.agent.threadId || "main";
1300
1372
  const messages = toAgUiMessages(
1301
- historyMessages ?? this.session.getMessages(),
1373
+ this.selectRunMessages(
1374
+ historyMessages ?? this.session.getMessages(),
1375
+ resume,
1376
+ ),
1302
1377
  );
1303
1378
  const context = this.runtime?.thread.getModelContext();
1304
1379
  const input = {
@@ -1318,22 +1393,51 @@ export class AgUiThreadRuntimeCore {
1318
1393
  ? { a2uiAction: { userAction: this.pendingA2uiAction } }
1319
1394
  : {}),
1320
1395
  },
1321
- ...(resume !== undefined ? { resume } : {}),
1396
+ ...(resume !== undefined ? { resume: resume.entries } : {}),
1322
1397
  };
1323
1398
  this.pendingA2uiAction = undefined;
1324
1399
  return input;
1325
1400
  }
1326
1401
 
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
+ }
1430
+ }
1431
+
1327
1432
  private setRunning(running: boolean) {
1328
1433
  this.isRunningFlag = running;
1329
1434
  this.notifyUpdate();
1330
1435
  }
1331
1436
 
1332
1437
  private finishRun(controller: AbortController | null) {
1333
- if (this.abortController === controller) {
1334
- this.abortController = null;
1335
- this.activeRunAgent = null;
1336
- }
1438
+ if (this.abortController !== controller) return;
1439
+ this.abortController = null;
1440
+ this.activeRunAgent = null;
1337
1441
  this.setRunning(false);
1338
1442
  }
1339
1443
 
@@ -87,6 +87,7 @@ type ToolCallState = {
87
87
  toolCallId: string;
88
88
  toolCallName: string;
89
89
  argsText: string;
90
+ argsTextScanner?: JSONContainerScanner | undefined;
90
91
  parsedArgs: Record<string, unknown> | undefined;
91
92
  result: unknown;
92
93
  isError: boolean | undefined;
@@ -99,6 +100,80 @@ type ToolCallState = {
99
100
  subagentRunId?: string;
100
101
  };
101
102
 
103
+ type JSONContainerScanner = {
104
+ state: "pending" | "open" | "complete" | "invalid";
105
+ stack: ("{" | "[")[];
106
+ inString: boolean;
107
+ escaped: boolean;
108
+ };
109
+
110
+ const createJSONContainerScanner = (): JSONContainerScanner => ({
111
+ state: "pending",
112
+ stack: [],
113
+ inString: false,
114
+ escaped: false,
115
+ });
116
+
117
+ const isJSONWhitespace = (char: string): boolean =>
118
+ char === " " || char === "\t" || char === "\n" || char === "\r";
119
+
120
+ const scanJSONContainerDelta = (
121
+ scanner: JSONContainerScanner,
122
+ delta: string,
123
+ ): boolean => {
124
+ let completed = false;
125
+
126
+ for (const char of delta) {
127
+ if (scanner.state === "invalid") continue;
128
+ if (scanner.state === "complete") {
129
+ if (isJSONWhitespace(char)) continue;
130
+ scanner.state = "invalid";
131
+ continue;
132
+ }
133
+ if (scanner.state === "pending") {
134
+ if (isJSONWhitespace(char)) continue;
135
+ if (char !== "{" && char !== "[") {
136
+ scanner.state = "invalid";
137
+ continue;
138
+ }
139
+ scanner.state = "open";
140
+ scanner.stack.push(char);
141
+ continue;
142
+ }
143
+ if (scanner.inString) {
144
+ if (scanner.escaped) {
145
+ scanner.escaped = false;
146
+ } else if (char === "\\") {
147
+ scanner.escaped = true;
148
+ } else if (char === '"') {
149
+ scanner.inString = false;
150
+ }
151
+ continue;
152
+ }
153
+ if (char === '"') {
154
+ scanner.inString = true;
155
+ continue;
156
+ }
157
+ if (char === "{" || char === "[") {
158
+ scanner.stack.push(char);
159
+ continue;
160
+ }
161
+ if (char === "}" || char === "]") {
162
+ const expected = char === "}" ? "{" : "[";
163
+ if (scanner.stack.pop() !== expected) {
164
+ scanner.state = "invalid";
165
+ continue;
166
+ }
167
+ if (scanner.stack.length === 0) {
168
+ scanner.state = "complete";
169
+ completed = true;
170
+ }
171
+ }
172
+ }
173
+
174
+ return completed && scanner.state === "complete";
175
+ };
176
+
102
177
  export const MCP_APPS_ACTIVITY_TYPE = "mcp-apps";
103
178
 
104
179
  export const A2UI_SURFACE_ACTIVITY_TYPE = "a2ui-surface";
@@ -816,7 +891,17 @@ export class RunAggregator {
816
891
  private appendToolArgs(id: string | undefined, delta: string) {
817
892
  const entry = id ? this.toolCalls.get(id) : undefined;
818
893
  if (!entry) return;
894
+ if (!entry.argsTextScanner) {
895
+ entry.argsTextScanner = createJSONContainerScanner();
896
+ }
819
897
  entry.argsText += delta;
898
+ const shouldParse = scanJSONContainerDelta(entry.argsTextScanner, delta);
899
+ if (!shouldParse) {
900
+ if (entry.argsTextScanner.state === "invalid") {
901
+ entry.parsedArgs = undefined;
902
+ }
903
+ return;
904
+ }
820
905
  try {
821
906
  const parsed = JSON.parse(entry.argsText);
822
907
  if (parsed && typeof parsed === "object") {
@@ -56,10 +56,36 @@ export type UseAgUiRuntimeAdapters = {
56
56
  threadList?: UseAgUiThreadListAdapter;
57
57
  };
58
58
 
59
+ export type AgUiResumeTranscript = "full" | "appended";
60
+
59
61
  export type UseAgUiRuntimeOptions = ExternalStoreSharedOptions & {
60
62
  agent: AbstractAgent;
61
63
  logger?: Partial<Logger>;
62
64
  showThinking?: boolean;
65
+ /**
66
+ * What `messages` carries on a resume run, meaning a `RunAgentInput` that
67
+ * also carries `resume`. The AG-UI interrupt spec leaves this undefined: its
68
+ * contract rules constrain the thread id, interrupt coverage, idempotency,
69
+ * and expiry, and none of them mentions `messages`. Hosts therefore disagree,
70
+ * and both readings are conformant.
71
+ *
72
+ * `"full"` sends the whole thread, which is what `@ag-ui/client` itself does.
73
+ * Hosts that resume from a checkpoint ignore the transcript, and hosts that
74
+ * rebuild the interrupted run from it need it.
75
+ *
76
+ * `"appended"` sends only what was appended locally after the interrupted
77
+ * assistant message, which is nothing for an approval or a denial and the new
78
+ * user turn for `steerAway`. Choose it for a host that owns the thread and
79
+ * seeds a resume request from its own stored snapshot, because such a host
80
+ * appends the request body to that snapshot and a re-sent transcript
81
+ * duplicates the interrupted turn in its stored history. A host that rebuilds
82
+ * the run from `messages` has nothing to resume from under `"appended"`, and
83
+ * a host that derives its outgoing `MESSAGES_SNAPSHOT` from the request body
84
+ * emits a truncated one.
85
+ *
86
+ * Defaults to `"full"`.
87
+ */
88
+ resumeTranscript?: AgUiResumeTranscript | undefined;
63
89
  /**
64
90
  * When the user sends, edits, or reloads a message while client-side tool
65
91
  * calls are still pending, automatically cancel the unresolved tool calls
@@ -1,6 +1,7 @@
1
1
  // @vitest-environment jsdom
2
2
 
3
3
  import { describe, it, expect, vi, afterEach } from "vitest";
4
+ import { startTransition, useEffect } from "react";
4
5
  import {
5
6
  act,
6
7
  cleanup,
@@ -409,6 +410,59 @@ describe("useAgUiRuntime unstable_enableMessageQueue", () => {
409
410
  );
410
411
  });
411
412
 
413
+ it("does not clear the committed queue from a suspended render", async () => {
414
+ const { agent, runAgent } = gatedAgent();
415
+ let runtime: AssistantRuntime | undefined;
416
+ const never = new Promise<void>(() => {});
417
+
418
+ const Harness = ({
419
+ enabled,
420
+ suspend,
421
+ }: {
422
+ enabled: boolean;
423
+ suspend: boolean;
424
+ }) => {
425
+ const nextRuntime = useAgUiRuntime({
426
+ agent,
427
+ unstable_enableMessageQueue: enabled,
428
+ });
429
+ useEffect(() => {
430
+ runtime = nextRuntime;
431
+ }, [nextRuntime]);
432
+ if (suspend) throw never;
433
+ return null;
434
+ };
435
+
436
+ const view = render(<Harness enabled suspend={false} />);
437
+ await waitFor(() => expect(runtime).toBeDefined());
438
+ mount(runtime!);
439
+
440
+ await act(async () => {
441
+ await runtime!.thread.append({
442
+ role: "user",
443
+ content: [{ type: "text", text: "first" }],
444
+ });
445
+ });
446
+ await waitFor(() => expect(runAgent).toHaveBeenCalledTimes(1));
447
+
448
+ await act(async () => {
449
+ await runtime!.thread.append({
450
+ role: "user",
451
+ content: [{ type: "text", text: "second" }],
452
+ parentId: runtime!.thread.getState().messages.at(-1)?.id ?? null,
453
+ });
454
+ });
455
+ await waitFor(() =>
456
+ expect(screen.getByTestId("queued").textContent).toBe("second"),
457
+ );
458
+
459
+ act(() => {
460
+ startTransition(() => view.rerender(<Harness enabled={false} suspend />));
461
+ });
462
+
463
+ expect(screen.getByTestId("queued").textContent).toBe("second");
464
+ });
465
+
412
466
  it("leaves the queue capability off when the flag is not set", () => {
413
467
  const { agent } = gatedAgent();
414
468