@assistant-ui/react-ag-ui 0.0.56 → 0.0.58

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 (42) hide show
  1. package/README.md +16 -0
  2. package/dist/runtime/AgUiThreadRuntimeCore.d.ts +5 -17
  3. package/dist/runtime/AgUiThreadRuntimeCore.d.ts.map +1 -1
  4. package/dist/runtime/AgUiThreadRuntimeCore.js +133 -234
  5. package/dist/runtime/AgUiThreadRuntimeCore.js.map +1 -1
  6. package/dist/runtime/adapter/conversions.d.ts.map +1 -1
  7. package/dist/runtime/adapter/conversions.js +54 -25
  8. package/dist/runtime/adapter/conversions.js.map +1 -1
  9. package/dist/runtime/adapter/run-aggregator.d.ts +31 -4
  10. package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
  11. package/dist/runtime/adapter/run-aggregator.js +419 -123
  12. package/dist/runtime/adapter/run-aggregator.js.map +1 -1
  13. package/dist/runtime/adapter/subscriber.d.ts +9 -0
  14. package/dist/runtime/adapter/subscriber.d.ts.map +1 -1
  15. package/dist/runtime/adapter/subscriber.js +4 -1
  16. package/dist/runtime/adapter/subscriber.js.map +1 -1
  17. package/dist/runtime/adapter/tool-approval.d.ts +3 -1
  18. package/dist/runtime/adapter/tool-approval.d.ts.map +1 -1
  19. package/dist/runtime/adapter/tool-approval.js +11 -12
  20. package/dist/runtime/adapter/tool-approval.js.map +1 -1
  21. package/dist/runtime/event-parser.d.ts.map +1 -1
  22. package/dist/runtime/event-parser.js +176 -45
  23. package/dist/runtime/event-parser.js.map +1 -1
  24. package/dist/runtime/types.d.ts +42 -1
  25. package/dist/runtime/types.d.ts.map +1 -1
  26. package/dist/useAgUiRuntime.js +12 -9
  27. package/dist/useAgUiRuntime.js.map +1 -1
  28. package/package.json +10 -10
  29. package/src/runtime/AgUiThreadRuntimeCore.test.ts +105 -0
  30. package/src/runtime/AgUiThreadRuntimeCore.ts +190 -319
  31. package/src/runtime/adapter/conversions.ts +128 -36
  32. package/src/runtime/adapter/run-aggregator.approval.test.ts +128 -0
  33. package/src/runtime/adapter/run-aggregator.ts +670 -148
  34. package/src/runtime/adapter/subscriber.ts +10 -1
  35. package/src/runtime/adapter/tool-approval.test.ts +75 -0
  36. package/src/runtime/adapter/tool-approval.ts +17 -12
  37. package/src/runtime/event-parser.ts +216 -36
  38. package/src/runtime/types.ts +70 -12
  39. package/src/useAgUiRuntime.approval.test.tsx +34 -24
  40. package/src/useAgUiRuntime.history.test.tsx +76 -0
  41. package/src/useAgUiRuntime.toolDeferral.test.tsx +186 -0
  42. package/src/useAgUiRuntime.ts +12 -9
@@ -20,7 +20,12 @@ import type {
20
20
  ThreadMessage,
21
21
  ToolCallMessagePart,
22
22
  } from "@assistant-ui/core";
