@assistant-ui/react-ag-ui 0.0.65 → 0.0.66

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.
@@ -38,17 +38,16 @@ import jsonpatch, { type Operation } from "fast-json-patch";
38
38
  import type { Logger } from "./logger";
39
39
  import { readMcpAppResourceUri } from "./mcp-tool-result";
40
40
  import type {
41
+ AgUiCustomMetadata,
41
42
  AgUiEvent,
42
43
  AgUiInterrupt,
43
44
  AgUiResumeEntry,
44
45
  AgUiResumeTranscript,
45
46
  } from "./types";
47
+ import { AG_UI_METADATA_NAMESPACE, MCP_APPS_ACTIVITY_TYPE } from "./types";
46
48
  import type { ReadonlyJSONValue } from "assistant-stream/utils";
47
49
  import {
48
- AG_UI_METADATA_NAMESPACE,
49
- type AgUiCustomMetadata,
50
50
  isPlainObject,
51
- MCP_APPS_ACTIVITY_TYPE,
52
51
  RunAggregator,
53
52
  tryParseJSON,
54
53
  } from "./adapter/run-aggregator";
@@ -83,6 +82,9 @@ const isResolvedToolCall = (
83
82
  ): boolean =>
84
83
  part.type === "tool-call" && "result" in part && part.result !== undefined;
85
84
 
85
+ const isActivityPart = (part: ThreadAssistantMessage["content"][number]) =>
86
+ part.type === "data" && part.name.startsWith("agui-activity/");
87
+
86
88
  type RunConfig = NonNullable<AppendMessage["runConfig"]>;
