@assistant-ui/react-ag-ui 0.1.1 → 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 (41) 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 +212 -22
  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 +1 -0
  11. package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
  12. package/dist/runtime/adapter/run-aggregator.js +34 -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 +5 -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 +167 -0
  30. package/src/runtime/AgUiThreadController.subagent-snapshot.test.ts +854 -0
  31. package/src/runtime/AgUiThreadController.test.ts +40 -0
  32. package/src/runtime/AgUiThreadController.ts +459 -27
  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 +52 -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
@@ -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 {
@@ -229,6 +230,73 @@ type CoreOptions = {
229
230
 
230
231
  const FALLBACK_USER_STATUS = { type: "complete", reason: "unknown" } as const;
231
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
+
232
300
  type AgUiRuntimeCallbackName = "onError" | "onCancel";
233
301
 
234
302
  const invokeRuntimeCallback = <TArgs extends readonly unknown[]>(
@@ -306,6 +374,10 @@ export class AgUiThreadController {
306
374
  private activeRunAgent: AbstractAgent | null = null;
307
375
  private history: ThreadHistoryAdapter | undefined;
308
376
  private readonly assistantHistoryParents = new Map<string, string | null>();
377
+ private readonly subagentParentToolCallIds = new Map<
378
+ string,
379
+ string | undefined
380
+ >();
309
381
  private readonly snapshotHistoryIds = new Set<string>();
310
382
  private readonly persistedHistoryIds = new Set<string>();
311
383
  private readonly historyWrites = createHistoryWriteChain();
@@ -412,6 +484,7 @@ export class AgUiThreadController {
412
484
 
413
485
  this.session.applyExternalMessageRepository(repo);
414
486
  this.assistantHistoryParents.clear();
487
+ this.subagentParentToolCallIds.clear();
415
488
  this.snapshotHistoryIds.clear();
416
489
  this.persistedHistoryIds.clear();
417
490
  for (const { message } of repo.messages) {
@@ -1067,6 +1140,7 @@ export class AgUiThreadController {
1067
1140
  });
1068
1141
  if (touched) {
1069
1142
  this.publishUpdate();
1143
+ this.persistSettledAssistant(messageId);
1070
1144
  }
1071
1145
  }
1072
1146
 
@@ -1135,7 +1209,35 @@ export class AgUiThreadController {
1135
1209
  };
1136
1210
  });
1137
1211
  if (!matchedToolCall) return message;
1138
- 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 };
1139
1241
  });
1140
1242
 
1141
1243
  if (!updated) return;
@@ -1312,7 +1414,11 @@ export class AgUiThreadController {
1312
1414
  );
1313
1415
  if (!updated) return false;
1314
1416
  this.publishUpdate();
1315
- this.persistAssistantHistory(messageId);
1417
+ if (this.history?.update) {
1418
+ this.persistSettledAssistant(messageId);
1419
+ } else {
1420
+ this.persistAssistantHistory(messageId);
1421
+ }
1316
1422
  return true;
1317
1423
  }
1318
1424
 
