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

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 (40) hide show
  1. package/dist/agUiExtras.js +1 -2
  2. package/dist/hooks.js +1 -2
  3. package/dist/runtime/AgUiThreadController.d.ts.map +1 -1
  4. package/dist/runtime/AgUiThreadController.js +354 -68
  5. package/dist/runtime/AgUiThreadController.js.map +1 -1
  6. package/dist/runtime/adapter/conversions.d.ts.map +1 -1
  7. package/dist/runtime/adapter/conversions.js +10 -3
  8. package/dist/runtime/adapter/conversions.js.map +1 -1
  9. package/dist/runtime/adapter/replay-content.d.ts +9 -0
  10. package/dist/runtime/adapter/replay-content.d.ts.map +1 -0
  11. package/dist/runtime/adapter/replay-content.js +185 -0
  12. package/dist/runtime/adapter/replay-content.js.map +1 -0
  13. package/dist/runtime/adapter/run-aggregator.js +1 -2
  14. package/dist/runtime/adapter/subscriber.js +1 -2
  15. package/dist/runtime/adapter/tool-approval.d.ts +2 -0
  16. package/dist/runtime/adapter/tool-approval.d.ts.map +1 -1
  17. package/dist/runtime/adapter/tool-approval.js +23 -6
  18. package/dist/runtime/adapter/tool-approval.js.map +1 -1
  19. package/dist/runtime/agUiThreadState.d.ts +1 -1
  20. package/dist/runtime/agUiThreadState.d.ts.map +1 -1
  21. package/dist/runtime/agUiThreadState.js +3 -4
  22. package/dist/runtime/agUiThreadState.js.map +1 -1
  23. package/dist/runtime/event-parser.js +1 -2
  24. package/dist/runtime/interrupt-internals.js +1 -2
  25. package/dist/runtime/logger.js +1 -2
  26. package/dist/runtime/mcp-tool-result.js +1 -2
  27. package/dist/runtime/types.js +1 -2
  28. package/dist/useAgUiRuntime.js +1 -2
  29. package/package.json +5 -5
  30. package/src/runtime/AgUiThreadController.deferred-results.test.ts +548 -0
  31. package/src/runtime/AgUiThreadController.replay.test.ts +3932 -0
  32. package/src/runtime/AgUiThreadController.test.ts +79 -0
  33. package/src/runtime/AgUiThreadController.ts +725 -109
  34. package/src/runtime/adapter/conversions.content.test.ts +113 -1
  35. package/src/runtime/adapter/conversions.ts +18 -8
  36. package/src/runtime/adapter/replay-content.test.ts +235 -0
  37. package/src/runtime/adapter/replay-content.ts +306 -0
  38. package/src/runtime/adapter/tool-approval.ts +41 -3
  39. package/src/runtime/agUiThreadState.test.ts +21 -2
  40. package/src/runtime/agUiThreadState.ts +12 -3
@@ -67,9 +67,16 @@ import {
67
67
  toAgUiTools,
68
68
  } from "./adapter/conversions";
69
69
  import { createAgUiSubscriber } from "./adapter/subscriber";
70
+ import {
71
+ createReplayContentMerger,
72
+ groupReplayToolContent,
73
+ indexReplayToolOwners,
74
+ preserveToolResult,
75
+ } from "./adapter/replay-content";
70
76
  import {
71
77
  buildToolApprovalResume,
72
78
  projectAgUiToolApprovals,
79
+ preserveToolApprovalDecision,
73
80
  withSettledToolApprovals,
74
81
  withToolApprovalDecision,
75
82
  } from "./adapter/tool-approval";
@@ -92,17 +99,38 @@ const isResolvedToolCall = (
92
99
  ): boolean =>
93
100
  part.type === "tool-call" && "result" in part && part.result !== undefined;
94
101
 
102
+ const hasResolvedToolCalls = (message: ThreadMessage): boolean => {
103
+ if (message.role !== "assistant") return false;
104
+ for (const part of iterateToolCallParts(message.content)) {
105
+ if (isResolvedToolCall(part)) return true;
106
+ }
107
+ return false;
108
+ };
109
+
95
110
  const listedPendingToolCallIds = (
96
- assistant: ThreadAssistantMessage,
111
+ assistant: Pick<ThreadAssistantMessage, "metadata">,
97
112
  ): ReadonlySet<string> | undefined => {
98
113
  const ids = (
99
114
  assistant.metadata.custom[AG_UI_METADATA_NAMESPACE] as
100
115
  | AgUiCustomMetadata
101
116
  | undefined
102
117
  )?.pendingToolCallIds;
103
- return ids?.length ? new Set(ids) : undefined;
118
+ return ids === undefined ? undefined : new Set(ids);
119
+ };
120
+
121
+ type ReplayContext = {
122
+ // Immutable history reference: partial argument frames cannot redefine consent.
123
+ readonly originalContent: ThreadAssistantMessage["content"];
124
+ accountedResultIds: ReadonlySet<string>;
125
+ admittedToolCallIds: ReadonlySet<string>;
126
+ merge: ReturnType<typeof createReplayContentMerger>;
104
127
  };
105
128
 
129
+ type SnapshotMessageReconciler = (
130
+ snapshot: ThreadAssistantMessage,
131
+ previous: ThreadAssistantMessage | undefined,
132
+ ) => ThreadAssistantMessage;
133
+
106
134
  const withoutPendingToolCallIds = (
107
135
  metadata: ThreadAssistantMessage["metadata"],
108
136
  ): ThreadAssistantMessage["metadata"] => {
@@ -125,6 +153,43 @@ const withoutPendingToolCallIds = (
125
153
  };
126
154
  };
127
155
 
156
+ const withPendingToolCallIds = (
157
+ metadata: ThreadAssistantMessage["metadata"],
158
+ ids: Iterable<string>,
159
+ ): ThreadAssistantMessage["metadata"] => ({
160
+ ...metadata,
161
+ custom: {
162
+ ...metadata.custom,
163
+ [AG_UI_METADATA_NAMESPACE]: {
164
+ ...(metadata.custom[AG_UI_METADATA_NAMESPACE] as
165
+ | AgUiCustomMetadata
166
+ | undefined),
167
+ pendingToolCallIds: [...ids],
168
+ },
169
+ },
170
+ });
171
+
172
+ const actionableToolCallIds = (
173
+ message: ThreadMessage | undefined,
174
+ ): ReadonlySet<string> => {
175
+ if (
176
+ message?.role !== "assistant" ||
177
+ message.status?.type !== "requires-action" ||
178
+ message.status.reason !== "tool-calls"
179
+ )
180
+ return new Set();
181
+ const permitted = listedPendingToolCallIds(message);
182
+ return new Set(
183
+ Array.from(iterateToolCallParts(message.content))
184
+ .filter(
185
+ (part) =>
186
+ !isResolvedToolCall(part) &&
187
+ (!permitted || permitted.has(part.toolCallId)),
188
+ )
189
+ .map((part) => part.toolCallId),
190
+ );
191
+ };
192
+
128
193
  const isActivityPart = (part: ThreadAssistantMessage["content"][number]) =>
