@assistant-ui/react-ag-ui 0.0.54 → 0.0.57

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 (47) hide show
  1. package/dist/index.d.ts +2 -2
  2. package/dist/index.js +2 -2
  3. package/dist/runtime/AgUiThreadRuntimeCore.d.ts +25 -15
  4. package/dist/runtime/AgUiThreadRuntimeCore.d.ts.map +1 -1
  5. package/dist/runtime/AgUiThreadRuntimeCore.js +257 -210
  6. package/dist/runtime/AgUiThreadRuntimeCore.js.map +1 -1
  7. package/dist/runtime/adapter/conversions.d.ts +13 -3
  8. package/dist/runtime/adapter/conversions.d.ts.map +1 -1
  9. package/dist/runtime/adapter/conversions.js +77 -39
  10. package/dist/runtime/adapter/conversions.js.map +1 -1
  11. package/dist/runtime/adapter/run-aggregator.d.ts +23 -2
  12. package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
  13. package/dist/runtime/adapter/run-aggregator.js +155 -41
  14. package/dist/runtime/adapter/run-aggregator.js.map +1 -1
  15. package/dist/runtime/adapter/subscriber.d.ts +3 -0
  16. package/dist/runtime/adapter/subscriber.d.ts.map +1 -1
  17. package/dist/runtime/adapter/subscriber.js +9 -0
  18. package/dist/runtime/adapter/subscriber.js.map +1 -1
  19. package/dist/runtime/adapter/tool-approval.d.ts +56 -0
  20. package/dist/runtime/adapter/tool-approval.d.ts.map +1 -0
  21. package/dist/runtime/adapter/tool-approval.js +244 -0
  22. package/dist/runtime/adapter/tool-approval.js.map +1 -0
  23. package/dist/runtime/event-parser.d.ts.map +1 -1
  24. package/dist/runtime/event-parser.js +2 -1
  25. package/dist/runtime/event-parser.js.map +1 -1
  26. package/dist/runtime/interrupt-internals.d.ts +8 -0
  27. package/dist/runtime/interrupt-internals.d.ts.map +1 -0
  28. package/dist/runtime/interrupt-internals.js +25 -0
  29. package/dist/runtime/interrupt-internals.js.map +1 -0
  30. package/dist/useAgUiRuntime.js +14 -9
  31. package/dist/useAgUiRuntime.js.map +1 -1
  32. package/package.json +9 -9
  33. package/src/index.ts +8 -2
  34. package/src/runtime/AgUiThreadRuntimeCore.test.ts +105 -0
  35. package/src/runtime/AgUiThreadRuntimeCore.ts +436 -272
  36. package/src/runtime/adapter/conversions.content.test.ts +61 -3
  37. package/src/runtime/adapter/conversions.ts +123 -57
  38. package/src/runtime/adapter/run-aggregator.approval.test.ts +103 -0
  39. package/src/runtime/adapter/run-aggregator.ts +234 -47
  40. package/src/runtime/adapter/subscriber.ts +14 -0
  41. package/src/runtime/adapter/tool-approval.test.ts +681 -0
  42. package/src/runtime/adapter/tool-approval.ts +366 -0
  43. package/src/runtime/event-parser.ts +10 -1
  44. package/src/runtime/interrupt-internals.ts +35 -0
  45. package/src/useAgUiRuntime.approval.test.tsx +885 -0
  46. package/src/useAgUiRuntime.history.test.tsx +76 -0
  47. package/src/useAgUiRuntime.ts +15 -9
@@ -14,12 +14,16 @@ import type {
14
14
  ChatModelRunResult,
15
15
  ExportedMessageRepository,
16
16
  MessageStatus,
17
+ RespondToToolApprovalOptions,
17
18
  ThreadAssistantMessage,
18
19
  ThreadHistoryAdapter,
19
20
  ThreadMessage,
20
21
  ToolCallMessagePart,
21
22
  } from "@assistant-ui/core";
