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

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 (43) hide show
  1. package/README.md +2 -0
  2. package/dist/runtime/AgUiThreadController.d.ts +11 -0
  3. package/dist/runtime/AgUiThreadController.d.ts.map +1 -1
  4. package/dist/runtime/AgUiThreadController.js +242 -25
  5. package/dist/runtime/AgUiThreadController.js.map +1 -1
  6. package/dist/runtime/adapter/conversions.d.ts +9 -1
  7. package/dist/runtime/adapter/conversions.d.ts.map +1 -1
  8. package/dist/runtime/adapter/conversions.js +184 -9
  9. package/dist/runtime/adapter/conversions.js.map +1 -1
  10. package/dist/runtime/adapter/run-aggregator.d.ts +2 -0
  11. package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
  12. package/dist/runtime/adapter/run-aggregator.js +42 -12
  13. package/dist/runtime/adapter/run-aggregator.js.map +1 -1
  14. package/dist/runtime/adapter/subscriber.d.ts +0 -1
  15. package/dist/runtime/adapter/subscriber.d.ts.map +1 -1
  16. package/dist/runtime/adapter/subscriber.js +1 -8
  17. package/dist/runtime/adapter/subscriber.js.map +1 -1
  18. package/dist/runtime/event-parser.d.ts.map +1 -1
  19. package/dist/runtime/event-parser.js +1 -0
  20. package/dist/runtime/event-parser.js.map +1 -1
  21. package/dist/runtime/types.d.ts +2 -0
  22. package/dist/runtime/types.d.ts.map +1 -1
  23. package/dist/runtime/types.js +2 -1
  24. package/dist/runtime/types.js.map +1 -1
  25. package/package.json +6 -5
  26. package/src/runtime/AgUiThreadController.disconnect.test.ts +914 -0
  27. package/src/runtime/AgUiThreadController.interrupt-history.test.ts +392 -0
  28. package/src/runtime/AgUiThreadController.replay.test.ts +8 -1
  29. package/src/runtime/AgUiThreadController.snapshot-head-move.test.ts +346 -0
  30. package/src/runtime/AgUiThreadController.subagent-snapshot.test.ts +854 -0
  31. package/src/runtime/AgUiThreadController.test.ts +173 -1
  32. package/src/runtime/AgUiThreadController.ts +528 -33
  33. package/src/runtime/adapter/conversions.content.test.ts +129 -0
  34. package/src/runtime/adapter/conversions.subagent-messages.test.ts +550 -0
  35. package/src/runtime/adapter/conversions.ts +331 -20
  36. package/src/runtime/adapter/run-aggregator.metadata.test.ts +151 -0
  37. package/src/runtime/adapter/run-aggregator.ts +63 -9
  38. package/src/runtime/adapter/subscriber.test.ts +1 -1
  39. package/src/runtime/adapter/subscriber.ts +1 -6
  40. package/src/runtime/event-parser.ts +3 -0
  41. package/src/runtime/types.ts +5 -0
  42. package/src/useAgUiRuntime.cloud-run-leases.test.tsx +384 -0
  43. package/src/useAgUiRuntime.state.test.tsx +87 -0
