@assistant-ui/react-ag-ui 0.0.43 → 0.0.45

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/dist/agUiExtras.d.ts +0 -1
  2. package/dist/agUiExtras.d.ts.map +1 -1
  3. package/dist/hooks.d.ts +15 -9
  4. package/dist/hooks.d.ts.map +1 -1
  5. package/dist/hooks.js +17 -8
  6. package/dist/hooks.js.map +1 -1
  7. package/dist/index.d.ts +2 -2
  8. package/dist/index.js +2 -2
  9. package/dist/runtime/AgUiThreadRuntimeCore.d.ts +26 -4
  10. package/dist/runtime/AgUiThreadRuntimeCore.d.ts.map +1 -1
  11. package/dist/runtime/AgUiThreadRuntimeCore.js +296 -89
  12. package/dist/runtime/AgUiThreadRuntimeCore.js.map +1 -1
  13. package/dist/runtime/adapter/conversions.d.ts +0 -1
  14. package/dist/runtime/adapter/conversions.d.ts.map +1 -1
  15. package/dist/runtime/adapter/conversions.js +24 -5
  16. package/dist/runtime/adapter/conversions.js.map +1 -1
  17. package/dist/runtime/adapter/run-aggregator.d.ts +0 -1
  18. package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
  19. package/dist/runtime/adapter/run-aggregator.js +36 -7
  20. package/dist/runtime/adapter/run-aggregator.js.map +1 -1
  21. package/dist/runtime/adapter/subscriber.d.ts +0 -1
  22. package/dist/runtime/adapter/subscriber.d.ts.map +1 -1
  23. package/dist/runtime/event-parser.d.ts +0 -1
  24. package/dist/runtime/event-parser.d.ts.map +1 -1
  25. package/dist/runtime/event-parser.js +18 -9
  26. package/dist/runtime/event-parser.js.map +1 -1
  27. package/dist/runtime/logger.d.ts.map +1 -1
  28. package/dist/runtime/types.d.ts +13 -1
  29. package/dist/runtime/types.d.ts.map +1 -1
  30. package/dist/useAgUiRuntime.d.ts +0 -1
  31. package/dist/useAgUiRuntime.d.ts.map +1 -1
  32. package/dist/useAgUiRuntime.js +8 -2
  33. package/dist/useAgUiRuntime.js.map +1 -1
  34. package/package.json +6 -6
  35. package/src/hooks.ts +29 -7
  36. package/src/index.ts +2 -0
  37. package/src/runtime/AgUiThreadRuntimeCore.ts +390 -108
  38. package/src/runtime/adapter/conversions.ts +71 -15
  39. package/src/runtime/adapter/run-aggregator.ts +69 -5
  40. package/src/runtime/types.ts +17 -0
  41. package/src/useAgUiRuntime.ts +8 -1
@@ -8,12 +8,14 @@ import type {
8
8
  AssistantRuntime,
9
9
  ChatModelRunOptions,
10
10
  ChatModelRunResult,
11
+ ExportedMessageRepository,
11
12
  MessageStatus,
12
13
  ThreadAssistantMessage,
13
14
  ThreadHistoryAdapter,
14
15
  ThreadMessage,
15
16
  ToolCallMessagePart,
16
17
  } from "@assistant-ui/core";
18
+ import { MessageRepository } from "@assistant-ui/core/internal";
17
19
  import type { AbstractAgent } from "@ag-ui/client";
18
20
  import jsonpatch, { type Operation } from "fast-json-patch";
19
21
  import type { Logger } from "./logger";
@@ -36,6 +38,11 @@ const optimisticPrefix = "__optimistic__";
36
38
  const generateOptimisticId = () => `${optimisticPrefix}${generateId()}`;
37
39
  const isOptimisticId = (id: string) => id.startsWith(optimisticPrefix);
38
40
 
41
+ const isResolvedToolCall = (
42
+ part: ThreadAssistantMessage["content"][number],
43
+ ): boolean =>
44
+ part.type === "tool-call" && "result" in part && part.result !== undefined;
45
+
39
46
  const symbolResumeShim = Symbol("agui-resume-shim");
40
47
 
41
48
  type RunConfig = NonNullable<AppendMessage["runConfig"]>;
