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

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 (65) hide show
  1. package/README.md +2 -0
  2. package/dist/agUiExtras.js +1 -2
  3. package/dist/hooks.js +1 -2
  4. package/dist/runtime/AgUiThreadController.d.ts +11 -0
  5. package/dist/runtime/AgUiThreadController.d.ts.map +1 -1
  6. package/dist/runtime/AgUiThreadController.js +565 -89
  7. package/dist/runtime/AgUiThreadController.js.map +1 -1
  8. package/dist/runtime/adapter/conversions.d.ts +9 -1
  9. package/dist/runtime/adapter/conversions.d.ts.map +1 -1
  10. package/dist/runtime/adapter/conversions.js +194 -12
  11. package/dist/runtime/adapter/conversions.js.map +1 -1
  12. package/dist/runtime/adapter/replay-content.d.ts +9 -0
  13. package/dist/runtime/adapter/replay-content.d.ts.map +1 -0
  14. package/dist/runtime/adapter/replay-content.js +185 -0
  15. package/dist/runtime/adapter/replay-content.js.map +1 -0
  16. package/dist/runtime/adapter/run-aggregator.d.ts +1 -0
  17. package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
  18. package/dist/runtime/adapter/run-aggregator.js +35 -14
  19. package/dist/runtime/adapter/run-aggregator.js.map +1 -1
  20. package/dist/runtime/adapter/subscriber.d.ts +0 -1
  21. package/dist/runtime/adapter/subscriber.d.ts.map +1 -1
  22. package/dist/runtime/adapter/subscriber.js +2 -10
  23. package/dist/runtime/adapter/subscriber.js.map +1 -1
  24. package/dist/runtime/adapter/tool-approval.d.ts +2 -0
  25. package/dist/runtime/adapter/tool-approval.d.ts.map +1 -1
  26. package/dist/runtime/adapter/tool-approval.js +23 -6
  27. package/dist/runtime/adapter/tool-approval.js.map +1 -1
  28. package/dist/runtime/agUiThreadState.d.ts +1 -1
  29. package/dist/runtime/agUiThreadState.d.ts.map +1 -1
  30. package/dist/runtime/agUiThreadState.js +3 -4
  31. package/dist/runtime/agUiThreadState.js.map +1 -1
  32. package/dist/runtime/event-parser.d.ts.map +1 -1
  33. package/dist/runtime/event-parser.js +2 -2
  34. package/dist/runtime/event-parser.js.map +1 -1
  35. package/dist/runtime/interrupt-internals.js +1 -2
  36. package/dist/runtime/logger.js +1 -2
  37. package/dist/runtime/mcp-tool-result.js +1 -2
  38. package/dist/runtime/types.d.ts +2 -0
  39. package/dist/runtime/types.d.ts.map +1 -1
  40. package/dist/runtime/types.js +3 -3
  41. package/dist/runtime/types.js.map +1 -1
  42. package/dist/useAgUiRuntime.js +1 -2
  43. package/package.json +6 -6
  44. package/src/runtime/AgUiThreadController.deferred-results.test.ts +548 -0
  45. package/src/runtime/AgUiThreadController.disconnect.test.ts +914 -0
  46. package/src/runtime/AgUiThreadController.interrupt-history.test.ts +392 -0
  47. package/src/runtime/AgUiThreadController.replay.test.ts +3939 -0
  48. package/src/runtime/AgUiThreadController.snapshot-head-move.test.ts +167 -0
  49. package/src/runtime/AgUiThreadController.subagent-snapshot.test.ts +854 -0
  50. package/src/runtime/AgUiThreadController.test.ts +119 -0
  51. package/src/runtime/AgUiThreadController.ts +1184 -136
  52. package/src/runtime/adapter/conversions.content.test.ts +242 -1
  53. package/src/runtime/adapter/conversions.subagent-messages.test.ts +550 -0
  54. package/src/runtime/adapter/conversions.ts +349 -28
  55. package/src/runtime/adapter/replay-content.test.ts +235 -0
  56. package/src/runtime/adapter/replay-content.ts +306 -0
  57. package/src/runtime/adapter/run-aggregator.metadata.test.ts +151 -0
  58. package/src/runtime/adapter/run-aggregator.ts +52 -9
  59. package/src/runtime/adapter/subscriber.test.ts +1 -1
  60. package/src/runtime/adapter/subscriber.ts +1 -6
  61. package/src/runtime/adapter/tool-approval.ts +41 -3
  62. package/src/runtime/agUiThreadState.test.ts +21 -2
  63. package/src/runtime/agUiThreadState.ts +12 -3
  64. package/src/runtime/event-parser.ts +3 -0
  65. package/src/runtime/types.ts +5 -0
@@ -62,14 +62,22 @@ import {
62
62
  tryParseJSON,
63
63
  } from "./adapter/run-aggregator";
64
64
  import {
65
- fromAgUiMessages,
65
+ fromAgUiSnapshotMessages,
66
66
  toAgUiMessages,
67
67
  toAgUiTools,
68
+ withAgUiPartMessageMetadata,
68
69
  } from "./adapter/conversions";
69
70
  import { createAgUiSubscriber } from "./adapter/subscriber";
71
+ import {
72
+ createReplayContentMerger,
73
+ groupReplayToolContent,
74
+ indexReplayToolOwners,
75
+ preserveToolResult,
76
+ } from "./adapter/replay-content";
70
77
  import {
71
78
  buildToolApprovalResume,
72
79
  projectAgUiToolApprovals,
80
+ preserveToolApprovalDecision,
73
81
  withSettledToolApprovals,
74
82
  withToolApprovalDecision,
75
83
  } from "./adapter/tool-approval";
@@ -92,17 +100,38 @@ const isResolvedToolCall = (
92
100
  ): boolean =>
93
101
  part.type === "tool-call" && "result" in part && part.result !== undefined;
94
102
 
103
+ const hasResolvedToolCalls = (message: ThreadMessage): boolean => {
104
+ if (message.role !== "assistant") return false;
105
+ for (const part of iterateToolCallParts(message.content)) {
106
+ if (isResolvedToolCall(part)) return true;
107
+ }
108
+ return false;
109
+ };
110
+
95
111
  const listedPendingToolCallIds = (
96
- assistant: ThreadAssistantMessage,
112
+ assistant: Pick<ThreadAssistantMessage, "metadata">,
97
113
  ): ReadonlySet<string> | undefined => {
98
114
  const ids = (
99
115
  assistant.metadata.custom[AG_UI_METADATA_NAMESPACE] as
100
116
  | AgUiCustomMetadata
101
117
  | undefined
102
118
  )?.pendingToolCallIds;
103
- return ids?.length ? new Set(ids) : undefined;
119
+ return ids === undefined ? undefined : new Set(ids);
104
120
  };
105
121
 
122
+ type ReplayContext = {
123
+ // Immutable history reference: partial argument frames cannot redefine consent.
124
+ readonly originalContent: ThreadAssistantMessage["content"];
125
+ accountedResultIds: ReadonlySet<string>;
126
+ admittedToolCallIds: ReadonlySet<string>;
127
+ merge: ReturnType<typeof createReplayContentMerger>;
128
+ };
129
+
130
+ type SnapshotMessageReconciler = (
131
+ snapshot: ThreadAssistantMessage,
132
+ previous: ThreadAssistantMessage | undefined,
133
+ ) => ThreadAssistantMessage;
134
+
106
135
  const withoutPendingToolCallIds = (
107
136
  metadata: ThreadAssistantMessage["metadata"],
108
137
  ): ThreadAssistantMessage["metadata"] => {
@@ -125,6 +154,43 @@ const withoutPendingToolCallIds = (
125
154
  };
126
155
  };
127
156
 
157
+ const withPendingToolCallIds = (
158
+ metadata: ThreadAssistantMessage["metadata"],
159
+ ids: Iterable<string>,
160
+ ): ThreadAssistantMessage["metadata"] => ({
161
+ ...metadata,
162
+ custom: {
163
+ ...metadata.custom,
164
+ [AG_UI_METADATA_NAMESPACE]: {
165
+ ...(metadata.custom[AG_UI_METADATA_NAMESPACE] as
166
+ | AgUiCustomMetadata
167
+ | undefined),
168
+ pendingToolCallIds: [...ids],
169
+ },
170
+ },
171
+ });
172
+
173
+ const actionableToolCallIds = (
174
+ message: ThreadMessage | undefined,
175
+ ): ReadonlySet<string> => {
176
+ if (
177
+ message?.role !== "assistant" ||
178
+ message.status?.type !== "requires-action" ||
179
+ message.status.reason !== "tool-calls"
180
+ )
181
+ return new Set();
182
+ const permitted = listedPendingToolCallIds(message);
183
+ return new Set(
184
+ Array.from(iterateToolCallParts(message.content))
185
+ .filter(
186
+ (part) =>
187
+ !isResolvedToolCall(part) &&
188
+ (!permitted || permitted.has(part.toolCallId)),
189
+ )
190
+ .map((part) => part.toolCallId),
191
+ );
192
+ };
193
+
128
194
  const isActivityPart = (part: ThreadAssistantMessage["content"][number]) =>
129
195
  part.type === "data" && part.name.startsWith("agui-activity/");
130
196
 