@@ -62,9 +62,10 @@ 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";
70
71
  import {
@@ -81,6 +82,26 @@ import {
81
82
  withToolApprovalDecision,
82
83
  } from "./adapter/tool-approval";
83
84
 
85
+ // The SDK does not retain initialState.
86
+ const agentInitialStates = new WeakMap<
87
+ AbstractAgent,
88
+ ReadonlyJSONValue | undefined
89
+ >();
90
+
91
+ const getAgentInitialState = (agent: AbstractAgent) => {
92
+ if (!agentInitialStates.has(agent)) {
93
+ const state = agent.state;
94
+ agentInitialStates.set(
95
+ agent,
96
+ state === undefined ||
97
+ (isPlainObject(state) && Object.keys(state).length === 0)
98
+ ? undefined
99
+ : (structuredClone(state) as ReadonlyJSONValue),
100
+ );
101
+ }
102
+ return agentInitialStates.get(agent);
103
+ };
104
+
84
105
  // AbstractAgent.runAgent declares two parameters. HttpAgent ignores a third and
85
106
  // is cancelled through agent.abortRun(); the run options stay for subclasses
86
107
  // that inherit the base no-op abortRun and have no other cancellation hook.
@@ -229,6 +250,73 @@ type CoreOptions = {
229
250
 
230
251
  const FALLBACK_USER_STATUS = { type: "complete", reason: "unknown" } as const;
231
252
 
253
+ const textOf = (content: ThreadAssistantMessage["content"]) =>
254
+ content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("");
255
+
256
+ /**
257
+ * Swaps `content`'s text parts, in order, for `source`'s. A text part `source`
258
+ * has beyond `content`'s text slots goes after the nearest earlier part of
259
+ * `source` that `content` also has: a tool call by id, another part by its
260
+ * type and position among parts of that type. With none, it goes first.
261
+ */
262
+ const replaceTextParts = (
263
+ content: ThreadAssistantMessage["content"],
264
+ source: ThreadAssistantMessage["content"],
265
+ ): ThreadAssistantMessage["content"] => {
266
+ const texts = source.filter((part) => part.type === "text");
267
+ const result: ThreadAssistantMessage["content"][number][] = [];
268
+ let used = 0;
269
+ for (const part of content) {
270
+ if (part.type !== "text") result.push(part);
271
+ else if (used < texts.length) result.push(texts[used++]!);
272
+ }
273
+ const positionIn = (
274
+ parts: readonly ThreadAssistantMessage["content"][number][],
275
+ part: ThreadAssistantMessage["content"][number],
276
+ ) => parts.filter((other) => other.type === part.type).indexOf(part);
277
+ for (const text of texts.slice(used)) {
278
+ let anchor = -1;
279
+ for (
280
+ let index = source.indexOf(text) - 1;
281
+ index >= 0 && anchor < 0;
282
+ index--
283
+ ) {
284
+ const before = source[index]!;
285
+ if (before.type === "tool-call") {
286
+ anchor = result.findIndex(
287
+ (part) =>
288
+ part.type === "tool-call" && part.toolCallId === before.toolCallId,
289
+ );
290
+ } else if (before.type === "text") {
291
+ anchor = result.indexOf(before);
292
+ } else {
293
+ const sameType = result.filter((part) => part.type === before.type);
294
+ const match = sameType[positionIn(source, before)];
295
+ anchor = match ? result.indexOf(match) : -1;
296
+ }
297
+ }
298
+ result.splice(anchor + 1, 0, text);
299
+ }
300
+ return result;
301
+ };
302
+
303
+ const settleNestedContent = (
304
+ content: ThreadAssistantMessage["content"],
305
+ status: MessageStatus,
306
+ ) =>
307
+ mapToolCallPartsDeep(content, (part) =>
308
+ part.messages
309
+ ? {
310
+ ...part,
311
+ messages: part.messages.map((message) =>
312
+ message.role === "assistant" && message.status?.type === "running"
313
+ ? { ...message, status }
314
+ : message,
315
+ ),
316
+ }
317
+ : part,
318
+ ).content;
319
+
232
320
  type AgUiRuntimeCallbackName = "onError" | "onCancel";
233
321
 
234
322
  const invokeRuntimeCallback = <TArgs extends readonly unknown[]>(
@@ -306,6 +394,10 @@ export class AgUiThreadController {
306
394
  private activeRunAgent: AbstractAgent | null = null;
307
395
  private history: ThreadHistoryAdapter | undefined;
308
396
  private readonly assistantHistoryParents = new Map<string, string | null>();
397
+ private readonly subagentParentToolCallIds = new Map<
398
+ string,
399
+ string | undefined
400
+ >();
309
401
  private readonly snapshotHistoryIds = new Set<string>();
310
402
  private readonly persistedHistoryIds = new Set<string>();
311
403
  private readonly historyWrites = createHistoryWriteChain();
@@ -314,6 +406,13 @@ export class AgUiThreadController {
314
406
 
315
407
  constructor(options: CoreOptions) {
316
408
  this.agent = options.agent;
409
+ const initialState = getAgentInitialState(this.agent);
410
+ if (initialState !== undefined) {
411
+ this.dispatch({
412
+ type: "stateSnapshot",
413
+ snapshot: initialState,
414
+ });
415
+ }
317
416
  this.logger = options.logger;
318
417
  this.showThinking = options.showThinking;
319
418
  this.joinStrategy = options.joinStrategy;
@@ -349,7 +448,18 @@ export class AgUiThreadController {
349
448
  }
350
449
 
351
450
  updateOptions(options: CoreOptions) {
451
+ const agentChanged = this.agent !== options.agent;
352
452
  this.agent = options.agent;
453
+ if (agentChanged) {
454
+ const initialState = getAgentInitialState(this.agent);
455
+ if (
456
+ this.state.stateSnapshot === undefined &&
457
+ initialState !== undefined
458
+ ) {
459
+ this.dispatch({ type: "stateSnapshot", snapshot: initialState });
460
+ this.publishUpdate();
461
+ }
462
+ }
353
463
  this.logger = options.logger;
354
464
  this.showThinking = options.showThinking;
355
465
  this.joinStrategy = options.joinStrategy;
@@ -412,6 +522,7 @@ export class AgUiThreadController {
412
522
 
413
523
  this.session.applyExternalMessageRepository(repo);
414
524
  this.assistantHistoryParents.clear();
525
+ this.subagentParentToolCallIds.clear();
415
526
  this.snapshotHistoryIds.clear();
416
527
  this.persistedHistoryIds.clear();
417
528
  for (const { message } of repo.messages) {
@@ -1067,6 +1178,7 @@ export class AgUiThreadController {
1067
1178
  });
1068
1179
  if (touched) {
1069
1180
  this.publishUpdate();
1181
+ this.persistSettledAssistant(messageId);
1070
1182
  }
1071
1183
  }
1072
1184
 
@@ -1135,7 +1247,35 @@ export class AgUiThreadController {
1135
1247
  };
1136
1248
  });
1137
1249
  if (!matchedToolCall) return message;
1138
- return { ...assistant, content };
1250
+ const settled = mapToolCallPartsDeep(content, (part) => {
1251
+ if (!part.messages) return part;
1252
+ let changed = false;
1253
+ const messages = part.messages.map((nested) => {
1254
+ if (
1255
+ nested.role !== "assistant" ||
1256
+ nested.status.type !== "requires-action" ||
1257
+ nested.status.reason !== "tool-calls"
1258
+ )
1259
+ return nested;
1260
+ const listed = listedPendingToolCallIds(nested);
1261
+ if (
1262
+ Array.from(iterateToolCallParts(nested.content)).some(
1263
+ (call) =>
1264
+ !isResolvedToolCall(call) &&
1265
+ (!listed || listed.has(call.toolCallId)),
1266
+ )
1267
+ )
1268
+ return nested;
1269
+ changed = true;
1270
+ return {
1271
+ ...nested,
1272
+ status: { type: "complete" as const, reason: "unknown" as const },
1273
+ metadata: withoutPendingToolCallIds(nested.metadata),
1274
+ };
1275
+ });
1276
+ return changed ? { ...part, messages } : part;
1277
+ }).content;
1278
+ return { ...assistant, content: settled };
1139
1279
  });
1140
1280
 
1141
1281
  if (!updated) return;
@@ -1312,7 +1452,11 @@ export class AgUiThreadController {
1312
1452
  );
1313
1453
  if (!updated) return false;
1314
1454
  this.publishUpdate();
1315
- this.persistAssistantHistory(messageId);
1455
+ if (this.history?.update) {
1456
+ this.persistSettledAssistant(messageId);
1457
+ } else {
1458
+ this.persistAssistantHistory(messageId);
1459
+ }
1316
1460
  return true;
1317
1461
  }
1318
1462
 
@@ -1471,6 +1615,7 @@ export class AgUiThreadController {
1471
1615
  const activeRunAgent = this.activeRunAgent;
1472
1616
 
1473
1617
  this.dispatch({ type: "resetThread" });
1618
+ this.subagentParentToolCallIds.clear();
1474
1619
 
1475
1620
  if (controller) {
1476
1621
  this.abortController = null;
@@ -1589,6 +1734,8 @@ export class AgUiThreadController {
1589
1734
  let frameOwnerIds = new Set<string>();
1590
1735
  let restrictedPendingToolCallIds: ReadonlySet<string> | undefined;
1591
1736
  let settlingRun = false;
1737
+ let latestSnapshot: ThreadAssistantMessage | undefined;
1738
+ let snapshotToolCallIds = new Set<string>();
1592
1739
  const ensureAssistant = (allowRecreate = false): string => {
1593
1740
  const cached = assistantMessageId;
1594
1741
  if (cached !== undefined && this.session.tryGetMessage(cached))
@@ -1993,7 +2140,102 @@ export class AgUiThreadController {
1993
2140
  showThinking: this.showThinking,
1994
2141
  joinStrategy,
1995
2142
  logger: this.logger,
1996
- emit: applyUpdate,
2143
+ emit: (update) => {
2144
+ const currentSnapshotAssistant =
2145
+ assistantMessageId === undefined
2146
+ ? undefined
2147
+ : this.session.tryGetMessage(assistantMessageId)?.message;
2148
+ const listedSnapshotToolCallIds =
2149
+ currentSnapshotAssistant?.role === "assistant"
2150
+ ? listedPendingToolCallIds(currentSnapshotAssistant)
2151
+ : undefined;
2152
+ const hasPendingSnapshotToolCall =
2153
+ latestSnapshot?.id === assistantMessageId &&
2154
+ currentSnapshotAssistant?.role === "assistant" &&
2155
+ Array.from(
2156
+ iterateToolCallParts(currentSnapshotAssistant.content),
2157
+ ).some(
2158
+ (part) =>
2159
+ !isResolvedToolCall(part) &&
2160
+ (!listedSnapshotToolCallIds ||
2161
+ listedSnapshotToolCallIds.has(part.toolCallId)) &&
2162
+ (!restrictedPendingToolCallIds ||
2163
+ restrictedPendingToolCallIds.has(part.toolCallId)),
2164
+ );
2165
+ const reconciledUpdate =
2166
+ settlingRun &&
2167
+ update.status?.type === "complete" &&
2168
+ hasPendingSnapshotToolCall
2169
+ ? {
2170
+ ...update,
2171
+ status: {
2172
+ type: "requires-action",
2173
+ reason: "tool-calls",
2174
+ } as const,
2175
+ ...(restrictedPendingToolCallIds
2176
+ ? {
2177
+ metadata: {
2178
+ ...update.metadata,
2179
+ custom: {
2180
+ ...update.metadata?.custom,
2181
+ [AG_UI_METADATA_NAMESPACE]: {
2182
+ ...(update.metadata?.custom?.[
2183
+ AG_UI_METADATA_NAMESPACE
2184
+ ] as AgUiCustomMetadata | undefined),
2185
+ pendingToolCallIds: [
2186
+ ...restrictedPendingToolCallIds,
2187
+ ],
2188
+ },
2189
+ },
2190
+ },
2191
+ }
2192
+ : {}),
2193
+ }
2194
+ : update;
2195
+ if (
2196
+ settlingRun &&
2197
+ latestSnapshot &&
2198
+ latestSnapshot.id === assistantMessageId &&
2199
+ reconciledUpdate.status &&
2200
+ reconciledUpdate.status.type !== "running" &&
2201
+ reconciledUpdate.status.type !== "requires-action"
2202
+ ) {
2203
+ const status = reconciledUpdate.status;
2204
+ const current =
2205
+ this.session.tryGetMessage(assistantMessageId)?.message;
2206
+ const content = settleNestedContent(
2207
+ current?.role === "assistant"
2208
+ ? current.content
2209
+ : latestSnapshot.content,
2210
+ status,
2211
+ );
2212
+ applyUpdate({ ...reconciledUpdate, content });
2213
+ } else if (
2214
+ latestSnapshot &&
2215
+ latestSnapshot.id === assistantMessageId &&
2216
+ reconciledUpdate.content !== undefined
2217
+ ) {
2218
+ const content = this.mergeSnapshotAssistantContent(
2219
+ latestSnapshot.content,
2220
+ reconciledUpdate.content as ThreadAssistantMessage["content"],
2221
+ reconciledUpdate.content.some((part) => part.type === "reasoning"),
2222
+ reconciledUpdate.content.some(isActivityPart),
2223
+ snapshotToolCallIds,
2224
+ );
2225
+ applyUpdate({ ...reconciledUpdate, content });
2226
+ } else {
2227
+ applyUpdate(reconciledUpdate);
2228
+ }
2229
+ const current = assistantMessageId
2230
+ ? this.session.tryGetMessage(assistantMessageId)?.message
2231
+ : undefined;
2232
+ latestSnapshot =
2233
+ !settlingRun &&
2234
+ current?.role === "assistant" &&
2235
+ current.id === latestSnapshot?.id
2236
+ ? current
2237
+ : undefined;
2238
+ },
1997
2239
  onServerMessageId: (serverId) => {
1998
2240
  adoptServerMessageId(serverId, false);
1999
2241
  },
@@ -2068,6 +2310,22 @@ export class AgUiThreadController {
2068
2310
  }
2069
2311
  }
2070
2312
  assistantMessageId = nextAssistantMessageId;
2313
+ const imported = assistantMessageId
2314
+ ? this.session.tryGetMessage(assistantMessageId)?.message
2315
+ : undefined;
2316
+ const source = event.messages.find(
2317
+ (message) =>
2318
+ message?.role === "assistant" && message.id === imported?.id,
2319
+ );
2320
+ latestSnapshot =
2321
+ imported?.role === "assistant" && source ? imported : undefined;
2322
+ snapshotToolCallIds = new Set(
2323
+ Array.isArray(source?.toolCalls)
2324
+ ? source.toolCalls
2325
+ .map((call: { id?: unknown }) => call.id)
2326
+ .filter((id: unknown): id is string => typeof id === "string")
2327
+ : [],
2328
+ );
2071
2329
  if (!replayEnded && assistantMessageId === undefined) {
2072
2330
  const tail = this.session.getMessages().at(-1);
2073
2331
  snapshotAssistantId =
@@ -2129,7 +2387,6 @@ export class AgUiThreadController {
2129
2387
  );
2130
2388
  const subscriber = createAgUiSubscriber({
2131
2389
  dispatch,
2132
- runId,
2133
2390
  logger: this.logger,
2134
2391
  onRunFailed: (error) => {
2135
2392
  if (abortSignal.aborted) return;
@@ -2140,7 +2397,7 @@ export class AgUiThreadController {
2140
2397
  try {
2141
2398
  runAgentInstance.messages = input.messages;
2142
2399
  runAgentInstance.threadId = input.threadId;
2143
- runAgentInstance.state = input.state ?? null;
2400
+ runAgentInstance.state = input.state;
2144
2401
  } catch {
2145
2402
  // ignore
2146
2403
  }
@@ -2158,6 +2415,26 @@ export class AgUiThreadController {
2158
2415
  pendingError = err;
2159
2416
  }
2160
2417
  } finally {
2418
+ const current = assistantMessageId
2419
+ ? this.session.tryGetMessage(assistantMessageId)?.message
2420
+ : undefined;
2421
+ if (
2422
+ this.abortController === abortController &&
2423
+ current?.role === "assistant" &&
2424
+ current.status?.type === "running"
2425
+ ) {
2426
+ // Finalization is transport cleanup, not proof of a terminal event.
2427
+ const status: MessageStatus = {
2428
+ type: "incomplete",
2429
+ reason: "error",
2430
+ error: "The connection ended before the answer finished.",
2431
+ };
2432
+ settleHeldMessages(status);
2433
+ applyUpdate({
2434
+ status,
2435
+ content: settleNestedContent(current.content, status),
2436
+ });
2437
+ }
2161
2438
  this.finishRun(abortController);
2162
2439
  }
2163
2440
 
@@ -2355,7 +2632,8 @@ export class AgUiThreadController {
2355
2632
  const input = {
2356
2633
  threadId,
2357
2634
  runId,
2358
- state: this.state.stateSnapshot ?? null,
2635
+ state:
2636
+ this.state.stateSnapshot === undefined ? {} : this.state.stateSnapshot,
2359
2637
  messages,
2360
2638
  tools: toAgUiTools(context?.tools),
2361
2639
  context: [
@@ -2535,7 +2813,7 @@ export class AgUiThreadController {
2535
2813
  update.content as ThreadAssistantMessage["content"],
2536
2814
  )
2537
2815
  : assistant.content;
2538
- const calls = replay ? Array.from(iterateToolCallParts(content)) : [];
2816
+ const calls = Array.from(iterateToolCallParts(content));
2539
2817
  // A retained historical call needs existing or current stream authority.
2540
2818
  const pending =
2541
2819
  pendingToolCallIds ??
@@ -2549,7 +2827,11 @@ export class AgUiThreadController {
2549
2827
  : undefined);
2550
2828
  if (replay && latestStatus?.type !== "running" && pending)
2551
2829
  replay.admittedToolCallIds = pending;
2552
- if (replay) {
2830
+ if (
2831
+ replay ||
2832
+ (latestStatus?.type === "requires-action" &&
2833
+ latestStatus.reason === "interrupt")
2834
+ ) {
2553
2835
  // Project gates against the content the user can actually answer.
2554
2836
  const interrupts =
2555
2837
  latestStatus?.type === "requires-action" &&
@@ -2565,10 +2847,10 @@ export class AgUiThreadController {
2565
2847
  new Set(calls.map((part) => part.toolCallId)),
2566
2848
  );
2567
2849
  const originalCalls = new Map(
2568
- Array.from(iterateToolCallParts(replay.originalContent), (part) => [
2569
- part.toolCallId,
2570
- part,
2571
- ]),
2850
+ Array.from(
2851
+ iterateToolCallParts(replay?.originalContent ?? assistant.content),
2852
+ (part) => [part.toolCallId, part],
2853
+ ),
2572
2854
  );
2573
2855
  content = mapToolCallPartsDeep(content, (part) => {
2574
2856
  const approval = approvals.get(part.toolCallId);
@@ -2713,7 +2995,7 @@ export class AgUiThreadController {
2713
2995
  }
2714
2996
 
2715
2997
  // A snapshot built from user, assistant, and tool records alone carries no
2716
- // reasoning or activity of its own, so the parts streamed locally would be
2998
+ // reasoning, activity, or custom data of its own, so locally streamed parts would be
2717
2999
  // lost on every refresh; keep the local ones where they streamed when the
2718
3000
  // snapshot has none of that kind, matching the merge rule of @ag-ui/client.
2719
3001
  private mergeSnapshotAssistantContent(
@@ -2721,15 +3003,30 @@ export class AgUiThreadController {
2721
3003
  next: ThreadAssistantMessage["content"],
2722
3004
  snapshotHasReasoning: boolean,
2723
3005
  snapshotHasActivity: boolean,
3006
+ keepMissingToolCalls: ReadonlySet<string> = new Set(),
3007
+ keepPriorTranscripts = false,
2724
3008
  ): ThreadAssistantMessage["content"] {
2725
3009
  const shouldKeep = (part: ThreadAssistantMessage["content"][number]) =>
2726
- part.type === "reasoning"
2727
- ? this.showThinking && !snapshotHasReasoning
2728
- : isActivityPart(part) && !snapshotHasActivity;
3010
+ part.type === "tool-call"
3011
+ ? keepMissingToolCalls.has(part.toolCallId) &&
3012
+ !next.some(
3013
+ (incoming) =>
3014
+ incoming.type === "tool-call" &&
3015
+ incoming.toolCallId === part.toolCallId,
3016
+ )
3017
+ : part.type === "reasoning"
3018
+ ? this.showThinking && !snapshotHasReasoning
3019
+ : part.type === "data" && !isActivityPart(part)
3020
+ ? !next.some(
3021
+ (incoming) =>
3022
+ incoming.type === "data" && incoming.name === part.name,
3023
+ )
3024
+ : isActivityPart(part) && !snapshotHasActivity;
2729
3025
  const kept = previous.filter(shouldKeep);
2730
3026
  const merged = this.reconcileSnapshotNestedContent(
2731
3027
  previous,
2732
3028
  this.preserveToolInteractions(previous, next),
3029
+ keepPriorTranscripts,
2733
3030
  );
2734
3031
  if (kept.length === 0) return merged;
2735
3032
 
@@ -2779,6 +3076,7 @@ export class AgUiThreadController {
2779
3076
  private reconcileSnapshotNestedContent(
2780
3077
  previous: ThreadAssistantMessage["content"],
2781
3078
  next: ThreadAssistantMessage["content"],
3079
+ keepPriorTranscripts: boolean,
2782
3080
  ): ThreadAssistantMessage["content"] {
2783
3081
  const nestedIds = new Set<string>();
2784
3082
  for (const part of previous) {
@@ -2828,10 +3126,92 @@ export class AgUiThreadController {
2828
3126
  }
2829
3127
  if (part.type !== "tool-call") return [part];
2830
3128
  const prior = previousCalls.get(part.toolCallId);
2831
- return prior?.messages === undefined
2832
- ? [part]
2833
- : [{ ...part, messages: prior.messages }];
3129
+ if (prior?.messages === undefined) return [part];
3130
+ if (part.messages === undefined)
3131
+ return [{ ...part, messages: prior.messages }];
3132
+ if (part.messages.length === 0) return [part];
3133
+ return [
3134
+ {
3135
+ ...part,
3136
+ messages: this.mergeNestedTranscripts(
3137
+ prior.messages,
3138
+ part.messages,
3139
+ keepPriorTranscripts,
3140
+ ),
3141
+ },
3142
+ ];
3143
+ });
3144
+ }
3145
+
3146
+ /**
3147
+ * Merges one tool call's nested transcripts message by message. The incoming
3148
+ * copy is the base for tool calls and reasoning; resolved results and calls
3149
+ * only the prior copy has carry over, and the prior text wins when it extends
3150
+ * the incoming text. Importing a snapshot keeps prior messages it lacks, a
3151
+ * prior status that is running or incomplete, and the prior status when the
3152
+ * snapshot waits on calls the merged content has already resolved.
3153
+ */
3154
+ private mergeNestedTranscripts(
3155
+ prior: readonly ThreadMessage[],
3156
+ next: readonly ThreadMessage[],
3157
+ importingSnapshot: boolean,
3158
+ ): readonly ThreadMessage[] {
3159
+ const priorById = new Map(prior.map((message) => [message.id, message]));
3160
+ const merged = next.map((message) => {
3161
+ const previous = priorById.get(message.id);
3162
+ if (previous?.role !== "assistant" || message.role !== "assistant")
3163
+ return message;
3164
+ let content = this.mergeSnapshotAssistantContent(
3165
+ previous.content,
3166
+ this.preserveToolResults(previous.content, message.content),
3167
+ message.content.some((part) => part.type === "reasoning"),
3168
+ message.content.some(isActivityPart),
3169
+ new Set(
3170
+ Array.from(
3171
+ iterateToolCallParts(previous.content),
3172
+ (call) => call.toolCallId,
3173
+ ),
3174
+ ),
3175
+ importingSnapshot,
3176
+ );
3177
+ const priorText = textOf(previous.content);
3178
+ const incomingText = textOf(message.content);
3179
+ if (
3180
+ priorText.length > incomingText.length &&
3181
+ priorText.startsWith(incomingText)
3182
+ ) {
3183
+ content = replaceTextParts(content, previous.content);
3184
+ }
3185
+ const status = !importingSnapshot
3186
+ ? message.status
3187
+ : previous.status.type === "running" ||
3188
+ previous.status.type === "incomplete" ||
3189
+ (message.status.type === "requires-action" &&
3190
+ message.status.reason === "tool-calls" &&
3191
+ !Array.from(iterateToolCallParts(content)).some(
3192
+ (call) => !isResolvedToolCall(call),
3193
+ ))
3194
+ ? previous.status
3195
+ : message.status;
3196
+ return {
3197
+ ...message,
3198
+ status,
3199
+ content,
3200
+ metadata: {
3201
+ ...message.metadata,
3202
+ custom: mergeAgUiNamespace(
3203
+ previous.metadata.custom,
3204
+ message.metadata.custom,
3205
+ ),
3206
+ },
3207
+ };
2834
3208
  });
3209
+ if (!importingSnapshot) return merged;
3210
+ const mergedById = new Map(merged.map((message) => [message.id, message]));
3211
+ return [
3212
+ ...prior.map((message) => mergedById.get(message.id) ?? message),
3213
+ ...merged.filter((message) => !priorById.has(message.id)),
3214
+ ];
2835
3215
  }
2836
3216
 
2837
3217
  private mergeAssistantMetadata(
@@ -2872,6 +3252,15 @@ export class AgUiThreadController {
2872
3252
  hasRunUserBoundary: boolean,
2873
3253
  reconcileSnapshot?: SnapshotMessageReconciler,
2874
3254
  ): string | undefined {
3255
+ if (
3256
+ event.type === "SUBAGENT_STARTED" &&
3257
+ !this.subagentParentToolCallIds.has(event.subagentRunId)
3258
+ ) {
3259
+ this.subagentParentToolCallIds.set(
3260
+ event.subagentRunId,
3261
+ event.parentToolCallId,
3262
+ );
3263
+ }
2875
3264
  switch (event.type) {
2876
3265
  case "STATE_SNAPSHOT": {
2877
3266
  this.dispatch({
@@ -2901,6 +3290,7 @@ export class AgUiThreadController {
2901
3290
  break;
2902
3291
  }
2903
3292
  case "MESSAGES_SNAPSHOT": {
3293
+ aggregator.handle(event);
2904
3294
  const previousHeadId = this.session.headId;
2905
3295
  const activeAssistant =
2906
3296
  activeAssistantId === undefined
@@ -2918,13 +3308,49 @@ export class AgUiThreadController {
2918
3308
  reconcileSnapshot,
2919
3309
  );
2920
3310
  const headId = this.session.headId;
2921
- if (hasActiveText && headId !== previousHeadId) {
2922
- const head =
2923
- headId === null
2924
- ? undefined
2925
- : this.session.tryGetMessage(headId)?.message;
2926
- activeAssistantId =
3311
+ const head =
3312
+ headId === null
3313
+ ? undefined
3314
+ : this.session.tryGetMessage(headId)?.message;
3315
+ const hasNewPendingToolCallAnswer =
3316
+ head?.role === "assistant" &&
3317
+ !runStartMessageIds.has(head.id) &&
3318
+ head.status?.type === "requires-action" &&
3319
+ head.status.reason === "tool-calls";
3320
+ if (
3321
+ headId !== previousHeadId &&
3322
+ (hasActiveText || hasNewPendingToolCallAnswer)
3323
+ ) {
3324
+ const nextActiveAssistantId =
2927
3325
  head?.role === "assistant" ? (headId ?? undefined) : undefined;
3326
+ if (
3327
+ activeAssistantId !== nextActiveAssistantId &&
3328
+ activeAssistantId
3329
+ ) {
3330
+ const previous =
3331
+ this.session.tryGetMessage(activeAssistantId)?.message;
3332
+ if (
3333
+ previous?.role === "assistant" &&
3334
+ previous.status?.type === "running"
3335
+ ) {
3336
+ this.updateAssistantMessage(activeAssistantId, {
3337
+ status: FALLBACK_USER_STATUS,
3338
+ content: settleNestedContent(
3339
+ previous.content,
3340
+ FALLBACK_USER_STATUS,
3341
+ ),
3342
+ });
3343
+ }
3344
+ }
3345
+ activeAssistantId = nextActiveAssistantId;
3346
+ if (activeAssistantId && joinStrategy !== "concat-content") {
3347
+ aggregator.rebaseMessage(activeAssistantId);
3348
+ }
3349
+ if (hasNewPendingToolCallAnswer && head) {
3350
+ this.updateAssistantMessage(head.id, {
3351
+ status: { type: "running" },
3352
+ });
3353
+ }
2928
3354
  }
2929
3355
  this.updateActiveAssistantState(activeAssistantId);
2930
3356
  break;
@@ -3027,6 +3453,14 @@ export class AgUiThreadController {
3027
3453
  ...(event.messageId
3028
3454
  ? { unstable_toolMessageId: event.messageId }
3029
3455
  : {}),
3456
+ ...(event.metadata !== undefined
3457
+ ? {
3458
+ providerMetadata: withAgUiPartMessageMetadata(
3459
+ part.providerMetadata,
3460
+ event.metadata,
3461
+ ),
3462
+ }
3463
+ : {}),
3030
3464
  };
3031
3465
  }
3032
3466
  const { modelContent: _modelContent, ...partWithoutModelContent } =
@@ -3053,6 +3487,14 @@ export class AgUiThreadController {
3053
3487
  ...(event.messageId
3054
3488
  ? { unstable_toolMessageId: event.messageId }
3055
3489
  : {}),
3490
+ ...(event.metadata !== undefined
3491
+ ? {
3492
+ providerMetadata: withAgUiPartMessageMetadata(
3493
+ part.providerMetadata,
3494
+ event.metadata,
3495
+ ),
3496
+ }
3497
+ : {}),
3056
3498
  };
3057
3499
  });
3058
3500
  if (!matchedToolCall) return message;
@@ -3063,7 +3505,8 @@ export class AgUiThreadController {
3063
3505
  this.publishUpdate();
3064
3506
  // Not maybeResumeAfterToolResults: the delivering run is already in
3065
3507
  // flight, and a resume from the owner would reset the head past it.
3066
- this.maybeCompleteAfterToolResults(messageId);
3508
+ if (this.maybeCompleteAfterToolResults(messageId)) return;
3509
+ this.persistSettledAssistant(messageId);
3067
3510
  }
3068
3511
 
3069
3512
  private applyCrossRunActivitySnapshot(
@@ -3124,7 +3567,8 @@ export class AgUiThreadController {
3124
3567
 
3125
3568
  if (!updated) return;
3126
3569
  this.publishUpdate();
3127
- this.maybeCompleteAfterToolResults(messageId);
3570
+ if (this.maybeCompleteAfterToolResults(messageId)) return;
3571
+ this.persistSettledAssistant(messageId);
3128
3572
  }
3129
3573
 
3130
3574
  private importMessagesSnapshot(
@@ -3143,14 +3587,24 @@ export class AgUiThreadController {
3143
3587
  const activeAssistantParentId = activeAssistantItem?.parentId;
3144
3588
  const activeAssistant =
3145
3589
  activeMessage?.role === "assistant" ? activeMessage : undefined;
3146
- const normalized = fromAgUiMessages(rawMessages, {
3147
- showThinking: this.showThinking,
3148
- });
3590
+ const normalized = fromAgUiSnapshotMessages(
3591
+ rawMessages,
3592
+ { showThinking: this.showThinking },
3593
+ this.subagentParentToolCallIds,
3594
+ );
3149
3595
  const converted: ThreadMessage[] = [];
3150
3596
  for (const message of normalized) {
3151
3597
  try {
3598
+ const previous = message.id
3599
+ ? this.session.tryGetMessage(message.id)?.message
3600
+ : undefined;
3601
+ const fallbackStatus =
3602
+ previous?.role === "assistant" &&
3603
+ previous.status?.type === "running"
3604
+ ? previous.status
3605
+ : FALLBACK_USER_STATUS;
3152
3606
  converted.push(
3153
- fromThreadMessageLike(message, generateId(), FALLBACK_USER_STATUS),
3607
+ fromThreadMessageLike(message, generateId(), fallbackStatus),
3154
3608
  );
3155
3609
  } catch (error) {
3156
3610
  this.logger.error?.(
@@ -3178,14 +3632,40 @@ export class AgUiThreadController {
3178
3632
  existing?.role === "assistant" ? existing : undefined;
3179
3633
  const snapshot =
3180
3634
  reconcileSnapshot?.(convertedMessage, previous) ?? convertedMessage;
3635
+ const preservesRunningStatus =
3636
+ convertedMessage.id === activeAssistantId &&
3637
+ previous?.status?.type === "running" &&
3638
+ snapshot.status?.type === "requires-action" &&
3639
+ snapshot.status.reason === "tool-calls";
3640
+ const previousToolCallIds = new Set(
3641
+ Array.from(
3642
+ iterateToolCallParts(previous?.content ?? []),
3643
+ (call) => call.toolCallId,
3644
+ ),
3645
+ );
3646
+ const preservesSettledStatus =
3647
+ convertedMessage.id !== activeAssistantId &&
3648
+ this.isTerminalStatus(previous?.status) &&
3649
+ snapshot.status?.type === "requires-action" &&
3650
+ snapshot.status.reason === "tool-calls" &&
3651
+ Array.from(iterateToolCallParts(snapshot.content)).every(
3652
+ (call) =>
3653
+ isResolvedToolCall(call) ||
3654
+ previousToolCallIds.has(call.toolCallId),
3655
+ );
3181
3656
  converted[index] = previous
3182
3657
  ? {
3183
3658
  ...snapshot,
3659
+ ...(preservesRunningStatus || preservesSettledStatus
3660
+ ? { status: previous.status }
3661
+ : {}),
3184
3662
  content: this.mergeSnapshotAssistantContent(
3185
3663
  previous.content,
3186
3664
  snapshot.content,
3187
3665
  snapshotHasReasoning,
3188
3666
  snapshotHasActivity,
3667
+ new Set(),
3668
+ true,
3189
3669
  ),
3190
3670
  }
3191
3671
  : snapshot;
@@ -3348,6 +3828,19 @@ export class AgUiThreadController {
3348
3828
  this.assistantHistoryParents.set(messageId, parentId);
3349
3829
  }
3350
3830
 
3831
+ private persistSettledAssistant(messageId: string): void {
3832
+ const item = this.session.tryGetMessage(messageId);
3833
+ if (!this.history?.update || !item) return;
3834
+ if (!this.isTerminalStatus(item.message.status)) return;
3835
+ if (
3836
+ !this.persistedHistoryIds.has(messageId) &&
3837
+ this.historyWrites.pending(messageId) === undefined
3838
+ )
3839
+ return;
3840
+ this.markPendingAssistantHistory(messageId, item.parentId);
3841
+ this.persistAssistantHistory(messageId);
3842
+ }
3843
+
3351
3844
  private persistAssistantHistory(messageId: string) {
3352
3845
  const history = this.history;
3353
3846
  if (!history) return;
@@ -3360,7 +3853,9 @@ export class AgUiThreadController {
3360
3853
  const update = history.update;
3361
3854
  const shouldUpdate =
3362
3855
  update !== undefined &&
3363
- (wasPersisted || this.snapshotHistoryIds.has(messageId));
3856
+ (wasPersisted ||
3857
+ this.snapshotHistoryIds.has(messageId) ||
3858
+ this.historyWrites.pending(messageId) !== undefined);
3364
3859
 
3365
3860
  if (shouldUpdate) {
3366
3861
  const write = this.historyWrites.chain(messageId, () =>