@@ -53,6 +60,7 @@ type CoreOptions = {
53
60
  agent: AbstractAgent;
54
61
  logger: Logger;
55
62
  showThinking: boolean;
63
+ autoCancelPendingToolCalls?: boolean | undefined;
56
64
  onError?: (error: Error) => void;
57
65
  onCancel?: () => void;
58
66
  history?: ThreadHistoryAdapter;
@@ -65,12 +73,14 @@ export class AgUiThreadRuntimeCore {
65
73
  private agent: AbstractAgent;
66
74
  private logger: Logger;
67
75
  private showThinking: boolean;
76
+ private autoCancelPendingToolCalls: boolean | undefined;
68
77
  private onError: ((error: Error) => void) | undefined;
69
78
  private onCancel: (() => void) | undefined;
70
79
  private readonly notifyUpdate: () => void;
71
80
 
72
81
  private runtime: AssistantRuntime | undefined;
73
- private messages: ThreadMessage[] = [];
82
+ private readonly repository = new MessageRepository();
83
+ private exportedRepository: ExportedMessageRepository | undefined;
74
84
  private isRunningFlag = false;
75
85
  private abortController: AbortController | null = null;
76
86
  private stateSnapshot: ReadonlyJSONValue | undefined;
@@ -87,6 +97,7 @@ export class AgUiThreadRuntimeCore {
87
97
  this.agent = options.agent;
88
98
  this.logger = options.logger;
89
99
  this.showThinking = options.showThinking;
100
+ this.autoCancelPendingToolCalls = options.autoCancelPendingToolCalls;
90
101
  this.onError = options.onError;
91
102
  this.onCancel = options.onCancel;
92
103
  this.history = options.history;
@@ -98,6 +109,7 @@ export class AgUiThreadRuntimeCore {
98
109
  this.agent = options.agent;
99
110
  this.logger = options.logger;
100
111
  this.showThinking = options.showThinking;
112
+ this.autoCancelPendingToolCalls = options.autoCancelPendingToolCalls;
101
113
  this.onError = options.onError;
102
114
  this.onCancel = options.onCancel;
103
115
  this.history = options.history;
@@ -113,7 +125,97 @@ export class AgUiThreadRuntimeCore {
113
125
  }
114
126
 
115
127
  getMessages(): readonly ThreadMessage[] {
116
- return this.messages;
128
+ return this.repository.getMessages();
129
+ }
130
+
131
+ getMessageRepository(): ExportedMessageRepository {
132
+ if (this.exportedRepository) return this.exportedRepository;
133
+
134
+ const exportedRepository = this.repository.export();
135
+ let parentId: string | null = null;
136
+ for (const message of this.repository.getMessages()) {
137
+ if (message.metadata.isOptimistic) {
138
+ exportedRepository.messages.push({ parentId, message });
139
+ }
140
+ parentId = message.id;
141
+ }
142
+
143
+ this.exportedRepository = {
144
+ ...exportedRepository,
145
+ headId: this.repository.headId,
146
+ };
147
+ return this.exportedRepository;
148
+ }
149
+
150
+ private tryGetMessage(messageId: string) {
151
+ try {
152
+ return this.repository.getMessage(messageId);
153
+ } catch {
154
+ return undefined;
155
+ }
156
+ }
157
+
158
+ private tryGetMessages(
159
+ messageId: string,
160
+ ): readonly ThreadMessage[] | undefined {
161
+ try {
162
+ return this.repository.getMessages(messageId);
163
+ } catch {
164
+ return undefined;
165
+ }
166
+ }
167
+
168
+ private hasMessage(messageId: string): boolean {
169
+ return this.tryGetMessage(messageId) !== undefined;
170
+ }
171
+
172
+ private addOrUpdateMessage(
173
+ parentId: string | null,
174
+ message: ThreadMessage,
175
+ ): void {
176
+ this.repository.addOrUpdateMessage(parentId, message);
177
+ this.exportedRepository = undefined;
178
+ }
179
+
180
+ private deleteMessage(
181
+ messageId: string,
182
+ replacementId?: string | null,
183
+ ): void {
184
+ this.repository.deleteMessage(messageId, replacementId);
185
+ this.exportedRepository = undefined;
186
+ }
187
+
188
+ private tryDeleteMessage(messageId: string): boolean {
189
+ if (!this.hasMessage(messageId)) return false;
190
+ this.deleteMessage(messageId);
191
+ return true;
192
+ }
193
+
194
+ private switchToBranch(messageId: string): void {
195
+ this.repository.switchToBranch(messageId);
196
+ this.exportedRepository = undefined;
197
+ }
198
+
199
+ private resetRepositoryHead(messageId: string | null): void {
200
+ this.repository.resetHead(messageId);
201
+ this.exportedRepository = undefined;
202
+ }
203
+
204
+ private clearRepository(): void {
205
+ this.repository.clear();
206
+ this.exportedRepository = undefined;
207
+ }
208
+
209
+ private updateMessage(
210
+ messageId: string,
211
+ updater: (message: ThreadMessage) => ThreadMessage,
212
+ ): boolean {
213
+ const item = this.tryGetMessage(messageId);
214
+ if (!item) return false;
215
+ const message = updater(item.message);
216
+ if (message === item.message) return false;
217
+ this.addOrUpdateMessage(item.parentId, message);
218
+ return true;
117
219
  }
118
220
 
119
221
  getState(): ReadonlyJSONValue | undefined {
@@ -139,15 +241,14 @@ export class AgUiThreadRuntimeCore {
139
241
  .then(async (repo) => {
140
242
  if (!repo) return;
141
243
 
142
- const messages = repo.messages.map((item) => item.message);
143
- this.applyExternalMessages(messages);
244
+ this.applyExternalMessageRepository(repo);
144
245
 
145
246
  if (repo.state !== undefined) {
146
247
  this.loadExternalState(repo.state);
147
248
  }
148
249
 
149
250
  if (repo.unstable_resume) {
150
- const parentId = repo.headId ?? messages.at(-1)?.id ?? null;
251
+ const parentId = repo.headId ?? this.repository.headId;
151
252
  const resumeStream = this.history?.resume?.bind(this.history);
152
253
  await this.startRun(
153
254
  parentId,
@@ -174,24 +275,37 @@ export class AgUiThreadRuntimeCore {
174
275
 
175
276
  async append(message: AppendMessage): Promise<void> {
176
277
  const startRun = message.startRun ?? message.role === "user";
177
- if (startRun) this.assertNoPendingInterrupts();
278
+ if (startRun) {
279
+ this.assertNoPendingInterrupts();
280
+ this.maybeAutoCancelPendingToolCalls();
281
+ }
178
282
  const threadMessageId = this.appendEntry(message);
179
283
  if (!startRun) return;
180
284
  await this.startRun(threadMessageId, message.runConfig);
181
285
  }
182
286
 
287
+ private maybeAutoCancelPendingToolCalls(): void {
288
+ if (this.autoCancelPendingToolCalls === false) return;
289
+ const pending = this.getPendingToolCalls();
290
+ if (!pending) return;
291
+ this.cancelUnresolvedToolCalls(pending.messageId);
292
+ this.maybeCompleteAfterToolResults(pending.messageId);
293
+ }
294
+
183
295
  private appendEntry(message: AppendMessage): string {
184
- if (message.sourceId) {
185
- this.messages = this.messages.filter(
186
- (entry) => entry.id !== message.sourceId,
187
- );
188
- }
189
- this.resetHead(message.parentId);
296
+ if (message.sourceId) this.tryDeleteMessage(message.sourceId);
190
297
 
191
298
  const threadMessage = this.toThreadMessage(message);
192
- this.messages = [...this.messages, threadMessage];
299
+ const parentId =
300
+ message.parentId === null
301
+ ? null
302
+ : message.parentId && this.hasMessage(message.parentId)
303
+ ? message.parentId
304
+ : this.repository.headId;
305
+ this.addOrUpdateMessage(parentId, threadMessage);
306
+ this.switchToBranch(threadMessage.id);
193
307
  this.notifyUpdate();
194
- this.recordHistoryEntry(message.parentId ?? null, threadMessage);
308
+ this.recordHistoryEntry(parentId, threadMessage);
195
309
  return threadMessage.id;
196
310
  }
197
311
 
@@ -204,8 +318,7 @@ export class AgUiThreadRuntimeCore {
204
318
  config: { runConfig?: RunConfig } = {},
205
319
  ): Promise<void> {
206
320
  this.assertNoPendingInterrupts();
207
- this.resetHead(parentId);
208
- this.notifyUpdate();
321
+ this.maybeAutoCancelPendingToolCalls();
209
322
  await this.startRun(parentId, config.runConfig);
210
323
  }
211
324
 
@@ -255,20 +368,28 @@ export class AgUiThreadRuntimeCore {
255
368
  );
256
369
  }
257
370
 
258
- getPendingInterrupts(): {
259
- messageId: string;
260
- interrupts: readonly AgUiInterrupt[];
261
- } | null {
262
- const assistant = this.messages.findLast((m) => m.role === "assistant") as
263
- | ThreadAssistantMessage
264
- | undefined;
371
+ private findRequiresActionAssistant(
372
+ reason: "interrupt" | "tool-calls",
373
+ ): ThreadAssistantMessage | null {
374
+ const assistant = this.getMessages().findLast(
375
+ (message) => message.role === "assistant",
376
+ ) as ThreadAssistantMessage | undefined;
265
377
  if (
266
378
  !assistant ||
267
379
  assistant.status?.type !== "requires-action" ||
268
- assistant.status.reason !== "interrupt"
380
+ assistant.status.reason !== reason
269
381
  ) {
270
382
  return null;
271
383
  }
384
+ return assistant;
385
+ }
386
+
387
+ getPendingInterrupts(): {
388
+ messageId: string;
389
+ interrupts: readonly AgUiInterrupt[];
390
+ } | null {
391
+ const assistant = this.findRequiresActionAssistant("interrupt");
392
+ if (!assistant) return null;
272
393
  const stored = (
273
394
  assistant.metadata.custom[AG_UI_METADATA_NAMESPACE] as
274
395
  | AgUiCustomMetadata
@@ -278,6 +399,22 @@ export class AgUiThreadRuntimeCore {
278
399
  return { messageId: assistant.id, interrupts: stored };
279
400
  }
280
401
 
402
+ getPendingToolCalls(): {
403
+ messageId: string;
404
+ toolCallIds: string[];
405
+ } | null {
406
+ const assistant = this.findRequiresActionAssistant("tool-calls");
407
+ if (!assistant) return null;
408
+ const toolCallIds: string[] = [];
409
+ for (const part of assistant.content) {
410
+ if (part.type !== "tool-call") continue;
411
+ if (isResolvedToolCall(part)) continue;
412
+ toolCallIds.push(part.toolCallId);
413
+ }
414
+ if (toolCallIds.length === 0) return null;
415
+ return { messageId: assistant.id, toolCallIds };
416
+ }
417
+
281
418
  async submitInterruptResponses(
282
419
  responses: readonly AgUiResumeEntry[],
283
420
  ): Promise<void> {
@@ -339,8 +476,8 @@ export class AgUiThreadRuntimeCore {
339
476
  }
340
477
  }
341
478
 
342
- const resume: AgUiResumeEntry[] = openIds.map(
343
- (id) => responsesById.get(id)!,
479
+ const resume: AgUiResumeEntry[] = openIds.map((id) =>
480
+ responsesById.get(id)!,
344
481
  );
345
482
 
346
483
  if (this.isRunningFlag) {
@@ -359,6 +496,23 @@ export class AgUiThreadRuntimeCore {
359
496
  ): Promise<void> {
360
497
  const pending = this.getPendingInterrupts();
361
498
  if (!pending) {
499
+ const pendingTools = this.getPendingToolCalls();
500
+ if (pendingTools) {
501
+ if (responses?.length) {
502
+ throw new Error(
503
+ "[agui] steerAway: responses are only valid for pending interrupts",
504
+ );
505
+ }
506
+ if (this.isRunningFlag) {
507
+ throw new Error("[agui] steerAway: a run is already in progress");
508
+ }
509
+ this.cancelUnresolvedToolCalls(pendingTools.messageId);
510
+ this.maybeCompleteAfterToolResults(pendingTools.messageId);
511
+ const normalized = this.toAppendMessage(message);
512
+ const threadMessageId = this.appendEntry(normalized);
513
+ await this.startRun(threadMessageId, normalized.runConfig);
514
+ return;
515
+ }
362
516
  if (responses?.length) {
363
517
  throw new Error(
364
518
  "[agui] steerAway: no pending interrupts on this thread",
@@ -375,9 +529,6 @@ export class AgUiThreadRuntimeCore {
375
529
  }
376
530
 
377
531
  const normalized = this.toAppendMessage(message);
378
- // Clear before appendEntry so the interrupted assistant is still in
379
- // this.messages when its status is flipped; a parentId that points at an
380
- // ancestor would otherwise truncate it away before it can be cleared.
381
532
  this.clearPendingInterrupts(pending.messageId);
382
533
  const threadMessageId = this.appendEntry(normalized);
383
534
  await this.startRun(threadMessageId, normalized.runConfig, resume);
@@ -422,7 +573,7 @@ export class AgUiThreadRuntimeCore {
422
573
  if (typeof message === "string") {
423
574
  return {
424
575
  createdAt: new Date(),
425
- parentId: this.messages.at(-1)?.id ?? null,
576
+ parentId: this.repository.headId,
426
577
  sourceId: null,
427
578
  runConfig: {},
428
579
  role: "user",
@@ -433,7 +584,7 @@ export class AgUiThreadRuntimeCore {
433
584
  }
434
585
  return {
435
586
  createdAt: message.createdAt ?? new Date(),
436
- parentId: message.parentId ?? this.messages.at(-1)?.id ?? null,
587
+ parentId: message.parentId ?? this.repository.headId,
437
588
  sourceId: message.sourceId ?? null,
438
589
  role: message.role ?? "user",
439
590
  content: message.content,
@@ -445,10 +596,8 @@ export class AgUiThreadRuntimeCore {
445
596
  }
446
597
 
447
598
  private clearPendingInterrupts(messageId: string): void {
448
- let touched = false;
449
- this.messages = this.messages.map((message) => {
450
- if (message.id !== messageId || message.role !== "assistant")
451
- return message;
599
+ const touched = this.updateMessage(messageId, (message) => {
600
+ if (message.role !== "assistant") return message;
452
601
  const assistant = message as ThreadAssistantMessage;
453
602
  if (
454
603
  assistant.status?.type !== "requires-action" ||
@@ -456,7 +605,6 @@ export class AgUiThreadRuntimeCore {
456
605
  ) {
457
606
  return assistant;
458
607
  }
459
- touched = true;
460
608
  const aguiMeta = assistant.metadata.custom[AG_UI_METADATA_NAMESPACE] as
461
609
  | AgUiCustomMetadata
462
610
  | undefined;
@@ -473,18 +621,21 @@ export class AgUiThreadRuntimeCore {
473
621
  metadata: { ...assistant.metadata, custom: newCustom },
474
622
  };
475
623
  });
476
- if (touched) this.notifyUpdate();
624
+ if (touched) {
625
+ this.notifyUpdate();
626
+ }
477
627
  }
478
628
 
479
629
  findMessageIdForToolCall(toolCallId: string): string | undefined {
480
630
  let fallbackMessageId: string | undefined;
481
- for (let index = this.messages.length - 1; index >= 0; index--) {
482
- const message = this.messages[index];
631
+ const messages = this.getMessages();
632
+ for (let index = messages.length - 1; index >= 0; index--) {
633
+ const message = messages[index];
483
634
  if (!message || message.role !== "assistant") continue;
484
635
  for (const part of message.content) {
485
636
  if (part.type !== "tool-call" || part.toolCallId !== toolCallId)
486
637
  continue;
487
- if (!("result" in part) || part.result === undefined) {
638
+ if (!isResolvedToolCall(part)) {
488
639
  return message.id;
489
640
  }
490
641
  fallbackMessageId ??= message.id;
@@ -493,11 +644,26 @@ export class AgUiThreadRuntimeCore {
493
644
  return fallbackMessageId;
494
645
  }
495
646
 
647
+ private cancelUnresolvedToolCalls(messageId: string): void {
648
+ const updated = this.updateMessage(messageId, (message) => {
649
+ if (message.role !== "assistant") return message;
650
+ const assistant = message as ThreadAssistantMessage;
651
+ const content = assistant.content.map((part) => {
652
+ if (part.type !== "tool-call" || isResolvedToolCall(part)) return part;
653
+ return {
654
+ ...part,
655
+ result: { error: "Tool call cancelled by user" },
656
+ isError: true,
657
+ };
658
+ });
659
+ return { ...assistant, content };
660
+ });
661
+ if (updated) this.notifyUpdate();
662
+ }
663
+
496
664
  addToolResult(options: AddToolResultOptions): void {
497
- let updated = false;
498
- this.messages = this.messages.map((message) => {
499
- if (message.id !== options.messageId || message.role !== "assistant")
500
- return message;
665
+ const updated = this.updateMessage(options.messageId, (message) => {
666
+ if (message.role !== "assistant") return message;
501
667
  const assistant = message as ThreadAssistantMessage;
502
668
  let matchedToolCall = false;
503
669
  const content = assistant.content.map((part) => {
@@ -512,7 +678,6 @@ export class AgUiThreadRuntimeCore {
512
678
  };
513
679
  });
514
680
  if (!matchedToolCall) return message;
515
- updated = true;
516
681
  return { ...assistant, content };
517
682
  });
518
683
 
@@ -537,7 +702,7 @@ export class AgUiThreadRuntimeCore {
537
702
  }
538
703
 
539
704
  private maybeCompleteAfterToolResults(messageId: string): boolean {
540
- const message = this.messages.find((m) => m.id === messageId);
705
+ const message = this.tryGetMessage(messageId)?.message;
541
706
  if (!message || message.role !== "assistant") return false;
542
707
  const assistant = message as ThreadAssistantMessage;
543
708
  if (
@@ -547,20 +712,19 @@ export class AgUiThreadRuntimeCore {
547
712
  return false;
548
713
  }
549
714
  const allResolved = assistant.content.every(
550
- (part) =>
551
- part.type !== "tool-call" ||
552
- ("result" in part && part.result !== undefined),
715
+ (part) => part.type !== "tool-call" || isResolvedToolCall(part),
553
716
  );
554
717
  if (!allResolved) return false;
555
718
 
556
- this.messages = this.messages.map((m) =>
557
- m.id === messageId && m.role === "assistant"
719
+ const updated = this.updateMessage(messageId, (current) =>
720
+ current.role === "assistant"
558
721
  ? {
559
- ...(m as ThreadAssistantMessage),
722
+ ...(current as ThreadAssistantMessage),
560
723
  status: { type: "complete" as const, reason: "unknown" as const },
561
724
  }
562
- : m,
725
+ : current,
563
726
  );
727
+ if (!updated) return false;
564
728
  this.notifyUpdate();
565
729
  this.persistAssistantHistory(messageId);
566
730
  return true;
@@ -574,9 +738,111 @@ export class AgUiThreadRuntimeCore {
574
738
 
575
739
  applyExternalMessages(messages: readonly ThreadMessage[]): void {
576
740
  this.assistantHistoryParents.clear();
577
- this.messages = [...messages];
741
+
742
+ if (messages.length === 0) {
743
+ this.clearRepository();
744
+ } else {
745
+ let expectedParentId: string | null = null;
746
+ let lastAppliedId: string | null = null;
747
+ let hardReplace = false;
748
+ const seen = new Set<string>();
749
+
750
+ for (const message of messages) {
751
+ if (seen.has(message.id)) continue;
752
+ seen.add(message.id);
753
+ const existing = this.tryGetMessage(message.id);
754
+ if (existing && existing.parentId !== expectedParentId) {
755
+ hardReplace = true;
756
+ break;
757
+ }
758
+ this.addOrUpdateMessage(expectedParentId, message);
759
+ expectedParentId = message.id;
760
+ lastAppliedId = message.id;
761
+ }
762
+
763
+ if (hardReplace) {
764
+ this.clearRepository();
765
+ expectedParentId = null;
766
+ lastAppliedId = null;
767
+ seen.clear();
768
+ for (const message of messages) {
769
+ if (seen.has(message.id)) continue;
770
+ seen.add(message.id);
771
+ this.addOrUpdateMessage(expectedParentId, message);
772
+ expectedParentId = message.id;
773
+ lastAppliedId = message.id;
774
+ }
775
+ }
776
+
777
+ this.resetRepositoryHead(lastAppliedId);
778
+ }
779
+
780
+ this.recordedHistoryIds.clear();
781
+ for (const { message } of this.getMessageRepository().messages) {
782
+ this.recordedHistoryIds.add(message.id);
783
+ }
784
+ this.notifyUpdate();
785
+ }
786
+
787
+ private applyExternalMessageRepository(
788
+ loaded: ExportedMessageRepository,
789
+ ): void {
790
+ const headId = loaded.headId ?? loaded.messages.at(-1)?.message.id ?? null;
791
+ const ids = new Set<string>();
792
+ let degenerate = false;
793
+ for (const { message } of loaded.messages) {
794
+ if (ids.has(message.id)) {
795
+ degenerate = true;
796
+ break;
797
+ }
798
+ ids.add(message.id);
799
+ }
800
+ if (headId !== null && !ids.has(headId)) degenerate = true;
801
+
802
+ if (!degenerate) {
803
+ this.clearRepository();
804
+ let pending = [...loaded.messages];
805
+ const importedIds = new Set<string>();
806
+
807
+ while (pending.length > 0) {
808
+ const unresolved: typeof pending = [];
809
+ let progressed = false;
810
+ for (const item of pending) {
811
+ if (item.parentId !== null && !importedIds.has(item.parentId)) {
812
+ unresolved.push(item);
813
+ continue;
814
+ }
815
+ this.addOrUpdateMessage(item.parentId, item.message);
816
+ importedIds.add(item.message.id);
817
+ progressed = true;
818
+ }
819
+ if (!progressed) {
820
+ degenerate = true;
821
+ break;
822
+ }
823
+ pending = unresolved;
824
+ }
825
+ }
826
+
827
+ if (degenerate) {
828
+ this.clearRepository();
829
+ let previousId: string | null = null;
830
+ for (const { message } of loaded.messages) {
831
+ const existing = this.tryGetMessage(message.id);
832
+ this.addOrUpdateMessage(
833
+ existing ? existing.parentId : previousId,
834
+ message,
835
+ );
836
+ previousId = message.id;
837
+ }
838
+ this.resetRepositoryHead(previousId);
839
+ } else {
840
+ this.resetRepositoryHead(headId);
841
+ }
842
+
843
+ this.assistantHistoryParents.clear();
578
844
  this.recordedHistoryIds.clear();
579
- for (const message of this.messages) {
845
+ for (const { message } of loaded.messages) {
580
846
  this.recordedHistoryIds.add(message.id);
581
847
  }
582
848
  this.notifyUpdate();
@@ -587,6 +853,21 @@ export class AgUiThreadRuntimeCore {
587
853
  this.notifyUpdate();
588
854
  }
589
855
 
856
+ setState(
857
+ next:
858
+ | ReadonlyJSONValue
859
+ | ((prev: ReadonlyJSONValue | undefined) => ReadonlyJSONValue),
860
+ ): void {
861
+ this.stateSnapshot =
862
+ typeof next === "function" ? next(this.stateSnapshot) : next;
863
+ this.notifyUpdate();
864
+ }
865
+
866
+ resetState(): void {
867
+ this.stateSnapshot = undefined;
868
+ this.notifyUpdate();
869
+ }
870
+
590
871
  private async startRun(
591
872
  parentId: string | null,
592
873
  runConfig?: RunConfig,
@@ -595,20 +876,32 @@ export class AgUiThreadRuntimeCore {
595
876
  ): Promise<void> {
596
877
  const normalizedRunConfig = runConfig ?? {};
597
878
  this.lastRunConfig = normalizedRunConfig;
598
- this.resetHead(parentId);
599
- const historicalMessages = [...this.messages];
879
+ const parent = parentId === null ? undefined : this.tryGetMessage(parentId);
880
+ const shouldEagerlyInsertAssistant =
881
+ parentId !== null &&
882
+ parent !== undefined &&
883
+ parentId !== this.repository.headId;
884
+ const historicalMessages = [
885
+ ...(shouldEagerlyInsertAssistant
886
+ ? (this.tryGetMessages(parentId) ?? this.repository.getMessages())
887
+ : this.repository.getMessages()),
888
+ ];
600
889
 
601
890
  this.pendingError = null;
602
- const assistantParentId = parentId ?? this.messages.at(-1)?.id ?? null;
891
+ const assistantParentId = parent ? parentId : this.repository.headId;
603
892
  let assistantMessageId: string | undefined;
604
893
  const ensureAssistant = () => {
605
894
  if (assistantMessageId) return assistantMessageId;
606
- const created = this.insertAssistantPlaceholder();
895
+ const created = this.insertAssistantPlaceholder(
896
+ assistantParentId ?? null,
897
+ );
607
898
  assistantMessageId = created;
608
899
  this.markPendingAssistantHistory(created, assistantParentId ?? null);
609
900
  return created;
610
901
  };
611
902
 
903
+ if (shouldEagerlyInsertAssistant) ensureAssistant();
904
+
612
905
  const applyUpdate = (update: ChatModelRunResult) => {
613
906
  const resolved = this.updateAssistantMessage(ensureAssistant(), update);
614
907
  if (resolved !== assistantMessageId) {
@@ -623,8 +916,9 @@ export class AgUiThreadRuntimeCore {
623
916
  onServerMessageId: (serverId) => {
624
917
  const placeholder = ensureAssistant();
625
918
  if (placeholder === serverId) return;
626
- this.reassignAssistantId(placeholder, serverId);
627
- assistantMessageId = serverId;
919
+ if (this.reassignAssistantId(placeholder, serverId)) {
920
+ assistantMessageId = serverId;
921
+ }
628
922
  },
629
923
  });
630
924
  const dispatch = (event: AgUiEvent) => this.handleEvent(aggregator, event);
@@ -744,7 +1038,7 @@ export class AgUiThreadRuntimeCore {
744
1038
  unstable_threadId: ctx.threadId,
745
1039
  unstable_parentId: ctx.parentId,
746
1040
  unstable_getMessage: () => {
747
- const message = this.messages.find((m) => m.id === currentId());
1041
+ const message = this.tryGetMessage(currentId())?.message;
748
1042
  if (!message) {
749
1043
  throw new Error(
750
1044
  "[agui] resume stream requested the assistant message before it existed",
@@ -771,7 +1065,7 @@ export class AgUiThreadRuntimeCore {
771
1065
  }
772
1066
 
773
1067
  if (ctx.abortSignal.aborted) return;
774
- const current = this.messages.find((m) => m.id === currentId());
1068
+ const current = this.tryGetMessage(currentId())?.message;
775
1069
  if (!current || current.status?.type === "running") {
776
1070
  ctx.applyUpdate({ status: { type: "complete", reason: "unknown" } });
777
1071
  }
@@ -784,7 +1078,9 @@ export class AgUiThreadRuntimeCore {
784
1078
  resume?: AgUiResumeEntry[],
785
1079
  ) {
786
1080
  const threadId = this.agent.threadId || "main";
787
- const messages = toAgUiMessages(historyMessages ?? this.messages);
1081
+ const messages = toAgUiMessages(
1082
+ historyMessages ?? this.repository.getMessages(),
1083
+ );
788
1084
  const context = this.runtime?.thread.getModelContext();
789
1085
  return {
790
1086
  threadId,
@@ -837,7 +1133,7 @@ export class AgUiThreadRuntimeCore {
837
1133
  this.setRunning(false);
838
1134
  }
839
1135
 
840
- private insertAssistantPlaceholder(): string {
1136
+ private insertAssistantPlaceholder(parentId: string | null): string {
841
1137
  const id = generateOptimisticId();
842
1138
  const assistant: ThreadAssistantMessage = {
843
1139
  id,
@@ -854,56 +1150,62 @@ export class AgUiThreadRuntimeCore {
854
1150
  custom: {},
855
1151
  },
856
1152
  };
857
- this.messages = [...this.messages, assistant];
1153
+ this.addOrUpdateMessage(parentId ?? this.repository.headId, assistant);
1154
+ this.switchToBranch(id);
858
1155
  this.notifyUpdate();
859
1156
  return id;
860
1157
  }
861
1158
 
862
- private reassignAssistantId(oldId: string, newId: string): void {
863
- if (oldId === newId) return;
1159
+ private reassignAssistantId(oldId: string, newId: string): boolean {
1160
+ if (oldId === newId) return true;
1161
+ const oldItem = this.tryGetMessage(oldId);
1162
+ if (!oldItem) return false;
864
1163
 
865
- const collidesWithExisting = this.messages.some((m) => m.id === newId);
1164
+ const collidesWithExisting = this.hasMessage(newId);
866
1165
 
867
1166
  if (collidesWithExisting) {
868
1167
  this.logger.debug?.(
869
- "[agui] reassignAssistantId: server id already present in messages, dropping placeholder",
1168
+ "[agui] reassignAssistantId: server id already present in messages or repository, dropping placeholder",
870
1169
  { oldId, newId },
871
1170
  );
872
- this.messages = this.messages.filter((m) => m.id !== oldId);
1171
+ this.deleteMessage(oldId, oldItem.parentId ?? null);
873
1172
  } else {
874
- this.messages = this.messages.map((m) => {
875
- if (m.id !== oldId) return m;
876
- const { isOptimistic: _, ...metadata } = m.metadata;
877
- return { ...m, id: newId, metadata } as ThreadMessage;
878
- });
1173
+ const { isOptimistic: _, ...metadata } = oldItem.message.metadata;
1174
+ this.addOrUpdateMessage(oldItem.parentId, {
1175
+ ...oldItem.message,
1176
+ id: newId,
1177
+ metadata,
1178
+ } as ThreadMessage);
1179
+ this.switchToBranch(newId);
1180
+ this.tryDeleteMessage(oldId);
879
1181
  }
880
1182
 
881
1183
  const pendingParent = this.assistantHistoryParents.get(oldId);
882
1184
  if (pendingParent !== undefined) {
883
1185
  this.assistantHistoryParents.delete(oldId);
884
- if (!this.assistantHistoryParents.has(newId)) {
1186
+ if (!collidesWithExisting && !this.assistantHistoryParents.has(newId)) {
885
1187
  this.assistantHistoryParents.set(newId, pendingParent);
886
1188
  }
887
1189
  }
888
1190
 
889
1191
  if (this.recordedHistoryIds.has(oldId)) {
890
1192
  this.recordedHistoryIds.delete(oldId);
891
- this.recordedHistoryIds.add(newId);
1193
+ if (!collidesWithExisting) {
1194
+ this.recordedHistoryIds.add(newId);
1195
+ }
892
1196
  }
893
1197
 
894
1198
  this.notifyUpdate();
1199
+ return !collidesWithExisting;
895
1200
  }
896
1201
 
897
1202
  private updateAssistantMessage(
898
1203
  messageId: string,
899
1204
  update: ChatModelRunResult,
900
1205
  ): string {
901
- let touched = false;
902
1206
  let latestStatus: MessageStatus | undefined;
903
- this.messages = this.messages.map((message) => {
904
- if (message.id !== messageId || message.role !== "assistant")
905
- return message;
906
- touched = true;
1207
+ const touched = this.updateMessage(messageId, (message) => {
1208
+ if (message.role !== "assistant") return message;
907
1209
  const assistant = message as ThreadAssistantMessage;
908
1210
  const metadata = update.metadata
909
1211
  ? this.mergeAssistantMetadata(assistant.metadata, update.metadata)
@@ -952,11 +1254,7 @@ export class AgUiThreadRuntimeCore {
952
1254
  ): ThreadAssistantMessage["content"] {
953
1255
  const resolved = new Map<string, ToolCallMessagePart>();
954
1256
  for (const part of previous) {
955
- if (
956
- part.type === "tool-call" &&
957
- "result" in part &&
958
- part.result !== undefined
959
- ) {
1257
+ if (part.type === "tool-call" && isResolvedToolCall(part)) {
960
1258
  resolved.set(part.toolCallId, part);
961
1259
  }
962
1260
  }
@@ -964,8 +1262,7 @@ export class AgUiThreadRuntimeCore {
964
1262
 
965
1263
  let changed = false;
966
1264
  const merged = next.map((part) => {
967
- if (part.type !== "tool-call") return part;
968
- if ("result" in part && part.result !== undefined) return part;
1265
+ if (part.type !== "tool-call" || isResolvedToolCall(part)) return part;
969
1266
  const prior = resolved.get(part.toolCallId);
970
1267
  if (!prior) return part;
971
1268
  changed = true;
@@ -1056,10 +1353,8 @@ export class AgUiThreadRuntimeCore {
1056
1353
  messageId: string,
1057
1354
  event: Extract<AgUiEvent, { type: "TOOL_CALL_RESULT" }>,
1058
1355
  ): void {
1059
- let updated = false;
1060
- this.messages = this.messages.map((message) => {
1061
- if (message.id !== messageId || message.role !== "assistant")
1062
- return message;
1356
+ const updated = this.updateMessage(messageId, (message) => {
1357
+ if (message.role !== "assistant") return message;
1063
1358
  const assistant = message as ThreadAssistantMessage;
1064
1359
  let matchedToolCall = false;
1065
1360
  const content = assistant.content.map((part) => {
@@ -1076,7 +1371,6 @@ export class AgUiThreadRuntimeCore {
1076
1371
  };
1077
1372
  });
1078
1373
  if (!matchedToolCall) return message;
1079
- updated = true;
1080
1374
  return { ...assistant, content };
1081
1375
  });
1082
1376
 
@@ -1119,18 +1413,6 @@ export class AgUiThreadRuntimeCore {
1119
1413
  );
1120
1414
  }
1121
1415
 
1122
- private resetHead(parentId: string | null | undefined) {
1123
- if (!parentId) {
1124
- if (this.messages.length) {
1125
- this.messages = [];
1126
- }
1127
- return;
1128
- }
1129
- const idx = this.messages.findIndex((message) => message.id === parentId);
1130
- if (idx === -1) return;
1131
- this.messages = this.messages.slice(0, idx + 1);
1132
- }
1133
-
1134
1416
  private isTerminalStatus(status?: MessageStatus): boolean {
1135
1417
  return status?.type === "complete" || status?.type === "incomplete";
1136
1418
  }
@@ -1156,7 +1438,7 @@ export class AgUiThreadRuntimeCore {
1156
1438
  if (!this.history) return;
1157
1439
  const parentId = this.assistantHistoryParents.get(messageId);
1158
1440
  if (parentId === undefined) return;
1159
- const message = this.messages.find((m) => m.id === messageId);
1441
+ const message = this.tryGetMessage(messageId)?.message;
1160
1442
  if (!message || message.role !== "assistant") return;
1161
1443
  if (!this.isPersistableStatus(message.status)) return;
1162
1444
  this.assistantHistoryParents.delete(messageId);