@@ -1471,6 +1577,7 @@ export class AgUiThreadController {
1471
1577
  const activeRunAgent = this.activeRunAgent;
1472
1578
 
1473
1579
  this.dispatch({ type: "resetThread" });
1580
+ this.subagentParentToolCallIds.clear();
1474
1581
 
1475
1582
  if (controller) {
1476
1583
  this.abortController = null;
@@ -1589,6 +1696,8 @@ export class AgUiThreadController {
1589
1696
  let frameOwnerIds = new Set<string>();
1590
1697
  let restrictedPendingToolCallIds: ReadonlySet<string> | undefined;
1591
1698
  let settlingRun = false;
1699
+ let latestSnapshot: ThreadAssistantMessage | undefined;
1700
+ let snapshotToolCallIds = new Set<string>();
1592
1701
  const ensureAssistant = (allowRecreate = false): string => {
1593
1702
  const cached = assistantMessageId;
1594
1703
  if (cached !== undefined && this.session.tryGetMessage(cached))
@@ -1993,7 +2102,102 @@ export class AgUiThreadController {
1993
2102
  showThinking: this.showThinking,
1994
2103
  joinStrategy,
1995
2104
  logger: this.logger,
1996
- 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
+ },
1997
2201
  onServerMessageId: (serverId) => {
1998
2202
  adoptServerMessageId(serverId, false);
1999
2203
  },
@@ -2068,6 +2272,22 @@ export class AgUiThreadController {
2068
2272
  }
2069
2273
  }
2070
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
+ );
2071
2291
  if (!replayEnded && assistantMessageId === undefined) {
2072
2292
  const tail = this.session.getMessages().at(-1);
2073
2293
  snapshotAssistantId =
@@ -2129,7 +2349,6 @@ export class AgUiThreadController {
2129
2349
  );
2130
2350
  const subscriber = createAgUiSubscriber({
2131
2351
  dispatch,
2132
- runId,
2133
2352
  logger: this.logger,
2134
2353
  onRunFailed: (error) => {
2135
2354
  if (abortSignal.aborted) return;
@@ -2140,7 +2359,7 @@ export class AgUiThreadController {
2140
2359
  try {
2141
2360
  runAgentInstance.messages = input.messages;
2142
2361
  runAgentInstance.threadId = input.threadId;
2143
- runAgentInstance.state = input.state ?? null;
2362
+ runAgentInstance.state = input.state;
2144
2363
  } catch {
2145
2364
  // ignore
2146
2365
  }
@@ -2158,6 +2377,26 @@ export class AgUiThreadController {
2158
2377
  pendingError = err;
2159
2378
  }
2160
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
+ }
2161
2400
  this.finishRun(abortController);
2162
2401
  }
2163
2402
 
@@ -2355,7 +2594,8 @@ export class AgUiThreadController {
2355
2594
  const input = {
2356
2595
  threadId,
2357
2596
  runId,
2358
- state: this.state.stateSnapshot ?? null,
2597
+ state:
2598
+ this.state.stateSnapshot === undefined ? {} : this.state.stateSnapshot,
2359
2599
  messages,
2360
2600
  tools: toAgUiTools(context?.tools),
2361
2601
  context: [
@@ -2713,7 +2953,7 @@ export class AgUiThreadController {
2713
2953
  }
2714
2954
 
2715
2955
  // 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
2956
+ // reasoning, activity, or custom data of its own, so locally streamed parts would be
2717
2957
  // lost on every refresh; keep the local ones where they streamed when the
2718
2958
  // snapshot has none of that kind, matching the merge rule of @ag-ui/client.
2719
2959
  private mergeSnapshotAssistantContent(
@@ -2721,15 +2961,30 @@ export class AgUiThreadController {
2721
2961
  next: ThreadAssistantMessage["content"],
2722
2962
  snapshotHasReasoning: boolean,
2723
2963
  snapshotHasActivity: boolean,
2964
+ keepMissingToolCalls: ReadonlySet<string> = new Set(),
2965
+ keepPriorTranscripts = false,
2724
2966
  ): ThreadAssistantMessage["content"] {
2725
2967
  const shouldKeep = (part: ThreadAssistantMessage["content"][number]) =>
2726
- part.type === "reasoning"
2727
- ? this.showThinking && !snapshotHasReasoning
2728
- : 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;
2729
2983
  const kept = previous.filter(shouldKeep);
2730
2984
  const merged = this.reconcileSnapshotNestedContent(
2731
2985
  previous,
2732
2986
  this.preserveToolInteractions(previous, next),
2987
+ keepPriorTranscripts,
2733
2988
  );
2734
2989
  if (kept.length === 0) return merged;
2735
2990
 
@@ -2779,6 +3034,7 @@ export class AgUiThreadController {
2779
3034
  private reconcileSnapshotNestedContent(
2780
3035
  previous: ThreadAssistantMessage["content"],
2781
3036
  next: ThreadAssistantMessage["content"],
3037
+ keepPriorTranscripts: boolean,
2782
3038
  ): ThreadAssistantMessage["content"] {
2783
3039
  const nestedIds = new Set<string>();
2784
3040
  for (const part of previous) {
@@ -2828,10 +3084,92 @@ export class AgUiThreadController {
2828
3084
  }
2829
3085
  if (part.type !== "tool-call") return [part];
2830
3086
  const prior = previousCalls.get(part.toolCallId);
2831
- return prior?.messages === undefined
2832
- ? [part]
2833
- : [{ ...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
+ };
2834
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
+ ];
2835
3173
  }
2836
3174
 
2837
3175
  private mergeAssistantMetadata(
@@ -2872,6 +3210,15 @@ export class AgUiThreadController {
2872
3210
  hasRunUserBoundary: boolean,
2873
3211
  reconcileSnapshot?: SnapshotMessageReconciler,
2874
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
+ }
2875
3222
  switch (event.type) {
2876
3223
  case "STATE_SNAPSHOT": {
2877
3224
  this.dispatch({
@@ -2901,6 +3248,7 @@ export class AgUiThreadController {
2901
3248
  break;
2902
3249
  }
2903
3250
  case "MESSAGES_SNAPSHOT": {
3251
+ aggregator.handle(event);
2904
3252
  const previousHeadId = this.session.headId;
2905
3253
  const activeAssistant =
2906
3254
  activeAssistantId === undefined
@@ -2918,13 +3266,46 @@ export class AgUiThreadController {
2918
3266
  reconcileSnapshot,
2919
3267
  );
2920
3268
  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 =
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 =
2927
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
+ }
2928
3309
  }
2929
3310
  this.updateActiveAssistantState(activeAssistantId);
2930
3311
  break;
@@ -3027,6 +3408,14 @@ export class AgUiThreadController {
3027
3408
  ...(event.messageId
3028
3409
  ? { unstable_toolMessageId: event.messageId }
3029
3410
  : {}),
3411
+ ...(event.metadata !== undefined
3412
+ ? {
3413
+ providerMetadata: withAgUiPartMessageMetadata(
3414
+ part.providerMetadata,
3415
+ event.metadata,
3416
+ ),
3417
+ }
3418
+ : {}),
3030
3419
  };
3031
3420
  }
3032
3421
  const { modelContent: _modelContent, ...partWithoutModelContent } =
@@ -3053,6 +3442,14 @@ export class AgUiThreadController {
3053
3442
  ...(event.messageId
3054
3443
  ? { unstable_toolMessageId: event.messageId }
3055
3444
  : {}),
3445
+ ...(event.metadata !== undefined
3446
+ ? {
3447
+ providerMetadata: withAgUiPartMessageMetadata(
3448
+ part.providerMetadata,
3449
+ event.metadata,
3450
+ ),
3451
+ }
3452
+ : {}),
3056
3453
  };
3057
3454
  });
3058
3455
  if (!matchedToolCall) return message;
@@ -3063,7 +3460,8 @@ export class AgUiThreadController {
3063
3460
  this.publishUpdate();
3064
3461
  // Not maybeResumeAfterToolResults: the delivering run is already in
3065
3462
  // flight, and a resume from the owner would reset the head past it.
3066
- this.maybeCompleteAfterToolResults(messageId);
3463
+ if (this.maybeCompleteAfterToolResults(messageId)) return;
3464
+ this.persistSettledAssistant(messageId);
3067
3465
  }
3068
3466
 
3069
3467
  private applyCrossRunActivitySnapshot(
@@ -3124,7 +3522,8 @@ export class AgUiThreadController {
3124
3522
 
3125
3523
  if (!updated) return;
3126
3524
  this.publishUpdate();
3127
- this.maybeCompleteAfterToolResults(messageId);
3525
+ if (this.maybeCompleteAfterToolResults(messageId)) return;
3526
+ this.persistSettledAssistant(messageId);
3128
3527
  }
3129
3528
 
3130
3529
  private importMessagesSnapshot(
@@ -3143,14 +3542,24 @@ export class AgUiThreadController {
3143
3542
  const activeAssistantParentId = activeAssistantItem?.parentId;
3144
3543
  const activeAssistant =
3145
3544
  activeMessage?.role === "assistant" ? activeMessage : undefined;
3146
- const normalized = fromAgUiMessages(rawMessages, {
3147
- showThinking: this.showThinking,
3148
- });
3545
+ const normalized = fromAgUiSnapshotMessages(
3546
+ rawMessages,
3547
+ { showThinking: this.showThinking },
3548
+ this.subagentParentToolCallIds,
3549
+ );
3149
3550
  const converted: ThreadMessage[] = [];
3150
3551
  for (const message of normalized) {
3151
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;
3152
3561
  converted.push(
3153
- fromThreadMessageLike(message, generateId(), FALLBACK_USER_STATUS),
3562
+ fromThreadMessageLike(message, generateId(), fallbackStatus),
3154
3563
  );
3155
3564
  } catch (error) {
3156
3565
  this.logger.error?.(
@@ -3178,14 +3587,22 @@ export class AgUiThreadController {
3178
3587
  existing?.role === "assistant" ? existing : undefined;
3179
3588
  const snapshot =
3180
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";
3181
3595
  converted[index] = previous
3182
3596
  ? {
3183
3597
  ...snapshot,
3598
+ ...(preservesRunningStatus ? { status: previous.status } : {}),
3184
3599
  content: this.mergeSnapshotAssistantContent(
3185
3600
  previous.content,
3186
3601
  snapshot.content,
3187
3602
  snapshotHasReasoning,
3188
3603
  snapshotHasActivity,
3604
+ new Set(),
3605
+ true,
3189
3606
  ),
3190
3607
  }
3191
3608
  : snapshot;
@@ -3348,6 +3765,19 @@ export class AgUiThreadController {
3348
3765
  this.assistantHistoryParents.set(messageId, parentId);
3349
3766
  }
3350
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
+
3351
3781
  private persistAssistantHistory(messageId: string) {
3352
3782
  const history = this.history;
3353
3783
  if (!history) return;
@@ -3360,7 +3790,9 @@ export class AgUiThreadController {
3360
3790
  const update = history.update;
3361
3791
  const shouldUpdate =
3362
3792
  update !== undefined &&
3363
- (wasPersisted || this.snapshotHistoryIds.has(messageId));
3793
+ (wasPersisted ||
3794
+ this.snapshotHistoryIds.has(messageId) ||
3795
+ this.historyWrites.pending(messageId) !== undefined);
3364
3796
 
3365
3797
  if (shouldUpdate) {
3366
3798
  const write = this.historyWrites.chain(messageId, () =>