87
89
  type ResumeStream = (
88
90
  options: ChatModelRunOptions,
@@ -113,7 +115,7 @@ type CoreOptions = {
113
115
  onError?: (error: Error) => void;
114
116
  onCancel?: () => void;
115
117
  history?: ThreadHistoryAdapter;
116
- notifyUpdate: () => void;
118
+ isThreadSwitching?: () => boolean;
117
119
  };
118
120
 
119
121
  const FALLBACK_USER_STATUS = { type: "complete", reason: "unknown" } as const;
@@ -157,7 +159,16 @@ export class AgUiThreadRuntimeCore {
157
159
  private autoCancelPendingToolCalls: boolean | undefined;
158
160
  private onError: ((error: Error) => void) | undefined;
159
161
  private onCancel: (() => void) | undefined;
160
- private readonly notifyUpdate: () => void;
162
+ private readonly listeners = new Set<() => void>();
163
+ private snapshot:
164
+ | {
165
+ isLoading: boolean;
166
+ isRunning: boolean;
167
+ messageRepository: ExportedMessageRepository;
168
+ state: ReadonlyJSONValue | undefined;
169
+ }
170
+ | undefined;
171
+ private readonly isThreadSwitching: (() => boolean) | undefined;
161
172
  private readonly reportedErrors = new WeakSet<object>();
162
173
 
163
174
  private runtime: AssistantRuntime | undefined;
@@ -190,6 +201,8 @@ export class AgUiThreadRuntimeCore {
190
201
  private readonly persistedHistoryIds = new Set<string>();
191
202
  private readonly historyWrites = new Map<string, Promise<void>>();
192
203
  private _isLoading = false;
204
+ private _historyImportPromise: Promise<void> | undefined;
205
+ private historyMutationGeneration = 0;
193
206
  private _loadPromise: Promise<void> | undefined;
194
207
  private _loadRequested = false;
195
208
  private pendingResume: { owner: AbortController; messageId: string } | null =
@@ -206,10 +219,28 @@ export class AgUiThreadRuntimeCore {
206
219
  this.onError = options.onError;
207
220
  this.onCancel = options.onCancel;
208
221
  this.history = options.history;
209
- this.notifyUpdate = options.notifyUpdate;
222
+ this.isThreadSwitching = options.isThreadSwitching;
210
223
  }
211
224
 
212
- updateOptions(options: Omit<CoreOptions, "notifyUpdate">) {
225
+ subscribe = (listener: () => void): (() => void) => {
226
+ this.listeners.add(listener);
227
+ return () => this.listeners.delete(listener);
228
+ };
229
+
230
+ getSnapshot = () =>
231
+ (this.snapshot ??= {
232
+ isLoading: this._isLoading,
233
+ isRunning: this.isRunningFlag,
234
+ messageRepository: this.session.export(),
235
+ state: this.stateSnapshot,
236
+ });
237
+
238
+ private publishUpdate(): void {
239
+ this.snapshot = undefined;
240
+ for (const listener of this.listeners) listener();
241
+ }
242
+
243
+ updateOptions(options: CoreOptions) {
213
244
  this.agent = options.agent;
214
245
  this.logger = options.logger;
215
246
  this.showThinking = options.showThinking;
@@ -266,28 +297,38 @@ export class AgUiThreadRuntimeCore {
266
297
  if (this._loadPromise) return this._loadPromise;
267
298
  if (!this.history) return Promise.resolve();
268
299
 
269
- const promise = this.history.load();
300
+ const promise = this.history.load().then((repo) => {
301
+ if (!repo) return;
302
+
303
+ this.session.applyExternalMessageRepository(repo);
304
+ this.assistantHistoryParents.clear();
305
+ this.snapshotHistoryIds.clear();
306
+ this.persistedHistoryIds.clear();
307
+ for (const { message } of repo.messages) {
308
+ this.persistedHistoryIds.add(message.id);
309
+ }
310
+ this.publishUpdate();
311
+
312
+ if (repo.state !== undefined) {
313
+ this.loadExternalState(repo.state);
314
+ }
315
+
316
+ return repo;
317
+ });
270
318
 
271
319
  this._isLoading = true;
320
+ this._historyImportPromise = promise.then(
321
+ () => {
322
+ this._historyImportPromise = undefined;
323
+ },
324
+ () => {
325
+ this._historyImportPromise = undefined;
326
+ },
327
+ );
272
328
 
273
329
  this._loadPromise = promise
274
330
  .then(async (repo) => {
275
- if (!repo) return;
276
-
277
- this.session.applyExternalMessageRepository(repo);
278
- this.assistantHistoryParents.clear();
279
- this.snapshotHistoryIds.clear();
280
- this.persistedHistoryIds.clear();
281
- for (const { message } of repo.messages) {
282
- this.persistedHistoryIds.add(message.id);
283
- }
284
- this.notifyUpdate();
285
-
286
- if (repo.state !== undefined) {
287
- this.loadExternalState(repo.state);
288
- }
289
-
290
- if (repo.unstable_resume) {
331
+ if (repo?.unstable_resume) {
291
332
  const parentId = repo.headId ?? this.session.headId;
292
333
  const resumeStream = this.history?.resume?.bind(this.history);
293
334
  await this.startRun(
@@ -304,14 +345,60 @@ export class AgUiThreadRuntimeCore {
304
345
  })
305
346
  .finally(() => {
306
347
  this._isLoading = false;
307
- this.notifyUpdate();
348
+ this.publishUpdate();
308
349
  });
309
350
 
310
- this.notifyUpdate();
351
+ this.publishUpdate();
311
352
  return this._loadPromise;
312
353
  }
313
354
 
355
+ private waitForHistoryImport(): Promise<boolean> | undefined {
356
+ const historyImport = this._historyImportPromise;
357
+ if (!historyImport) return undefined;
358
+ const generation = this.historyMutationGeneration;
359
+ return historyImport.then(
360
+ () =>
361
+ generation === this.historyMutationGeneration &&
362
+ !this.isThreadSwitching?.(),
363
+ );
364
+ }
365
+
366
+ private deferUntilHistoryImported(callback: () => void): boolean {
367
+ const historyImport = this._historyImportPromise;
368
+ if (!historyImport) return false;
369
+ const generation = this.historyMutationGeneration;
370
+ void historyImport.then(() => {
371
+ if (
372
+ generation !== this.historyMutationGeneration ||
373
+ this.isThreadSwitching?.()
374
+ ) {
375
+ return;
376
+ }
377
+ try {
378
+ callback();
379
+ } catch (error) {
380
+ this.reportError(error);
381
+ }
382
+ });
383
+ return true;
384
+ }
385
+
314
386
  async append(message: AppendMessage): Promise<void> {
387
+ const historyImport = this._historyImportPromise;
388
+ if (historyImport) {
389
+ const wasAtTail = message.parentId === this.session.headId;
390
+ const generation = this.historyMutationGeneration;
391
+ await historyImport;
392
+ if (
393
+ generation !== this.historyMutationGeneration ||
394
+ this.isThreadSwitching?.()
395
+ )
396
+ return;
397
+ if (wasAtTail) {
398
+ message = { ...message, parentId: this.session.headId };
399
+ }
400
+ }
401
+
315
402
  const startRun = message.startRun ?? message.role === "user";
316
403
  let ownsThread = true;
317
404
  if (startRun) {
@@ -331,7 +418,7 @@ export class AgUiThreadRuntimeCore {
331
418
  const parentId = this.session.headId;
332
419
  this.session.addOrUpdateMessage(parentId, message);
333
420
  this.session.switchToBranch(message.id);
334
- this.notifyUpdate();
421
+ this.publishUpdate();
335
422
  this.recordHistoryEntry(parentId, message);
336
423
  }
337
424
 
@@ -353,7 +440,7 @@ export class AgUiThreadRuntimeCore {
353
440
  : this.session.headId;
354
441
  this.session.addOrUpdateMessage(parentId, threadMessage);
355
442
  this.session.switchToBranch(threadMessage.id);
356
- this.notifyUpdate();
443
+ this.publishUpdate();
357
444
  this.recordHistoryEntry(parentId, threadMessage);
358
445
  return threadMessage.id;
359
446
  }
@@ -366,12 +453,15 @@ export class AgUiThreadRuntimeCore {
366
453
  parentId: string | null,
367
454
  config: { runConfig?: RunConfig } = {},
368
455
  ): Promise<void> {
456
+ const historyImport = this.waitForHistoryImport();
457
+ if (historyImport && !(await historyImport)) return;
369
458
  this.assertNoPendingInterrupts();
370
459
  this.maybeAutoCancelPendingToolCalls();
371
460
  await this.startRun(parentId, config.runConfig);
372
461
  }
373
462
 
374
463
  async cancel(): Promise<void> {
464
+ this.historyMutationGeneration++;
375
465
  this.abortActiveRun();
376
466
  }
377
467
 
@@ -413,6 +503,8 @@ export class AgUiThreadRuntimeCore {
413
503
  }
414
504
 
415
505
  async resume(config: ResumeRunConfig): Promise<void> {
506
+ const historyImport = this.waitForHistoryImport();
507
+ if (historyImport && !(await historyImport)) return;
416
508
  this.assertNoPendingInterrupts();
417
509
  await this.startRun(
418
510
  config.parentId,
@@ -423,6 +515,8 @@ export class AgUiThreadRuntimeCore {
423
515
  }
424
516
 
425
517
  async resumeInFlightRun(messages: readonly ThreadMessage[]): Promise<void> {
518
+ const historyImport = this.waitForHistoryImport();
519
+ if (historyImport && !(await historyImport)) return;
426
520
  // Without a resume stream startRun would re-run the agent from scratch.
427
521
  const resumeStream = this.history?.resume?.bind(this.history);
428
522
  if (!resumeStream) {
@@ -502,6 +596,8 @@ export class AgUiThreadRuntimeCore {
502
596
  async submitInterruptResponses(
503
597
  responses: readonly AgUiResumeEntry[],
504
598
  ): Promise<void> {
599
+ const historyImport = this.waitForHistoryImport();
600
+ if (historyImport && !(await historyImport)) return;
505
601
  const pending = this.getPendingInterrupts();
506
602
  if (!pending) {
507
603
  throw new Error(
@@ -597,6 +693,8 @@ export class AgUiThreadRuntimeCore {
597
693
  async respondToToolApproval(
598
694
  options: RespondToToolApprovalOptions,
599
695
  ): Promise<void> {
696
+ const historyImport = this.waitForHistoryImport();
697
+ if (historyImport && !(await historyImport)) return;
600
698
  const pending = this.getPendingInterrupts();
601
699
  if (!pending) {
602
700
  throw new Error(
@@ -648,7 +746,7 @@ export class AgUiThreadRuntimeCore {
648
746
  `[agui] respondToToolApproval: approval "${options.approvalId}" is already decided`,
649
747
  );
650
748
  }
651
- this.notifyUpdate();
749
+ this.publishUpdate();
652
750
 
653
751
  const assistant = this.session.tryGetMessage(pending.messageId)?.message as
654
752
  | ThreadAssistantMessage
@@ -670,6 +768,8 @@ export class AgUiThreadRuntimeCore {
670
768
  message: CreateAppendMessage,
671
769
  responses?: readonly AgUiResumeEntry[],
672
770
  ): Promise<void> {
771
+ const historyImport = this.waitForHistoryImport();
772
+ if (historyImport && !(await historyImport)) return;
673
773
  const pending = this.getPendingInterrupts();
674
774
  if (!pending) {
675
775
  const pendingTools = this.getPendingToolCalls();
@@ -822,7 +922,7 @@ export class AgUiThreadRuntimeCore {
822
922
  };
823
923
  });
824
924
  if (touched) {
825
- this.notifyUpdate();
925
+ this.publishUpdate();
826
926
  }
827
927
  }
828
928
 
@@ -857,10 +957,12 @@ export class AgUiThreadRuntimeCore {
857
957
  });
858
958
  return { ...assistant, content };
859
959
  });
860
- if (updated) this.notifyUpdate();
960
+ if (updated) this.publishUpdate();
861
961
  }
862
962
 
863
963
  addToolResult(options: AddToolResultOptions): void {
964
+ if (this.deferUntilHistoryImported(() => this.addToolResult(options)))
965
+ return;
864
966
  // Core's ToolInvocationTracker resolves a nested call to the nested
865
967
  // subagent message's id, which is not a session message; re-anchor on the
866
968
  // top-level message that owns the tree so the update can land.
@@ -893,7 +995,7 @@ export class AgUiThreadRuntimeCore {
893
995
  });
894
996
 
895
997
  if (!updated) return;
896
- this.notifyUpdate();
998
+ this.publishUpdate();
897
999
  this.maybeResumeAfterToolResults(sessionMessageId);
898
1000
  }
899
1001
 
@@ -902,6 +1004,8 @@ export class AgUiThreadRuntimeCore {
902
1004
  toolCallId: string;
903
1005
  interaction: Unstable_ToolInteraction;
904
1006
  }): Promise<void> {
1007
+ const historyImport = this.waitForHistoryImport();
1008
+ if (historyImport && !(await historyImport)) return;
905
1009
  const sessionMessageId = this.session.tryGetMessage(options.messageId)
906
1010
  ? options.messageId
907
1011
  : this.findMessageIdForToolCall(options.toolCallId);
@@ -941,7 +1045,7 @@ export class AgUiThreadRuntimeCore {
941
1045
  );
942
1046
  }
943
1047
 
944
- this.notifyUpdate();
1048
+ this.publishUpdate();
945
1049
 
946
1050
  const message = this.session.tryGetMessage(sessionMessageId)?.message;
947
1051
  const history = this.history;
@@ -959,6 +1063,8 @@ export class AgUiThreadRuntimeCore {
959
1063
  }
960
1064
 
961
1065
  sendA2uiAction(action: Record<string, unknown>): void {
1066
+ if (this.deferUntilHistoryImported(() => this.sendA2uiAction(action)))
1067
+ return;
962
1068
  this.assertNoPendingInterrupts();
963
1069
  this.maybeAutoCancelPendingToolCalls();
964
1070
  const parentId = this.session.headId;
@@ -1028,7 +1134,7 @@ export class AgUiThreadRuntimeCore {
1028
1134
  : current,
1029
1135
  );
1030
1136
  if (!updated) return false;
1031
- this.notifyUpdate();
1137
+ this.publishUpdate();
1032
1138
  this.persistAssistantHistory(messageId);
1033
1139
  return true;
1034
1140
  }
@@ -1108,12 +1214,12 @@ export class AgUiThreadRuntimeCore {
1108
1214
  ) {
1109
1215
  this.pendingResume = null;
1110
1216
  }
1111
- this.notifyUpdate();
1217
+ this.publishUpdate();
1112
1218
  }
1113
1219
 
1114
1220
  loadExternalState(state: ReadonlyJSONValue): void {
1115
1221
  this.stateSnapshot = state;
1116
- this.notifyUpdate();
1222
+ this.publishUpdate();
1117
1223
  }
1118
1224
 
1119
1225
  setState(
@@ -1123,15 +1229,16 @@ export class AgUiThreadRuntimeCore {
1123
1229
  ): void {
1124
1230
  this.stateSnapshot =
1125
1231
  typeof next === "function" ? next(this.stateSnapshot) : next;
1126
- this.notifyUpdate();
1232
+ this.publishUpdate();
1127
1233
  }
1128
1234
 
1129
1235
  resetState(): void {
1130
1236
  this.stateSnapshot = undefined;
1131
- this.notifyUpdate();
1237
+ this.publishUpdate();
1132
1238
  }
1133
1239
 
1134
1240
  resetThreadState(): void {
1241
+ this.historyMutationGeneration++;
1135
1242
  const controller = this.abortController;
1136
1243
  const activeRunAgent = this.activeRunAgent;
1137
1244
 
@@ -1152,7 +1259,7 @@ export class AgUiThreadRuntimeCore {
1152
1259
  controller.abort();
1153
1260
  }
1154
1261
  } else {
1155
- this.notifyUpdate();
1262
+ this.publishUpdate();
1156
1263
  }
1157
1264
  }
1158
1265
 
@@ -1162,6 +1269,7 @@ export class AgUiThreadRuntimeCore {
1162
1269
  resume?: ResumeDispatch,
1163
1270
  resumeStream?: ResumeStream,
1164
1271
  ): Promise<void> {
1272
+ if (this.isThreadSwitching?.()) return;
1165
1273
  // A default AG-UI run supersedes the active run; the hook's opt-in message
1166
1274
  // queue serializes sends instead. append supersedes earlier, before it links
1167
1275
  // its message; this covers the entry points that start a run without one.
@@ -1553,7 +1661,7 @@ export class AgUiThreadRuntimeCore {
1553
1661
 
1554
1662
  private setRunning(running: boolean) {
1555
1663
  this.isRunningFlag = running;
1556
- this.notifyUpdate();
1664
+ this.publishUpdate();
1557
1665
  }
1558
1666
 
1559
1667
  private finishRun(controller: AbortController | null) {
@@ -1584,7 +1692,7 @@ export class AgUiThreadRuntimeCore {
1584
1692
  };
1585
1693
  this.session.addOrUpdateMessage(parentId ?? this.session.headId, assistant);
1586
1694
  this.session.switchToBranch(id);
1587
- this.notifyUpdate();
1695
+ this.publishUpdate();
1588
1696
  return id;
1589
1697
  }
1590
1698
 
@@ -1650,7 +1758,7 @@ export class AgUiThreadRuntimeCore {
1650
1758
  }
1651
1759
  }
1652
1760
 
1653
- this.notifyUpdate();
1761
+ this.publishUpdate();
1654
1762
  return !collidesWithExisting;
1655
1763
  }
1656
1764
 
@@ -1690,7 +1798,7 @@ export class AgUiThreadRuntimeCore {
1690
1798
  this.reassignAssistantId(messageId, stableId);
1691
1799
  resolvedMessageId = stableId;
1692
1800
  } else {
1693
- this.notifyUpdate();
1801
+ this.publishUpdate();
1694
1802
  }
1695
1803
  if (this.isPersistableStatus(latestStatus)) {
1696
1804
  this.persistAssistantHistory(resolvedMessageId);
@@ -1760,6 +1868,128 @@ export class AgUiThreadRuntimeCore {
1760
1868
  return changed ? content : next;
1761
1869
  }
1762
1870
 
1871
+ // A snapshot built from user, assistant, and tool records alone carries no
1872
+ // reasoning or activity of its own, so the parts streamed locally would be
1873
+ // lost on every refresh; keep the local ones where they streamed when the
1874
+ // snapshot has none of that kind, matching the merge rule of @ag-ui/client.
1875
+ private mergeSnapshotAssistantContent(
1876
+ previous: ThreadAssistantMessage["content"],
1877
+ next: ThreadAssistantMessage["content"],
1878
+ snapshotHasReasoning: boolean,
1879
+ snapshotHasActivity: boolean,
1880
+ ): ThreadAssistantMessage["content"] {
1881
+ const shouldKeep = (part: ThreadAssistantMessage["content"][number]) =>
1882
+ part.type === "reasoning"
1883
+ ? this.showThinking && !snapshotHasReasoning
1884
+ : isActivityPart(part) && !snapshotHasActivity;
1885
+ const kept = previous.filter(shouldKeep);
1886
+ const merged = this.reconcileSnapshotNestedContent(
1887
+ previous,
1888
+ this.preserveToolInteractions(previous, next),
1889
+ );
1890
+ if (kept.length === 0) return merged;
1891
+
1892
+ const mergedByType = new Map<string, number[]>();
1893
+ const mergedTools = new Map<string, number>();
1894
+ for (const [index, part] of merged.entries()) {
1895
+ if (part.type === "tool-call") {
1896
+ mergedTools.set(part.toolCallId, index);
1897
+ } else {
1898
+ const indexes = mergedByType.get(part.type) ?? [];
1899
+ indexes.push(index);
1900
+ mergedByType.set(part.type, indexes);
1901
+ }
1902
+ }
1903
+
1904
+ const previousOrdinals = new Map<string, number>();
1905
+ const insertions: (typeof kept)[] = Array.from(
1906
+ { length: merged.length + 1 },
1907
+ () => [],
1908
+ );
1909
+ let predecessorIndex = -1;
1910
+ for (const part of previous) {
1911
+ if (shouldKeep(part)) {
1912
+ insertions[predecessorIndex + 1]!.push(part);
1913
+ continue;
1914
+ }
1915
+
1916
+ let matchedIndex: number | undefined;
1917
+ if (part.type === "tool-call") {
1918
+ matchedIndex = mergedTools.get(part.toolCallId);
1919
+ } else {
1920
+ const ordinal = previousOrdinals.get(part.type) ?? 0;
1921
+ matchedIndex = mergedByType.get(part.type)?.[ordinal];
1922
+ previousOrdinals.set(part.type, ordinal + 1);
1923
+ }
1924
+ if (matchedIndex !== undefined) predecessorIndex = matchedIndex;
1925
+ }
1926
+
1927
+ const result: ThreadAssistantMessage["content"][number][] = [];
1928
+ for (const [index, part] of merged.entries()) {
1929
+ result.push(...insertions[index]!, part);
1930
+ }
1931
+ result.push(...insertions[merged.length]!);
1932
+ return result;
1933
+ }
1934
+
1935
+ private reconcileSnapshotNestedContent(
1936
+ previous: ThreadAssistantMessage["content"],
1937
+ next: ThreadAssistantMessage["content"],
1938
+ ): ThreadAssistantMessage["content"] {
1939
+ const nestedIds = new Set<string>();
1940
+ for (const part of previous) {
1941
+ if (part.type !== "tool-call" || !part.messages) continue;
1942
+ for (const nested of part.messages) {
1943
+ if (nested.role !== "assistant") continue;
1944
+ for (const call of iterateToolCallParts(nested.content)) {
1945
+ nestedIds.add(call.toolCallId);
1946
+ }
1947
+ }
1948
+ }
1949
+
1950
+ const flattened = new Map<string, ToolCallMessagePart>();
1951
+ for (const part of next) {
1952
+ if (part.type === "tool-call" && nestedIds.has(part.toolCallId)) {
1953
+ flattened.set(part.toolCallId, part);
1954
+ }
1955
+ }
1956
+ const carried =
1957
+ flattened.size === 0
1958
+ ? previous
1959
+ : mapToolCallPartsDeep(previous, (part) => {
1960
+ const snapshot = flattened.get(part.toolCallId);
1961
+ if (!snapshot) return part;
1962
+ return {
1963
+ ...part,
1964
+ ...(snapshot.result !== undefined
1965
+ ? { result: snapshot.result }
1966
+ : {}),
1967
+ ...(snapshot.isError !== undefined
1968
+ ? { isError: snapshot.isError }
1969
+ : {}),
1970
+ ...(snapshot.artifact !== undefined
1971
+ ? { artifact: snapshot.artifact }
1972
+ : {}),
1973
+ };
1974
+ }).content;
1975
+ const previousCalls = new Map<string, ToolCallMessagePart>();
1976
+ for (const part of carried) {
1977
+ if (part.type !== "tool-call") continue;
1978
+ previousCalls.set(part.toolCallId, part);
1979
+ }
1980
+
1981
+ return next.flatMap<ThreadAssistantMessage["content"][number]>((part) => {
1982
+ if (part.type === "tool-call" && flattened.has(part.toolCallId)) {
1983
+ return [];
1984
+ }
1985
+ if (part.type !== "tool-call") return [part];
1986
+ const prior = previousCalls.get(part.toolCallId);
1987
+ return prior?.messages === undefined
1988
+ ? [part]
1989
+ : [{ ...part, messages: prior.messages }];
1990
+ });
1991
+ }
1992
+
1763
1993
  private mergeAssistantMetadata(
1764
1994
  current: ThreadAssistantMessage["metadata"],
1765
1995
  incoming: NonNullable<ChatModelRunResult["metadata"]>,
@@ -1894,7 +2124,7 @@ export class AgUiThreadRuntimeCore {
1894
2124
  };
1895
2125
  });
1896
2126
  }
1897
- this.notifyUpdate();
2127
+ this.publishUpdate();
1898
2128
  }
1899
2129
 
1900
2130
  private applyCrossRunToolResult(
@@ -1965,7 +2195,7 @@ export class AgUiThreadRuntimeCore {
1965
2195
  });
1966
2196
 
1967
2197
  if (!updated) return;
1968
- this.notifyUpdate();
2198
+ this.publishUpdate();
1969
2199
  // Not maybeResumeAfterToolResults: the delivering run is already in
1970
2200
  // flight, and a resume from the owner would reset the head past it.
1971
2201
  this.maybeCompleteAfterToolResults(messageId);
@@ -2028,7 +2258,7 @@ export class AgUiThreadRuntimeCore {
2028
2258
  });
2029
2259
 
2030
2260
  if (!updated) return;
2031
- this.notifyUpdate();
2261
+ this.publishUpdate();
2032
2262
  this.maybeCompleteAfterToolResults(messageId);
2033
2263
  }
2034
2264
 
@@ -2037,9 +2267,11 @@ export class AgUiThreadRuntimeCore {
2037
2267
  activeAssistantId: string | undefined,
2038
2268
  ) {
2039
2269
  try {
2040
- const activeMessage = activeAssistantId
2041
- ? this.session.tryGetMessage(activeAssistantId)?.message
2270
+ const activeAssistantItem = activeAssistantId
2271
+ ? this.session.tryGetMessage(activeAssistantId)
2042
2272
  : undefined;
2273
+ const activeMessage = activeAssistantItem?.message;
2274
+ const activeAssistantParentId = activeAssistantItem?.parentId;
2043
2275
  const activeAssistant =
2044
2276
  activeMessage?.role === "assistant" ? activeMessage : undefined;
2045
2277
  const normalized = fromAgUiMessages(rawMessages, {
@@ -2048,28 +2280,9 @@ export class AgUiThreadRuntimeCore {
2048
2280
  const converted: ThreadMessage[] = [];
2049
2281
  for (const message of normalized) {
2050
2282
  try {
2051
- const convertedMessage = fromThreadMessageLike(
2052
- message,
2053
- generateId(),
2054
- FALLBACK_USER_STATUS,
2283
+ converted.push(
2284
+ fromThreadMessageLike(message, generateId(), FALLBACK_USER_STATUS),
2055
2285
  );
2056
- const existing = this.session.tryGetMessage(
2057
- convertedMessage.id,
2058
- )?.message;
2059
- if (
2060
- convertedMessage.role === "assistant" &&
2061
- existing?.role === "assistant"
2062
- ) {
2063
- converted.push({
2064
- ...convertedMessage,
2065
- content: this.preserveToolInteractions(
2066
- existing.content,
2067
- convertedMessage.content,
2068
- ),
2069
- });
2070
- } else {
2071
- converted.push(convertedMessage);
2072
- }
2073
2286
  } catch (error) {
2074
2287
  this.logger.error?.(
2075
2288
  "[agui] failed to import message from snapshot",
@@ -2077,6 +2290,35 @@ export class AgUiThreadRuntimeCore {
2077
2290
  );
2078
2291
  }
2079
2292
  }
2293
+ const snapshotHasReasoning = converted.some(
2294
+ (message) =>
2295
+ message.role === "assistant" &&
2296
+ message.content.some((part) => part.type === "reasoning"),
2297
+ );
2298
+ const snapshotHasActivity = converted.some(
2299
+ (message) =>
2300
+ message.role === "assistant" && message.content.some(isActivityPart),
2301
+ );
2302
+ for (let index = 0; index < converted.length; index++) {
2303
+ const convertedMessage = converted[index]!;
2304
+ const existing = this.session.tryGetMessage(
2305
+ convertedMessage.id,
2306
+ )?.message;
2307
+ if (
2308
+ convertedMessage.role === "assistant" &&
2309
+ existing?.role === "assistant"
2310
+ ) {
2311
+ converted[index] = {
2312
+ ...convertedMessage,
2313
+ content: this.mergeSnapshotAssistantContent(
2314
+ existing.content,
2315
+ convertedMessage.content,
2316
+ snapshotHasReasoning,
2317
+ snapshotHasActivity,
2318
+ ),
2319
+ };
2320
+ }
2321
+ }
2080
2322
  const snapshotContainsActiveAssistant = converted.some(
2081
2323
  (message) => message.id === activeAssistant?.id,
2082
2324
  );
@@ -2084,7 +2326,9 @@ export class AgUiThreadRuntimeCore {
2084
2326
  activeAssistant !== undefined &&
2085
2327
  !snapshotContainsActiveAssistant &&
2086
2328
  (activeAssistant.metadata.isOptimistic !== true ||
2087
- converted.at(-1)?.role !== "assistant");
2329
+ converted.at(-1)?.role !== "assistant" ||
2330
+ (activeAssistantParentId !== undefined &&
2331
+ converted.at(-1)?.id === activeAssistantParentId));
2088
2332
  if (preservesActiveAssistant) {
2089
2333
  converted.push(activeAssistant);
2090
2334
  }