23
- import { MessageRepository } from "@assistant-ui/core/internal";
23
+ import {
24
+ createMessageRepositorySession,
25
+ invokeUserCallback,
26
+ iterateToolCallParts,
27
+ mapToolCallPartsDeep,
28
+ } from "@assistant-ui/core/internal";
24
29
  import type {
25
30
  AbstractAgent,
26
31
  AgentSubscriber,
@@ -71,8 +76,6 @@ const isResolvedToolCall = (
71
76
  ): boolean =>
72
77
  part.type === "tool-call" && "result" in part && part.result !== undefined;
73
78
 
74
- const symbolResumeShim = Symbol("agui-resume-shim");
75
-
76
79
  type RunConfig = NonNullable<AppendMessage["runConfig"]>;
77
80
  type ResumeStream = (
78
81
  options: ChatModelRunOptions,
@@ -99,36 +102,17 @@ const FALLBACK_USER_STATUS = { type: "complete", reason: "unknown" } as const;
99
102
 
100
103
  type AgUiRuntimeCallbackName = "onError" | "onCancel";
101
104
 
102
- const reportCallbackError = (name: AgUiRuntimeCallbackName, error: unknown) => {
103
- console.error(`[react-ag-ui] ${name} callback threw an error`, error);
104
- };
105
-
106
- const invokeRuntimeCallback = <TArgs extends unknown[]>(
105
+ const invokeRuntimeCallback = <TArgs extends readonly unknown[]>(
107
106
  name: AgUiRuntimeCallbackName,
108
- callback: ((...args: TArgs) => void) | undefined,
107
+ callback: ((...args: TArgs) => unknown) | undefined,
109
108
  ...args: TArgs
110
- ) => {
111
- if (!callback) return;
112
-
113
- try {
114
- const result = callback(...args) as unknown;
115
- if (
116
- result !== null &&
117
- (typeof result === "object" || typeof result === "function") &&
118
- "then" in result &&
119
- typeof result.then === "function"
120
- ) {
121
- void Promise.resolve(result).catch((error) => {
122
- reportCallbackError(name, error);
123
- });
124
- }
125
- } catch (error) {
126
- reportCallbackError(name, error);
127
- }
109
+ ): void => {
110
+ void invokeUserCallback("react-ag-ui", name, callback, ...args);
128
111
  };
129
112
 
130
- // The aggregator only ever sends the interrupts it owns, so a shallow spread at
131
- // the custom level would drop sibling agui state such as opaqueReasoning.
113
+ // The aggregator sends only the agui keys it owns (interrupts and
114
+ // opaqueReasoning, each as a full replacement), so a shallow spread at the
115
+ // custom level would drop any sibling agui state a snapshot import attached.
132
116
  const mergeAgUiNamespace = (
133
117
  current: Record<string, unknown> | undefined,
134
118
  incoming: Record<string, unknown>,
@@ -158,8 +142,22 @@ export class AgUiThreadRuntimeCore {
158
142
  private readonly reportedErrors = new WeakSet<object>();
159
143
 
160
144
  private runtime: AssistantRuntime | undefined;
161
- private readonly repository = new MessageRepository();
162
- private exportedRepository: ExportedMessageRepository | undefined;
145
+ private readonly session = createMessageRepositorySession({
146
+ decorateExport: (exported, repository) => {
147
+ let parentId: string | null = null;
148
+ for (const message of repository.getMessages()) {
149
+ if (message.metadata.isOptimistic) {
150
+ exported.messages.push({ parentId, message });
151
+ }
152
+ parentId = message.id;
153
+ }
154
+
155
+ return {
156
+ ...exported,
157
+ headId: repository.headId,
158
+ };
159
+ },
160
+ });
163
161
  private isRunningFlag = false;
164
162
  private abortController: AbortController | null = null;
165
163
  // The agent that started the active run. updateOptions can swap this.agent
@@ -175,6 +173,7 @@ export class AgUiThreadRuntimeCore {
175
173
  private readonly historyWrites = new Map<string, Promise<void>>();
176
174
  private _isLoading = false;
177
175
  private _loadPromise: Promise<void> | undefined;
176
+ private _loadRequested = false;
178
177
  private pendingResumeMessageId: string | null = null;
179
178
  private pendingA2uiResume = false;
180
179
  private pendingA2uiAction: Record<string, unknown> | undefined;
@@ -188,7 +187,6 @@ export class AgUiThreadRuntimeCore {
188
187
  this.onCancel = options.onCancel;
189
188
  this.history = options.history;
190
189
  this.notifyUpdate = options.notifyUpdate;
191
- this.installResumeShim();
192
190
  }
193
191
 
194
192
  updateOptions(options: Omit<CoreOptions, "notifyUpdate">) {
@@ -198,8 +196,17 @@ export class AgUiThreadRuntimeCore {
198
196
  this.autoCancelPendingToolCalls = options.autoCancelPendingToolCalls;
199
197
  this.onError = options.onError;
200
198
  this.onCancel = options.onCancel;
199
+ const previousHistory = this.history;
201
200
  this.history = options.history;
202
- this.installResumeShim();
201
+ if (
202
+ this._loadRequested &&
203
+ !this._loadPromise &&
204
+ !previousHistory &&
205
+ options.history &&
206
+ this.session.getMessages().length === 0
207
+ ) {
208
+ void this.__internal_load();
209
+ }
203
210
  }
204
211
 
205
212
  attachRuntime(runtime: AssistantRuntime) {
@@ -214,97 +221,11 @@ export class AgUiThreadRuntimeCore {
214
221
  }
215
222
 
216
223
  getMessages(): readonly ThreadMessage[] {
217
- return this.repository.getMessages();
224
+ return this.session.getMessages();
218
225
  }
219
226
 
220
227
  getMessageRepository(): ExportedMessageRepository {
221
- if (this.exportedRepository) return this.exportedRepository;
222
-
223
- const exportedRepository = this.repository.export();
224
- let parentId: string | null = null;
225
- for (const message of this.repository.getMessages()) {
226
- if (message.metadata.isOptimistic) {
227
- exportedRepository.messages.push({ parentId, message });
228
- }
229
- parentId = message.id;
230
- }
231
-
232
- this.exportedRepository = {
233
- ...exportedRepository,
234
- headId: this.repository.headId,
235
- };
236
- return this.exportedRepository;
237
- }
238
-
239
- private tryGetMessage(messageId: string) {
240
- try {
241
- return this.repository.getMessage(messageId);
242
- } catch {
243
- return undefined;
244
- }
245
- }
246
-
247
- private tryGetMessages(
248
- messageId: string,
249
- ): readonly ThreadMessage[] | undefined {
250
- try {
251
- return this.repository.getMessages(messageId);
252
- } catch {
253
- return undefined;
254
- }
255
- }
256
-
257
- private hasMessage(messageId: string): boolean {
258
- return this.tryGetMessage(messageId) !== undefined;
259
- }
260
-
261
- private addOrUpdateMessage(
262
- parentId: string | null,
263
- message: ThreadMessage,
264
- ): void {
265
- this.repository.addOrUpdateMessage(parentId, message);
266
- this.exportedRepository = undefined;
267
- }
268
-
269
- private deleteMessage(
270
- messageId: string,
271
- replacementId?: string | null,
272
- ): void {
273
- this.repository.deleteMessage(messageId, replacementId);
274
- this.exportedRepository = undefined;
275
- }
276
-
277
- private tryDeleteMessage(messageId: string): boolean {
278
- if (!this.hasMessage(messageId)) return false;
279
- this.deleteMessage(messageId);
280
- return true;
281
- }
282
-
283
- private switchToBranch(messageId: string): void {
284
- this.repository.switchToBranch(messageId);
285
- this.exportedRepository = undefined;
286
- }
287
-
288
- private resetRepositoryHead(messageId: string | null): void {
289
- this.repository.resetHead(messageId);
290
- this.exportedRepository = undefined;
291
- }
292
-
293
- private clearRepository(): void {
294
- this.repository.clear();
295
- this.exportedRepository = undefined;
296
- }
297
-
298
- private updateMessage(
299
- messageId: string,
300
- updater: (message: ThreadMessage) => ThreadMessage,
301
- ): boolean {
302
- const item = this.tryGetMessage(messageId);
303
- if (!item) return false;
304
- const message = updater(item.message);
305
- if (message === item.message) return false;
306
- this.addOrUpdateMessage(item.parentId, message);
307
- return true;
228
+ return this.session.export();
308
229
  }
309
230
 
310
231
  getState(): ReadonlyJSONValue | undefined {
@@ -320,9 +241,11 @@ export class AgUiThreadRuntimeCore {
320
241
  }
321
242
 
322
243
  __internal_load(): Promise<void> {
244
+ this._loadRequested = true;
323
245
  if (this._loadPromise) return this._loadPromise;
246
+ if (!this.history) return Promise.resolve();
324
247
 
325
- const promise = this.history?.load() ?? Promise.resolve(null);
248
+ const promise = this.history.load();
326
249
 
327
250
  this._isLoading = true;
328
251
 
@@ -330,14 +253,21 @@ export class AgUiThreadRuntimeCore {
330
253
  .then(async (repo) => {
331
254
  if (!repo) return;
332
255
 
333
- this.applyExternalMessageRepository(repo);
256
+ this.session.applyExternalMessageRepository(repo);
257
+ this.assistantHistoryParents.clear();
258
+ this.snapshotHistoryIds.clear();
259
+ this.persistedHistoryIds.clear();
260
+ for (const { message } of repo.messages) {
261
+ this.persistedHistoryIds.add(message.id);
262
+ }
263
+ this.notifyUpdate();
334
264
 
335
265
  if (repo.state !== undefined) {
336
266
  this.loadExternalState(repo.state);
337
267
  }
338
268
 
339
269
  if (repo.unstable_resume) {
340
- const parentId = repo.headId ?? this.repository.headId;
270
+ const parentId = repo.headId ?? this.session.headId;
341
271
  const resumeStream = this.history?.resume?.bind(this.history);
342
272
  await this.startRun(
343
273
  parentId,
@@ -384,17 +314,17 @@ export class AgUiThreadRuntimeCore {
384
314
  }
385
315
 
386
316
  private appendEntry(message: AppendMessage): string {
387
- if (message.sourceId) this.tryDeleteMessage(message.sourceId);
317
+ if (message.sourceId) this.session.tryDeleteMessage(message.sourceId);
388
318
 
389
319
  const threadMessage = this.toThreadMessage(message);
390
320
  const parentId =
391
321
  message.parentId === null
392
322
  ? null
393
- : message.parentId && this.hasMessage(message.parentId)
323
+ : message.parentId && this.session.hasMessage(message.parentId)
394
324
  ? message.parentId
395
- : this.repository.headId;
396
- this.addOrUpdateMessage(parentId, threadMessage);
397
- this.switchToBranch(threadMessage.id);
325
+ : this.session.headId;
326
+ this.session.addOrUpdateMessage(parentId, threadMessage);
327
+ this.session.switchToBranch(threadMessage.id);
398
328
  this.notifyUpdate();
399
329
  this.recordHistoryEntry(parentId, threadMessage);
400
330
  return threadMessage.id;
@@ -506,8 +436,7 @@ export class AgUiThreadRuntimeCore {
506
436
  const assistant = this.findRequiresActionAssistant("tool-calls");
507
437
  if (!assistant) return null;
508
438
  const toolCallIds: string[] = [];
509
- for (const part of assistant.content) {
510
- if (part.type !== "tool-call") continue;
439
+ for (const part of iterateToolCallParts(assistant.content)) {
511
440
  if (isResolvedToolCall(part)) continue;
512
441
  toolCallIds.push(part.toolCallId);
513
442
  }
@@ -565,9 +494,9 @@ export class AgUiThreadRuntimeCore {
565
494
  }
566
495
 
567
496
  /**
568
- * The core seams that reach `respondToToolApproval` discard the promise they
569
- * receive, so a rejected decision would otherwise surface only as an
570
- * unhandled rejection. A failure raised by the resumed run itself was already
497
+ * A rejected decision reaches the caller through the approval seam, and
498
+ * `onError` in addition, because a consumer watching only the runtime would
499
+ * otherwise miss it. A failure raised by the resumed run itself was already
571
500
  * reported by `startRun` before it rethrew, so reporting it here again would
572
501
  * give the consumer two notifications for one failure.
573
502
  */
@@ -619,15 +548,14 @@ export class AgUiThreadRuntimeCore {
619
548
 
620
549
  // Bound against the message the gates landed on, so this check claims a
621
550
  // batch only where the projection did.
622
- const gatedMessage = this.tryGetMessage(pending.messageId)?.message as
623
- | ThreadAssistantMessage
624
- | undefined;
551
+ const gatedMessage = this.session.tryGetMessage(pending.messageId)
552
+ ?.message as ThreadAssistantMessage | undefined;
625
553
  const gated = projectAgUiToolApprovals(
626
554
  pending.interrupts,
627
555
  new Set(
628
- (gatedMessage?.content ?? [])
629
- .filter((part) => part.type === "tool-call")
630
- .map((part) => part.toolCallId),
556
+ Array.from(iterateToolCallParts(gatedMessage?.content ?? [])).map(
557
+ (part) => part.toolCallId,
558
+ ),
631
559
  ),
632
560
  );
633
561
  const isGated = [...gated.values()].some(
@@ -647,13 +575,16 @@ export class AgUiThreadRuntimeCore {
647
575
  pending.interrupts,
648
576
  );
649
577
 
650
- const recorded = this.updateMessage(pending.messageId, (message) => {
651
- if (message.role !== "assistant") return message;
652
- const assistant = message as ThreadAssistantMessage;
653
- const content = withToolApprovalDecision(assistant.content, options);
654
- if (content === assistant.content) return assistant;
655
- return { ...assistant, content };
656
- });
578
+ const recorded = this.session.updateMessage(
579
+ pending.messageId,
580
+ (message) => {
581
+ if (message.role !== "assistant") return message;
582
+ const assistant = message as ThreadAssistantMessage;
583
+ const content = withToolApprovalDecision(assistant.content, options);
584
+ if (content === assistant.content) return assistant;
585
+ return { ...assistant, content };
586
+ },
587
+ );
657
588
  if (!recorded) {
658
589
  throw new Error(
659
590
  `[agui] respondToToolApproval: approval "${options.approvalId}" is already decided`,
@@ -661,7 +592,7 @@ export class AgUiThreadRuntimeCore {
661
592
  }
662
593
  this.notifyUpdate();
663
594
 
664
- const assistant = this.tryGetMessage(pending.messageId)?.message as
595
+ const assistant = this.session.tryGetMessage(pending.messageId)?.message as
665
596
  | ThreadAssistantMessage
666
597
  | undefined;
667
598
  if (!assistant) return;
@@ -776,7 +707,7 @@ export class AgUiThreadRuntimeCore {
776
707
  if (typeof message === "string") {
777
708
  return {
778
709
  createdAt: new Date(),
779
- parentId: this.repository.headId,
710
+ parentId: this.session.headId,
780
711
  sourceId: null,
781
712
  runConfig: {},
782
713
  role: "user",
@@ -787,7 +718,7 @@ export class AgUiThreadRuntimeCore {
787
718
  }
788
719
  return {
789
720
  createdAt: message.createdAt ?? new Date(),
790
- parentId: message.parentId ?? this.repository.headId,
721
+ parentId: message.parentId ?? this.session.headId,
791
722
  sourceId: message.sourceId ?? null,
792
723
  role: message.role ?? "user",
793
724
  content: message.content,
@@ -802,7 +733,7 @@ export class AgUiThreadRuntimeCore {
802
733
  messageId: string,
803
734
  resume: readonly AgUiResumeEntry[],
804
735
  ): void {
805
- const touched = this.updateMessage(messageId, (message) => {
736
+ const touched = this.session.updateMessage(messageId, (message) => {
806
737
  if (message.role !== "assistant") return message;
807
738
  const assistant = message as ThreadAssistantMessage;
808
739
  if (
@@ -839,9 +770,8 @@ export class AgUiThreadRuntimeCore {
839
770
  for (let index = messages.length - 1; index >= 0; index--) {
840
771
  const message = messages[index];
841
772
  if (!message || message.role !== "assistant") continue;
842
- for (const part of message.content) {
843
- if (part.type !== "tool-call" || part.toolCallId !== toolCallId)
844
- continue;
773
+ for (const part of iterateToolCallParts(message.content)) {
774
+ if (part.toolCallId !== toolCallId) continue;
845
775
  if (!isResolvedToolCall(part)) {
846
776
  return message.id;
847
777
  }
@@ -852,11 +782,11 @@ export class AgUiThreadRuntimeCore {
852
782
  }
853
783
 
854
784
  private cancelUnresolvedToolCalls(messageId: string): void {
855
- const updated = this.updateMessage(messageId, (message) => {
785
+ const updated = this.session.updateMessage(messageId, (message) => {
856
786
  if (message.role !== "assistant") return message;
857
787
  const assistant = message as ThreadAssistantMessage;
858
- const content = assistant.content.map((part) => {
859
- if (part.type !== "tool-call" || isResolvedToolCall(part)) return part;
788
+ const { content } = mapToolCallPartsDeep(assistant.content, (part) => {
789
+ if (isResolvedToolCall(part)) return part;
860
790
  return {
861
791
  ...part,
862
792
  result: { error: "Tool call cancelled by user" },
@@ -869,13 +799,19 @@ export class AgUiThreadRuntimeCore {
869
799
  }
870
800
 
871
801
  addToolResult(options: AddToolResultOptions): void {
872
- const updated = this.updateMessage(options.messageId, (message) => {
802
+ // Core's ToolInvocationTracker resolves a nested call to the nested
803
+ // subagent message's id, which is not a session message; re-anchor on the
804
+ // top-level message that owns the tree so the update can land.
805
+ const sessionMessageId = this.session.tryGetMessage(options.messageId)
806
+ ? options.messageId
807
+ : this.findMessageIdForToolCall(options.toolCallId);
808
+ if (sessionMessageId === undefined) return;
809
+ const updated = this.session.updateMessage(sessionMessageId, (message) => {
873
810
  if (message.role !== "assistant") return message;
874
811
  const assistant = message as ThreadAssistantMessage;
875
812
  let matchedToolCall = false;
876
- const content = assistant.content.map((part) => {
877
- if (part.type !== "tool-call" || part.toolCallId !== options.toolCallId)
878
- return part;
813
+ const { content } = mapToolCallPartsDeep(assistant.content, (part) => {
814
+ if (part.toolCallId !== options.toolCallId) return part;
879
815
  matchedToolCall = true;
880
816
  return {
881
817
  ...part,
@@ -890,13 +826,13 @@ export class AgUiThreadRuntimeCore {
890
826
 
891
827
  if (!updated) return;
892
828
  this.notifyUpdate();
893
- this.maybeResumeAfterToolResults(options.messageId);
829
+ this.maybeResumeAfterToolResults(sessionMessageId);
894
830
  }
895
831
 
896
832
  sendA2uiAction(action: Record<string, unknown>): void {
897
833
  this.assertNoPendingInterrupts();
898
834
  this.maybeAutoCancelPendingToolCalls();
899
- const parentId = this.repository.headId;
835
+ const parentId = this.session.headId;
900
836
  if (parentId === null) {
901
837
  this.logger.debug(
902
838
  "[agui] sendA2uiAction: no messages to resume, dropping action",
@@ -934,7 +870,7 @@ export class AgUiThreadRuntimeCore {
934
870
  }
935
871
 
936
872
  private maybeCompleteAfterToolResults(messageId: string): boolean {
937
- const message = this.tryGetMessage(messageId)?.message;
873
+ const message = this.session.tryGetMessage(messageId)?.message;
938
874
  if (!message || message.role !== "assistant") return false;
939
875
  const assistant = message as ThreadAssistantMessage;
940
876
  if (
@@ -943,12 +879,16 @@ export class AgUiThreadRuntimeCore {
943
879
  ) {
944
880
  return false;
945
881
  }
946
- const allResolved = assistant.content.every(
947
- (part) => part.type !== "tool-call" || isResolvedToolCall(part),
948
- );
882
+ let allResolved = true;
883
+ for (const part of iterateToolCallParts(assistant.content)) {
884
+ if (!isResolvedToolCall(part)) {
885
+ allResolved = false;
886
+ break;
887
+ }
888
+ }
949
889
  if (!allResolved) return false;
950
890
 
951
- const updated = this.updateMessage(messageId, (current) =>
891
+ const updated = this.session.updateMessage(messageId, (current) =>
952
892
  current.role === "assistant"
953
893
  ? {
954
894
  ...(current as ThreadAssistantMessage),
@@ -978,7 +918,7 @@ export class AgUiThreadRuntimeCore {
978
918
  this.assistantHistoryParents.clear();
979
919
 
980
920
  if (messages.length === 0) {
981
- this.clearRepository();
921
+ this.session.clear();
982
922
  } else {
983
923
  let expectedParentId: string | null = null;
984
924
  let lastAppliedId: string | null = null;
@@ -988,31 +928,31 @@ export class AgUiThreadRuntimeCore {
988
928
  for (const message of messages) {
989
929
  if (seen.has(message.id)) continue;
990
930
  seen.add(message.id);
991
- const existing = this.tryGetMessage(message.id);
931
+ const existing = this.session.tryGetMessage(message.id);
992
932
  if (existing && existing.parentId !== expectedParentId) {
993
933
  hardReplace = true;
994
934
  break;
995
935
  }
996
- this.addOrUpdateMessage(expectedParentId, message);
936
+ this.session.addOrUpdateMessage(expectedParentId, message);
997
937
  expectedParentId = message.id;
998
938
  lastAppliedId = message.id;
999
939
  }
1000
940
 
1001
941
  if (hardReplace) {
1002
- this.clearRepository();
942
+ this.session.clear();
1003
943
  expectedParentId = null;
1004
944
  lastAppliedId = null;
1005
945
  seen.clear();
1006
946
  for (const message of messages) {
1007
947
  if (seen.has(message.id)) continue;
1008
948
  seen.add(message.id);
1009
- this.addOrUpdateMessage(expectedParentId, message);
949
+ this.session.addOrUpdateMessage(expectedParentId, message);
1010
950
  expectedParentId = message.id;
1011
951
  lastAppliedId = message.id;
1012
952
  }
1013
953
  }
1014
954
 
1015
- this.resetRepositoryHead(lastAppliedId);
955
+ this.session.resetHead(lastAppliedId);
1016
956
  }
1017
957
 
1018
958
  this.snapshotHistoryIds.clear();
@@ -1022,71 +962,6 @@ export class AgUiThreadRuntimeCore {
1022
962
  this.notifyUpdate();
1023
963
  }
1024
964
 
1025
- private applyExternalMessageRepository(
1026
- loaded: ExportedMessageRepository,
1027
- ): void {
1028
- const headId = loaded.headId ?? loaded.messages.at(-1)?.message.id ?? null;
1029
- const ids = new Set<string>();
1030
- let degenerate = false;
1031
- for (const { message } of loaded.messages) {
1032
- if (ids.has(message.id)) {
1033
- degenerate = true;
1034
- break;
1035
- }
1036
- ids.add(message.id);
1037
- }
1038
- if (headId !== null && !ids.has(headId)) degenerate = true;
1039
-
1040
- if (!degenerate) {
1041
- this.clearRepository();
1042
- let pending = [...loaded.messages];
1043
- const importedIds = new Set<string>();
1044
-
1045
- while (pending.length > 0) {
1046
- const unresolved: typeof pending = [];
1047
- let progressed = false;
1048
- for (const item of pending) {
1049
- if (item.parentId !== null && !importedIds.has(item.parentId)) {
1050
- unresolved.push(item);
1051
- continue;
1052
- }
1053
- this.addOrUpdateMessage(item.parentId, item.message);
1054
- importedIds.add(item.message.id);
1055
- progressed = true;
1056
- }
1057
- if (!progressed) {
1058
- degenerate = true;
1059
- break;
1060
- }
1061
- pending = unresolved;
1062
- }
1063
- }
1064
-
1065
- if (degenerate) {
1066
- this.clearRepository();
1067
- let previousId: string | null = null;
1068
- for (const { message } of loaded.messages) {
1069
- const existing = this.tryGetMessage(message.id);
1070
- this.addOrUpdateMessage(
1071
- existing ? existing.parentId : previousId,
1072
- message,
1073
- );
1074
- previousId = message.id;
1075
- }
1076
- this.resetRepositoryHead(previousId);
1077
- } else {
1078
- this.resetRepositoryHead(headId);
1079
- }
1080
-
1081
- this.assistantHistoryParents.clear();
1082
- this.snapshotHistoryIds.clear();
1083
- this.persistedHistoryIds.clear();
1084
- for (const { message } of loaded.messages) {
1085
- this.persistedHistoryIds.add(message.id);
1086
- }
1087
- this.notifyUpdate();
1088
- }
1089
-
1090
965
  loadExternalState(state: ReadonlyJSONValue): void {
1091
966
  this.stateSnapshot = state;
1092
967
  this.notifyUpdate();
@@ -1115,22 +990,23 @@ export class AgUiThreadRuntimeCore {
1115
990
  ): Promise<void> {
1116
991
  const normalizedRunConfig = runConfig ?? {};
1117
992
  this.lastRunConfig = normalizedRunConfig;
1118
- const parent = parentId === null ? undefined : this.tryGetMessage(parentId);
993
+ const parent =
994
+ parentId === null ? undefined : this.session.tryGetMessage(parentId);
1119
995
  const shouldEagerlyInsertAssistant =
1120
996
  parentId !== null &&
1121
997
  parent !== undefined &&
1122
- parentId !== this.repository.headId;
998
+ parentId !== this.session.headId;
1123
999
  const historicalMessages = [
1124
1000
  ...(shouldEagerlyInsertAssistant
1125
- ? (this.tryGetMessages(parentId) ?? this.repository.getMessages())
1126
- : this.repository.getMessages()),
1001
+ ? (this.session.tryGetMessages(parentId) ?? this.session.getMessages())
1002
+ : this.session.getMessages()),
1127
1003
  ];
1128
1004
  const runStartMessageIds = new Set(
1129
1005
  this.getMessageRepository().messages.map(({ message }) => message.id),
1130
1006
  );
1131
1007
 
1132
1008
  this.pendingError = null;
1133
- const assistantParentId = parent ? parentId : this.repository.headId;
1009
+ const assistantParentId = parent ? parentId : this.session.headId;
1134
1010
  let assistantMessageId: string | undefined;
1135
1011
  // A snapshot the preserve gate declines still evicts the in-flight
1136
1012
  // assistant; recreating under the cached id on the next content-bearing
@@ -1142,11 +1018,12 @@ export class AgUiThreadRuntimeCore {
1142
1018
  let assistantCollided = false;
1143
1019
  const ensureAssistant = (allowRecreate = false): string => {
1144
1020
  const cached = assistantMessageId;
1145
- if (cached !== undefined && this.tryGetMessage(cached)) return cached;
1021
+ if (cached !== undefined && this.session.tryGetMessage(cached))
1022
+ return cached;
1146
1023
  if (cached !== undefined && (assistantCollided || !allowRecreate)) {
1147
1024
  return cached;
1148
1025
  }
1149
- const repositoryHeadId = this.repository.headId;
1026
+ const repositoryHeadId = this.session.headId;
1150
1027
  const shouldUseSelectedParent =
1151
1028
  cached === undefined ||
1152
1029
  (shouldEagerlyInsertAssistant &&
@@ -1157,7 +1034,7 @@ export class AgUiThreadRuntimeCore {
1157
1034
  const parentId =
1158
1035
  shouldUseSelectedParent &&
1159
1036
  assistantParentId &&
1160
- this.hasMessage(assistantParentId)
1037
+ this.session.hasMessage(assistantParentId)
1161
1038
  ? assistantParentId
1162
1039
  : repositoryHeadId;
1163
1040
  const created = this.insertAssistantPlaceholder(parentId, cached);
@@ -1181,32 +1058,51 @@ export class AgUiThreadRuntimeCore {
1181
1058
  }
1182
1059
  };
1183
1060
 
1061
+ const adoptServerMessageId = (
1062
+ serverId: string,
1063
+ startNewMessage: boolean,
1064
+ ) => {
1065
+ if (startNewMessage && assistantMessageId !== undefined) {
1066
+ applyUpdate({ status: { type: "complete", reason: "unknown" } });
1067
+ const previousId = assistantMessageId;
1068
+ assistantMessageId = undefined;
1069
+ if (this.session.tryGetMessage(previousId)) {
1070
+ const created = this.insertAssistantPlaceholder(previousId);
1071
+ assistantMessageId = created;
1072
+ this.markPendingAssistantHistory(created, previousId);
1073
+ }
1074
+ }
1075
+ const placeholder = ensureAssistant(true);
1076
+ if (placeholder === serverId) return;
1077
+ const reassigned = this.reassignAssistantId(placeholder, serverId);
1078
+ // A collision drops the placeholder before revealing the existing
1079
+ // server message as the current head. Only messages introduced during
1080
+ // this run can replace the placeholder; regeneration must not rewrite
1081
+ // a previous branch when the server incorrectly reuses its id.
1082
+ const adoptsVisibleCollision =
1083
+ !reassigned &&
1084
+ !runStartMessageIds.has(serverId) &&
1085
+ this.session.headId === serverId;
1086
+ if (reassigned || adoptsVisibleCollision) {
1087
+ assistantMessageId = serverId;
1088
+ if (adoptsVisibleCollision) {
1089
+ const parentId =
1090
+ this.session.tryGetMessage(serverId)?.parentId ?? null;
1091
+ this.markPendingAssistantHistory(serverId, parentId);
1092
+ }
1093
+ } else {
1094
+ assistantCollided = true;
1095
+ }
1096
+ };
1097
+
1184
1098
  const aggregator = new RunAggregator({
1185
1099
  showThinking: this.showThinking,
1186
1100
  logger: this.logger,
1187
1101
  emit: applyUpdate,
1188
1102
  onServerMessageId: (serverId) => {
1189
- const placeholder = ensureAssistant(true);
1190
- if (placeholder === serverId) return;
1191
- const reassigned = this.reassignAssistantId(placeholder, serverId);
1192
- // A collision drops the placeholder before revealing the existing
1193
- // server message as the current head. Only messages introduced during
1194
- // this run can replace the placeholder; regeneration must not rewrite
1195
- // a previous branch when the server incorrectly reuses its id.
1196
- const adoptsVisibleCollision =
1197
- !reassigned &&
1198
- !runStartMessageIds.has(serverId) &&
1199
- this.repository.headId === serverId;
1200
- if (reassigned || adoptsVisibleCollision) {
1201
- assistantMessageId = serverId;
1202
- if (adoptsVisibleCollision) {
1203
- const parentId = this.tryGetMessage(serverId)?.parentId ?? null;
1204
- this.markPendingAssistantHistory(serverId, parentId);
1205
- }
1206
- } else {
1207
- assistantCollided = true;
1208
- }
1103
+ adoptServerMessageId(serverId, false);
1209
1104
  },
1105
+ onTextMessageStart: (serverId) => adoptServerMessageId(serverId, true),
1210
1106
  });
1211
1107
  const dispatch = (event: AgUiEvent) =>
1212
1108
  this.handleEvent(aggregator, event, assistantMessageId);
@@ -1319,7 +1215,7 @@ export class AgUiThreadRuntimeCore {
1319
1215
  return;
1320
1216
  }
1321
1217
  this.maybeAutoCancelPendingToolCalls();
1322
- const parentId = this.repository.headId;
1218
+ const parentId = this.session.headId;
1323
1219
  if (parentId !== null) {
1324
1220
  this.startResumeRun(parentId);
1325
1221
  } else {
@@ -1361,7 +1257,7 @@ export class AgUiThreadRuntimeCore {
1361
1257
  unstable_threadId: ctx.threadId,
1362
1258
  unstable_parentId: ctx.parentId,
1363
1259
  unstable_getMessage: () => {
1364
- const message = this.tryGetMessage(currentId())?.message;
1260
+ const message = this.session.tryGetMessage(currentId())?.message;
1365
1261
  if (!message) {
1366
1262
  throw new Error(
1367
1263
  "[agui] resume stream requested the assistant message before it existed",
@@ -1388,7 +1284,7 @@ export class AgUiThreadRuntimeCore {
1388
1284
  }
1389
1285
 
1390
1286
  if (ctx.abortSignal.aborted) return;
1391
- const current = this.tryGetMessage(currentId())?.message;
1287
+ const current = this.session.tryGetMessage(currentId())?.message;
1392
1288
  if (!current || current.status?.type === "running") {
1393
1289
  ctx.applyUpdate({ status: { type: "complete", reason: "unknown" } });
1394
1290
  }
@@ -1402,7 +1298,7 @@ export class AgUiThreadRuntimeCore {
1402
1298
  ) {
1403
1299
  const threadId = this.agent.threadId || "main";
1404
1300
  const messages = toAgUiMessages(
1405
- historyMessages ?? this.repository.getMessages(),
1301
+ historyMessages ?? this.session.getMessages(),
1406
1302
  );
1407
1303
  const context = this.runtime?.thread.getModelContext();
1408
1304
  const input = {
@@ -1428,27 +1324,6 @@ export class AgUiThreadRuntimeCore {
1428
1324
  return input;
1429
1325
  }
1430
1326
 
1431
- private installResumeShim(): void {
1432
- const agent = this.agent as any;
1433
- if (agent[symbolResumeShim]) return;
1434
- agent[symbolResumeShim] = true;
1435
- const onInstance = Object.hasOwn(agent, "prepareRunAgentInput");
1436
- const original = onInstance
1437
- ? agent.prepareRunAgentInput
1438
- : Object.getPrototypeOf(agent)?.prepareRunAgentInput;
1439
- if (typeof original !== "function") return;
1440
- agent.prepareRunAgentInput = function (
1441
- this: unknown,
1442
- params: { resume?: unknown } | undefined,
1443
- ) {
1444
- const input = original.call(this, params);
1445
- if (params?.resume !== undefined && input && typeof input === "object") {
1446
- return { ...(input as object), resume: params.resume };
1447
- }
1448
- return input;
1449
- };
1450
- }
1451
-
1452
1327
  private setRunning(running: boolean) {
1453
1328
  this.isRunningFlag = running;
1454
1329
  this.notifyUpdate();
@@ -1481,34 +1356,34 @@ export class AgUiThreadRuntimeCore {
1481
1356
  custom: {},
1482
1357
  },
1483
1358
  };
1484
- this.addOrUpdateMessage(parentId ?? this.repository.headId, assistant);
1485
- this.switchToBranch(id);
1359
+ this.session.addOrUpdateMessage(parentId ?? this.session.headId, assistant);
1360
+ this.session.switchToBranch(id);
1486
1361
  this.notifyUpdate();
1487
1362
  return id;
1488
1363
  }
1489
1364
 
1490
1365
  private reassignAssistantId(oldId: string, newId: string): boolean {
1491
1366
  if (oldId === newId) return true;
1492
- const oldItem = this.tryGetMessage(oldId);
1367
+ const oldItem = this.session.tryGetMessage(oldId);
1493
1368
  if (!oldItem) return false;
1494
1369
 
1495
- const collidesWithExisting = this.hasMessage(newId);
1370
+ const collidesWithExisting = this.session.hasMessage(newId);
1496
1371
 
1497
1372
  if (collidesWithExisting) {
1498
1373
  this.logger.debug?.(
1499
1374
  "[agui] reassignAssistantId: server id already present in messages or repository, dropping placeholder",
1500
1375
  { oldId, newId },
1501
1376
  );
1502
- this.deleteMessage(oldId, oldItem.parentId ?? null);
1377
+ this.session.deleteMessage(oldId, oldItem.parentId ?? null);
1503
1378
  } else {
1504
1379
  const { isOptimistic: _, ...metadata } = oldItem.message.metadata;
1505
- this.addOrUpdateMessage(oldItem.parentId, {
1380
+ this.session.addOrUpdateMessage(oldItem.parentId, {
1506
1381
  ...oldItem.message,
1507
1382
  id: newId,
1508
1383
  metadata,
1509
1384
  } as ThreadMessage);
1510
- this.switchToBranch(newId);
1511
- this.tryDeleteMessage(oldId);
1385
+ this.session.switchToBranch(newId);
1386
+ this.session.tryDeleteMessage(oldId);
1512
1387
  }
1513
1388
 
1514
1389
  const pendingParent = this.assistantHistoryParents.get(oldId);
@@ -1558,7 +1433,7 @@ export class AgUiThreadRuntimeCore {
1558
1433
  update: ChatModelRunResult,
1559
1434
  ): string {
1560
1435
  let latestStatus: MessageStatus | undefined;
1561
- const touched = this.updateMessage(messageId, (message) => {
1436
+ const touched = this.session.updateMessage(messageId, (message) => {
1562
1437
  if (message.role !== "assistant") return message;
1563
1438
  const assistant = message as ThreadAssistantMessage;
1564
1439
  const metadata = update.metadata
@@ -1607,19 +1482,17 @@ export class AgUiThreadRuntimeCore {
1607
1482
  next: ThreadAssistantMessage["content"],
1608
1483
  ): ThreadAssistantMessage["content"] {
1609
1484
  const resolved = new Map<string, ToolCallMessagePart>();
1610
- for (const part of previous) {
1611
- if (part.type === "tool-call" && isResolvedToolCall(part)) {
1485
+ for (const part of iterateToolCallParts(previous)) {
1486
+ if (isResolvedToolCall(part)) {
1612
1487
  resolved.set(part.toolCallId, part);
1613
1488
  }
1614
1489
  }
1615
1490
  if (resolved.size === 0) return next;
1616
1491
 
1617
- let changed = false;
1618
- const merged = next.map((part) => {
1619
- if (part.type !== "tool-call" || isResolvedToolCall(part)) return part;
1492
+ const { content: merged, changed } = mapToolCallPartsDeep(next, (part) => {
1493
+ if (isResolvedToolCall(part)) return part;
1620
1494
  const prior = resolved.get(part.toolCallId);
1621
1495
  if (!prior) return part;
1622
- changed = true;
1623
1496
  return {
1624
1497
  ...part,
1625
1498
  result: prior.result,
@@ -1627,7 +1500,7 @@ export class AgUiThreadRuntimeCore {
1627
1500
  ...(prior.isError !== undefined ? { isError: prior.isError } : {}),
1628
1501
  };
1629
1502
  });
1630
- return changed ? (merged as ThreadAssistantMessage["content"]) : next;
1503
+ return changed ? merged : next;
1631
1504
  }
1632
1505
 
1633
1506
  private mergeAssistantMetadata(
@@ -1727,14 +1600,13 @@ export class AgUiThreadRuntimeCore {
1727
1600
  messageId: string,
1728
1601
  event: Extract<AgUiEvent, { type: "TOOL_CALL_RESULT" }>,
1729
1602
  ): void {
1730
- const updated = this.updateMessage(messageId, (message) => {
1603
+ const updated = this.session.updateMessage(messageId, (message) => {
1731
1604
  if (message.role !== "assistant") return message;
1732
1605
  const assistant = message as ThreadAssistantMessage;
1733
1606
  const mcpAppUri = readMcpAppResourceUri(event.mcpResult?._meta);
1734
1607
  let matchedToolCall = false;
1735
- const content = assistant.content.map((part) => {
1736
- if (part.type !== "tool-call" || part.toolCallId !== event.toolCallId)
1737
- return part;
1608
+ const { content } = mapToolCallPartsDeep(assistant.content, (part) => {
1609
+ if (part.toolCallId !== event.toolCallId) return part;
1738
1610
  matchedToolCall = true;
1739
1611
  // An applied activity snapshot owns part.result; a later result only
1740
1612
  // fills what is missing, mirroring the aggregator's finishToolCall.
@@ -1814,13 +1686,12 @@ export class AgUiThreadRuntimeCore {
1814
1686
  ? serverHash
1815
1687
  : undefined;
1816
1688
  const result = event.content["result"];
1817
- const updated = this.updateMessage(messageId, (message) => {
1689
+ const updated = this.session.updateMessage(messageId, (message) => {
1818
1690
  if (message.role !== "assistant") return message;
1819
1691
  const assistant = message as ThreadAssistantMessage;
1820
1692
  let matchedToolCall = false;
1821
- const content = assistant.content.map((part) => {
1822
- if (part.type !== "tool-call" || part.toolCallId !== toolCallId)
1823
- return part;
1693
+ const { content } = mapToolCallPartsDeep(assistant.content, (part) => {
1694
+ if (part.toolCallId !== toolCallId) return part;
1824
1695
  matchedToolCall = true;
1825
1696
  return {
1826
1697
  ...part,
@@ -1866,7 +1737,7 @@ export class AgUiThreadRuntimeCore {
1866
1737
  ) {
1867
1738
  try {
1868
1739
  const activeMessage = activeAssistantId
1869
- ? this.tryGetMessage(activeAssistantId)?.message
1740
+ ? this.session.tryGetMessage(activeAssistantId)?.message
1870
1741
  : undefined;
1871
1742
  const activeAssistant =
1872
1743
  activeMessage?.role === "assistant" ? activeMessage : undefined;
@@ -1899,7 +1770,7 @@ export class AgUiThreadRuntimeCore {
1899
1770
  }
1900
1771
  this.applyExternalMessages(converted);
1901
1772
  if (activeAssistant !== undefined) {
1902
- const activeItem = this.tryGetMessage(activeAssistant.id);
1773
+ const activeItem = this.session.tryGetMessage(activeAssistant.id);
1903
1774
  if (activeItem) {
1904
1775
  if (preservesActiveAssistant) {
1905
1776
  this.snapshotHistoryIds.delete(activeAssistant.id);
@@ -1951,7 +1822,7 @@ export class AgUiThreadRuntimeCore {
1951
1822
  if (!history) return;
1952
1823
  const parentId = this.assistantHistoryParents.get(messageId);
1953
1824
  if (parentId === undefined) return;
1954
- const message = this.tryGetMessage(messageId)?.message;
1825
+ const message = this.session.tryGetMessage(messageId)?.message;
1955
1826
  if (!message || message.role !== "assistant") return;
1956
1827
  if (!this.isPersistableStatus(message.status)) return;
1957
1828
  const wasPersisted = this.persistedHistoryIds.has(messageId);