129
194
  part.type === "data" && part.name.startsWith("agui-activity/");
130
195
 
@@ -560,6 +625,7 @@ export class AgUiThreadController {
560
625
  config.runConfig ?? this.state.lastRunConfig,
561
626
  undefined,
562
627
  config.stream,
628
+ true,
563
629
  );
564
630
  }
565
631
 
@@ -599,17 +665,13 @@ export class AgUiThreadController {
599
665
  private findRequiresActionAssistant(
600
666
  reason: "interrupt" | "tool-calls",
601
667
  ): 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;
668
+ return (
669
+ this.getTrailingAssistantTurn().findLast(
670
+ (message) =>
671
+ message.status?.type === "requires-action" &&
672
+ message.status.reason === reason,
673
+ ) ?? null
674
+ );
613
675
  }
614
676
 
615
677
  private getTrailingAssistantTurn(): ThreadAssistantMessage[] {
@@ -715,8 +777,9 @@ export class AgUiThreadController {
715
777
  messageId: string,
716
778
  resume: AgUiResumeEntry[],
717
779
  ): Promise<void> {
780
+ const parentId = this.session.headId ?? messageId;
718
781
  this.clearPendingInterrupts(messageId, resume);
719
- await this.startRun(messageId, this.state.lastRunConfig, {
782
+ await this.startRun(parentId, this.state.lastRunConfig, {
720
783
  entries: resume,
721
784
  interruptedMessageId: messageId,
722
785
  });
@@ -1180,15 +1243,26 @@ export class AgUiThreadController {
1180
1243
  // The continuation fires whether the frontend result lands before
1181
1244
  // RUN_FINISHED (the status flips to requires-action only later, while the
1182
1245
  // run is still draining) or after it.
1183
- private maybeResumeAfterToolResults(messageId: string): void {
1246
+ private maybeResumeAfterToolResults(
1247
+ messageId: string,
1248
+ accountedResultIds?: ReadonlySet<string>,
1249
+ ): void {
1184
1250
  if (!this.maybeCompleteAfterToolResults(messageId)) return;
1251
+ if (accountedResultIds) {
1252
+ const message = this.session.tryGetMessage(messageId)?.message;
1253
+ if (
1254
+ message?.role !== "assistant" ||
1255
+ !Array.from(iterateToolCallParts(message.content)).some(
1256
+ (part) =>
1257
+ isResolvedToolCall(part) &&
1258
+ !accountedResultIds.has(part.toolCallId),
1259
+ )
1260
+ )
1261
+ return;
1262
+ }
1185
1263
  if (this.getPendingInterrupts()) return;
1186
1264
  const turn = this.getTrailingAssistantTurn();
1187
1265
  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
1266
  const owner = this.abortController;
1193
1267
  if (owner) {
1194
1268
  // A run is still draining (RUN_FINISHED arrived but the stream has not
@@ -1196,10 +1270,13 @@ export class AgUiThreadController {
1196
1270
  this.dispatch({
1197
1271
  type: "resumeDeferred",
1198
1272
  owner,
1199
- messageId: headId,
1273
+ messageId,
1200
1274
  });
1201
1275
  return;
1202
1276
  }
1277
+ if (this.getPendingToolCalls()) return;
1278
+ const headId = turn.at(-1)?.id;
1279
+ if (headId === undefined) return;
1203
1280
  this.startResumeRun(headId);
1204
1281
  }
1205
1282
 
@@ -1269,6 +1346,19 @@ export class AgUiThreadController {
1269
1346
  let lastAppliedId: string | null = null;
1270
1347
  let hardReplace = false;
1271
1348
  const seen = new Set<string>();
1349
+ const retainToolResults = (
1350
+ existing: ThreadMessage | undefined,
1351
+ message: ThreadMessage,
1352
+ ): ThreadMessage =>
1353
+ existing?.role === "assistant" && message.role === "assistant"
1354
+ ? {
1355
+ ...message,
1356
+ content: this.preserveToolResults(
1357
+ existing.content,
1358
+ message.content,
1359
+ ),
1360
+ }
1361
+ : message;
1272
1362
 
1273
1363
  for (const message of messages) {
1274
1364
  if (seen.has(message.id)) continue;
@@ -1278,12 +1368,21 @@ export class AgUiThreadController {
1278
1368
  hardReplace = true;
1279
1369
  break;
1280
1370
  }
1281
- this.session.addOrUpdateMessage(expectedParentId, message);
1371
+ this.session.addOrUpdateMessage(
1372
+ expectedParentId,
1373
+ retainToolResults(existing?.message, message),
1374
+ );
1282
1375
  expectedParentId = message.id;
1283
1376
  lastAppliedId = message.id;
1284
1377
  }
1285
1378
 
1286
1379
  if (hardReplace) {
1380
+ const previous = new Map(
1381
+ this.getMessageRepository().messages.map(({ message }) => [
1382
+ message.id,
1383
+ message,
1384
+ ]),
1385
+ );
1287
1386
  this.session.clear();
1288
1387
  expectedParentId = null;
1289
1388
  lastAppliedId = null;
@@ -1291,29 +1390,48 @@ export class AgUiThreadController {
1291
1390
  for (const message of messages) {
1292
1391
  if (seen.has(message.id)) continue;
1293
1392
  seen.add(message.id);
1294
- this.session.addOrUpdateMessage(expectedParentId, message);
1393
+ this.session.addOrUpdateMessage(
1394
+ expectedParentId,
1395
+ retainToolResults(previous.get(message.id), message),
1396
+ );
1295
1397
  expectedParentId = message.id;
1296
1398
  lastAppliedId = message.id;
1297
1399
  }
1298
1400
  }
1299
1401
 
1300
1402
  this.session.resetHead(lastAppliedId);
1403
+ for (const messageId of seen) {
1404
+ this.maybeCompleteAfterToolResults(messageId);
1405
+ }
1301
1406
  }
1302
1407
 
1303
1408
  this.snapshotHistoryIds.clear();
1304
1409
  for (const { message } of this.getMessageRepository().messages) {
1305
1410
  this.snapshotHistoryIds.add(message.id);
1306
1411
  }
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" });
1412
+ // A soft merge leaves dropped messages off branch; only selected
1413
+ // messages with resolved tool calls can still own a continuation.
1414
+ const pendingResume = this.state.pendingResume;
1415
+ if (pendingResume) {
1416
+ const selectedResultOwnerIds = new Set(
1417
+ this.session
1418
+ .getMessages()
1419
+ .filter(hasResolvedToolCalls)
1420
+ .map((message) => message.id),
1421
+ );
1422
+ const retained = pendingResume.messageIds.filter((id) =>
1423
+ selectedResultOwnerIds.has(id),
1424
+ );
1425
+ if (retained.length !== pendingResume.messageIds.length) {
1426
+ this.dispatch({ type: "resumeCleared" });
1427
+ for (const messageId of retained) {
1428
+ this.dispatch({
1429
+ type: "resumeDeferred",
1430
+ owner: pendingResume.owner,
1431
+ messageId,
1432
+ });
1433
+ }
1434
+ }
1317
1435
  }
1318
1436
  this.publishUpdate();
1319
1437
  }
@@ -1375,6 +1493,7 @@ export class AgUiThreadController {
1375
1493
  runConfig?: RunConfig,
1376
1494
  resume?: ResumeDispatch,
1377
1495
  resumeStream?: ResumeStream,
1496
+ replayExistingMessages = false,
1378
1497
  ): Promise<void> {
1379
1498
  if (this.isThreadSwitching?.()) return;
1380
1499
  // A default AG-UI run supersedes the active run; the hook's opt-in message
@@ -1382,6 +1501,7 @@ export class AgUiThreadController {
1382
1501
  // its message; this covers the entry points that start a run without one.
1383
1502
  if (!this.supersedeActiveRun()) return;
1384
1503
 
1504
+ const generation = this.state.historyMutationGeneration;
1385
1505
  const normalizedRunConfig = runConfig ?? {};
1386
1506
  this.dispatch({
1387
1507
  type: "runConfigRecorded",
@@ -1401,12 +1521,62 @@ export class AgUiThreadController {
1401
1521
  const runStartMessageIds = new Set(
1402
1522
  this.getMessageRepository().messages.map(({ message }) => message.id),
1403
1523
  );
1524
+ const selectedMessages = this.session.getMessages();
1525
+ const parentIndex = selectedMessages.findIndex(
1526
+ (message) => message.id === parentId,
1527
+ );
1528
+ let replayStart = parentIndex + 1;
1529
+ if (selectedMessages[parentIndex]?.role === "assistant") {
1530
+ replayStart = parentIndex;
1531
+ while (
1532
+ replayStart > 0 &&
1533
+ selectedMessages[replayStart - 1]?.role === "assistant"
1534
+ )
1535
+ replayStart--;
1536
+ }
1537
+ const replayMessageIds: string[] = [];
1538
+ if (replayExistingMessages && !resumeStream && parentIndex >= 0) {
1539
+ for (const message of selectedMessages.slice(replayStart)) {
1540
+ if (message.role !== "assistant") break;
1541
+ replayMessageIds.push(message.id);
1542
+ }
1543
+ }
1544
+ // Only request-delivered and producer-observed results suppress continuation.
1545
+ const accountedResultIds = new Set(
1546
+ historicalMessages.flatMap((message) =>
1547
+ message.role === "assistant"
1548
+ ? Array.from(iterateToolCallParts(message.content))
1549
+ .filter(isResolvedToolCall)
1550
+ .map((part) => part.toolCallId)
1551
+ : [],
1552
+ ),
1553
+ );
1554
+ const candidateContexts = new Map<string, ReplayContext>();
1555
+ for (const message of selectedMessages) {
1556
+ if (
1557
+ message.role !== "assistant" ||
1558
+ !replayMessageIds.includes(message.id)
1559
+ )
1560
+ continue;
1561
+ candidateContexts.set(message.id, {
1562
+ originalContent: message.content,
1563
+ accountedResultIds,
1564
+ admittedToolCallIds: actionableToolCallIds(message),
1565
+ merge: createReplayContentMerger(),
1566
+ });
1567
+ }
1568
+ let replayContexts = new Map<string, ReplayContext>();
1569
+ let lastReplayedIndex = -1;
1570
+ let replayEnded = replayMessageIds.length === 0;
1571
+ const replayTail = replayMessageIds.at(-1);
1404
1572
  const joinStrategy = this.joinStrategy;
1405
1573
  const hasRunUserBoundary = parent?.message.role === "user" && !resume;
1406
1574
 
1407
1575
  let pendingError: Error | null = null;
1408
1576
  const assistantParentId = parent ? parentId : this.session.headId;
1409
1577
  let assistantMessageId: string | undefined;
1578
+ let snapshotAssistantId: string | undefined;
1579
+ let replayInterruptMessageId: string | undefined;
1410
1580
  // A snapshot the preserve gate declines still evicts the in-flight
1411
1581
  // assistant; recreating under the cached id on the next content-bearing
1412
1582
  // emit keeps both the stream and the message identity. Status-only emits,
@@ -1416,6 +1586,7 @@ export class AgUiThreadController {
1416
1586
  // duplicate.
1417
1587
  let assistantCollided = false;
1418
1588
  const heldMessages = new Map<string, Set<string>>();
1589
+ let frameOwnerIds = new Set<string>();
1419
1590
  let restrictedPendingToolCallIds: ReadonlySet<string> | undefined;
1420
1591
  let settlingRun = false;
1421
1592
  const ensureAssistant = (allowRecreate = false): string => {
@@ -1427,10 +1598,16 @@ export class AgUiThreadController {
1427
1598
  }
1428
1599
  const repositoryHeadId = this.session.headId;
1429
1600
  const shouldUseSelectedParent =
1430
- cached === undefined ||
1431
- (shouldEagerlyInsertAssistant &&
1432
- (repositoryHeadId === null ||
1433
- runStartMessageIds.has(repositoryHeadId)));
1601
+ replayContexts.size === 0 &&
1602
+ (cached === undefined ||
1603
+ (shouldEagerlyInsertAssistant &&
1604
+ (repositoryHeadId === null ||
1605
+ runStartMessageIds.has(repositoryHeadId)))) &&
1606
+ // Replay candidates defer the placeholder; honor snapshots that advanced
1607
+ // the head before the producer identified its first assistant message.
1608
+ (replayMessageIds.length === 0 ||
1609
+ repositoryHeadId === null ||
1610
+ runStartMessageIds.has(repositoryHeadId));
1434
1611
  // Branch runs keep their selected parent unless the snapshot advanced to
1435
1612
  // a message introduced during this run.
1436
1613
  const parentId =
@@ -1445,23 +1622,59 @@ export class AgUiThreadController {
1445
1622
  return created;
1446
1623
  };
1447
1624
 
1448
- if (shouldEagerlyInsertAssistant) ensureAssistant();
1625
+ if (shouldEagerlyInsertAssistant && replayEnded) ensureAssistant();
1449
1626
 
1450
1627
  const settleHeldMessages = (terminalStatus: MessageStatus): string[] => {
1451
1628
  const pendingMessages: string[] = [];
1629
+ const producerPending = restrictedPendingToolCallIds;
1630
+ const canWaitForTools =
1631
+ terminalStatus.type === "complete" ||
1632
+ terminalStatus.type === "requires-action";
1633
+ if (producerPending && canWaitForTools) {
1634
+ for (const message of this.getTrailingAssistantTurn()) {
1635
+ const replay = replayContexts.get(message.id);
1636
+ if (
1637
+ message.id === assistantMessageId ||
1638
+ heldMessages.has(message.id) ||
1639
+ !replay ||
1640
+ (message.status?.type === "requires-action" &&
1641
+ message.status.reason === "interrupt")
1642
+ )
1643
+ continue;
1644
+ if (
1645
+ replay.admittedToolCallIds.size > 0 ||
1646
+ actionableToolCallIds(message).size > 0 ||
1647
+ Array.from(iterateToolCallParts(message.content)).some(
1648
+ (part) =>
1649
+ producerPending.has(part.toolCallId) &&
1650
+ !accountedResultIds.has(part.toolCallId),
1651
+ )
1652
+ )
1653
+ heldMessages.set(message.id, new Set());
1654
+ }
1655
+ }
1452
1656
  for (const [messageId, openToolCallIds] of heldMessages) {
1657
+ const existing = this.session.tryGetMessage(messageId);
1658
+ if (existing)
1659
+ this.markPendingAssistantHistory(messageId, existing.parentId);
1453
1660
  let requiresToolResult = false;
1454
1661
  const updated = this.session.updateMessage(messageId, (message) => {
1455
1662
  if (message.role !== "assistant") return message;
1456
1663
  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";
1664
+ const pendingToolCallIds = producerPending
1665
+ ? Array.from(iterateToolCallParts(assistant.content))
1666
+ .filter(
1667
+ (part) =>
1668
+ producerPending.has(part.toolCallId) &&
1669
+ (!isResolvedToolCall(part) ||
1670
+ openToolCallIds.has(part.toolCallId) ||
1671
+ (replayContexts.has(messageId) &&
1672
+ !accountedResultIds.has(part.toolCallId))),
1673
+ )
1674
+ .map((part) => part.toolCallId)
1675
+ : Array.from(openToolCallIds);
1676
+ const replay = replayContexts.get(messageId);
1677
+ if (replay) replay.admittedToolCallIds = new Set(pendingToolCallIds);
1465
1678
  const status: MessageStatus = canWaitForTools
1466
1679
  ? pendingToolCallIds.length > 0
1467
1680
  ? { type: "requires-action", reason: "tool-calls" }
@@ -1473,19 +1686,9 @@ export class AgUiThreadController {
1473
1686
  ...assistant,
1474
1687
  status,
1475
1688
  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
- }
1689
+ status.type === "requires-action" &&
1690
+ (restrictedPendingToolCallIds || replayContexts.has(messageId))
1691
+ ? withPendingToolCallIds(metadata, pendingToolCallIds)
1489
1692
  : metadata,
1490
1693
  };
1491
1694
  });
@@ -1501,24 +1704,190 @@ export class AgUiThreadController {
1501
1704
  return pendingMessages;
1502
1705
  };
1503
1706
 
1707
+ const accountProducerResults = (
1708
+ content: ThreadAssistantMessage["content"],
1709
+ ) => {
1710
+ for (const part of iterateToolCallParts(content))
1711
+ if (isResolvedToolCall(part)) accountedResultIds.add(part.toolCallId);
1712
+ };
1713
+ const reconcileReplaySnapshot: SnapshotMessageReconciler = (
1714
+ snapshot,
1715
+ previous,
1716
+ ) => {
1717
+ // Observe the producer before preserving any local frontend results.
1718
+ accountProducerResults(snapshot.content);
1719
+ const replay = candidateContexts.get(snapshot.id);
1720
+ if (!replay) return snapshot;
1721
+ replay.admittedToolCallIds = actionableToolCallIds(snapshot);
1722
+ if (heldMessages.has(snapshot.id))
1723
+ heldMessages.set(snapshot.id, new Set(replay.admittedToolCallIds));
1724
+ if (!replayContexts.has(snapshot.id)) return snapshot;
1725
+ return previous
1726
+ ? {
1727
+ ...snapshot,
1728
+ content: this.preserveToolResults(
1729
+ previous.content,
1730
+ snapshot.content,
1731
+ replay.originalContent,
1732
+ ),
1733
+ }
1734
+ : snapshot;
1735
+ };
1736
+
1504
1737
  const applyUpdate = (update: ChatModelRunResult) => {
1738
+ if (
1739
+ !assistantCollided &&
1740
+ replayContexts.size &&
1741
+ update.content !== undefined
1742
+ )
1743
+ accountProducerResults(
1744
+ update.content as ThreadAssistantMessage["content"],
1745
+ );
1746
+ let ownerParts:
1747
+ | Map<string, ThreadAssistantMessage["content"][number][]>
1748
+ | undefined;
1749
+ let contentOwnerIds: Set<string> | undefined;
1750
+ const hasStreamContent =
1751
+ Array.isArray(update.content) &&
1752
+ update.content.some((part) => part.type !== "data");
1753
+ if (!replayEnded && assistantMessageId === undefined) {
1754
+ if (!update.status || update.status.type === "running") return;
1755
+ // No matching producer identity arrived: retain native regeneration.
1756
+ replayEnded = true;
1757
+ if (
1758
+ replayContexts.size === 0 &&
1759
+ snapshotAssistantId !== undefined &&
1760
+ this.session.headId === snapshotAssistantId &&
1761
+ !hasStreamContent
1762
+ ) {
1763
+ if (update.status.type !== "complete") {
1764
+ this.updateAssistantMessage(snapshotAssistantId, {
1765
+ status: update.status,
1766
+ ...(update.metadata ? { metadata: update.metadata } : {}),
1767
+ });
1768
+ }
1769
+ return;
1770
+ }
1771
+ }
1772
+ const streamMessageId = assistantMessageId;
1773
+ let primaryMessageId = streamMessageId;
1505
1774
  const terminalStatus = update.status;
1775
+ const replayInterrupt =
1776
+ replayContexts.size > 0 &&
1777
+ terminalStatus?.type === "requires-action" &&
1778
+ terminalStatus.reason === "interrupt";
1779
+ if (
1780
+ streamMessageId !== undefined &&
1781
+ !assistantCollided &&
1782
+ replayContexts.size > 0 &&
1783
+ (update.content !== undefined || replayInterrupt)
1784
+ ) {
1785
+ const owners = this.getTrailingAssistantTurn().filter((message) =>
1786
+ replayContexts.has(message.id),
1787
+ );
1788
+ // Buffered calls keep their owners when a later text frame arrives.
1789
+ const grouped =
1790
+ update.content !== undefined
1791
+ ? groupReplayToolContent(owners, update.content, streamMessageId)
1792
+ : new Map([[streamMessageId, [...(update.content ?? [])]]]);
1793
+ if (update.content !== undefined) {
1794
+ const currentOwnerIds = new Set(grouped.keys());
1795
+ for (const ownerId of frameOwnerIds)
1796
+ grouped.set(ownerId, grouped.get(ownerId) ?? []);
1797
+ contentOwnerIds = new Set(grouped.keys());
1798
+ frameOwnerIds = currentOwnerIds;
1799
+ }
1800
+ ownerParts = grouped;
1801
+ if (replayInterrupt) {
1802
+ const interrupts = (
1803
+ update.metadata?.custom?.[AG_UI_METADATA_NAMESPACE] as
1804
+ | AgUiCustomMetadata
1805
+ | undefined
1806
+ )?.interrupts;
1807
+ const approvals = projectAgUiToolApprovals(interrupts);
1808
+ for (const owner of owners) {
1809
+ if (
1810
+ Array.from(iterateToolCallParts(owner.content)).some((part) =>
1811
+ approvals.has(part.toolCallId),
1812
+ )
1813
+ )
1814
+ grouped.set(owner.id, grouped.get(owner.id) ?? []);
1815
+ }
1816
+ const anchor = owners.find(
1817
+ (message) =>
1818
+ projectAgUiToolApprovals(
1819
+ interrupts,
1820
+ new Set(
1821
+ Array.from(
1822
+ iterateToolCallParts([
1823
+ ...message.content,
1824
+ ...(grouped.get(message.id) ?? []),
1825
+ ]),
1826
+ ).map((part) => part.toolCallId),
1827
+ ),
1828
+ ).size > 0,
1829
+ );
1830
+ if (anchor) {
1831
+ primaryMessageId = anchor.id;
1832
+ replayInterruptMessageId = anchor.id;
1833
+ grouped.set(anchor.id, grouped.get(anchor.id) ?? []);
1834
+ }
1835
+ }
1836
+ }
1506
1837
  const pendingMessages =
1507
1838
  settlingRun && terminalStatus && terminalStatus.type !== "running"
1508
1839
  ? settleHeldMessages(terminalStatus)
1509
1840
  : [];
1510
- const hasStreamContent =
1511
- Array.isArray(update.content) &&
1512
- update.content.some((part) => part.type !== "data");
1841
+ const frameMessageId = ensureAssistant(hasStreamContent);
1842
+ const messageId = primaryMessageId ?? frameMessageId;
1843
+ let primaryUpdate = update;
1844
+ if (ownerParts) {
1845
+ const interrupt =
1846
+ terminalStatus?.type === "requires-action" &&
1847
+ terminalStatus.reason === "interrupt";
1848
+ const ownerStatus: MessageStatus | undefined =
1849
+ terminalStatus?.type === "requires-action"
1850
+ ? { type: "complete", reason: "unknown" }
1851
+ : terminalStatus;
1852
+ for (const [ownerId, content] of ownerParts) {
1853
+ const existing = this.session.tryGetMessage(ownerId);
1854
+ if (existing)
1855
+ this.markPendingAssistantHistory(ownerId, existing.parentId);
1856
+ if (ownerId === messageId) continue;
1857
+ this.updateAssistantMessage(
1858
+ ownerId,
1859
+ {
1860
+ ...(contentOwnerIds?.has(ownerId) ? { content } : {}),
1861
+ ...(ownerStatus ? { status: ownerStatus } : {}),
1862
+ },
1863
+ replayContexts.get(ownerId),
1864
+ interrupt ? new Set<string>() : restrictedPendingToolCallIds,
1865
+ );
1866
+ }
1867
+ const { content: _content, ...controlUpdate } = update;
1868
+ const content = ownerParts.get(messageId) ?? [];
1869
+ primaryUpdate = {
1870
+ ...controlUpdate,
1871
+ ...(contentOwnerIds?.has(messageId) ? { content } : {}),
1872
+ ...(ownerStatus && !interrupt && replayContexts.has(messageId)
1873
+ ? { status: ownerStatus }
1874
+ : {}),
1875
+ };
1876
+ }
1513
1877
  const resolved = this.updateAssistantMessage(
1514
- ensureAssistant(hasStreamContent),
1515
- update,
1878
+ messageId,
1879
+ primaryUpdate,
1880
+ replayContexts.get(messageId),
1881
+ restrictedPendingToolCallIds,
1516
1882
  );
1517
- if (resolved !== assistantMessageId) {
1883
+ if (messageId === frameMessageId && resolved !== assistantMessageId) {
1518
1884
  assistantMessageId = resolved;
1519
1885
  }
1520
1886
  for (const messageId of pendingMessages) {
1521
- this.maybeResumeAfterToolResults(messageId);
1887
+ this.maybeResumeAfterToolResults(
1888
+ messageId,
1889
+ replayMessageIds.includes(messageId) ? accountedResultIds : undefined,
1890
+ );
1522
1891
  }
1523
1892
  };
1524
1893
 
@@ -1527,20 +1896,74 @@ export class AgUiThreadController {
1527
1896
  startNewMessage: boolean,
1528
1897
  openToolCallIds: string[] = [],
1529
1898
  ) => {
1899
+ const existing = this.session.tryGetMessage(serverId);
1900
+ const replayIndex = replayMessageIds.indexOf(serverId);
1901
+ const adoptsReplay =
1902
+ !replayEnded &&
1903
+ !assistantCollided &&
1904
+ replayIndex > lastReplayedIndex &&
1905
+ existing?.message.role === "assistant" &&
1906
+ this.session.getMessages().some((message) => message.id === serverId);
1907
+ if (adoptsReplay && replayContexts.size === 0) {
1908
+ replayContexts = candidateContexts;
1909
+ const selected = this.session.getMessages();
1910
+ let tailId = serverId;
1911
+ for (const message of selected.slice(
1912
+ selected.findIndex((message) => message.id === serverId) + 1,
1913
+ )) {
1914
+ if (message.role !== "assistant") break;
1915
+ tailId = message.id;
1916
+ }
1917
+ this.session.selectHead(tailId);
1918
+ }
1530
1919
  if (startNewMessage && assistantMessageId !== undefined) {
1531
- if (openToolCallIds.length > 0) {
1920
+ let hasReplayCallsToHold = false;
1921
+ const replay = replayContexts.get(assistantMessageId);
1922
+ if (replay) {
1923
+ const current =
1924
+ this.session.tryGetMessage(assistantMessageId)?.message;
1925
+ if (current?.role === "assistant") {
1926
+ const retainedPending = Array.from(
1927
+ iterateToolCallParts(current.content),
1928
+ ).filter(
1929
+ (part) =>
1930
+ !isResolvedToolCall(part) ||
1931
+ (replay.admittedToolCallIds.has(part.toolCallId) &&
1932
+ !accountedResultIds.has(part.toolCallId)),
1933
+ );
1934
+ hasReplayCallsToHold = retainedPending.length > 0;
1935
+ openToolCallIds = [
1936
+ ...new Set([
1937
+ ...openToolCallIds,
1938
+ ...retainedPending
1939
+ .filter((part) =>
1940
+ replay.admittedToolCallIds.has(part.toolCallId),
1941
+ )
1942
+ .map((part) => part.toolCallId),
1943
+ ]),
1944
+ ];
1945
+ }
1946
+ }
1947
+ if (openToolCallIds.length > 0 || hasReplayCallsToHold) {
1532
1948
  heldMessages.set(assistantMessageId, new Set(openToolCallIds));
1533
1949
  } else {
1534
1950
  applyUpdate({ status: { type: "complete", reason: "unknown" } });
1535
1951
  }
1536
1952
  const previousId = assistantMessageId;
1537
1953
  assistantMessageId = undefined;
1538
- if (this.session.tryGetMessage(previousId)) {
1954
+ if (!adoptsReplay && this.session.tryGetMessage(previousId)) {
1539
1955
  const created = this.insertAssistantPlaceholder(previousId);
1540
1956
  assistantMessageId = created;
1541
1957
  this.markPendingAssistantHistory(created, previousId);
1542
1958
  }
1543
1959
  }
1960
+ if (adoptsReplay) {
1961
+ assistantMessageId = serverId;
1962
+ lastReplayedIndex = replayIndex;
1963
+ this.markPendingAssistantHistory(serverId, existing.parentId);
1964
+ return;
1965
+ }
1966
+ replayEnded = true;
1544
1967
  const placeholder = ensureAssistant(true);
1545
1968
  if (placeholder === serverId) return;
1546
1969
  const reassigned = this.reassignAssistantId(placeholder, serverId);
@@ -1574,21 +1997,47 @@ export class AgUiThreadController {
1574
1997
  onServerMessageId: (serverId) => {
1575
1998
  adoptServerMessageId(serverId, false);
1576
1999
  },
1577
- onTextMessageStart: (serverId, openToolCallIds) =>
1578
- adoptServerMessageId(serverId, true, openToolCallIds),
2000
+ onTextMessageStart: (serverId, openToolCallIds) => {
2001
+ const owners = this.getTrailingAssistantTurn().filter((message) =>
2002
+ replayContexts.has(message.id),
2003
+ );
2004
+ const toolOwners = indexReplayToolOwners(owners);
2005
+ const frameOpenIds = openToolCallIds.filter((id) => {
2006
+ const ownerId = toolOwners.get(id);
2007
+ if (!ownerId || ownerId === assistantMessageId || assistantCollided)
2008
+ return true;
2009
+ const held = heldMessages.get(ownerId) ?? new Set<string>();
2010
+ held.add(id);
2011
+ heldMessages.set(ownerId, held);
2012
+ return false;
2013
+ });
2014
+ // The native accumulator just reset; prior frame contributions are
2015
+ // retained history, while removals within this frame remain authoritative.
2016
+ for (const ownerId of frameOwnerIds) {
2017
+ const replay = replayContexts.get(ownerId);
2018
+ if (replay) replay.merge = createReplayContentMerger();
2019
+ }
2020
+ frameOwnerIds.clear();
2021
+ adoptServerMessageId(serverId, true, frameOpenIds);
2022
+ },
1579
2023
  });
1580
2024
  let runFinished = false;
1581
2025
  const dispatch = (event: AgUiEvent) => {
1582
2026
  if (this.abortController !== abortController) return;
1583
2027
  if (event.type === "RUN_FINISHED") {
1584
2028
  runFinished = true;
2029
+ if (event.outcome?.type === "interrupt")
2030
+ replayInterruptMessageId = assistantMessageId ?? replayTail;
1585
2031
  const ids =
1586
2032
  event.outcome?.type === "success"
1587
2033
  ? event.outcome.pendingToolCallIds
1588
2034
  : undefined;
1589
- restrictedPendingToolCallIds = ids?.length ? new Set(ids) : undefined;
2035
+ restrictedPendingToolCallIds =
2036
+ ids === undefined ? undefined : new Set(ids);
1590
2037
  }
1591
2038
  if (event.type === "TOOL_CALL_RESULT") {
2039
+ if (!assistantCollided && candidateContexts.size)
2040
+ accountedResultIds.add(event.toolCallId);
1592
2041
  for (const openToolCallIds of heldMessages.values()) {
1593
2042
  openToolCallIds.delete(event.toolCallId);
1594
2043
  }
@@ -1597,6 +2046,8 @@ export class AgUiThreadController {
1597
2046
  event.type === "RUN_FINISHED" ||
1598
2047
  event.type === "RUN_ERROR" ||
1599
2048
  event.type === "RUN_CANCELLED";
2049
+ const beforeSnapshot =
2050
+ event.type === "MESSAGES_SNAPSHOT" ? this.session.getMessages() : [];
1600
2051
  try {
1601
2052
  const nextAssistantMessageId = this.handleEvent(
1602
2053
  aggregator,
@@ -1605,9 +2056,23 @@ export class AgUiThreadController {
1605
2056
  joinStrategy,
1606
2057
  runStartMessageIds,
1607
2058
  hasRunUserBoundary,
2059
+ candidateContexts.size ? reconcileReplaySnapshot : undefined,
1608
2060
  );
1609
2061
  if (event.type === "MESSAGES_SNAPSHOT") {
2062
+ for (const message of beforeSnapshot) {
2063
+ const context = replayContexts.get(message.id);
2064
+ const current = this.session.tryGetMessage(message.id)?.message;
2065
+ if (context && current?.content !== message.content) {
2066
+ context.merge = createReplayContentMerger();
2067
+ frameOwnerIds.delete(message.id);
2068
+ }
2069
+ }
1610
2070
  assistantMessageId = nextAssistantMessageId;
2071
+ if (!replayEnded && assistantMessageId === undefined) {
2072
+ const tail = this.session.getMessages().at(-1);
2073
+ snapshotAssistantId =
2074
+ tail?.role === "assistant" ? tail.id : undefined;
2075
+ }
1611
2076
  }
1612
2077
  } finally {
1613
2078
  settlingRun = false;
@@ -1703,13 +2168,66 @@ export class AgUiThreadController {
1703
2168
  throw err;
1704
2169
  }
1705
2170
 
2171
+ const pending = replayInterruptMessageId
2172
+ ? this.getPendingInterrupts()
2173
+ : null;
2174
+ if (
2175
+ pending &&
2176
+ pending.messageId === replayInterruptMessageId &&
2177
+ replayContexts.has(pending.messageId) &&
2178
+ !abortSignal.aborted &&
2179
+ this.abortController === null &&
2180
+ generation === this.state.historyMutationGeneration &&
2181
+ !this.isThreadSwitching?.()
2182
+ ) {
2183
+ const message = this.session.tryGetMessage(pending.messageId)?.message;
2184
+ const responses =
2185
+ message?.role === "assistant"
2186
+ ? buildToolApprovalResume(message.content, pending.interrupts)
2187
+ : null;
2188
+ if (responses) {
2189
+ this.clearDeferredContinuations(abortController);
2190
+ try {
2191
+ await this.submitInterruptResponses(responses);
2192
+ } catch (error) {
2193
+ const err = error instanceof Error ? error : new Error(String(error));
2194
+ if (!this.reportedErrors.has(err)) {
2195
+ this.reportError(err);
2196
+ this.reportedErrors.add(err);
2197
+ }
2198
+ throw err;
2199
+ }
2200
+ return;
2201
+ }
2202
+ }
2203
+
1706
2204
  // A tool result that landed before the run settled deferred its
1707
2205
  // continuation here so a second run never overlaps the first.
1708
2206
  if (this.state.pendingResume?.owner === abortController) {
1709
- const { messageId } = this.state.pendingResume;
2207
+ const { messageIds } = this.state.pendingResume;
1710
2208
  this.dispatch({ type: "resumeCleared" });
1711
- if (!abortSignal.aborted) {
1712
- this.startResumeRun(messageId);
2209
+ if (
2210
+ !abortSignal.aborted &&
2211
+ !this.getPendingInterrupts() &&
2212
+ !this.getPendingToolCalls()
2213
+ ) {
2214
+ const turn = this.getTrailingAssistantTurn();
2215
+ if (
2216
+ turn.some((message) => {
2217
+ if (!messageIds.includes(message.id)) return false;
2218
+ const replay = replayContexts.get(message.id);
2219
+ if (!replay) return hasResolvedToolCalls(message);
2220
+ return Array.from(iterateToolCallParts(message.content)).some(
2221
+ (part) =>
2222
+ isResolvedToolCall(part) &&
2223
+ replay.admittedToolCallIds.has(part.toolCallId) &&
2224
+ !accountedResultIds.has(part.toolCallId),
2225
+ );
2226
+ })
2227
+ ) {
2228
+ const headId = turn.at(-1)?.id;
2229
+ if (headId !== undefined) this.startResumeRun(headId);
2230
+ }
1713
2231
  }
1714
2232
  }
1715
2233
 
@@ -1994,6 +2512,8 @@ export class AgUiThreadController {
1994
2512
  private updateAssistantMessage(
1995
2513
  messageId: string,
1996
2514
  update: ChatModelRunResult,
2515
+ replay?: ReplayContext,
2516
+ pendingToolCallIds?: ReadonlySet<string>,
1997
2517
  ): string {
1998
2518
  let latestStatus: MessageStatus | undefined;
1999
2519
  const touched = this.session.updateMessage(messageId, (message) => {
@@ -2003,18 +2523,102 @@ export class AgUiThreadController {
2003
2523
  ? this.mergeAssistantMetadata(assistant.metadata, update.metadata)
2004
2524
  : assistant.metadata;
2005
2525
  latestStatus = update.status ?? assistant.status;
2526
+ let content =
2527
+ update.content !== undefined
2528
+ ? replay
2529
+ ? replay.merge(
2530
+ assistant.content,
2531
+ update.content as ThreadAssistantMessage["content"],
2532
+ )
2533
+ : this.preserveToolResults(
2534
+ assistant.content,
2535
+ update.content as ThreadAssistantMessage["content"],
2536
+ )
2537
+ : assistant.content;
2538
+ const calls = replay ? Array.from(iterateToolCallParts(content)) : [];
2539
+ // A retained historical call needs existing or current stream authority.
2540
+ const pending =
2541
+ pendingToolCallIds ??
2542
+ (replay
2543
+ ? new Set([
2544
+ ...replay.admittedToolCallIds,
2545
+ ...Array.from(iterateToolCallParts(update.content ?? []))
2546
+ .filter((part) => !isResolvedToolCall(part))
2547
+ .map((part) => part.toolCallId),
2548
+ ])
2549
+ : undefined);
2550
+ if (replay && latestStatus?.type !== "running" && pending)
2551
+ replay.admittedToolCallIds = pending;
2552
+ if (replay) {
2553
+ // Project gates against the content the user can actually answer.
2554
+ const interrupts =
2555
+ latestStatus?.type === "requires-action" &&
2556
+ latestStatus.reason === "interrupt"
2557
+ ? (
2558
+ mergedMetadata.custom[AG_UI_METADATA_NAMESPACE] as
2559
+ | AgUiCustomMetadata
2560
+ | undefined
2561
+ )?.interrupts
2562
+ : undefined;
2563
+ const approvals = projectAgUiToolApprovals(
2564
+ interrupts,
2565
+ new Set(calls.map((part) => part.toolCallId)),
2566
+ );
2567
+ const originalCalls = new Map(
2568
+ Array.from(iterateToolCallParts(replay.originalContent), (part) => [
2569
+ part.toolCallId,
2570
+ part,
2571
+ ]),
2572
+ );
2573
+ content = mapToolCallPartsDeep(content, (part) => {
2574
+ const approval = approvals.get(part.toolCallId);
2575
+ if (approval)
2576
+ return {
2577
+ ...part,
2578
+ approval:
2579
+ preserveToolApprovalDecision(
2580
+ originalCalls.get(part.toolCallId),
2581
+ { ...part, approval },
2582
+ ) ?? approval,
2583
+ };
2584
+ if (
2585
+ !part.approval ||
2586
+ part.approval.approved !== undefined ||
2587
+ part.approval.resolution
2588
+ )
2589
+ return part;
2590
+ const { approval: _approval, ...withoutApproval } = part;
2591
+ return withoutApproval;
2592
+ }).content;
2593
+ }
2594
+ if (replay && latestStatus?.type === "complete") {
2595
+ const listed =
2596
+ pending ??
2597
+ listedPendingToolCallIds({
2598
+ ...assistant,
2599
+ metadata: mergedMetadata,
2600
+ });
2601
+ if (
2602
+ calls.some(
2603
+ (part) =>
2604
+ (!isResolvedToolCall(part) ||
2605
+ !replay.accountedResultIds.has(part.toolCallId)) &&
2606
+ (!listed || listed.has(part.toolCallId)),
2607
+ )
2608
+ )
2609
+ latestStatus = { type: "requires-action", reason: "tool-calls" };
2610
+ }
2611
+ const admission =
2612
+ pending && replay
2613
+ ? withPendingToolCallIds(mergedMetadata, pending)
2614
+ : mergedMetadata;
2006
2615
  const metadata =
2007
2616
  latestStatus?.type === "requires-action" &&
2008
2617
  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;
2618
+ ? admission
2619
+ : replay && latestStatus?.type === "running"
2620
+ ? mergedMetadata
2621
+ : withoutPendingToolCallIds(admission);
2018
2622
  return {
2019
2623
  ...assistant,
2020
2624
  content,
@@ -2037,7 +2641,10 @@ export class AgUiThreadController {
2037
2641
  if (this.isPersistableStatus(latestStatus)) {
2038
2642
  this.persistAssistantHistory(resolvedMessageId);
2039
2643
  }
2040
- this.maybeResumeAfterToolResults(resolvedMessageId);
2644
+ this.maybeResumeAfterToolResults(
2645
+ resolvedMessageId,
2646
+ replay?.accountedResultIds,
2647
+ );
2041
2648
  return resolvedMessageId;
2042
2649
  }
2043
2650
 
@@ -2048,6 +2655,7 @@ export class AgUiThreadController {
2048
2655
  private preserveToolResults(
2049
2656
  previous: ThreadAssistantMessage["content"],
2050
2657
  next: ThreadAssistantMessage["content"],
2658
+ decisionSource?: ThreadAssistantMessage["content"],
2051
2659
  ): ThreadAssistantMessage["content"] {
2052
2660
  const preserved = new Map<string, ToolCallMessagePart>();
2053
2661
  for (const part of iterateToolCallParts(previous)) {
@@ -2055,25 +2663,27 @@ export class AgUiThreadController {
2055
2663
  preserved.set(part.toolCallId, part);
2056
2664
  }
2057
2665
  }
2058
- if (preserved.size === 0) {
2666
+ const decisions = new Map(
2667
+ Array.from(iterateToolCallParts(decisionSource ?? []))
2668
+ .filter((part) => part.approval?.approved !== undefined)
2669
+ .map((part) => [part.toolCallId, part]),
2670
+ );
2671
+ if (preserved.size === 0 && decisions.size === 0) {
2059
2672
  return this.preserveToolInteractions(previous, next);
2060
2673
  }
2061
2674
 
2062
2675
  const { content: merged, changed } = mapToolCallPartsDeep(next, (part) => {
2063
2676
  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
- };
2677
+ const result = prior ? preserveToolResult(prior, part) : part;
2678
+ if (result.approval && decisions.has(part.toolCallId)) {
2679
+ const approval = preserveToolApprovalDecision(
2680
+ decisions.get(part.toolCallId),
2681
+ result,
2682
+ );
2683
+ if (approval && approval !== result.approval)
2684
+ return { ...result, approval };
2075
2685
  }
2076
- return part;
2686
+ return result;
2077
2687
  });
2078
2688
  return this.preserveToolInteractions(previous, changed ? merged : next);
2079
2689
  }
@@ -2260,6 +2870,7 @@ export class AgUiThreadController {
2260
2870
  joinStrategy: UseAgUiRuntimeOptions["joinStrategy"],
2261
2871
  runStartMessageIds: ReadonlySet<string>,
2262
2872
  hasRunUserBoundary: boolean,
2873
+ reconcileSnapshot?: SnapshotMessageReconciler,
2263
2874
  ): string | undefined {
2264
2875
  switch (event.type) {
2265
2876
  case "STATE_SNAPSHOT": {
@@ -2304,6 +2915,7 @@ export class AgUiThreadController {
2304
2915
  joinStrategy,
2305
2916
  runStartMessageIds,
2306
2917
  hasRunUserBoundary,
2918
+ reconcileSnapshot,
2307
2919
  );
2308
2920
  const headId = this.session.headId;
2309
2921
  if (hasActiveText && headId !== previousHeadId) {
@@ -2521,6 +3133,7 @@ export class AgUiThreadController {
2521
3133
  joinStrategy: UseAgUiRuntimeOptions["joinStrategy"],
2522
3134
  runStartMessageIds: ReadonlySet<string>,
2523
3135
  hasRunUserBoundary: boolean,
3136
+ reconcileSnapshot?: SnapshotMessageReconciler,
2524
3137
  ) {
2525
3138
  try {
2526
3139
  const activeAssistantItem = activeAssistantId
@@ -2560,19 +3173,22 @@ export class AgUiThreadController {
2560
3173
  const existing = this.session.tryGetMessage(
2561
3174
  convertedMessage.id,
2562
3175
  )?.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
- };
3176
+ if (convertedMessage.role === "assistant") {
3177
+ const previous =
3178
+ existing?.role === "assistant" ? existing : undefined;
3179
+ const snapshot =
3180
+ reconcileSnapshot?.(convertedMessage, previous) ?? convertedMessage;
3181
+ converted[index] = previous
3182
+ ? {
3183
+ ...snapshot,
3184
+ content: this.mergeSnapshotAssistantContent(
3185
+ previous.content,
3186
+ snapshot.content,
3187
+ snapshotHasReasoning,
3188
+ snapshotHasActivity,
3189
+ ),
3190
+ }
3191
+ : snapshot;
2576
3192
  }
2577
3193
  }
2578
3194
  if (joinStrategy === "concat-content") {