@@ -164,6 +230,73 @@ type CoreOptions = {
164
230
 
165
231
  const FALLBACK_USER_STATUS = { type: "complete", reason: "unknown" } as const;
166
232
 
233
+ const textOf = (content: ThreadAssistantMessage["content"]) =>
234
+ content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("");
235
+
236
+ /**
237
+ * Swaps `content`'s text parts, in order, for `source`'s. A text part `source`
238
+ * has beyond `content`'s text slots goes after the nearest earlier part of
239
+ * `source` that `content` also has: a tool call by id, another part by its
240
+ * type and position among parts of that type. With none, it goes first.
241
+ */
242
+ const replaceTextParts = (
243
+ content: ThreadAssistantMessage["content"],
244
+ source: ThreadAssistantMessage["content"],
245
+ ): ThreadAssistantMessage["content"] => {
246
+ const texts = source.filter((part) => part.type === "text");
247
+ const result: ThreadAssistantMessage["content"][number][] = [];
248
+ let used = 0;
249
+ for (const part of content) {
250
+ if (part.type !== "text") result.push(part);
251
+ else if (used < texts.length) result.push(texts[used++]!);
252
+ }
253
+ const positionIn = (
254
+ parts: readonly ThreadAssistantMessage["content"][number][],
255
+ part: ThreadAssistantMessage["content"][number],
256
+ ) => parts.filter((other) => other.type === part.type).indexOf(part);
257
+ for (const text of texts.slice(used)) {
258
+ let anchor = -1;
259
+ for (
260
+ let index = source.indexOf(text) - 1;
261
+ index >= 0 && anchor < 0;
262
+ index--
263
+ ) {
264
+ const before = source[index]!;
265
+ if (before.type === "tool-call") {
266
+ anchor = result.findIndex(
267
+ (part) =>
268
+ part.type === "tool-call" && part.toolCallId === before.toolCallId,
269
+ );
270
+ } else if (before.type === "text") {
271
+ anchor = result.indexOf(before);
272
+ } else {
273
+ const sameType = result.filter((part) => part.type === before.type);
274
+ const match = sameType[positionIn(source, before)];
275
+ anchor = match ? result.indexOf(match) : -1;
276
+ }
277
+ }
278
+ result.splice(anchor + 1, 0, text);
279
+ }
280
+ return result;
281
+ };
282
+
283
+ const settleNestedContent = (
284
+ content: ThreadAssistantMessage["content"],
285
+ status: MessageStatus,
286
+ ) =>
287
+ mapToolCallPartsDeep(content, (part) =>
288
+ part.messages
289
+ ? {
290
+ ...part,
291
+ messages: part.messages.map((message) =>
292
+ message.role === "assistant" && message.status?.type === "running"
293
+ ? { ...message, status }
294
+ : message,
295
+ ),
296
+ }
297
+ : part,
298
+ ).content;
299
+
167
300
  type AgUiRuntimeCallbackName = "onError" | "onCancel";
168
301
 
169
302
  const invokeRuntimeCallback = <TArgs extends readonly unknown[]>(
@@ -241,6 +374,10 @@ export class AgUiThreadController {
241
374
  private activeRunAgent: AbstractAgent | null = null;
242
375
  private history: ThreadHistoryAdapter | undefined;
243
376
  private readonly assistantHistoryParents = new Map<string, string | null>();
377
+ private readonly subagentParentToolCallIds = new Map<
378
+ string,
379
+ string | undefined
380
+ >();
244
381
  private readonly snapshotHistoryIds = new Set<string>();
245
382
  private readonly persistedHistoryIds = new Set<string>();
246
383
  private readonly historyWrites = createHistoryWriteChain();
@@ -347,6 +484,7 @@ export class AgUiThreadController {
347
484
 
348
485
  this.session.applyExternalMessageRepository(repo);
349
486
  this.assistantHistoryParents.clear();
487
+ this.subagentParentToolCallIds.clear();
350
488
  this.snapshotHistoryIds.clear();
351
489
  this.persistedHistoryIds.clear();
352
490
  for (const { message } of repo.messages) {
@@ -560,6 +698,7 @@ export class AgUiThreadController {
560
698
  config.runConfig ?? this.state.lastRunConfig,
561
699
  undefined,
562
700
  config.stream,
701
+ true,
563
702
  );
564
703
  }
565
704
 
@@ -599,17 +738,13 @@ export class AgUiThreadController {
599
738
  private findRequiresActionAssistant(
600
739
  reason: "interrupt" | "tool-calls",
601
740
  ): ThreadAssistantMessage | null {
602
- const assistant = this.getMessages().findLast(
603
- (message) => message.role === "assistant",
604
- ) as ThreadAssistantMessage | undefined;
605
- if (
606
- !assistant ||
607
- assistant.status?.type !== "requires-action" ||
608
- assistant.status.reason !== reason
609
- ) {
610
- return null;
611
- }
612
- return assistant;
741
+ return (
742
+ this.getTrailingAssistantTurn().findLast(
743
+ (message) =>
744
+ message.status?.type === "requires-action" &&
745
+ message.status.reason === reason,
746
+ ) ?? null
747
+ );
613
748
  }
614
749
 
615
750
  private getTrailingAssistantTurn(): ThreadAssistantMessage[] {
@@ -715,8 +850,9 @@ export class AgUiThreadController {
715
850
  messageId: string,
716
851
  resume: AgUiResumeEntry[],
717
852
  ): Promise<void> {
853
+ const parentId = this.session.headId ?? messageId;
718
854
  this.clearPendingInterrupts(messageId, resume);
719
- await this.startRun(messageId, this.state.lastRunConfig, {
855
+ await this.startRun(parentId, this.state.lastRunConfig, {
720
856
  entries: resume,
721
857
  interruptedMessageId: messageId,
722
858
  });
@@ -1004,6 +1140,7 @@ export class AgUiThreadController {
1004
1140
  });
1005
1141
  if (touched) {
1006
1142
  this.publishUpdate();
1143
+ this.persistSettledAssistant(messageId);
1007
1144
  }
1008
1145
  }
1009
1146
 
@@ -1072,7 +1209,35 @@ export class AgUiThreadController {
1072
1209
  };
1073
1210
  });
1074
1211
  if (!matchedToolCall) return message;
1075
- return { ...assistant, content };
1212
+ const settled = mapToolCallPartsDeep(content, (part) => {
1213
+ if (!part.messages) return part;
1214
+ let changed = false;
1215
+ const messages = part.messages.map((nested) => {
1216
+ if (
1217
+ nested.role !== "assistant" ||
1218
+ nested.status.type !== "requires-action" ||
1219
+ nested.status.reason !== "tool-calls"
1220
+ )
1221
+ return nested;
1222
+ const listed = listedPendingToolCallIds(nested);
1223
+ if (
1224
+ Array.from(iterateToolCallParts(nested.content)).some(
1225
+ (call) =>
1226
+ !isResolvedToolCall(call) &&
1227
+ (!listed || listed.has(call.toolCallId)),
1228
+ )
1229
+ )
1230
+ return nested;
1231
+ changed = true;
1232
+ return {
1233
+ ...nested,
1234
+ status: { type: "complete" as const, reason: "unknown" as const },
1235
+ metadata: withoutPendingToolCallIds(nested.metadata),
1236
+ };
1237
+ });
1238
+ return changed ? { ...part, messages } : part;
1239
+ }).content;
1240
+ return { ...assistant, content: settled };
1076
1241
  });
1077
1242
 
1078
1243
  if (!updated) return;
@@ -1180,15 +1345,26 @@ export class AgUiThreadController {
1180
1345
  // The continuation fires whether the frontend result lands before
1181
1346
  // RUN_FINISHED (the status flips to requires-action only later, while the
1182
1347
  // run is still draining) or after it.
1183
- private maybeResumeAfterToolResults(messageId: string): void {
1348
+ private maybeResumeAfterToolResults(
1349
+ messageId: string,
1350
+ accountedResultIds?: ReadonlySet<string>,
1351
+ ): void {
1184
1352
  if (!this.maybeCompleteAfterToolResults(messageId)) return;
1353
+ if (accountedResultIds) {
1354
+ const message = this.session.tryGetMessage(messageId)?.message;
1355
+ if (
1356
+ message?.role !== "assistant" ||
1357
+ !Array.from(iterateToolCallParts(message.content)).some(
1358
+ (part) =>
1359
+ isResolvedToolCall(part) &&
1360
+ !accountedResultIds.has(part.toolCallId),
1361
+ )
1362
+ )
1363
+ return;
1364
+ }
1185
1365
  if (this.getPendingInterrupts()) return;
1186
1366
  const turn = this.getTrailingAssistantTurn();
1187
1367
  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;
1191
-
1192
1368
  const owner = this.abortController;
1193
1369
  if (owner) {
1194
1370
  // A run is still draining (RUN_FINISHED arrived but the stream has not
@@ -1196,10 +1372,13 @@ export class AgUiThreadController {
1196
1372
  this.dispatch({
1197
1373
  type: "resumeDeferred",
1198
1374
  owner,
1199
- messageId: headId,
1375
+ messageId,
1200
1376
  });
1201
1377
  return;
1202
1378
  }
1379
+ if (this.getPendingToolCalls()) return;
1380
+ const headId = turn.at(-1)?.id;
1381
+ if (headId === undefined) return;
1203
1382
  this.startResumeRun(headId);
1204
1383
  }
1205
1384
 
@@ -1235,7 +1414,11 @@ export class AgUiThreadController {
1235
1414
  );
1236
1415
  if (!updated) return false;
1237
1416
  this.publishUpdate();
1238
- this.persistAssistantHistory(messageId);
1417
+ if (this.history?.update) {
1418
+ this.persistSettledAssistant(messageId);
1419
+ } else {
1420
+ this.persistAssistantHistory(messageId);
1421
+ }
1239
1422
  return true;
1240
1423
  }
1241
1424
 
@@ -1269,6 +1452,19 @@ export class AgUiThreadController {
1269
1452
  let lastAppliedId: string | null = null;
1270
1453
  let hardReplace = false;
1271
1454
  const seen = new Set<string>();
1455
+ const retainToolResults = (
1456
+ existing: ThreadMessage | undefined,
1457
+ message: ThreadMessage,
1458
+ ): ThreadMessage =>
1459
+ existing?.role === "assistant" && message.role === "assistant"
1460
+ ? {
1461
+ ...message,
1462
+ content: this.preserveToolResults(
1463
+ existing.content,
1464
+ message.content,
1465
+ ),
1466
+ }
1467
+ : message;
1272
1468
 
1273
1469
  for (const message of messages) {
1274
1470
  if (seen.has(message.id)) continue;
@@ -1278,12 +1474,21 @@ export class AgUiThreadController {
1278
1474
  hardReplace = true;
1279
1475
  break;
1280
1476
  }
1281
- this.session.addOrUpdateMessage(expectedParentId, message);
1477
+ this.session.addOrUpdateMessage(
1478
+ expectedParentId,
1479
+ retainToolResults(existing?.message, message),
1480
+ );
1282
1481
  expectedParentId = message.id;
1283
1482
  lastAppliedId = message.id;
1284
1483
  }
1285
1484
 
1286
1485
  if (hardReplace) {
1486
+ const previous = new Map(
1487
+ this.getMessageRepository().messages.map(({ message }) => [
1488
+ message.id,
1489
+ message,
1490
+ ]),
1491
+ );
1287
1492
  this.session.clear();
1288
1493
  expectedParentId = null;
1289
1494
  lastAppliedId = null;
@@ -1291,29 +1496,48 @@ export class AgUiThreadController {
1291
1496
  for (const message of messages) {
1292
1497
  if (seen.has(message.id)) continue;
1293
1498
  seen.add(message.id);
1294
- this.session.addOrUpdateMessage(expectedParentId, message);
1499
+ this.session.addOrUpdateMessage(
1500
+ expectedParentId,
1501
+ retainToolResults(previous.get(message.id), message),
1502
+ );
1295
1503
  expectedParentId = message.id;
1296
1504
  lastAppliedId = message.id;
1297
1505
  }
1298
1506
  }
1299
1507
 
1300
1508
  this.session.resetHead(lastAppliedId);
1509
+ for (const messageId of seen) {
1510
+ this.maybeCompleteAfterToolResults(messageId);
1511
+ }
1301
1512
  }
1302
1513
 
1303
1514
  this.snapshotHistoryIds.clear();
1304
1515
  for (const { message } of this.getMessageRepository().messages) {
1305
1516
  this.snapshotHistoryIds.add(message.id);
1306
1517
  }
1307
- // MESSAGES_SNAPSHOT re-appends the active assistant, so a parked
1308
- // continuation whose target survived the snapshot is still answerable.
1309
- // Membership is the head branch, not the repository: a soft merge leaves
1310
- // the messages it dropped behind as off-branch nodes.
1311
- const resumeTarget = this.state.pendingResume?.messageId;
1312
- if (
1313
- resumeTarget !== undefined &&
1314
- !this.session.getMessages().some((message) => message.id === resumeTarget)
1315
- ) {
1316
- this.dispatch({ type: "resumeCleared" });
1518
+ // A soft merge leaves dropped messages off branch; only selected
1519
+ // messages with resolved tool calls can still own a continuation.
1520
+ const pendingResume = this.state.pendingResume;
1521
+ if (pendingResume) {
1522
+ const selectedResultOwnerIds = new Set(
1523
+ this.session
1524
+ .getMessages()
1525
+ .filter(hasResolvedToolCalls)
1526
+ .map((message) => message.id),
1527
+ );
1528
+ const retained = pendingResume.messageIds.filter((id) =>
1529
+ selectedResultOwnerIds.has(id),
1530
+ );
1531
+ if (retained.length !== pendingResume.messageIds.length) {
1532
+ this.dispatch({ type: "resumeCleared" });
1533
+ for (const messageId of retained) {
1534
+ this.dispatch({
1535
+ type: "resumeDeferred",
1536
+ owner: pendingResume.owner,
1537
+ messageId,
1538
+ });
1539
+ }
1540
+ }
1317
1541
  }
1318
1542
  this.publishUpdate();
1319
1543
  }
@@ -1353,6 +1577,7 @@ export class AgUiThreadController {
1353
1577
  const activeRunAgent = this.activeRunAgent;
1354
1578
 
1355
1579
  this.dispatch({ type: "resetThread" });
1580
+ this.subagentParentToolCallIds.clear();
1356
1581
 
1357
1582
  if (controller) {
1358
1583
  this.abortController = null;
@@ -1375,6 +1600,7 @@ export class AgUiThreadController {
1375
1600
  runConfig?: RunConfig,
1376
1601
  resume?: ResumeDispatch,
1377
1602
  resumeStream?: ResumeStream,
1603
+ replayExistingMessages = false,
1378
1604
  ): Promise<void> {
1379
1605
  if (this.isThreadSwitching?.()) return;
1380
1606
  // A default AG-UI run supersedes the active run; the hook's opt-in message
@@ -1382,6 +1608,7 @@ export class AgUiThreadController {
1382
1608
  // its message; this covers the entry points that start a run without one.
1383
1609
  if (!this.supersedeActiveRun()) return;
1384
1610
 
1611
+ const generation = this.state.historyMutationGeneration;
1385
1612
  const normalizedRunConfig = runConfig ?? {};
1386
1613
  this.dispatch({
1387
1614
  type: "runConfigRecorded",
@@ -1401,12 +1628,62 @@ export class AgUiThreadController {
1401
1628
  const runStartMessageIds = new Set(
1402
1629
  this.getMessageRepository().messages.map(({ message }) => message.id),
1403
1630
  );
1631
+ const selectedMessages = this.session.getMessages();
1632
+ const parentIndex = selectedMessages.findIndex(
1633
+ (message) => message.id === parentId,
1634
+ );
1635
+ let replayStart = parentIndex + 1;
1636
+ if (selectedMessages[parentIndex]?.role === "assistant") {
1637
+ replayStart = parentIndex;
1638
+ while (
1639
+ replayStart > 0 &&
1640
+ selectedMessages[replayStart - 1]?.role === "assistant"
1641
+ )
1642
+ replayStart--;
1643
+ }
1644
+ const replayMessageIds: string[] = [];
1645
+ if (replayExistingMessages && !resumeStream && parentIndex >= 0) {
1646
+ for (const message of selectedMessages.slice(replayStart)) {
1647
+ if (message.role !== "assistant") break;
1648
+ replayMessageIds.push(message.id);
1649
+ }
1650
+ }
1651
+ // Only request-delivered and producer-observed results suppress continuation.
1652
+ const accountedResultIds = new Set(
1653
+ historicalMessages.flatMap((message) =>
1654
+ message.role === "assistant"
1655
+ ? Array.from(iterateToolCallParts(message.content))
1656
+ .filter(isResolvedToolCall)
1657
+ .map((part) => part.toolCallId)
1658
+ : [],
1659
+ ),
1660
+ );
1661
+ const candidateContexts = new Map<string, ReplayContext>();
1662
+ for (const message of selectedMessages) {
1663
+ if (
1664
+ message.role !== "assistant" ||
1665
+ !replayMessageIds.includes(message.id)
1666
+ )
1667
+ continue;
1668
+ candidateContexts.set(message.id, {
1669
+ originalContent: message.content,
1670
+ accountedResultIds,
1671
+ admittedToolCallIds: actionableToolCallIds(message),
1672
+ merge: createReplayContentMerger(),
1673
+ });
1674
+ }
1675
+ let replayContexts = new Map<string, ReplayContext>();
1676
+ let lastReplayedIndex = -1;
1677
+ let replayEnded = replayMessageIds.length === 0;
1678
+ const replayTail = replayMessageIds.at(-1);
1404
1679
  const joinStrategy = this.joinStrategy;
1405
1680
  const hasRunUserBoundary = parent?.message.role === "user" && !resume;
1406
1681
 
1407
1682
  let pendingError: Error | null = null;
1408
1683
  const assistantParentId = parent ? parentId : this.session.headId;
1409
1684
  let assistantMessageId: string | undefined;
1685
+ let snapshotAssistantId: string | undefined;
1686
+ let replayInterruptMessageId: string | undefined;
1410
1687
  // A snapshot the preserve gate declines still evicts the in-flight
1411
1688
  // assistant; recreating under the cached id on the next content-bearing
1412
1689
  // emit keeps both the stream and the message identity. Status-only emits,
@@ -1416,8 +1693,11 @@ export class AgUiThreadController {
1416
1693
  // duplicate.
1417
1694
  let assistantCollided = false;
1418
1695
  const heldMessages = new Map<string, Set<string>>();
1696
+ let frameOwnerIds = new Set<string>();
1419
1697
  let restrictedPendingToolCallIds: ReadonlySet<string> | undefined;
1420
1698
  let settlingRun = false;
1699
+ let latestSnapshot: ThreadAssistantMessage | undefined;
1700
+ let snapshotToolCallIds = new Set<string>();
1421
1701
  const ensureAssistant = (allowRecreate = false): string => {
1422
1702
  const cached = assistantMessageId;
1423
1703
  if (cached !== undefined && this.session.tryGetMessage(cached))
@@ -1427,10 +1707,16 @@ export class AgUiThreadController {
1427
1707
  }
1428
1708
  const repositoryHeadId = this.session.headId;
1429
1709
  const shouldUseSelectedParent =
1430
- cached === undefined ||
1431
- (shouldEagerlyInsertAssistant &&
1432
- (repositoryHeadId === null ||
1433
- runStartMessageIds.has(repositoryHeadId)));
1710
+ replayContexts.size === 0 &&
1711
+ (cached === undefined ||
1712
+ (shouldEagerlyInsertAssistant &&
1713
+ (repositoryHeadId === null ||
1714
+ runStartMessageIds.has(repositoryHeadId)))) &&
1715
+ // Replay candidates defer the placeholder; honor snapshots that advanced
1716
+ // the head before the producer identified its first assistant message.
1717
+ (replayMessageIds.length === 0 ||
1718
+ repositoryHeadId === null ||
1719
+ runStartMessageIds.has(repositoryHeadId));
1434
1720
  // Branch runs keep their selected parent unless the snapshot advanced to
1435
1721
  // a message introduced during this run.
1436
1722
  const parentId =
@@ -1445,23 +1731,59 @@ export class AgUiThreadController {
1445
1731
  return created;
1446
1732
  };
1447
1733
 
1448
- if (shouldEagerlyInsertAssistant) ensureAssistant();
1734
+ if (shouldEagerlyInsertAssistant && replayEnded) ensureAssistant();
1449
1735
 
1450
1736
  const settleHeldMessages = (terminalStatus: MessageStatus): string[] => {
1451
1737
  const pendingMessages: string[] = [];
1738
+ const producerPending = restrictedPendingToolCallIds;
1739
+ const canWaitForTools =
1740
+ terminalStatus.type === "complete" ||
1741
+ terminalStatus.type === "requires-action";
1742
+ if (producerPending && canWaitForTools) {
1743
+ for (const message of this.getTrailingAssistantTurn()) {
1744
+ const replay = replayContexts.get(message.id);
1745
+ if (
1746
+ message.id === assistantMessageId ||
1747
+ heldMessages.has(message.id) ||
1748
+ !replay ||
1749
+ (message.status?.type === "requires-action" &&
1750
+ message.status.reason === "interrupt")
1751
+ )
1752
+ continue;
1753
+ if (
1754
+ replay.admittedToolCallIds.size > 0 ||
1755
+ actionableToolCallIds(message).size > 0 ||
1756
+ Array.from(iterateToolCallParts(message.content)).some(
1757
+ (part) =>
1758
+ producerPending.has(part.toolCallId) &&
1759
+ !accountedResultIds.has(part.toolCallId),
1760
+ )
1761
+ )
1762
+ heldMessages.set(message.id, new Set());
1763
+ }
1764
+ }
1452
1765
  for (const [messageId, openToolCallIds] of heldMessages) {
1766
+ const existing = this.session.tryGetMessage(messageId);
1767
+ if (existing)
1768
+ this.markPendingAssistantHistory(messageId, existing.parentId);
1453
1769
  let requiresToolResult = false;
1454
1770
  const updated = this.session.updateMessage(messageId, (message) => {
1455
1771
  if (message.role !== "assistant") return message;
1456
1772
  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";
1773
+ const pendingToolCallIds = producerPending
1774
+ ? Array.from(iterateToolCallParts(assistant.content))
1775
+ .filter(
1776
+ (part) =>
1777
+ producerPending.has(part.toolCallId) &&
1778
+ (!isResolvedToolCall(part) ||
1779
+ openToolCallIds.has(part.toolCallId) ||
1780
+ (replayContexts.has(messageId) &&
1781
+ !accountedResultIds.has(part.toolCallId))),
1782
+ )
1783
+ .map((part) => part.toolCallId)
1784
+ : Array.from(openToolCallIds);
1785
+ const replay = replayContexts.get(messageId);
1786
+ if (replay) replay.admittedToolCallIds = new Set(pendingToolCallIds);
1465
1787
  const status: MessageStatus = canWaitForTools
1466
1788
  ? pendingToolCallIds.length > 0
1467
1789
  ? { type: "requires-action", reason: "tool-calls" }
@@ -1473,19 +1795,9 @@ export class AgUiThreadController {
1473
1795
  ...assistant,
1474
1796
  status,
1475
1797
  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
- }
1798
+ status.type === "requires-action" &&
1799
+ (restrictedPendingToolCallIds || replayContexts.has(messageId))
1800
+ ? withPendingToolCallIds(metadata, pendingToolCallIds)
1489
1801
  : metadata,
1490
1802
  };
1491
1803
  });
@@ -1501,24 +1813,190 @@ export class AgUiThreadController {
1501
1813
  return pendingMessages;
1502
1814
  };
1503
1815
 
1816
+ const accountProducerResults = (
1817
+ content: ThreadAssistantMessage["content"],
1818
+ ) => {
1819
+ for (const part of iterateToolCallParts(content))
1820
+ if (isResolvedToolCall(part)) accountedResultIds.add(part.toolCallId);
1821
+ };
1822
+ const reconcileReplaySnapshot: SnapshotMessageReconciler = (
1823
+ snapshot,
1824
+ previous,
1825
+ ) => {
1826
+ // Observe the producer before preserving any local frontend results.
1827
+ accountProducerResults(snapshot.content);
1828
+ const replay = candidateContexts.get(snapshot.id);
1829
+ if (!replay) return snapshot;
1830
+ replay.admittedToolCallIds = actionableToolCallIds(snapshot);
1831
+ if (heldMessages.has(snapshot.id))
1832
+ heldMessages.set(snapshot.id, new Set(replay.admittedToolCallIds));
1833
+ if (!replayContexts.has(snapshot.id)) return snapshot;
1834
+ return previous
1835
+ ? {
1836
+ ...snapshot,
1837
+ content: this.preserveToolResults(
1838
+ previous.content,
1839
+ snapshot.content,
1840
+ replay.originalContent,
1841
+ ),
1842
+ }
1843
+ : snapshot;
1844
+ };
1845
+
1504
1846
  const applyUpdate = (update: ChatModelRunResult) => {
1847
+ if (
1848
+ !assistantCollided &&
1849
+ replayContexts.size &&
1850
+ update.content !== undefined
1851
+ )
1852
+ accountProducerResults(
1853
+ update.content as ThreadAssistantMessage["content"],
1854
+ );
1855
+ let ownerParts:
1856
+ | Map<string, ThreadAssistantMessage["content"][number][]>
1857
+ | undefined;
1858
+ let contentOwnerIds: Set<string> | undefined;
1859
+ const hasStreamContent =
1860
+ Array.isArray(update.content) &&
1861
+ update.content.some((part) => part.type !== "data");
1862
+ if (!replayEnded && assistantMessageId === undefined) {
1863
+ if (!update.status || update.status.type === "running") return;
1864
+ // No matching producer identity arrived: retain native regeneration.
1865
+ replayEnded = true;
1866
+ if (
1867
+ replayContexts.size === 0 &&
1868
+ snapshotAssistantId !== undefined &&
1869
+ this.session.headId === snapshotAssistantId &&
1870
+ !hasStreamContent
1871
+ ) {
1872
+ if (update.status.type !== "complete") {
1873
+ this.updateAssistantMessage(snapshotAssistantId, {
1874
+ status: update.status,
1875
+ ...(update.metadata ? { metadata: update.metadata } : {}),
1876
+ });
1877
+ }
1878
+ return;
1879
+ }
1880
+ }
1881
+ const streamMessageId = assistantMessageId;
1882
+ let primaryMessageId = streamMessageId;
1505
1883
  const terminalStatus = update.status;
1884
+ const replayInterrupt =
1885
+ replayContexts.size > 0 &&
1886
+ terminalStatus?.type === "requires-action" &&
1887
+ terminalStatus.reason === "interrupt";
1888
+ if (
1889
+ streamMessageId !== undefined &&
1890
+ !assistantCollided &&
1891
+ replayContexts.size > 0 &&
1892
+ (update.content !== undefined || replayInterrupt)
1893
+ ) {
1894
+ const owners = this.getTrailingAssistantTurn().filter((message) =>
1895
+ replayContexts.has(message.id),
1896
+ );
1897
+ // Buffered calls keep their owners when a later text frame arrives.
1898
+ const grouped =
1899
+ update.content !== undefined
1900
+ ? groupReplayToolContent(owners, update.content, streamMessageId)
1901
+ : new Map([[streamMessageId, [...(update.content ?? [])]]]);
1902
+ if (update.content !== undefined) {
1903
+ const currentOwnerIds = new Set(grouped.keys());
1904
+ for (const ownerId of frameOwnerIds)
1905
+ grouped.set(ownerId, grouped.get(ownerId) ?? []);
1906
+ contentOwnerIds = new Set(grouped.keys());
1907
+ frameOwnerIds = currentOwnerIds;
1908
+ }
1909
+ ownerParts = grouped;
1910
+ if (replayInterrupt) {
1911
+ const interrupts = (
1912
+ update.metadata?.custom?.[AG_UI_METADATA_NAMESPACE] as
1913
+ | AgUiCustomMetadata
1914
+ | undefined
1915
+ )?.interrupts;
1916
+ const approvals = projectAgUiToolApprovals(interrupts);
1917
+ for (const owner of owners) {
1918
+ if (
1919
+ Array.from(iterateToolCallParts(owner.content)).some((part) =>
1920
+ approvals.has(part.toolCallId),
1921
+ )
1922
+ )
1923
+ grouped.set(owner.id, grouped.get(owner.id) ?? []);
1924
+ }
1925
+ const anchor = owners.find(
1926
+ (message) =>
1927
+ projectAgUiToolApprovals(
1928
+ interrupts,
1929
+ new Set(
1930
+ Array.from(
1931
+ iterateToolCallParts([
1932
+ ...message.content,
1933
+ ...(grouped.get(message.id) ?? []),
1934
+ ]),
1935
+ ).map((part) => part.toolCallId),
1936
+ ),
1937
+ ).size > 0,
1938
+ );
1939
+ if (anchor) {
1940
+ primaryMessageId = anchor.id;
1941
+ replayInterruptMessageId = anchor.id;
1942
+ grouped.set(anchor.id, grouped.get(anchor.id) ?? []);
1943
+ }
1944
+ }
1945
+ }
1506
1946
  const pendingMessages =
1507
1947
  settlingRun && terminalStatus && terminalStatus.type !== "running"
1508
1948
  ? settleHeldMessages(terminalStatus)
1509
1949
  : [];
1510
- const hasStreamContent =
1511
- Array.isArray(update.content) &&
1512
- update.content.some((part) => part.type !== "data");
1950
+ const frameMessageId = ensureAssistant(hasStreamContent);
1951
+ const messageId = primaryMessageId ?? frameMessageId;
1952
+ let primaryUpdate = update;
1953
+ if (ownerParts) {
1954
+ const interrupt =
1955
+ terminalStatus?.type === "requires-action" &&
1956
+ terminalStatus.reason === "interrupt";
1957
+ const ownerStatus: MessageStatus | undefined =
1958
+ terminalStatus?.type === "requires-action"
1959
+ ? { type: "complete", reason: "unknown" }
1960
+ : terminalStatus;
1961
+ for (const [ownerId, content] of ownerParts) {
1962
+ const existing = this.session.tryGetMessage(ownerId);
1963
+ if (existing)
1964
+ this.markPendingAssistantHistory(ownerId, existing.parentId);
1965
+ if (ownerId === messageId) continue;
1966
+ this.updateAssistantMessage(
1967
+ ownerId,
1968
+ {
1969
+ ...(contentOwnerIds?.has(ownerId) ? { content } : {}),
1970
+ ...(ownerStatus ? { status: ownerStatus } : {}),
1971
+ },
1972
+ replayContexts.get(ownerId),
1973
+ interrupt ? new Set<string>() : restrictedPendingToolCallIds,
1974
+ );
1975
+ }
1976
+ const { content: _content, ...controlUpdate } = update;
1977
+ const content = ownerParts.get(messageId) ?? [];
1978
+ primaryUpdate = {
1979
+ ...controlUpdate,
1980
+ ...(contentOwnerIds?.has(messageId) ? { content } : {}),
1981
+ ...(ownerStatus && !interrupt && replayContexts.has(messageId)
1982
+ ? { status: ownerStatus }
1983
+ : {}),
1984
+ };
1985
+ }
1513
1986
  const resolved = this.updateAssistantMessage(
1514
- ensureAssistant(hasStreamContent),
1515
- update,
1987
+ messageId,
1988
+ primaryUpdate,
1989
+ replayContexts.get(messageId),
1990
+ restrictedPendingToolCallIds,
1516
1991
  );
1517
- if (resolved !== assistantMessageId) {
1992
+ if (messageId === frameMessageId && resolved !== assistantMessageId) {
1518
1993
  assistantMessageId = resolved;
1519
1994
  }
1520
1995
  for (const messageId of pendingMessages) {
1521
- this.maybeResumeAfterToolResults(messageId);
1996
+ this.maybeResumeAfterToolResults(
1997
+ messageId,
1998
+ replayMessageIds.includes(messageId) ? accountedResultIds : undefined,
1999
+ );
1522
2000
  }
1523
2001
  };
1524
2002
 
@@ -1527,20 +2005,74 @@ export class AgUiThreadController {
1527
2005
  startNewMessage: boolean,
1528
2006
  openToolCallIds: string[] = [],
1529
2007
  ) => {
2008
+ const existing = this.session.tryGetMessage(serverId);
2009
+ const replayIndex = replayMessageIds.indexOf(serverId);
2010
+ const adoptsReplay =
2011
+ !replayEnded &&
2012
+ !assistantCollided &&
2013
+ replayIndex > lastReplayedIndex &&
2014
+ existing?.message.role === "assistant" &&
2015
+ this.session.getMessages().some((message) => message.id === serverId);
2016
+ if (adoptsReplay && replayContexts.size === 0) {
2017
+ replayContexts = candidateContexts;
2018
+ const selected = this.session.getMessages();
2019
+ let tailId = serverId;
2020
+ for (const message of selected.slice(
2021
+ selected.findIndex((message) => message.id === serverId) + 1,
2022
+ )) {
2023
+ if (message.role !== "assistant") break;
2024
+ tailId = message.id;
2025
+ }
2026
+ this.session.selectHead(tailId);
2027
+ }
1530
2028
  if (startNewMessage && assistantMessageId !== undefined) {
1531
- if (openToolCallIds.length > 0) {
2029
+ let hasReplayCallsToHold = false;
2030
+ const replay = replayContexts.get(assistantMessageId);
2031
+ if (replay) {
2032
+ const current =
2033
+ this.session.tryGetMessage(assistantMessageId)?.message;
2034
+ if (current?.role === "assistant") {
2035
+ const retainedPending = Array.from(
2036
+ iterateToolCallParts(current.content),
2037
+ ).filter(
2038
+ (part) =>
2039
+ !isResolvedToolCall(part) ||
2040
+ (replay.admittedToolCallIds.has(part.toolCallId) &&
2041
+ !accountedResultIds.has(part.toolCallId)),
2042
+ );
2043
+ hasReplayCallsToHold = retainedPending.length > 0;
2044
+ openToolCallIds = [
2045
+ ...new Set([
2046
+ ...openToolCallIds,
2047
+ ...retainedPending
2048
+ .filter((part) =>
2049
+ replay.admittedToolCallIds.has(part.toolCallId),
2050
+ )
2051
+ .map((part) => part.toolCallId),
2052
+ ]),
2053
+ ];
2054
+ }
2055
+ }
2056
+ if (openToolCallIds.length > 0 || hasReplayCallsToHold) {
1532
2057
  heldMessages.set(assistantMessageId, new Set(openToolCallIds));
1533
2058
  } else {
1534
2059
  applyUpdate({ status: { type: "complete", reason: "unknown" } });
1535
2060
  }
1536
2061
  const previousId = assistantMessageId;
1537
2062
  assistantMessageId = undefined;
1538
- if (this.session.tryGetMessage(previousId)) {
2063
+ if (!adoptsReplay && this.session.tryGetMessage(previousId)) {
1539
2064
  const created = this.insertAssistantPlaceholder(previousId);
1540
2065
  assistantMessageId = created;
1541
2066
  this.markPendingAssistantHistory(created, previousId);
1542
2067
  }
1543
2068
  }
2069
+ if (adoptsReplay) {
2070
+ assistantMessageId = serverId;
2071
+ lastReplayedIndex = replayIndex;
2072
+ this.markPendingAssistantHistory(serverId, existing.parentId);
2073
+ return;
2074
+ }
2075
+ replayEnded = true;
1544
2076
  const placeholder = ensureAssistant(true);
1545
2077
  if (placeholder === serverId) return;
1546
2078
  const reassigned = this.reassignAssistantId(placeholder, serverId);
@@ -1570,25 +2102,146 @@ export class AgUiThreadController {
1570
2102
  showThinking: this.showThinking,
1571
2103
  joinStrategy,
1572
2104
  logger: this.logger,
1573
- emit: applyUpdate,
2105
+ emit: (update) => {
2106
+ const currentSnapshotAssistant =
2107
+ assistantMessageId === undefined
2108
+ ? undefined
2109
+ : this.session.tryGetMessage(assistantMessageId)?.message;
2110
+ const listedSnapshotToolCallIds =
2111
+ currentSnapshotAssistant?.role === "assistant"
2112
+ ? listedPendingToolCallIds(currentSnapshotAssistant)
2113
+ : undefined;
2114
+ const hasPendingSnapshotToolCall =
2115
+ latestSnapshot?.id === assistantMessageId &&
2116
+ currentSnapshotAssistant?.role === "assistant" &&
2117
+ Array.from(
2118
+ iterateToolCallParts(currentSnapshotAssistant.content),
2119
+ ).some(
2120
+ (part) =>
2121
+ !isResolvedToolCall(part) &&
2122
+ (!listedSnapshotToolCallIds ||
2123
+ listedSnapshotToolCallIds.has(part.toolCallId)) &&
2124
+ (!restrictedPendingToolCallIds ||
2125
+ restrictedPendingToolCallIds.has(part.toolCallId)),
2126
+ );
2127
+ const reconciledUpdate =
2128
+ settlingRun &&
2129
+ update.status?.type === "complete" &&
2130
+ hasPendingSnapshotToolCall
2131
+ ? {
2132
+ ...update,
2133
+ status: {
2134
+ type: "requires-action",
2135
+ reason: "tool-calls",
2136
+ } as const,
2137
+ ...(restrictedPendingToolCallIds
2138
+ ? {
2139
+ metadata: {
2140
+ ...update.metadata,
2141
+ custom: {
2142
+ ...update.metadata?.custom,
2143
+ [AG_UI_METADATA_NAMESPACE]: {
2144
+ ...(update.metadata?.custom?.[
2145
+ AG_UI_METADATA_NAMESPACE
2146
+ ] as AgUiCustomMetadata | undefined),
2147
+ pendingToolCallIds: [
2148
+ ...restrictedPendingToolCallIds,
2149
+ ],
2150
+ },
2151
+ },
2152
+ },
2153
+ }
2154
+ : {}),
2155
+ }
2156
+ : update;
2157
+ if (
2158
+ settlingRun &&
2159
+ latestSnapshot &&
2160
+ latestSnapshot.id === assistantMessageId &&
2161
+ reconciledUpdate.status &&
2162
+ reconciledUpdate.status.type !== "running" &&
2163
+ reconciledUpdate.status.type !== "requires-action"
2164
+ ) {
2165
+ const status = reconciledUpdate.status;
2166
+ const current =
2167
+ this.session.tryGetMessage(assistantMessageId)?.message;
2168
+ const content = settleNestedContent(
2169
+ current?.role === "assistant"
2170
+ ? current.content
2171
+ : latestSnapshot.content,
2172
+ status,
2173
+ );
2174
+ applyUpdate({ ...reconciledUpdate, content });
2175
+ } else if (
2176
+ latestSnapshot &&
2177
+ latestSnapshot.id === assistantMessageId &&
2178
+ reconciledUpdate.content !== undefined
2179
+ ) {
2180
+ const content = this.mergeSnapshotAssistantContent(
2181
+ latestSnapshot.content,
2182
+ reconciledUpdate.content as ThreadAssistantMessage["content"],
2183
+ reconciledUpdate.content.some((part) => part.type === "reasoning"),
2184
+ reconciledUpdate.content.some(isActivityPart),
2185
+ snapshotToolCallIds,
2186
+ );
2187
+ applyUpdate({ ...reconciledUpdate, content });
2188
+ } else {
2189
+ applyUpdate(reconciledUpdate);
2190
+ }
2191
+ const current = assistantMessageId
2192
+ ? this.session.tryGetMessage(assistantMessageId)?.message
2193
+ : undefined;
2194
+ latestSnapshot =
2195
+ !settlingRun &&
2196
+ current?.role === "assistant" &&
2197
+ current.id === latestSnapshot?.id
2198
+ ? current
2199
+ : undefined;
2200
+ },
1574
2201
  onServerMessageId: (serverId) => {
1575
2202
  adoptServerMessageId(serverId, false);
1576
2203
  },
1577
- onTextMessageStart: (serverId, openToolCallIds) =>
1578
- adoptServerMessageId(serverId, true, openToolCallIds),
2204
+ onTextMessageStart: (serverId, openToolCallIds) => {
2205
+ const owners = this.getTrailingAssistantTurn().filter((message) =>
2206
+ replayContexts.has(message.id),
2207
+ );
2208
+ const toolOwners = indexReplayToolOwners(owners);
2209
+ const frameOpenIds = openToolCallIds.filter((id) => {
2210
+ const ownerId = toolOwners.get(id);
2211
+ if (!ownerId || ownerId === assistantMessageId || assistantCollided)
2212
+ return true;
2213
+ const held = heldMessages.get(ownerId) ?? new Set<string>();
2214
+ held.add(id);
2215
+ heldMessages.set(ownerId, held);
2216
+ return false;
2217
+ });
2218
+ // The native accumulator just reset; prior frame contributions are
2219
+ // retained history, while removals within this frame remain authoritative.
2220
+ for (const ownerId of frameOwnerIds) {
2221
+ const replay = replayContexts.get(ownerId);
2222
+ if (replay) replay.merge = createReplayContentMerger();
2223
+ }
2224
+ frameOwnerIds.clear();
2225
+ adoptServerMessageId(serverId, true, frameOpenIds);
2226
+ },
1579
2227
  });
1580
2228
  let runFinished = false;
1581
2229
  const dispatch = (event: AgUiEvent) => {
1582
2230
  if (this.abortController !== abortController) return;
1583
2231
  if (event.type === "RUN_FINISHED") {
1584
2232
  runFinished = true;
2233
+ if (event.outcome?.type === "interrupt")
2234
+ replayInterruptMessageId = assistantMessageId ?? replayTail;
1585
2235
  const ids =
1586
2236
  event.outcome?.type === "success"
1587
2237
  ? event.outcome.pendingToolCallIds
1588
2238
  : undefined;
1589
- restrictedPendingToolCallIds = ids?.length ? new Set(ids) : undefined;
2239
+ restrictedPendingToolCallIds =
2240
+ ids === undefined ? undefined : new Set(ids);
1590
2241
  }
1591
2242
  if (event.type === "TOOL_CALL_RESULT") {
2243
+ if (!assistantCollided && candidateContexts.size)
2244
+ accountedResultIds.add(event.toolCallId);
1592
2245
  for (const openToolCallIds of heldMessages.values()) {
1593
2246
  openToolCallIds.delete(event.toolCallId);
1594
2247
  }
@@ -1597,6 +2250,8 @@ export class AgUiThreadController {
1597
2250
  event.type === "RUN_FINISHED" ||
1598
2251
  event.type === "RUN_ERROR" ||
1599
2252
  event.type === "RUN_CANCELLED";
2253
+ const beforeSnapshot =
2254
+ event.type === "MESSAGES_SNAPSHOT" ? this.session.getMessages() : [];
1600
2255
  try {
1601
2256
  const nextAssistantMessageId = this.handleEvent(
1602
2257
  aggregator,
@@ -1605,9 +2260,39 @@ export class AgUiThreadController {
1605
2260
  joinStrategy,
1606
2261
  runStartMessageIds,
1607
2262
  hasRunUserBoundary,
2263
+ candidateContexts.size ? reconcileReplaySnapshot : undefined,
1608
2264
  );
1609
2265
  if (event.type === "MESSAGES_SNAPSHOT") {
2266
+ for (const message of beforeSnapshot) {
2267
+ const context = replayContexts.get(message.id);
2268
+ const current = this.session.tryGetMessage(message.id)?.message;
2269
+ if (context && current?.content !== message.content) {
2270
+ context.merge = createReplayContentMerger();
2271
+ frameOwnerIds.delete(message.id);
2272
+ }
2273
+ }
1610
2274
  assistantMessageId = nextAssistantMessageId;
2275
+ const imported = assistantMessageId
2276
+ ? this.session.tryGetMessage(assistantMessageId)?.message
2277
+ : undefined;
2278
+ const source = event.messages.find(
2279
+ (message) =>
2280
+ message?.role === "assistant" && message.id === imported?.id,
2281
+ );
2282
+ latestSnapshot =
2283
+ imported?.role === "assistant" && source ? imported : undefined;
2284
+ snapshotToolCallIds = new Set(
2285
+ Array.isArray(source?.toolCalls)
2286
+ ? source.toolCalls
2287
+ .map((call: { id?: unknown }) => call.id)
2288
+ .filter((id: unknown): id is string => typeof id === "string")
2289
+ : [],
2290
+ );
2291
+ if (!replayEnded && assistantMessageId === undefined) {
2292
+ const tail = this.session.getMessages().at(-1);
2293
+ snapshotAssistantId =
2294
+ tail?.role === "assistant" ? tail.id : undefined;
2295
+ }
1611
2296
  }
1612
2297
  } finally {
1613
2298
  settlingRun = false;
@@ -1664,7 +2349,6 @@ export class AgUiThreadController {
1664
2349
  );
1665
2350
  const subscriber = createAgUiSubscriber({
1666
2351
  dispatch,
1667
- runId,
1668
2352
  logger: this.logger,
1669
2353
  onRunFailed: (error) => {
1670
2354
  if (abortSignal.aborted) return;
@@ -1675,7 +2359,7 @@ export class AgUiThreadController {
1675
2359
  try {
1676
2360
  runAgentInstance.messages = input.messages;
1677
2361
  runAgentInstance.threadId = input.threadId;
1678
- runAgentInstance.state = input.state ?? null;
2362
+ runAgentInstance.state = input.state;
1679
2363
  } catch {
1680
2364
  // ignore
1681
2365
  }
@@ -1693,6 +2377,26 @@ export class AgUiThreadController {
1693
2377
  pendingError = err;
1694
2378
  }
1695
2379
  } finally {
2380
+ const current = assistantMessageId
2381
+ ? this.session.tryGetMessage(assistantMessageId)?.message
2382
+ : undefined;
2383
+ if (
2384
+ this.abortController === abortController &&
2385
+ current?.role === "assistant" &&
2386
+ current.status?.type === "running"
2387
+ ) {
2388
+ // Finalization is transport cleanup, not proof of a terminal event.
2389
+ const status: MessageStatus = {
2390
+ type: "incomplete",
2391
+ reason: "error",
2392
+ error: "The connection ended before the answer finished.",
2393
+ };
2394
+ settleHeldMessages(status);
2395
+ applyUpdate({
2396
+ status,
2397
+ content: settleNestedContent(current.content, status),
2398
+ });
2399
+ }
1696
2400
  this.finishRun(abortController);
1697
2401
  }
1698
2402
 
@@ -1703,13 +2407,66 @@ export class AgUiThreadController {
1703
2407
  throw err;
1704
2408
  }
1705
2409
 
2410
+ const pending = replayInterruptMessageId
2411
+ ? this.getPendingInterrupts()
2412
+ : null;
2413
+ if (
2414
+ pending &&
2415
+ pending.messageId === replayInterruptMessageId &&
2416
+ replayContexts.has(pending.messageId) &&
2417
+ !abortSignal.aborted &&
2418
+ this.abortController === null &&
2419
+ generation === this.state.historyMutationGeneration &&
2420
+ !this.isThreadSwitching?.()
2421
+ ) {
2422
+ const message = this.session.tryGetMessage(pending.messageId)?.message;
2423
+ const responses =
2424
+ message?.role === "assistant"
2425
+ ? buildToolApprovalResume(message.content, pending.interrupts)
2426
+ : null;
2427
+ if (responses) {
2428
+ this.clearDeferredContinuations(abortController);
2429
+ try {
2430
+ await this.submitInterruptResponses(responses);
2431
+ } catch (error) {
2432
+ const err = error instanceof Error ? error : new Error(String(error));
2433
+ if (!this.reportedErrors.has(err)) {
2434
+ this.reportError(err);
2435
+ this.reportedErrors.add(err);
2436
+ }
2437
+ throw err;
2438
+ }
2439
+ return;
2440
+ }
2441
+ }
2442
+
1706
2443
  // A tool result that landed before the run settled deferred its
1707
2444
  // continuation here so a second run never overlaps the first.
1708
2445
  if (this.state.pendingResume?.owner === abortController) {
1709
- const { messageId } = this.state.pendingResume;
2446
+ const { messageIds } = this.state.pendingResume;
1710
2447
  this.dispatch({ type: "resumeCleared" });
1711
- if (!abortSignal.aborted) {
1712
- this.startResumeRun(messageId);
2448
+ if (
2449
+ !abortSignal.aborted &&
2450
+ !this.getPendingInterrupts() &&
2451
+ !this.getPendingToolCalls()
2452
+ ) {
2453
+ const turn = this.getTrailingAssistantTurn();
2454
+ if (
2455
+ turn.some((message) => {
2456
+ if (!messageIds.includes(message.id)) return false;
2457
+ const replay = replayContexts.get(message.id);
2458
+ if (!replay) return hasResolvedToolCalls(message);
2459
+ return Array.from(iterateToolCallParts(message.content)).some(
2460
+ (part) =>
2461
+ isResolvedToolCall(part) &&
2462
+ replay.admittedToolCallIds.has(part.toolCallId) &&
2463
+ !accountedResultIds.has(part.toolCallId),
2464
+ );
2465
+ })
2466
+ ) {
2467
+ const headId = turn.at(-1)?.id;
2468
+ if (headId !== undefined) this.startResumeRun(headId);
2469
+ }
1713
2470
  }
1714
2471
  }
1715
2472
 
@@ -1837,7 +2594,8 @@ export class AgUiThreadController {
1837
2594
  const input = {
1838
2595
  threadId,
1839
2596
  runId,
1840
- state: this.state.stateSnapshot ?? null,
2597
+ state:
2598
+ this.state.stateSnapshot === undefined ? {} : this.state.stateSnapshot,
1841
2599
  messages,
1842
2600
  tools: toAgUiTools(context?.tools),
1843
2601
  context: [
@@ -1994,6 +2752,8 @@ export class AgUiThreadController {
1994
2752
  private updateAssistantMessage(
1995
2753
  messageId: string,
1996
2754
  update: ChatModelRunResult,
2755
+ replay?: ReplayContext,
2756
+ pendingToolCallIds?: ReadonlySet<string>,
1997
2757
  ): string {
1998
2758
  let latestStatus: MessageStatus | undefined;
1999
2759
  const touched = this.session.updateMessage(messageId, (message) => {
@@ -2003,18 +2763,102 @@ export class AgUiThreadController {
2003
2763
  ? this.mergeAssistantMetadata(assistant.metadata, update.metadata)
2004
2764
  : assistant.metadata;
2005
2765
  latestStatus = update.status ?? assistant.status;
2766
+ let content =
2767
+ update.content !== undefined
2768
+ ? replay
2769
+ ? replay.merge(
2770
+ assistant.content,
2771
+ update.content as ThreadAssistantMessage["content"],
2772
+ )
2773
+ : this.preserveToolResults(
2774
+ assistant.content,
2775
+ update.content as ThreadAssistantMessage["content"],
2776
+ )
2777
+ : assistant.content;
2778
+ const calls = replay ? Array.from(iterateToolCallParts(content)) : [];
2779
+ // A retained historical call needs existing or current stream authority.
2780
+ const pending =
2781
+ pendingToolCallIds ??
2782
+ (replay
2783
+ ? new Set([
2784
+ ...replay.admittedToolCallIds,
2785
+ ...Array.from(iterateToolCallParts(update.content ?? []))
2786
+ .filter((part) => !isResolvedToolCall(part))
2787
+ .map((part) => part.toolCallId),
2788
+ ])
2789
+ : undefined);
2790
+ if (replay && latestStatus?.type !== "running" && pending)
2791
+ replay.admittedToolCallIds = pending;
2792
+ if (replay) {
2793
+ // Project gates against the content the user can actually answer.
2794
+ const interrupts =
2795
+ latestStatus?.type === "requires-action" &&
2796
+ latestStatus.reason === "interrupt"
2797
+ ? (
2798
+ mergedMetadata.custom[AG_UI_METADATA_NAMESPACE] as
2799
+ | AgUiCustomMetadata
2800
+ | undefined
2801
+ )?.interrupts
2802
+ : undefined;
2803
+ const approvals = projectAgUiToolApprovals(
2804
+ interrupts,
2805
+ new Set(calls.map((part) => part.toolCallId)),
2806
+ );
2807
+ const originalCalls = new Map(
2808
+ Array.from(iterateToolCallParts(replay.originalContent), (part) => [
2809
+ part.toolCallId,
2810
+ part,
2811
+ ]),
2812
+ );
2813
+ content = mapToolCallPartsDeep(content, (part) => {
2814
+ const approval = approvals.get(part.toolCallId);
2815
+ if (approval)
2816
+ return {
2817
+ ...part,
2818
+ approval:
2819
+ preserveToolApprovalDecision(
2820
+ originalCalls.get(part.toolCallId),
2821
+ { ...part, approval },
2822
+ ) ?? approval,
2823
+ };
2824
+ if (
2825
+ !part.approval ||
2826
+ part.approval.approved !== undefined ||
2827
+ part.approval.resolution
2828
+ )
2829
+ return part;
2830
+ const { approval: _approval, ...withoutApproval } = part;
2831
+ return withoutApproval;
2832
+ }).content;
2833
+ }
2834
+ if (replay && latestStatus?.type === "complete") {
2835
+ const listed =
2836
+ pending ??
2837
+ listedPendingToolCallIds({
2838
+ ...assistant,
2839
+ metadata: mergedMetadata,
2840
+ });
2841
+ if (
2842
+ calls.some(
2843
+ (part) =>
2844
+ (!isResolvedToolCall(part) ||
2845
+ !replay.accountedResultIds.has(part.toolCallId)) &&
2846
+ (!listed || listed.has(part.toolCallId)),
2847
+ )
2848
+ )
2849
+ latestStatus = { type: "requires-action", reason: "tool-calls" };
2850
+ }
2851
+ const admission =
2852
+ pending && replay
2853
+ ? withPendingToolCallIds(mergedMetadata, pending)
2854
+ : mergedMetadata;
2006
2855
  const metadata =
2007
2856
  latestStatus?.type === "requires-action" &&
2008
2857
  latestStatus.reason === "tool-calls"
2009
- ? mergedMetadata
2010
- : withoutPendingToolCallIds(mergedMetadata);
2011
- const content =
2012
- update.content !== undefined
2013
- ? this.preserveToolResults(
2014
- assistant.content,
2015
- update.content as ThreadAssistantMessage["content"],
2016
- )
2017
- : assistant.content;
2858
+ ? admission
2859
+ : replay && latestStatus?.type === "running"
2860
+ ? mergedMetadata
2861
+ : withoutPendingToolCallIds(admission);
2018
2862
  return {
2019
2863
  ...assistant,
2020
2864
  content,
@@ -2037,7 +2881,10 @@ export class AgUiThreadController {
2037
2881
  if (this.isPersistableStatus(latestStatus)) {
2038
2882
  this.persistAssistantHistory(resolvedMessageId);
2039
2883
  }
2040
- this.maybeResumeAfterToolResults(resolvedMessageId);
2884
+ this.maybeResumeAfterToolResults(
2885
+ resolvedMessageId,
2886
+ replay?.accountedResultIds,
2887
+ );
2041
2888
  return resolvedMessageId;
2042
2889
  }
2043
2890
 
@@ -2048,6 +2895,7 @@ export class AgUiThreadController {
2048
2895
  private preserveToolResults(
2049
2896
  previous: ThreadAssistantMessage["content"],
2050
2897
  next: ThreadAssistantMessage["content"],
2898
+ decisionSource?: ThreadAssistantMessage["content"],
2051
2899
  ): ThreadAssistantMessage["content"] {
2052
2900
  const preserved = new Map<string, ToolCallMessagePart>();
2053
2901
  for (const part of iterateToolCallParts(previous)) {
@@ -2055,25 +2903,27 @@ export class AgUiThreadController {
2055
2903
  preserved.set(part.toolCallId, part);
2056
2904
  }
2057
2905
  }
2058
- if (preserved.size === 0) {
2906
+ const decisions = new Map(
2907
+ Array.from(iterateToolCallParts(decisionSource ?? []))
2908
+ .filter((part) => part.approval?.approved !== undefined)
2909
+ .map((part) => [part.toolCallId, part]),
2910
+ );
2911
+ if (preserved.size === 0 && decisions.size === 0) {
2059
2912
  return this.preserveToolInteractions(previous, next);
2060
2913
  }
2061
2914
 
2062
2915
  const { content: merged, changed } = mapToolCallPartsDeep(next, (part) => {
2063
2916
  const prior = preserved.get(part.toolCallId);
2064
- if (!prior) return part;
2065
- if (!isResolvedToolCall(part) && isResolvedToolCall(prior)) {
2066
- return {
2067
- ...part,
2068
- result: prior.result,
2069
- ...(prior.artifact !== undefined ? { artifact: prior.artifact } : {}),
2070
- ...(prior.isError !== undefined ? { isError: prior.isError } : {}),
2071
- ...(prior.modelContent !== undefined
2072
- ? { modelContent: prior.modelContent }
2073
- : {}),
2074
- };
2917
+ const result = prior ? preserveToolResult(prior, part) : part;
2918
+ if (result.approval && decisions.has(part.toolCallId)) {
2919
+ const approval = preserveToolApprovalDecision(
2920
+ decisions.get(part.toolCallId),
2921
+ result,
2922
+ );
2923
+ if (approval && approval !== result.approval)
2924
+ return { ...result, approval };
2075
2925
  }
2076
- return part;
2926
+ return result;
2077
2927
  });
2078
2928
  return this.preserveToolInteractions(previous, changed ? merged : next);
2079
2929
  }
@@ -2103,7 +2953,7 @@ export class AgUiThreadController {
2103
2953
  }
2104
2954
 
2105
2955
  // A snapshot built from user, assistant, and tool records alone carries no
2106
- // reasoning or activity of its own, so the parts streamed locally would be
2956
+ // reasoning, activity, or custom data of its own, so locally streamed parts would be
2107
2957
  // lost on every refresh; keep the local ones where they streamed when the
2108
2958
  // snapshot has none of that kind, matching the merge rule of @ag-ui/client.
2109
2959
  private mergeSnapshotAssistantContent(
@@ -2111,15 +2961,30 @@ export class AgUiThreadController {
2111
2961
  next: ThreadAssistantMessage["content"],
2112
2962
  snapshotHasReasoning: boolean,
2113
2963
  snapshotHasActivity: boolean,
2964
+ keepMissingToolCalls: ReadonlySet<string> = new Set(),
2965
+ keepPriorTranscripts = false,
2114
2966
  ): ThreadAssistantMessage["content"] {
2115
2967
  const shouldKeep = (part: ThreadAssistantMessage["content"][number]) =>
2116
- part.type === "reasoning"
2117
- ? this.showThinking && !snapshotHasReasoning
2118
- : isActivityPart(part) && !snapshotHasActivity;
2968
+ part.type === "tool-call"
2969
+ ? keepMissingToolCalls.has(part.toolCallId) &&
2970
+ !next.some(
2971
+ (incoming) =>
2972
+ incoming.type === "tool-call" &&
2973
+ incoming.toolCallId === part.toolCallId,
2974
+ )
2975
+ : part.type === "reasoning"
2976
+ ? this.showThinking && !snapshotHasReasoning
2977
+ : part.type === "data" && !isActivityPart(part)
2978
+ ? !next.some(
2979
+ (incoming) =>
2980
+ incoming.type === "data" && incoming.name === part.name,
2981
+ )
2982
+ : isActivityPart(part) && !snapshotHasActivity;
2119
2983
  const kept = previous.filter(shouldKeep);
2120
2984
  const merged = this.reconcileSnapshotNestedContent(
2121
2985
  previous,
2122
2986
  this.preserveToolInteractions(previous, next),
2987
+ keepPriorTranscripts,
2123
2988
  );
2124
2989
  if (kept.length === 0) return merged;
2125
2990
 
@@ -2169,6 +3034,7 @@ export class AgUiThreadController {
2169
3034
  private reconcileSnapshotNestedContent(
2170
3035
  previous: ThreadAssistantMessage["content"],
2171
3036
  next: ThreadAssistantMessage["content"],
3037
+ keepPriorTranscripts: boolean,
2172
3038
  ): ThreadAssistantMessage["content"] {
2173
3039
  const nestedIds = new Set<string>();
2174
3040
  for (const part of previous) {
@@ -2218,10 +3084,92 @@ export class AgUiThreadController {
2218
3084
  }
2219
3085
  if (part.type !== "tool-call") return [part];
2220
3086
  const prior = previousCalls.get(part.toolCallId);
2221
- return prior?.messages === undefined
2222
- ? [part]
2223
- : [{ ...part, messages: prior.messages }];
3087
+ if (prior?.messages === undefined) return [part];
3088
+ if (part.messages === undefined)
3089
+ return [{ ...part, messages: prior.messages }];
3090
+ if (part.messages.length === 0) return [part];
3091
+ return [
3092
+ {
3093
+ ...part,
3094
+ messages: this.mergeNestedTranscripts(
3095
+ prior.messages,
3096
+ part.messages,
3097
+ keepPriorTranscripts,
3098
+ ),
3099
+ },
3100
+ ];
3101
+ });
3102
+ }
3103
+
3104
+ /**
3105
+ * Merges one tool call's nested transcripts message by message. The incoming
3106
+ * copy is the base for tool calls and reasoning; resolved results and calls
3107
+ * only the prior copy has carry over, and the prior text wins when it extends
3108
+ * the incoming text. Importing a snapshot keeps prior messages it lacks, a
3109
+ * prior status that is running or incomplete, and the prior status when the
3110
+ * snapshot waits on calls the merged content has already resolved.
3111
+ */
3112
+ private mergeNestedTranscripts(
3113
+ prior: readonly ThreadMessage[],
3114
+ next: readonly ThreadMessage[],
3115
+ importingSnapshot: boolean,
3116
+ ): readonly ThreadMessage[] {
3117
+ const priorById = new Map(prior.map((message) => [message.id, message]));
3118
+ const merged = next.map((message) => {
3119
+ const previous = priorById.get(message.id);
3120
+ if (previous?.role !== "assistant" || message.role !== "assistant")
3121
+ return message;
3122
+ let content = this.mergeSnapshotAssistantContent(
3123
+ previous.content,
3124
+ this.preserveToolResults(previous.content, message.content),
3125
+ message.content.some((part) => part.type === "reasoning"),
3126
+ message.content.some(isActivityPart),
3127
+ new Set(
3128
+ Array.from(
3129
+ iterateToolCallParts(previous.content),
3130
+ (call) => call.toolCallId,
3131
+ ),
3132
+ ),
3133
+ importingSnapshot,
3134
+ );
3135
+ const priorText = textOf(previous.content);
3136
+ const incomingText = textOf(message.content);
3137
+ if (
3138
+ priorText.length > incomingText.length &&
3139
+ priorText.startsWith(incomingText)
3140
+ ) {
3141
+ content = replaceTextParts(content, previous.content);
3142
+ }
3143
+ const status = !importingSnapshot
3144
+ ? message.status
3145
+ : previous.status.type === "running" ||
3146
+ previous.status.type === "incomplete" ||
3147
+ (message.status.type === "requires-action" &&
3148
+ message.status.reason === "tool-calls" &&
3149
+ !Array.from(iterateToolCallParts(content)).some(
3150
+ (call) => !isResolvedToolCall(call),
3151
+ ))
3152
+ ? previous.status
3153
+ : message.status;
3154
+ return {
3155
+ ...message,
3156
+ status,
3157
+ content,
3158
+ metadata: {
3159
+ ...message.metadata,
3160
+ custom: mergeAgUiNamespace(
3161
+ previous.metadata.custom,
3162
+ message.metadata.custom,
3163
+ ),
3164
+ },
3165
+ };
2224
3166
  });
3167
+ if (!importingSnapshot) return merged;
3168
+ const mergedById = new Map(merged.map((message) => [message.id, message]));
3169
+ return [
3170
+ ...prior.map((message) => mergedById.get(message.id) ?? message),
3171
+ ...merged.filter((message) => !priorById.has(message.id)),
3172
+ ];
2225
3173
  }
2226
3174
 
2227
3175
  private mergeAssistantMetadata(
@@ -2260,7 +3208,17 @@ export class AgUiThreadController {
2260
3208
  joinStrategy: UseAgUiRuntimeOptions["joinStrategy"],
2261
3209
  runStartMessageIds: ReadonlySet<string>,
2262
3210
  hasRunUserBoundary: boolean,
3211
+ reconcileSnapshot?: SnapshotMessageReconciler,
2263
3212
  ): string | undefined {
3213
+ if (
3214
+ event.type === "SUBAGENT_STARTED" &&
3215
+ !this.subagentParentToolCallIds.has(event.subagentRunId)
3216
+ ) {
3217
+ this.subagentParentToolCallIds.set(
3218
+ event.subagentRunId,
3219
+ event.parentToolCallId,
3220
+ );
3221
+ }
2264
3222
  switch (event.type) {
2265
3223
  case "STATE_SNAPSHOT": {
2266
3224
  this.dispatch({
@@ -2290,6 +3248,7 @@ export class AgUiThreadController {
2290
3248
  break;
2291
3249
  }
2292
3250
  case "MESSAGES_SNAPSHOT": {
3251
+ aggregator.handle(event);
2293
3252
  const previousHeadId = this.session.headId;
2294
3253
  const activeAssistant =
2295
3254
  activeAssistantId === undefined
@@ -2304,15 +3263,49 @@ export class AgUiThreadController {
2304
3263
  joinStrategy,
2305
3264
  runStartMessageIds,
2306
3265
  hasRunUserBoundary,
3266
+ reconcileSnapshot,
2307
3267
  );
2308
3268
  const headId = this.session.headId;
2309
- if (hasActiveText && headId !== previousHeadId) {
2310
- const head =
2311
- headId === null
2312
- ? undefined
2313
- : this.session.tryGetMessage(headId)?.message;
2314
- activeAssistantId =
3269
+ const head =
3270
+ headId === null
3271
+ ? undefined
3272
+ : this.session.tryGetMessage(headId)?.message;
3273
+ const hasNewPendingToolCallAnswer =
3274
+ head?.role === "assistant" &&
3275
+ !runStartMessageIds.has(head.id) &&
3276
+ head.status?.type === "requires-action" &&
3277
+ head.status.reason === "tool-calls";
3278
+ if (
3279
+ headId !== previousHeadId &&
3280
+ (hasActiveText || hasNewPendingToolCallAnswer)
3281
+ ) {
3282
+ const nextActiveAssistantId =
2315
3283
  head?.role === "assistant" ? (headId ?? undefined) : undefined;
3284
+ if (
3285
+ activeAssistantId !== nextActiveAssistantId &&
3286
+ activeAssistantId
3287
+ ) {
3288
+ const previous =
3289
+ this.session.tryGetMessage(activeAssistantId)?.message;
3290
+ if (
3291
+ previous?.role === "assistant" &&
3292
+ previous.status?.type === "running"
3293
+ ) {
3294
+ this.updateAssistantMessage(activeAssistantId, {
3295
+ status: FALLBACK_USER_STATUS,
3296
+ content: settleNestedContent(
3297
+ previous.content,
3298
+ FALLBACK_USER_STATUS,
3299
+ ),
3300
+ });
3301
+ }
3302
+ }
3303
+ activeAssistantId = nextActiveAssistantId;
3304
+ if (hasNewPendingToolCallAnswer && head) {
3305
+ this.updateAssistantMessage(head.id, {
3306
+ status: { type: "running" },
3307
+ });
3308
+ }
2316
3309
  }
2317
3310
  this.updateActiveAssistantState(activeAssistantId);
2318
3311
  break;
@@ -2415,6 +3408,14 @@ export class AgUiThreadController {
2415
3408
  ...(event.messageId
2416
3409
  ? { unstable_toolMessageId: event.messageId }
2417
3410
  : {}),
3411
+ ...(event.metadata !== undefined
3412
+ ? {
3413
+ providerMetadata: withAgUiPartMessageMetadata(
3414
+ part.providerMetadata,
3415
+ event.metadata,
3416
+ ),
3417
+ }
3418
+ : {}),
2418
3419
  };
2419
3420
  }
2420
3421
  const { modelContent: _modelContent, ...partWithoutModelContent } =
@@ -2441,6 +3442,14 @@ export class AgUiThreadController {
2441
3442
  ...(event.messageId
2442
3443
  ? { unstable_toolMessageId: event.messageId }
2443
3444
  : {}),
3445
+ ...(event.metadata !== undefined
3446
+ ? {
3447
+ providerMetadata: withAgUiPartMessageMetadata(
3448
+ part.providerMetadata,
3449
+ event.metadata,
3450
+ ),
3451
+ }
3452
+ : {}),
2444
3453
  };
2445
3454
  });
2446
3455
  if (!matchedToolCall) return message;
@@ -2451,7 +3460,8 @@ export class AgUiThreadController {
2451
3460
  this.publishUpdate();
2452
3461
  // Not maybeResumeAfterToolResults: the delivering run is already in
2453
3462
  // flight, and a resume from the owner would reset the head past it.
2454
- this.maybeCompleteAfterToolResults(messageId);
3463
+ if (this.maybeCompleteAfterToolResults(messageId)) return;
3464
+ this.persistSettledAssistant(messageId);
2455
3465
  }
2456
3466
 
2457
3467
  private applyCrossRunActivitySnapshot(
@@ -2512,7 +3522,8 @@ export class AgUiThreadController {
2512
3522
 
2513
3523
  if (!updated) return;
2514
3524
  this.publishUpdate();
2515
- this.maybeCompleteAfterToolResults(messageId);
3525
+ if (this.maybeCompleteAfterToolResults(messageId)) return;
3526
+ this.persistSettledAssistant(messageId);
2516
3527
  }
2517
3528
 
2518
3529
  private importMessagesSnapshot(
@@ -2521,6 +3532,7 @@ export class AgUiThreadController {
2521
3532
  joinStrategy: UseAgUiRuntimeOptions["joinStrategy"],
2522
3533
  runStartMessageIds: ReadonlySet<string>,
2523
3534
  hasRunUserBoundary: boolean,
3535
+ reconcileSnapshot?: SnapshotMessageReconciler,
2524
3536
  ) {
2525
3537
  try {
2526
3538
  const activeAssistantItem = activeAssistantId
@@ -2530,14 +3542,24 @@ export class AgUiThreadController {
2530
3542
  const activeAssistantParentId = activeAssistantItem?.parentId;
2531
3543
  const activeAssistant =
2532
3544
  activeMessage?.role === "assistant" ? activeMessage : undefined;
2533
- const normalized = fromAgUiMessages(rawMessages, {
2534
- showThinking: this.showThinking,
2535
- });
3545
+ const normalized = fromAgUiSnapshotMessages(
3546
+ rawMessages,
3547
+ { showThinking: this.showThinking },
3548
+ this.subagentParentToolCallIds,
3549
+ );
2536
3550
  const converted: ThreadMessage[] = [];
2537
3551
  for (const message of normalized) {
2538
3552
  try {
3553
+ const previous = message.id
3554
+ ? this.session.tryGetMessage(message.id)?.message
3555
+ : undefined;
3556
+ const fallbackStatus =
3557
+ previous?.role === "assistant" &&
3558
+ previous.status?.type === "running"
3559
+ ? previous.status
3560
+ : FALLBACK_USER_STATUS;
2539
3561
  converted.push(
2540
- fromThreadMessageLike(message, generateId(), FALLBACK_USER_STATUS),
3562
+ fromThreadMessageLike(message, generateId(), fallbackStatus),
2541
3563
  );
2542
3564
  } catch (error) {
2543
3565
  this.logger.error?.(
@@ -2560,19 +3582,30 @@ export class AgUiThreadController {
2560
3582
  const existing = this.session.tryGetMessage(
2561
3583
  convertedMessage.id,
2562
3584
  )?.message;
2563
- if (
2564
- convertedMessage.role === "assistant" &&
2565
- existing?.role === "assistant"
2566
- ) {
2567
- converted[index] = {
2568
- ...convertedMessage,
2569
- content: this.mergeSnapshotAssistantContent(
2570
- existing.content,
2571
- convertedMessage.content,
2572
- snapshotHasReasoning,
2573
- snapshotHasActivity,
2574
- ),
2575
- };
3585
+ if (convertedMessage.role === "assistant") {
3586
+ const previous =
3587
+ existing?.role === "assistant" ? existing : undefined;
3588
+ const snapshot =
3589
+ reconcileSnapshot?.(convertedMessage, previous) ?? convertedMessage;
3590
+ const preservesRunningStatus =
3591
+ convertedMessage.id === activeAssistantId &&
3592
+ previous?.status?.type === "running" &&
3593
+ snapshot.status?.type === "requires-action" &&
3594
+ snapshot.status.reason === "tool-calls";
3595
+ converted[index] = previous
3596
+ ? {
3597
+ ...snapshot,
3598
+ ...(preservesRunningStatus ? { status: previous.status } : {}),
3599
+ content: this.mergeSnapshotAssistantContent(
3600
+ previous.content,
3601
+ snapshot.content,
3602
+ snapshotHasReasoning,
3603
+ snapshotHasActivity,
3604
+ new Set(),
3605
+ true,
3606
+ ),
3607
+ }
3608
+ : snapshot;
2576
3609
  }
2577
3610
  }
2578
3611
  if (joinStrategy === "concat-content") {
@@ -2732,6 +3765,19 @@ export class AgUiThreadController {
2732
3765
  this.assistantHistoryParents.set(messageId, parentId);
2733
3766
  }
2734
3767
 
3768
+ private persistSettledAssistant(messageId: string): void {
3769
+ const item = this.session.tryGetMessage(messageId);
3770
+ if (!this.history?.update || !item) return;
3771
+ if (!this.isTerminalStatus(item.message.status)) return;
3772
+ if (
3773
+ !this.persistedHistoryIds.has(messageId) &&
3774
+ this.historyWrites.pending(messageId) === undefined
3775
+ )
3776
+ return;
3777
+ this.markPendingAssistantHistory(messageId, item.parentId);
3778
+ this.persistAssistantHistory(messageId);
3779
+ }
3780
+
2735
3781
  private persistAssistantHistory(messageId: string) {
2736
3782
  const history = this.history;
2737
3783
  if (!history) return;
@@ -2744,7 +3790,9 @@ export class AgUiThreadController {
2744
3790
  const update = history.update;
2745
3791
  const shouldUpdate =
2746
3792
  update !== undefined &&
2747
- (wasPersisted || this.snapshotHistoryIds.has(messageId));
3793
+ (wasPersisted ||
3794
+ this.snapshotHistoryIds.has(messageId) ||
3795
+ this.historyWrites.pending(messageId) !== undefined);
2748
3796
 
2749
3797
  if (shouldUpdate) {
2750
3798
  const write = this.historyWrites.chain(messageId, () =>