22
- import { MessageRepository } from "@assistant-ui/core/internal";
23
+ import {
24
+ createMessageRepositorySession,
25
+ invokeUserCallback,
26
+ } from "@assistant-ui/core/internal";
23
27
  import type {
24
28
  AbstractAgent,
25
29
  AgentSubscriber,
@@ -45,6 +49,12 @@ import {
45
49
  toAgUiTools,
46
50
  } from "./adapter/conversions";
47
51
  import { createAgUiSubscriber } from "./adapter/subscriber";
52
+ import {
53
+ buildToolApprovalResume,
54
+ projectAgUiToolApprovals,
55
+ withSettledToolApprovals,
56
+ withToolApprovalDecision,
57
+ } from "./adapter/tool-approval";
48
58
 
49
59
  // AbstractAgent.runAgent declares two parameters. HttpAgent ignores a third and
50
60
  // is cancelled through agent.abortRun(); the run options stay for subclasses
@@ -92,36 +102,17 @@ const FALLBACK_USER_STATUS = { type: "complete", reason: "unknown" } as const;
92
102
 
93
103
  type AgUiRuntimeCallbackName = "onError" | "onCancel";
94
104
 
95
- const reportCallbackError = (name: AgUiRuntimeCallbackName, error: unknown) => {
96
- console.error(`[react-ag-ui] ${name} callback threw an error`, error);
97
- };
98
-
99
- const invokeRuntimeCallback = <TArgs extends unknown[]>(
105
+ const invokeRuntimeCallback = <TArgs extends readonly unknown[]>(
100
106
  name: AgUiRuntimeCallbackName,
101
- callback: ((...args: TArgs) => void) | undefined,
107
+ callback: ((...args: TArgs) => unknown) | undefined,
102
108
  ...args: TArgs
103
- ) => {
104
- if (!callback) return;
105
-
106
- try {
107
- const result = callback(...args) as unknown;
108
- if (
109
- result !== null &&
110
- (typeof result === "object" || typeof result === "function") &&
111
- "then" in result &&
112
- typeof result.then === "function"
113
- ) {
114
- void Promise.resolve(result).catch((error) => {
115
- reportCallbackError(name, error);
116
- });
117
- }
118
- } catch (error) {
119
- reportCallbackError(name, error);
120
- }
109
+ ): void => {
110
+ void invokeUserCallback("react-ag-ui", name, callback, ...args);
121
111
  };
122
112
 
123
- // The aggregator only ever sends the interrupts it owns, so a shallow spread at
124
- // 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.
125
116
  const mergeAgUiNamespace = (
126
117
  current: Record<string, unknown> | undefined,
127
118
  incoming: Record<string, unknown>,
@@ -148,10 +139,25 @@ export class AgUiThreadRuntimeCore {
148
139
  private onError: ((error: Error) => void) | undefined;
149
140
  private onCancel: (() => void) | undefined;
150
141
  private readonly notifyUpdate: () => void;
142
+ private readonly reportedErrors = new WeakSet<object>();
151
143
 
152
144
  private runtime: AssistantRuntime | undefined;
153
- private readonly repository = new MessageRepository();
154
- 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
+ });
155
161
  private isRunningFlag = false;
156
162
  private abortController: AbortController | null = null;
157
163
  // The agent that started the active run. updateOptions can swap this.agent
@@ -162,9 +168,12 @@ export class AgUiThreadRuntimeCore {
162
168
  private history: ThreadHistoryAdapter | undefined;
163
169
  private lastRunConfig: RunConfig | undefined;
164
170
  private readonly assistantHistoryParents = new Map<string, string | null>();
165
- private readonly recordedHistoryIds = new Set<string>();
171
+ private readonly snapshotHistoryIds = new Set<string>();
172
+ private readonly persistedHistoryIds = new Set<string>();
173
+ private readonly historyWrites = new Map<string, Promise<void>>();
166
174
  private _isLoading = false;
167
175
  private _loadPromise: Promise<void> | undefined;
176
+ private _loadRequested = false;
168
177
  private pendingResumeMessageId: string | null = null;
169
178
  private pendingA2uiResume = false;
170
179
  private pendingA2uiAction: Record<string, unknown> | undefined;
@@ -188,8 +197,19 @@ export class AgUiThreadRuntimeCore {
188
197
  this.autoCancelPendingToolCalls = options.autoCancelPendingToolCalls;
189
198
  this.onError = options.onError;
190
199
  this.onCancel = options.onCancel;
200
+ const previousHistory = this.history;
191
201
  this.history = options.history;
192
202
  this.installResumeShim();
203
+
204
+ if (
205
+ this._loadRequested &&
206
+ !this._loadPromise &&
207
+ !previousHistory &&
208
+ options.history &&
209
+ this.session.getMessages().length === 0
210
+ ) {
211
+ void this.__internal_load();
212
+ }
193
213
  }
194
214
 
195
215
  attachRuntime(runtime: AssistantRuntime) {
@@ -198,100 +218,17 @@ export class AgUiThreadRuntimeCore {
198
218
 
199
219
  detachRuntime() {
200
220
  this.runtime = undefined;
221
+ void this.cancel().catch((error) => {
222
+ this.logger.error("[agui] failed to cancel run during teardown", error);
223
+ });
201
224
  }
202
225
 
203
226
  getMessages(): readonly ThreadMessage[] {
204
- return this.repository.getMessages();
227
+ return this.session.getMessages();
205
228
  }
206
229
 
207
230
  getMessageRepository(): ExportedMessageRepository {
208
- if (this.exportedRepository) return this.exportedRepository;
209
-
210
- const exportedRepository = this.repository.export();
211
- let parentId: string | null = null;
212
- for (const message of this.repository.getMessages()) {
213
- if (message.metadata.isOptimistic) {
214
- exportedRepository.messages.push({ parentId, message });
215
- }
216
- parentId = message.id;
217
- }
218
-
219
- this.exportedRepository = {
220
- ...exportedRepository,
221
- headId: this.repository.headId,
222
- };
223
- return this.exportedRepository;
224
- }
225
-
226
- private tryGetMessage(messageId: string) {
227
- try {
228
- return this.repository.getMessage(messageId);
229
- } catch {
230
- return undefined;
231
- }
232
- }
233
-
234
- private tryGetMessages(
235
- messageId: string,
236
- ): readonly ThreadMessage[] | undefined {
237
- try {
238
- return this.repository.getMessages(messageId);
239
- } catch {
240
- return undefined;
241
- }
242
- }
243
-
244
- private hasMessage(messageId: string): boolean {
245
- return this.tryGetMessage(messageId) !== undefined;
246
- }
247
-
248
- private addOrUpdateMessage(
249
- parentId: string | null,
250
- message: ThreadMessage,
251
- ): void {
252
- this.repository.addOrUpdateMessage(parentId, message);
253
- this.exportedRepository = undefined;
254
- }
255
-
256
- private deleteMessage(
257
- messageId: string,
258
- replacementId?: string | null,
259
- ): void {
260
- this.repository.deleteMessage(messageId, replacementId);
261
- this.exportedRepository = undefined;
262
- }
263
-
264
- private tryDeleteMessage(messageId: string): boolean {
265
- if (!this.hasMessage(messageId)) return false;
266
- this.deleteMessage(messageId);
267
- return true;
268
- }
269
-
270
- private switchToBranch(messageId: string): void {
271
- this.repository.switchToBranch(messageId);
272
- this.exportedRepository = undefined;
273
- }
274
-
275
- private resetRepositoryHead(messageId: string | null): void {
276
- this.repository.resetHead(messageId);
277
- this.exportedRepository = undefined;
278
- }
279
-
280
- private clearRepository(): void {
281
- this.repository.clear();
282
- this.exportedRepository = undefined;
283
- }
284
-
285
- private updateMessage(
286
- messageId: string,
287
- updater: (message: ThreadMessage) => ThreadMessage,
288
- ): boolean {
289
- const item = this.tryGetMessage(messageId);
290
- if (!item) return false;
291
- const message = updater(item.message);
292
- if (message === item.message) return false;
293
- this.addOrUpdateMessage(item.parentId, message);
294
- return true;
231
+ return this.session.export();
295
232
  }
296
233
 
297
234
  getState(): ReadonlyJSONValue | undefined {
@@ -307,9 +244,11 @@ export class AgUiThreadRuntimeCore {
307
244
  }
308
245
 
309
246
  __internal_load(): Promise<void> {
247
+ this._loadRequested = true;
310
248
  if (this._loadPromise) return this._loadPromise;
249
+ if (!this.history) return Promise.resolve();
311
250
 
312
- const promise = this.history?.load() ?? Promise.resolve(null);
251
+ const promise = this.history.load();
313
252
 
314
253
  this._isLoading = true;
315
254
 
@@ -317,14 +256,21 @@ export class AgUiThreadRuntimeCore {
317
256
  .then(async (repo) => {
318
257
  if (!repo) return;
319
258
 
320
- this.applyExternalMessageRepository(repo);
259
+ this.session.applyExternalMessageRepository(repo);
260
+ this.assistantHistoryParents.clear();
261
+ this.snapshotHistoryIds.clear();
262
+ this.persistedHistoryIds.clear();
263
+ for (const { message } of repo.messages) {
264
+ this.persistedHistoryIds.add(message.id);
265
+ }
266
+ this.notifyUpdate();
321
267
 
322
268
  if (repo.state !== undefined) {
323
269
  this.loadExternalState(repo.state);
324
270
  }
325
271
 
326
272
  if (repo.unstable_resume) {
327
- const parentId = repo.headId ?? this.repository.headId;
273
+ const parentId = repo.headId ?? this.session.headId;
328
274
  const resumeStream = this.history?.resume?.bind(this.history);
329
275
  await this.startRun(
330
276
  parentId,
@@ -371,17 +317,17 @@ export class AgUiThreadRuntimeCore {
371
317
  }
372
318
 
373
319
  private appendEntry(message: AppendMessage): string {
374
- if (message.sourceId) this.tryDeleteMessage(message.sourceId);
320
+ if (message.sourceId) this.session.tryDeleteMessage(message.sourceId);
375
321
 
376
322
  const threadMessage = this.toThreadMessage(message);
377
323
  const parentId =
378
324
  message.parentId === null
379
325
  ? null
380
- : message.parentId && this.hasMessage(message.parentId)
326
+ : message.parentId && this.session.hasMessage(message.parentId)
381
327
  ? message.parentId
382
- : this.repository.headId;
383
- this.addOrUpdateMessage(parentId, threadMessage);
384
- this.switchToBranch(threadMessage.id);
328
+ : this.session.headId;
329
+ this.session.addOrUpdateMessage(parentId, threadMessage);
330
+ this.session.switchToBranch(threadMessage.id);
385
331
  this.notifyUpdate();
386
332
  this.recordHistoryEntry(parentId, threadMessage);
387
333
  return threadMessage.id;
@@ -526,34 +472,144 @@ export class AgUiThreadRuntimeCore {
526
472
  );
527
473
  }
528
474
 
475
+ this.assertInterruptsAnswerable(
476
+ "submitInterruptResponses",
477
+ pending.interrupts,
478
+ );
479
+
480
+ await this.resumeWithResponses(
481
+ pending.messageId,
482
+ openIds.map((id) => responsesById.get(id)!),
483
+ );
484
+ }
485
+
486
+ /**
487
+ * Resumes with an already validated resume array. Answerability is checked
488
+ * once per submission: re-checking here would let a clock crossing reject a
489
+ * decision this runtime has already recorded, stranding the gate decided and
490
+ * unretryable.
491
+ */
492
+ private async resumeWithResponses(
493
+ messageId: string,
494
+ resume: AgUiResumeEntry[],
495
+ ): Promise<void> {
496
+ this.clearPendingInterrupts(messageId, resume);
497
+ await this.startRun(messageId, this.lastRunConfig, resume);
498
+ }
499
+
500
+ /**
501
+ * The core seams that reach `respondToToolApproval` discard the promise they
502
+ * receive, so a rejected decision would otherwise surface only as an
503
+ * unhandled rejection. A failure raised by the resumed run itself was already
504
+ * reported by `startRun` before it rethrew, so reporting it here again would
505
+ * give the consumer two notifications for one failure.
506
+ */
507
+ reportError(error: unknown): void {
508
+ if (this.reportedErrors.has(error as object)) {
509
+ this.reportedErrors.delete(error as object);
510
+ return;
511
+ }
512
+ invokeRuntimeCallback(
513
+ "onError",
514
+ this.onError,
515
+ error instanceof Error ? error : new Error(String(error)),
516
+ );
517
+ }
518
+
519
+ private assertInterruptsAnswerable(
520
+ method: "submitInterruptResponses" | "respondToToolApproval",
521
+ interrupts: readonly AgUiInterrupt[],
522
+ ): void {
529
523
  const now = Date.now();
530
- for (const interrupt of pending.interrupts) {
524
+ for (const interrupt of interrupts) {
531
525
  if (!interrupt.expiresAt) continue;
532
526
  const expiry = new Date(interrupt.expiresAt).getTime();
533
527
  if (Number.isNaN(expiry)) {
534
528
  throw new Error(
535
- `[agui] submitInterruptResponses: interrupt ${interrupt.id} has malformed expiresAt "${interrupt.expiresAt}"`,
529
+ `[agui] ${method}: interrupt ${interrupt.id} has malformed expiresAt "${interrupt.expiresAt}"`,
536
530
  );
537
531
  }
538
532
  if (expiry <= now) {
539
533
  throw new Error(
540
- `[agui] submitInterruptResponses: interrupt ${interrupt.id} expired at ${interrupt.expiresAt}`,
534
+ `[agui] ${method}: interrupt ${interrupt.id} expired at ${interrupt.expiresAt}`,
541
535
  );
542
536
  }
543
537
  }
538
+ if (this.isRunningFlag) {
539
+ throw new Error(`[agui] ${method}: a run is already in progress`);
540
+ }
541
+ }
544
542
 
545
- const resume: AgUiResumeEntry[] = openIds.map((id) =>
546
- responsesById.get(id)!,
543
+ async respondToToolApproval(
544
+ options: RespondToToolApprovalOptions,
545
+ ): Promise<void> {
546
+ const pending = this.getPendingInterrupts();
547
+ if (!pending) {
548
+ throw new Error(
549
+ "[agui] respondToToolApproval: no pending interrupts on this thread",
550
+ );
551
+ }
552
+
553
+ // Bound against the message the gates landed on, so this check claims a
554
+ // batch only where the projection did.
555
+ const gatedMessage = this.session.tryGetMessage(pending.messageId)
556
+ ?.message as ThreadAssistantMessage | undefined;
557
+ const gated = projectAgUiToolApprovals(
558
+ pending.interrupts,
559
+ new Set(
560
+ (gatedMessage?.content ?? [])
561
+ .filter((part) => part.type === "tool-call")
562
+ .map((part) => part.toolCallId),
563
+ ),
564
+ );
565
+ const isGated = [...gated.values()].some(
566
+ (approval) => approval.id === options.approvalId,
547
567
  );
568
+ if (!isGated) {
569
+ throw new Error(
570
+ `[agui] respondToToolApproval: no pending tool-call interrupt for approval id "${options.approvalId}"`,
571
+ );
572
+ }
548
573
 
549
- if (this.isRunningFlag) {
574
+ // The decision is recorded only once the batch is known to be answerable:
575
+ // a rejected submission would otherwise leave the gate decided and
576
+ // unretryable, because a second click reports it as already decided.
577
+ this.assertInterruptsAnswerable(
578
+ "respondToToolApproval",
579
+ pending.interrupts,
580
+ );
581
+
582
+ const recorded = this.session.updateMessage(
583
+ pending.messageId,
584
+ (message) => {
585
+ if (message.role !== "assistant") return message;
586
+ const assistant = message as ThreadAssistantMessage;
587
+ const content = withToolApprovalDecision(assistant.content, options);
588
+ if (content === assistant.content) return assistant;
589
+ return { ...assistant, content };
590
+ },
591
+ );
592
+ if (!recorded) {
550
593
  throw new Error(
551
- "[agui] submitInterruptResponses: a run is already in progress",
594
+ `[agui] respondToToolApproval: approval "${options.approvalId}" is already decided`,
552
595
  );
553
596
  }
597
+ this.notifyUpdate();
598
+
599
+ const assistant = this.session.tryGetMessage(pending.messageId)?.message as
600
+ | ThreadAssistantMessage
601
+ | undefined;
602
+ if (!assistant) return;
603
+
604
+ // AG-UI resumes a run with one response per open interrupt, so the run
605
+ // stays paused until every gate in the batch has been answered.
606
+ const resume = buildToolApprovalResume(
607
+ assistant.content,
608
+ pending.interrupts,
609
+ );
610
+ if (!resume) return;
554
611
 
555
- this.clearPendingInterrupts(pending.messageId);
556
- await this.startRun(pending.messageId, this.lastRunConfig, resume);
612
+ await this.resumeWithResponses(pending.messageId, resume);
557
613
  }
558
614
 
559
615
  async steerAway(
@@ -595,7 +651,7 @@ export class AgUiThreadRuntimeCore {
595
651
  }
596
652
 
597
653
  const normalized = this.toAppendMessage(message);
598
- this.clearPendingInterrupts(pending.messageId);
654
+ this.clearPendingInterrupts(pending.messageId, resume);
599
655
  const threadMessageId = this.appendEntry(normalized);
600
656
  await this.startRun(threadMessageId, normalized.runConfig, resume);
601
657
  }
@@ -655,7 +711,7 @@ export class AgUiThreadRuntimeCore {
655
711
  if (typeof message === "string") {
656
712
  return {
657
713
  createdAt: new Date(),
658
- parentId: this.repository.headId,
714
+ parentId: this.session.headId,
659
715
  sourceId: null,
660
716
  runConfig: {},
661
717
  role: "user",
@@ -666,7 +722,7 @@ export class AgUiThreadRuntimeCore {
666
722
  }
667
723
  return {
668
724
  createdAt: message.createdAt ?? new Date(),
669
- parentId: message.parentId ?? this.repository.headId,
725
+ parentId: message.parentId ?? this.session.headId,
670
726
  sourceId: message.sourceId ?? null,
671
727
  role: message.role ?? "user",
672
728
  content: message.content,
@@ -677,8 +733,11 @@ export class AgUiThreadRuntimeCore {
677
733
  } as AppendMessage;
678
734
  }
679
735
 
680
- private clearPendingInterrupts(messageId: string): void {
681
- const touched = this.updateMessage(messageId, (message) => {
736
+ private clearPendingInterrupts(
737
+ messageId: string,
738
+ resume: readonly AgUiResumeEntry[],
739
+ ): void {
740
+ const touched = this.session.updateMessage(messageId, (message) => {
682
741
  if (message.role !== "assistant") return message;
683
742
  const assistant = message as ThreadAssistantMessage;
684
743
  if (
@@ -699,6 +758,7 @@ export class AgUiThreadRuntimeCore {
699
758
  }
700
759
  return {
701
760
  ...assistant,
761
+ content: withSettledToolApprovals(assistant.content, resume),
702
762
  status: { type: "complete" as const, reason: "unknown" as const },
703
763
  metadata: { ...assistant.metadata, custom: newCustom },
704
764
  };
@@ -727,7 +787,7 @@ export class AgUiThreadRuntimeCore {
727
787
  }
728
788
 
729
789
  private cancelUnresolvedToolCalls(messageId: string): void {
730
- const updated = this.updateMessage(messageId, (message) => {
790
+ const updated = this.session.updateMessage(messageId, (message) => {
731
791
  if (message.role !== "assistant") return message;
732
792
  const assistant = message as ThreadAssistantMessage;
733
793
  const content = assistant.content.map((part) => {
@@ -744,7 +804,7 @@ export class AgUiThreadRuntimeCore {
744
804
  }
745
805
 
746
806
  addToolResult(options: AddToolResultOptions): void {
747
- const updated = this.updateMessage(options.messageId, (message) => {
807
+ const updated = this.session.updateMessage(options.messageId, (message) => {
748
808
  if (message.role !== "assistant") return message;
749
809
  const assistant = message as ThreadAssistantMessage;
750
810
  let matchedToolCall = false;
@@ -771,7 +831,7 @@ export class AgUiThreadRuntimeCore {
771
831
  sendA2uiAction(action: Record<string, unknown>): void {
772
832
  this.assertNoPendingInterrupts();
773
833
  this.maybeAutoCancelPendingToolCalls();
774
- const parentId = this.repository.headId;
834
+ const parentId = this.session.headId;
775
835
  if (parentId === null) {
776
836
  this.logger.debug(
777
837
  "[agui] sendA2uiAction: no messages to resume, dropping action",
@@ -809,7 +869,7 @@ export class AgUiThreadRuntimeCore {
809
869
  }
810
870
 
811
871
  private maybeCompleteAfterToolResults(messageId: string): boolean {
812
- const message = this.tryGetMessage(messageId)?.message;
872
+ const message = this.session.tryGetMessage(messageId)?.message;
813
873
  if (!message || message.role !== "assistant") return false;
814
874
  const assistant = message as ThreadAssistantMessage;
815
875
  if (
@@ -823,7 +883,7 @@ export class AgUiThreadRuntimeCore {
823
883
  );
824
884
  if (!allResolved) return false;
825
885
 
826
- const updated = this.updateMessage(messageId, (current) =>
886
+ const updated = this.session.updateMessage(messageId, (current) =>
827
887
  current.role === "assistant"
828
888
  ? {
829
889
  ...(current as ThreadAssistantMessage),
@@ -853,7 +913,7 @@ export class AgUiThreadRuntimeCore {
853
913
  this.assistantHistoryParents.clear();
854
914
 
855
915
  if (messages.length === 0) {
856
- this.clearRepository();
916
+ this.session.clear();
857
917
  } else {
858
918
  let expectedParentId: string | null = null;
859
919
  let lastAppliedId: string | null = null;
@@ -863,100 +923,36 @@ export class AgUiThreadRuntimeCore {
863
923
  for (const message of messages) {
864
924
  if (seen.has(message.id)) continue;
865
925
  seen.add(message.id);
866
- const existing = this.tryGetMessage(message.id);
926
+ const existing = this.session.tryGetMessage(message.id);
867
927
  if (existing && existing.parentId !== expectedParentId) {
868
928
  hardReplace = true;
869
929
  break;
870
930
  }
871
- this.addOrUpdateMessage(expectedParentId, message);
931
+ this.session.addOrUpdateMessage(expectedParentId, message);
872
932
  expectedParentId = message.id;
873
933
  lastAppliedId = message.id;
874
934
  }
875
935
 
876
936
  if (hardReplace) {
877
- this.clearRepository();
937
+ this.session.clear();
878
938
  expectedParentId = null;
879
939
  lastAppliedId = null;
880
940
  seen.clear();
881
941
  for (const message of messages) {
882
942
  if (seen.has(message.id)) continue;
883
943
  seen.add(message.id);
884
- this.addOrUpdateMessage(expectedParentId, message);
944
+ this.session.addOrUpdateMessage(expectedParentId, message);
885
945
  expectedParentId = message.id;
886
946
  lastAppliedId = message.id;
887
947
  }
888
948
  }
889
949
 
890
- this.resetRepositoryHead(lastAppliedId);
950
+ this.session.resetHead(lastAppliedId);
891
951
  }
892
952
 
893
- this.recordedHistoryIds.clear();
953
+ this.snapshotHistoryIds.clear();
894
954
  for (const { message } of this.getMessageRepository().messages) {
895
- this.recordedHistoryIds.add(message.id);
896
- }
897
- this.notifyUpdate();
898
- }
899
-
900
- private applyExternalMessageRepository(
901
- loaded: ExportedMessageRepository,
902
- ): void {
903
- const headId = loaded.headId ?? loaded.messages.at(-1)?.message.id ?? null;
904
- const ids = new Set<string>();
905
- let degenerate = false;
906
- for (const { message } of loaded.messages) {
907
- if (ids.has(message.id)) {
908
- degenerate = true;
909
- break;
910
- }
911
- ids.add(message.id);
912
- }
913
- if (headId !== null && !ids.has(headId)) degenerate = true;
914
-
915
- if (!degenerate) {
916
- this.clearRepository();
917
- let pending = [...loaded.messages];
918
- const importedIds = new Set<string>();
919
-
920
- while (pending.length > 0) {
921
- const unresolved: typeof pending = [];
922
- let progressed = false;
923
- for (const item of pending) {
924
- if (item.parentId !== null && !importedIds.has(item.parentId)) {
925
- unresolved.push(item);
926
- continue;
927
- }
928
- this.addOrUpdateMessage(item.parentId, item.message);
929
- importedIds.add(item.message.id);
930
- progressed = true;
931
- }
932
- if (!progressed) {
933
- degenerate = true;
934
- break;
935
- }
936
- pending = unresolved;
937
- }
938
- }
939
-
940
- if (degenerate) {
941
- this.clearRepository();
942
- let previousId: string | null = null;
943
- for (const { message } of loaded.messages) {
944
- const existing = this.tryGetMessage(message.id);
945
- this.addOrUpdateMessage(
946
- existing ? existing.parentId : previousId,
947
- message,
948
- );
949
- previousId = message.id;
950
- }
951
- this.resetRepositoryHead(previousId);
952
- } else {
953
- this.resetRepositoryHead(headId);
954
- }
955
-
956
- this.assistantHistoryParents.clear();
957
- this.recordedHistoryIds.clear();
958
- for (const { message } of loaded.messages) {
959
- this.recordedHistoryIds.add(message.id);
955
+ this.snapshotHistoryIds.add(message.id);
960
956
  }
961
957
  this.notifyUpdate();
962
958
  }
@@ -989,37 +985,53 @@ export class AgUiThreadRuntimeCore {
989
985
  ): Promise<void> {
990
986
  const normalizedRunConfig = runConfig ?? {};
991
987
  this.lastRunConfig = normalizedRunConfig;
992
- const parent = parentId === null ? undefined : this.tryGetMessage(parentId);
988
+ const parent =
989
+ parentId === null ? undefined : this.session.tryGetMessage(parentId);
993
990
  const shouldEagerlyInsertAssistant =
994
991
  parentId !== null &&
995
992
  parent !== undefined &&
996
- parentId !== this.repository.headId;
993
+ parentId !== this.session.headId;
997
994
  const historicalMessages = [
998
995
  ...(shouldEagerlyInsertAssistant
999
- ? (this.tryGetMessages(parentId) ?? this.repository.getMessages())
1000
- : this.repository.getMessages()),
996
+ ? (this.session.tryGetMessages(parentId) ?? this.session.getMessages())
997
+ : this.session.getMessages()),
1001
998
  ];
999
+ const runStartMessageIds = new Set(
1000
+ this.getMessageRepository().messages.map(({ message }) => message.id),
1001
+ );
1002
1002
 
1003
1003
  this.pendingError = null;
1004
- const assistantParentId = parent ? parentId : this.repository.headId;
1004
+ const assistantParentId = parent ? parentId : this.session.headId;
1005
1005
  let assistantMessageId: string | undefined;
1006
1006
  // A snapshot the preserve gate declines still evicts the in-flight
1007
1007
  // assistant; recreating under the cached id on the next content-bearing
1008
- // emit keeps both the stream and the message identity. Status-only emits
1009
- // and server-id collisions must not recreate.
1008
+ // emit keeps both the stream and the message identity. Status-only emits,
1009
+ // data-only content, and off-branch server-id collisions must not
1010
+ // recreate: the snapshot already carries this turn's assistant, so
1011
+ // resurrecting for custom-event data would leave a trailing data-only
1012
+ // duplicate.
1010
1013
  let assistantCollided = false;
1011
1014
  const ensureAssistant = (allowRecreate = false): string => {
1012
1015
  const cached = assistantMessageId;
1013
- if (cached !== undefined && this.tryGetMessage(cached)) return cached;
1016
+ if (cached !== undefined && this.session.tryGetMessage(cached))
1017
+ return cached;
1014
1018
  if (cached !== undefined && (assistantCollided || !allowRecreate)) {
1015
1019
  return cached;
1016
1020
  }
1021
+ const repositoryHeadId = this.session.headId;
1022
+ const shouldUseSelectedParent =
1023
+ cached === undefined ||
1024
+ (shouldEagerlyInsertAssistant &&
1025
+ (repositoryHeadId === null ||
1026
+ runStartMessageIds.has(repositoryHeadId)));
1027
+ // Branch runs keep their selected parent unless the snapshot advanced to
1028
+ // a message introduced during this run.
1017
1029
  const parentId =
1018
- cached === undefined &&
1030
+ shouldUseSelectedParent &&
1019
1031
  assistantParentId &&
1020
- this.hasMessage(assistantParentId)
1032
+ this.session.hasMessage(assistantParentId)
1021
1033
  ? assistantParentId
1022
- : this.repository.headId;
1034
+ : repositoryHeadId;
1023
1035
  const created = this.insertAssistantPlaceholder(parentId, cached);
1024
1036
  assistantMessageId = created;
1025
1037
  this.markPendingAssistantHistory(created, parentId);
@@ -1029,10 +1041,11 @@ export class AgUiThreadRuntimeCore {
1029
1041
  if (shouldEagerlyInsertAssistant) ensureAssistant();
1030
1042
 
1031
1043
  const applyUpdate = (update: ChatModelRunResult) => {
1032
- const hasContent =
1033
- Array.isArray(update.content) && update.content.length > 0;
1044
+ const hasStreamContent =
1045
+ Array.isArray(update.content) &&
1046
+ update.content.some((part) => part.type !== "data");
1034
1047
  const resolved = this.updateAssistantMessage(
1035
- ensureAssistant(hasContent),
1048
+ ensureAssistant(hasStreamContent),
1036
1049
  update,
1037
1050
  );
1038
1051
  if (resolved !== assistantMessageId) {
@@ -1040,19 +1053,51 @@ export class AgUiThreadRuntimeCore {
1040
1053
  }
1041
1054
  };
1042
1055
 
1056
+ const adoptServerMessageId = (
1057
+ serverId: string,
1058
+ startNewMessage: boolean,
1059
+ ) => {
1060
+ if (startNewMessage && assistantMessageId !== undefined) {
1061
+ applyUpdate({ status: { type: "complete", reason: "unknown" } });
1062
+ const previousId = assistantMessageId;
1063
+ assistantMessageId = undefined;
1064
+ if (this.session.tryGetMessage(previousId)) {
1065
+ const created = this.insertAssistantPlaceholder(previousId);
1066
+ assistantMessageId = created;
1067
+ this.markPendingAssistantHistory(created, previousId);
1068
+ }
1069
+ }
1070
+ const placeholder = ensureAssistant(true);
1071
+ if (placeholder === serverId) return;
1072
+ const reassigned = this.reassignAssistantId(placeholder, serverId);
1073
+ // A collision drops the placeholder before revealing the existing
1074
+ // server message as the current head. Only messages introduced during
1075
+ // this run can replace the placeholder; regeneration must not rewrite
1076
+ // a previous branch when the server incorrectly reuses its id.
1077
+ const adoptsVisibleCollision =
1078
+ !reassigned &&
1079
+ !runStartMessageIds.has(serverId) &&
1080
+ this.session.headId === serverId;
1081
+ if (reassigned || adoptsVisibleCollision) {
1082
+ assistantMessageId = serverId;
1083
+ if (adoptsVisibleCollision) {
1084
+ const parentId =
1085
+ this.session.tryGetMessage(serverId)?.parentId ?? null;
1086
+ this.markPendingAssistantHistory(serverId, parentId);
1087
+ }
1088
+ } else {
1089
+ assistantCollided = true;
1090
+ }
1091
+ };
1092
+
1043
1093
  const aggregator = new RunAggregator({
1044
1094
  showThinking: this.showThinking,
1045
1095
  logger: this.logger,
1046
1096
  emit: applyUpdate,
1047
1097
  onServerMessageId: (serverId) => {
1048
- const placeholder = ensureAssistant(true);
1049
- if (placeholder === serverId) return;
1050
- if (this.reassignAssistantId(placeholder, serverId)) {
1051
- assistantMessageId = serverId;
1052
- } else {
1053
- assistantCollided = true;
1054
- }
1098
+ adoptServerMessageId(serverId, false);
1055
1099
  },
1100
+ onTextMessageStart: (serverId) => adoptServerMessageId(serverId, true),
1056
1101
  });
1057
1102
  const dispatch = (event: AgUiEvent) =>
1058
1103
  this.handleEvent(aggregator, event, assistantMessageId);
@@ -1134,6 +1179,7 @@ export class AgUiThreadRuntimeCore {
1134
1179
 
1135
1180
  if (this.pendingError) {
1136
1181
  const err = this.pendingError;
1182
+ this.reportedErrors.add(err);
1137
1183
  this.pendingError = null;
1138
1184
  this.pendingResumeMessageId = null;
1139
1185
  this.pendingA2uiResume = false;
@@ -1164,7 +1210,7 @@ export class AgUiThreadRuntimeCore {
1164
1210
  return;
1165
1211
  }
1166
1212
  this.maybeAutoCancelPendingToolCalls();
1167
- const parentId = this.repository.headId;
1213
+ const parentId = this.session.headId;
1168
1214
  if (parentId !== null) {
1169
1215
  this.startResumeRun(parentId);
1170
1216
  } else {
@@ -1206,7 +1252,7 @@ export class AgUiThreadRuntimeCore {
1206
1252
  unstable_threadId: ctx.threadId,
1207
1253
  unstable_parentId: ctx.parentId,
1208
1254
  unstable_getMessage: () => {
1209
- const message = this.tryGetMessage(currentId())?.message;
1255
+ const message = this.session.tryGetMessage(currentId())?.message;
1210
1256
  if (!message) {
1211
1257
  throw new Error(
1212
1258
  "[agui] resume stream requested the assistant message before it existed",
@@ -1233,7 +1279,7 @@ export class AgUiThreadRuntimeCore {
1233
1279
  }
1234
1280
 
1235
1281
  if (ctx.abortSignal.aborted) return;
1236
- const current = this.tryGetMessage(currentId())?.message;
1282
+ const current = this.session.tryGetMessage(currentId())?.message;
1237
1283
  if (!current || current.status?.type === "running") {
1238
1284
  ctx.applyUpdate({ status: { type: "complete", reason: "unknown" } });
1239
1285
  }
@@ -1247,7 +1293,7 @@ export class AgUiThreadRuntimeCore {
1247
1293
  ) {
1248
1294
  const threadId = this.agent.threadId || "main";
1249
1295
  const messages = toAgUiMessages(
1250
- historyMessages ?? this.repository.getMessages(),
1296
+ historyMessages ?? this.session.getMessages(),
1251
1297
  );
1252
1298
  const context = this.runtime?.thread.getModelContext();
1253
1299
  const input = {
@@ -1326,34 +1372,34 @@ export class AgUiThreadRuntimeCore {
1326
1372
  custom: {},
1327
1373
  },
1328
1374
  };
1329
- this.addOrUpdateMessage(parentId ?? this.repository.headId, assistant);
1330
- this.switchToBranch(id);
1375
+ this.session.addOrUpdateMessage(parentId ?? this.session.headId, assistant);
1376
+ this.session.switchToBranch(id);
1331
1377
  this.notifyUpdate();
1332
1378
  return id;
1333
1379
  }
1334
1380
 
1335
1381
  private reassignAssistantId(oldId: string, newId: string): boolean {
1336
1382
  if (oldId === newId) return true;
1337
- const oldItem = this.tryGetMessage(oldId);
1383
+ const oldItem = this.session.tryGetMessage(oldId);
1338
1384
  if (!oldItem) return false;
1339
1385
 
1340
- const collidesWithExisting = this.hasMessage(newId);
1386
+ const collidesWithExisting = this.session.hasMessage(newId);
1341
1387
 
1342
1388
  if (collidesWithExisting) {
1343
1389
  this.logger.debug?.(
1344
1390
  "[agui] reassignAssistantId: server id already present in messages or repository, dropping placeholder",
1345
1391
  { oldId, newId },
1346
1392
  );
1347
- this.deleteMessage(oldId, oldItem.parentId ?? null);
1393
+ this.session.deleteMessage(oldId, oldItem.parentId ?? null);
1348
1394
  } else {
1349
1395
  const { isOptimistic: _, ...metadata } = oldItem.message.metadata;
1350
- this.addOrUpdateMessage(oldItem.parentId, {
1396
+ this.session.addOrUpdateMessage(oldItem.parentId, {
1351
1397
  ...oldItem.message,
1352
1398
  id: newId,
1353
1399
  metadata,
1354
1400
  } as ThreadMessage);
1355
- this.switchToBranch(newId);
1356
- this.tryDeleteMessage(oldId);
1401
+ this.session.switchToBranch(newId);
1402
+ this.session.tryDeleteMessage(oldId);
1357
1403
  }
1358
1404
 
1359
1405
  const pendingParent = this.assistantHistoryParents.get(oldId);
@@ -1364,10 +1410,33 @@ export class AgUiThreadRuntimeCore {
1364
1410
  }
1365
1411
  }
1366
1412
 
1367
- if (this.recordedHistoryIds.has(oldId)) {
1368
- this.recordedHistoryIds.delete(oldId);
1413
+ for (const ids of [this.snapshotHistoryIds, this.persistedHistoryIds]) {
1414
+ if (ids.has(oldId)) {
1415
+ ids.delete(oldId);
1416
+ if (!collidesWithExisting) {
1417
+ ids.add(newId);
1418
+ }
1419
+ }
1420
+ }
1421
+
1422
+ // An in-flight write completes under the id it was started with, so the
1423
+ // rename moves the chain entry and transfers the completion mark; without
1424
+ // this the resolve records the dead id and the live one appends again.
1425
+ const pendingWrite = this.historyWrites.get(oldId);
1426
+ if (pendingWrite) {
1427
+ this.historyWrites.delete(oldId);
1369
1428
  if (!collidesWithExisting) {
1370
- this.recordedHistoryIds.add(newId);
1429
+ this.historyWrites.set(newId, pendingWrite);
1430
+ const settle = () => {
1431
+ if (this.historyWrites.get(newId) === pendingWrite) {
1432
+ this.historyWrites.delete(newId);
1433
+ }
1434
+ };
1435
+ void pendingWrite.then(() => {
1436
+ this.persistedHistoryIds.delete(oldId);
1437
+ this.persistedHistoryIds.add(newId);
1438
+ settle();
1439
+ }, settle);
1371
1440
  }
1372
1441
  }
1373
1442
 
@@ -1380,7 +1449,7 @@ export class AgUiThreadRuntimeCore {
1380
1449
  update: ChatModelRunResult,
1381
1450
  ): string {
1382
1451
  let latestStatus: MessageStatus | undefined;
1383
- const touched = this.updateMessage(messageId, (message) => {
1452
+ const touched = this.session.updateMessage(messageId, (message) => {
1384
1453
  if (message.role !== "assistant") return message;
1385
1454
  const assistant = message as ThreadAssistantMessage;
1386
1455
  const metadata = update.metadata
@@ -1549,7 +1618,7 @@ export class AgUiThreadRuntimeCore {
1549
1618
  messageId: string,
1550
1619
  event: Extract<AgUiEvent, { type: "TOOL_CALL_RESULT" }>,
1551
1620
  ): void {
1552
- const updated = this.updateMessage(messageId, (message) => {
1621
+ const updated = this.session.updateMessage(messageId, (message) => {
1553
1622
  if (message.role !== "assistant") return message;
1554
1623
  const assistant = message as ThreadAssistantMessage;
1555
1624
  const mcpAppUri = readMcpAppResourceUri(event.mcpResult?._meta);
@@ -1636,7 +1705,7 @@ export class AgUiThreadRuntimeCore {
1636
1705
  ? serverHash
1637
1706
  : undefined;
1638
1707
  const result = event.content["result"];
1639
- const updated = this.updateMessage(messageId, (message) => {
1708
+ const updated = this.session.updateMessage(messageId, (message) => {
1640
1709
  if (message.role !== "assistant") return message;
1641
1710
  const assistant = message as ThreadAssistantMessage;
1642
1711
  let matchedToolCall = false;
@@ -1688,7 +1757,7 @@ export class AgUiThreadRuntimeCore {
1688
1757
  ) {
1689
1758
  try {
1690
1759
  const activeMessage = activeAssistantId
1691
- ? this.tryGetMessage(activeAssistantId)?.message
1760
+ ? this.session.tryGetMessage(activeAssistantId)?.message
1692
1761
  : undefined;
1693
1762
  const activeAssistant =
1694
1763
  activeMessage?.role === "assistant" ? activeMessage : undefined;
@@ -1708,7 +1777,6 @@ export class AgUiThreadRuntimeCore {
1708
1777
  );
1709
1778
  }
1710
1779
  }
1711
- const snapshotHeadId = converted.at(-1)?.id ?? null;
1712
1780
  const snapshotContainsActiveAssistant = converted.some(
1713
1781
  (message) => message.id === activeAssistant?.id,
1714
1782
  );
@@ -1721,9 +1789,17 @@ export class AgUiThreadRuntimeCore {
1721
1789
  converted.push(activeAssistant);
1722
1790
  }
1723
1791
  this.applyExternalMessages(converted);
1724
- if (preservesActiveAssistant) {
1725
- this.recordedHistoryIds.delete(activeAssistant.id);
1726
- this.markPendingAssistantHistory(activeAssistant.id, snapshotHeadId);
1792
+ if (activeAssistant !== undefined) {
1793
+ const activeItem = this.session.tryGetMessage(activeAssistant.id);
1794
+ if (activeItem) {
1795
+ if (preservesActiveAssistant) {
1796
+ this.snapshotHistoryIds.delete(activeAssistant.id);
1797
+ }
1798
+ this.markPendingAssistantHistory(
1799
+ activeAssistant.id,
1800
+ activeItem.parentId,
1801
+ );
1802
+ }
1727
1803
  }
1728
1804
  } catch (error) {
1729
1805
  this.logger.error?.("[agui] failed to import messages snapshot", error);
@@ -1748,7 +1824,9 @@ export class AgUiThreadRuntimeCore {
1748
1824
  }
1749
1825
 
1750
1826
  private recordHistoryEntry(parentId: string | null, message: ThreadMessage) {
1751
- this.appendHistoryItem(parentId, message);
1827
+ void this.appendHistoryItem(parentId, message)?.catch((error) => {
1828
+ this.logger.error?.("[agui] failed to append history entry", error);
1829
+ });
1752
1830
  }
1753
1831
 
1754
1832
  private markPendingAssistantHistory(
@@ -1760,22 +1838,108 @@ export class AgUiThreadRuntimeCore {
1760
1838
  }
1761
1839
 
1762
1840
  private persistAssistantHistory(messageId: string) {
1763
- if (!this.history) return;
1841
+ const history = this.history;
1842
+ if (!history) return;
1764
1843
  const parentId = this.assistantHistoryParents.get(messageId);
1765
1844
  if (parentId === undefined) return;
1766
- const message = this.tryGetMessage(messageId)?.message;
1845
+ const message = this.session.tryGetMessage(messageId)?.message;
1767
1846
  if (!message || message.role !== "assistant") return;
1768
1847
  if (!this.isPersistableStatus(message.status)) return;
1848
+ const wasPersisted = this.persistedHistoryIds.has(messageId);
1849
+ const update = history.update;
1850
+ const shouldUpdate =
1851
+ update !== undefined &&
1852
+ (wasPersisted || this.snapshotHistoryIds.has(messageId));
1853
+
1854
+ if (shouldUpdate) {
1855
+ const write = this.chainHistoryWrite(messageId, () =>
1856
+ update.call(history, { parentId, message }),
1857
+ );
1858
+ this.assistantHistoryParents.delete(messageId);
1859
+ void write.then(
1860
+ () => {
1861
+ this.persistedHistoryIds.add(messageId);
1862
+ },
1863
+ (error) => {
1864
+ const pending = this.historyWrites.get(messageId);
1865
+ if (pending === undefined || pending === write) {
1866
+ this.assistantHistoryParents.set(messageId, parentId);
1867
+ }
1868
+ this.logger.error?.("[agui] failed to update history entry", error);
1869
+ },
1870
+ );
1871
+ return;
1872
+ }
1873
+
1874
+ if (wasPersisted) {
1875
+ this.assistantHistoryParents.delete(messageId);
1876
+ return;
1877
+ }
1878
+
1879
+ const write = this.appendHistoryItem(parentId, message);
1880
+ if (!write) return;
1769
1881
  this.assistantHistoryParents.delete(messageId);
1770
- this.appendHistoryItem(parentId, message);
1882
+ void write.then(
1883
+ () => {},
1884
+ (error) => {
1885
+ const pending = this.historyWrites.get(messageId);
1886
+ if (pending === undefined || pending === write) {
1887
+ this.assistantHistoryParents.set(messageId, parentId);
1888
+ }
1889
+ this.logger.error?.("[agui] failed to append history entry", error);
1890
+ },
1891
+ );
1771
1892
  }
1772
1893
 
1773
- private appendHistoryItem(parentId: string | null, message: ThreadMessage) {
1774
- if (!this.history || this.recordedHistoryIds.has(message.id)) return;
1775
- this.recordedHistoryIds.add(message.id);
1776
- void this.history.append({ parentId, message }).catch((error) => {
1777
- this.recordedHistoryIds.delete(message.id);
1778
- this.logger.error?.("[agui] failed to append history entry", error);
1779
- });
1894
+ private appendHistoryItem(
1895
+ parentId: string | null,
1896
+ message: ThreadMessage,
1897
+ ): Promise<void> | undefined {
1898
+ if (!this.history || this.persistedHistoryIds.has(message.id)) return;
1899
+ const pending = this.historyWrites.get(message.id);
1900
+ if (pending) return pending;
1901
+
1902
+ const append = this.history.append.bind(this.history);
1903
+ const write = this.chainHistoryWrite(message.id, () =>
1904
+ append({ parentId, message }),
1905
+ );
1906
+ void write.then(
1907
+ () => {
1908
+ this.persistedHistoryIds.add(message.id);
1909
+ },
1910
+ () => {},
1911
+ );
1912
+ return write;
1913
+ }
1914
+
1915
+ private chainHistoryWrite(
1916
+ id: string,
1917
+ write: () => Promise<void>,
1918
+ ): Promise<void> {
1919
+ const pending = this.historyWrites.get(id);
1920
+ let next: Promise<void>;
1921
+ if (pending) {
1922
+ next = pending.then(write, write);
1923
+ } else {
1924
+ try {
1925
+ next = Promise.resolve(write());
1926
+ } catch (error) {
1927
+ next = Promise.reject(error);
1928
+ }
1929
+ }
1930
+ this.historyWrites.set(id, next);
1931
+ void next.then(
1932
+ () => {
1933
+ if (this.historyWrites.get(id) === next) {
1934
+ this.historyWrites.delete(id);
1935
+ }
1936
+ },
1937
+ () => {
1938
+ if (this.historyWrites.get(id) === next) {
1939
+ this.historyWrites.delete(id);
1940
+ }
1941
+ },
1942
+ );
1943
+ return next;
1780
1944
  }
1781
1945
  }