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

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 (49) hide show
  1. package/dist/index.d.ts +2 -2
  2. package/dist/index.js +2 -2
  3. package/dist/runtime/AgUiThreadRuntimeCore.d.ts +23 -2
  4. package/dist/runtime/AgUiThreadRuntimeCore.d.ts.map +1 -1
  5. package/dist/runtime/AgUiThreadRuntimeCore.js +183 -39
  6. package/dist/runtime/AgUiThreadRuntimeCore.js.map +1 -1
  7. package/dist/runtime/adapter/conversions.d.ts +19 -3
  8. package/dist/runtime/adapter/conversions.d.ts.map +1 -1
  9. package/dist/runtime/adapter/conversions.js +197 -36
  10. package/dist/runtime/adapter/conversions.js.map +1 -1
  11. package/dist/runtime/adapter/run-aggregator.d.ts +15 -2
  12. package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
  13. package/dist/runtime/adapter/run-aggregator.js +62 -10
  14. package/dist/runtime/adapter/run-aggregator.js.map +1 -1
  15. package/dist/runtime/adapter/subscriber.d.ts +6 -0
  16. package/dist/runtime/adapter/subscriber.d.ts.map +1 -1
  17. package/dist/runtime/adapter/subscriber.js +10 -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 +15 -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/runtime/types.d.ts +5 -0
  31. package/dist/runtime/types.d.ts.map +1 -1
  32. package/dist/useAgUiRuntime.js +4 -1
  33. package/dist/useAgUiRuntime.js.map +1 -1
  34. package/package.json +7 -7
  35. package/src/index.ts +8 -2
  36. package/src/runtime/AgUiThreadRuntimeCore.ts +335 -42
  37. package/src/runtime/adapter/conversions.content.test.ts +278 -0
  38. package/src/runtime/adapter/conversions.ts +298 -50
  39. package/src/runtime/adapter/run-aggregator.approval.test.ts +103 -0
  40. package/src/runtime/adapter/run-aggregator.ts +96 -5
  41. package/src/runtime/adapter/subscriber.ts +17 -0
  42. package/src/runtime/adapter/tool-approval.test.ts +681 -0
  43. package/src/runtime/adapter/tool-approval.ts +366 -0
  44. package/src/runtime/event-parser.ts +23 -1
  45. package/src/runtime/interrupt-internals.ts +35 -0
  46. package/src/runtime/types.ts +6 -0
  47. package/src/useAgUiRuntime.approval.test.tsx +885 -0
  48. package/src/useAgUiRuntime.queue.test.tsx +68 -1
  49. package/src/useAgUiRuntime.ts +5 -1
@@ -14,6 +14,7 @@ import type {
14
14
  ChatModelRunResult,
15
15
  ExportedMessageRepository,
16
16
  MessageStatus,
17
+ RespondToToolApprovalOptions,
17
18
  ThreadAssistantMessage,
18
19
  ThreadHistoryAdapter,
19
20
  ThreadMessage,
@@ -45,6 +46,12 @@ import {
45
46
  toAgUiTools,
46
47
  } from "./adapter/conversions";
47
48
  import { createAgUiSubscriber } from "./adapter/subscriber";
49
+ import {
50
+ buildToolApprovalResume,
51
+ projectAgUiToolApprovals,
52
+ withSettledToolApprovals,
53
+ withToolApprovalDecision,
54
+ } from "./adapter/tool-approval";
48
55
 
49
56
  // AbstractAgent.runAgent declares two parameters. HttpAgent ignores a third and
50
57
  // is cancelled through agent.abortRun(); the run options stay for subclasses
@@ -120,6 +127,26 @@ const invokeRuntimeCallback = <TArgs extends unknown[]>(
120
127
  }
121
128
  };
122
129
 
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.
132
+ const mergeAgUiNamespace = (
133
+ current: Record<string, unknown> | undefined,
134
+ incoming: Record<string, unknown>,
135
+ ) => {
136
+ const merged = { ...current, ...incoming };
137
+ const currentNs = current?.[AG_UI_METADATA_NAMESPACE];
138
+ const incomingNs = incoming[AG_UI_METADATA_NAMESPACE];
139
+ if (
140
+ typeof currentNs === "object" &&
141
+ currentNs !== null &&
142
+ typeof incomingNs === "object" &&
143
+ incomingNs !== null
144
+ ) {
145
+ merged[AG_UI_METADATA_NAMESPACE] = { ...currentNs, ...incomingNs };
146
+ }
147
+ return merged;
148
+ };
149
+
123
150
  export class AgUiThreadRuntimeCore {
124
151
  private agent: AbstractAgent;
125
152
  private logger: Logger;
@@ -128,6 +155,7 @@ export class AgUiThreadRuntimeCore {
128
155
  private onError: ((error: Error) => void) | undefined;
129
156
  private onCancel: (() => void) | undefined;
130
157
  private readonly notifyUpdate: () => void;
158
+ private readonly reportedErrors = new WeakSet<object>();
131
159
 
132
160
  private runtime: AssistantRuntime | undefined;
133
161
  private readonly repository = new MessageRepository();
@@ -142,7 +170,9 @@ export class AgUiThreadRuntimeCore {
142
170
  private history: ThreadHistoryAdapter | undefined;
143
171
  private lastRunConfig: RunConfig | undefined;
144
172
  private readonly assistantHistoryParents = new Map<string, string | null>();
145
- private readonly recordedHistoryIds = new Set<string>();
173
+ private readonly snapshotHistoryIds = new Set<string>();
174
+ private readonly persistedHistoryIds = new Set<string>();
175
+ private readonly historyWrites = new Map<string, Promise<void>>();
146
176
  private _isLoading = false;
147
177
  private _loadPromise: Promise<void> | undefined;
148
178
  private pendingResumeMessageId: string | null = null;
@@ -178,6 +208,9 @@ export class AgUiThreadRuntimeCore {
178
208
 
179
209
  detachRuntime() {
180
210
  this.runtime = undefined;
211
+ void this.cancel().catch((error) => {
212
+ this.logger.error("[agui] failed to cancel run during teardown", error);
213
+ });
181
214
  }
182
215
 
183
216
  getMessages(): readonly ThreadMessage[] {
@@ -506,34 +539,142 @@ export class AgUiThreadRuntimeCore {
506
539
  );
507
540
  }
508
541
 
542
+ this.assertInterruptsAnswerable(
543
+ "submitInterruptResponses",
544
+ pending.interrupts,
545
+ );
546
+
547
+ await this.resumeWithResponses(
548
+ pending.messageId,
549
+ openIds.map((id) => responsesById.get(id)!),
550
+ );
551
+ }
552
+
553
+ /**
554
+ * Resumes with an already validated resume array. Answerability is checked
555
+ * once per submission: re-checking here would let a clock crossing reject a
556
+ * decision this runtime has already recorded, stranding the gate decided and
557
+ * unretryable.
558
+ */
559
+ private async resumeWithResponses(
560
+ messageId: string,
561
+ resume: AgUiResumeEntry[],
562
+ ): Promise<void> {
563
+ this.clearPendingInterrupts(messageId, resume);
564
+ await this.startRun(messageId, this.lastRunConfig, resume);
565
+ }
566
+
567
+ /**
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
571
+ * reported by `startRun` before it rethrew, so reporting it here again would
572
+ * give the consumer two notifications for one failure.
573
+ */
574
+ reportError(error: unknown): void {
575
+ if (this.reportedErrors.has(error as object)) {
576
+ this.reportedErrors.delete(error as object);
577
+ return;
578
+ }
579
+ invokeRuntimeCallback(
580
+ "onError",
581
+ this.onError,
582
+ error instanceof Error ? error : new Error(String(error)),
583
+ );
584
+ }
585
+
586
+ private assertInterruptsAnswerable(
587
+ method: "submitInterruptResponses" | "respondToToolApproval",
588
+ interrupts: readonly AgUiInterrupt[],
589
+ ): void {
509
590
  const now = Date.now();
510
- for (const interrupt of pending.interrupts) {
591
+ for (const interrupt of interrupts) {
511
592
  if (!interrupt.expiresAt) continue;
512
593
  const expiry = new Date(interrupt.expiresAt).getTime();
513
594
  if (Number.isNaN(expiry)) {
514
595
  throw new Error(
515
- `[agui] submitInterruptResponses: interrupt ${interrupt.id} has malformed expiresAt "${interrupt.expiresAt}"`,
596
+ `[agui] ${method}: interrupt ${interrupt.id} has malformed expiresAt "${interrupt.expiresAt}"`,
516
597
  );
517
598
  }
518
599
  if (expiry <= now) {
519
600
  throw new Error(
520
- `[agui] submitInterruptResponses: interrupt ${interrupt.id} expired at ${interrupt.expiresAt}`,
601
+ `[agui] ${method}: interrupt ${interrupt.id} expired at ${interrupt.expiresAt}`,
521
602
  );
522
603
  }
523
604
  }
605
+ if (this.isRunningFlag) {
606
+ throw new Error(`[agui] ${method}: a run is already in progress`);
607
+ }
608
+ }
609
+
610
+ async respondToToolApproval(
611
+ options: RespondToToolApprovalOptions,
612
+ ): Promise<void> {
613
+ const pending = this.getPendingInterrupts();
614
+ if (!pending) {
615
+ throw new Error(
616
+ "[agui] respondToToolApproval: no pending interrupts on this thread",
617
+ );
618
+ }
524
619
 
525
- const resume: AgUiResumeEntry[] = openIds.map((id) =>
526
- responsesById.get(id)!,
620
+ // Bound against the message the gates landed on, so this check claims a
621
+ // batch only where the projection did.
622
+ const gatedMessage = this.tryGetMessage(pending.messageId)?.message as
623
+ | ThreadAssistantMessage
624
+ | undefined;
625
+ const gated = projectAgUiToolApprovals(
626
+ pending.interrupts,
627
+ new Set(
628
+ (gatedMessage?.content ?? [])
629
+ .filter((part) => part.type === "tool-call")
630
+ .map((part) => part.toolCallId),
631
+ ),
632
+ );
633
+ const isGated = [...gated.values()].some(
634
+ (approval) => approval.id === options.approvalId,
527
635
  );
636
+ if (!isGated) {
637
+ throw new Error(
638
+ `[agui] respondToToolApproval: no pending tool-call interrupt for approval id "${options.approvalId}"`,
639
+ );
640
+ }
528
641
 
529
- if (this.isRunningFlag) {
642
+ // The decision is recorded only once the batch is known to be answerable:
643
+ // a rejected submission would otherwise leave the gate decided and
644
+ // unretryable, because a second click reports it as already decided.
645
+ this.assertInterruptsAnswerable(
646
+ "respondToToolApproval",
647
+ pending.interrupts,
648
+ );
649
+
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
+ });
657
+ if (!recorded) {
530
658
  throw new Error(
531
- "[agui] submitInterruptResponses: a run is already in progress",
659
+ `[agui] respondToToolApproval: approval "${options.approvalId}" is already decided`,
532
660
  );
533
661
  }
662
+ this.notifyUpdate();
663
+
664
+ const assistant = this.tryGetMessage(pending.messageId)?.message as
665
+ | ThreadAssistantMessage
666
+ | undefined;
667
+ if (!assistant) return;
668
+
669
+ // AG-UI resumes a run with one response per open interrupt, so the run
670
+ // stays paused until every gate in the batch has been answered.
671
+ const resume = buildToolApprovalResume(
672
+ assistant.content,
673
+ pending.interrupts,
674
+ );
675
+ if (!resume) return;
534
676
 
535
- this.clearPendingInterrupts(pending.messageId);
536
- await this.startRun(pending.messageId, this.lastRunConfig, resume);
677
+ await this.resumeWithResponses(pending.messageId, resume);
537
678
  }
538
679
 
539
680
  async steerAway(
@@ -575,7 +716,7 @@ export class AgUiThreadRuntimeCore {
575
716
  }
576
717
 
577
718
  const normalized = this.toAppendMessage(message);
578
- this.clearPendingInterrupts(pending.messageId);
719
+ this.clearPendingInterrupts(pending.messageId, resume);
579
720
  const threadMessageId = this.appendEntry(normalized);
580
721
  await this.startRun(threadMessageId, normalized.runConfig, resume);
581
722
  }
@@ -657,7 +798,10 @@ export class AgUiThreadRuntimeCore {
657
798
  } as AppendMessage;
658
799
  }
659
800
 
660
- private clearPendingInterrupts(messageId: string): void {
801
+ private clearPendingInterrupts(
802
+ messageId: string,
803
+ resume: readonly AgUiResumeEntry[],
804
+ ): void {
661
805
  const touched = this.updateMessage(messageId, (message) => {
662
806
  if (message.role !== "assistant") return message;
663
807
  const assistant = message as ThreadAssistantMessage;
@@ -679,6 +823,7 @@ export class AgUiThreadRuntimeCore {
679
823
  }
680
824
  return {
681
825
  ...assistant,
826
+ content: withSettledToolApprovals(assistant.content, resume),
682
827
  status: { type: "complete" as const, reason: "unknown" as const },
683
828
  metadata: { ...assistant.metadata, custom: newCustom },
684
829
  };
@@ -870,9 +1015,9 @@ export class AgUiThreadRuntimeCore {
870
1015
  this.resetRepositoryHead(lastAppliedId);
871
1016
  }
872
1017
 
873
- this.recordedHistoryIds.clear();
1018
+ this.snapshotHistoryIds.clear();
874
1019
  for (const { message } of this.getMessageRepository().messages) {
875
- this.recordedHistoryIds.add(message.id);
1020
+ this.snapshotHistoryIds.add(message.id);
876
1021
  }
877
1022
  this.notifyUpdate();
878
1023
  }
@@ -934,9 +1079,10 @@ export class AgUiThreadRuntimeCore {
934
1079
  }
935
1080
 
936
1081
  this.assistantHistoryParents.clear();
937
- this.recordedHistoryIds.clear();
1082
+ this.snapshotHistoryIds.clear();
1083
+ this.persistedHistoryIds.clear();
938
1084
  for (const { message } of loaded.messages) {
939
- this.recordedHistoryIds.add(message.id);
1085
+ this.persistedHistoryIds.add(message.id);
940
1086
  }
941
1087
  this.notifyUpdate();
942
1088
  }
@@ -979,14 +1125,20 @@ export class AgUiThreadRuntimeCore {
979
1125
  ? (this.tryGetMessages(parentId) ?? this.repository.getMessages())
980
1126
  : this.repository.getMessages()),
981
1127
  ];
1128
+ const runStartMessageIds = new Set(
1129
+ this.getMessageRepository().messages.map(({ message }) => message.id),
1130
+ );
982
1131
 
983
1132
  this.pendingError = null;
984
1133
  const assistantParentId = parent ? parentId : this.repository.headId;
985
1134
  let assistantMessageId: string | undefined;
986
1135
  // A snapshot the preserve gate declines still evicts the in-flight
987
1136
  // assistant; recreating under the cached id on the next content-bearing
988
- // emit keeps both the stream and the message identity. Status-only emits
989
- // and server-id collisions must not recreate.
1137
+ // emit keeps both the stream and the message identity. Status-only emits,
1138
+ // data-only content, and off-branch server-id collisions must not
1139
+ // recreate: the snapshot already carries this turn's assistant, so
1140
+ // resurrecting for custom-event data would leave a trailing data-only
1141
+ // duplicate.
990
1142
  let assistantCollided = false;
991
1143
  const ensureAssistant = (allowRecreate = false): string => {
992
1144
  const cached = assistantMessageId;
@@ -994,12 +1146,20 @@ export class AgUiThreadRuntimeCore {
994
1146
  if (cached !== undefined && (assistantCollided || !allowRecreate)) {
995
1147
  return cached;
996
1148
  }
1149
+ const repositoryHeadId = this.repository.headId;
1150
+ const shouldUseSelectedParent =
1151
+ cached === undefined ||
1152
+ (shouldEagerlyInsertAssistant &&
1153
+ (repositoryHeadId === null ||
1154
+ runStartMessageIds.has(repositoryHeadId)));
1155
+ // Branch runs keep their selected parent unless the snapshot advanced to
1156
+ // a message introduced during this run.
997
1157
  const parentId =
998
- cached === undefined &&
1158
+ shouldUseSelectedParent &&
999
1159
  assistantParentId &&
1000
1160
  this.hasMessage(assistantParentId)
1001
1161
  ? assistantParentId
1002
- : this.repository.headId;
1162
+ : repositoryHeadId;
1003
1163
  const created = this.insertAssistantPlaceholder(parentId, cached);
1004
1164
  assistantMessageId = created;
1005
1165
  this.markPendingAssistantHistory(created, parentId);
@@ -1009,10 +1169,11 @@ export class AgUiThreadRuntimeCore {
1009
1169
  if (shouldEagerlyInsertAssistant) ensureAssistant();
1010
1170
 
1011
1171
  const applyUpdate = (update: ChatModelRunResult) => {
1012
- const hasContent =
1013
- Array.isArray(update.content) && update.content.length > 0;
1172
+ const hasStreamContent =
1173
+ Array.isArray(update.content) &&
1174
+ update.content.some((part) => part.type !== "data");
1014
1175
  const resolved = this.updateAssistantMessage(
1015
- ensureAssistant(hasContent),
1176
+ ensureAssistant(hasStreamContent),
1016
1177
  update,
1017
1178
  );
1018
1179
  if (resolved !== assistantMessageId) {
@@ -1027,8 +1188,21 @@ export class AgUiThreadRuntimeCore {
1027
1188
  onServerMessageId: (serverId) => {
1028
1189
  const placeholder = ensureAssistant(true);
1029
1190
  if (placeholder === serverId) return;
1030
- if (this.reassignAssistantId(placeholder, serverId)) {
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) {
1031
1201
  assistantMessageId = serverId;
1202
+ if (adoptsVisibleCollision) {
1203
+ const parentId = this.tryGetMessage(serverId)?.parentId ?? null;
1204
+ this.markPendingAssistantHistory(serverId, parentId);
1205
+ }
1032
1206
  } else {
1033
1207
  assistantCollided = true;
1034
1208
  }
@@ -1114,6 +1288,7 @@ export class AgUiThreadRuntimeCore {
1114
1288
 
1115
1289
  if (this.pendingError) {
1116
1290
  const err = this.pendingError;
1291
+ this.reportedErrors.add(err);
1117
1292
  this.pendingError = null;
1118
1293
  this.pendingResumeMessageId = null;
1119
1294
  this.pendingA2uiResume = false;
@@ -1344,10 +1519,33 @@ export class AgUiThreadRuntimeCore {
1344
1519
  }
1345
1520
  }
1346
1521
 
1347
- if (this.recordedHistoryIds.has(oldId)) {
1348
- this.recordedHistoryIds.delete(oldId);
1522
+ for (const ids of [this.snapshotHistoryIds, this.persistedHistoryIds]) {
1523
+ if (ids.has(oldId)) {
1524
+ ids.delete(oldId);
1525
+ if (!collidesWithExisting) {
1526
+ ids.add(newId);
1527
+ }
1528
+ }
1529
+ }
1530
+
1531
+ // An in-flight write completes under the id it was started with, so the
1532
+ // rename moves the chain entry and transfers the completion mark; without
1533
+ // this the resolve records the dead id and the live one appends again.
1534
+ const pendingWrite = this.historyWrites.get(oldId);
1535
+ if (pendingWrite) {
1536
+ this.historyWrites.delete(oldId);
1349
1537
  if (!collidesWithExisting) {
1350
- this.recordedHistoryIds.add(newId);
1538
+ this.historyWrites.set(newId, pendingWrite);
1539
+ const settle = () => {
1540
+ if (this.historyWrites.get(newId) === pendingWrite) {
1541
+ this.historyWrites.delete(newId);
1542
+ }
1543
+ };
1544
+ void pendingWrite.then(() => {
1545
+ this.persistedHistoryIds.delete(oldId);
1546
+ this.persistedHistoryIds.add(newId);
1547
+ settle();
1548
+ }, settle);
1351
1549
  }
1352
1550
  }
1353
1551
 
@@ -1456,7 +1654,7 @@ export class AgUiThreadRuntimeCore {
1456
1654
  ...(current.isOptimistic ? { isOptimistic: true } : {}),
1457
1655
  ...(incoming.timing ? { timing: incoming.timing } : {}),
1458
1656
  custom: incoming.custom
1459
- ? { ...current.custom, ...incoming.custom }
1657
+ ? mergeAgUiNamespace(current.custom, incoming.custom)
1460
1658
  : current.custom,
1461
1659
  };
1462
1660
  }
@@ -1688,7 +1886,6 @@ export class AgUiThreadRuntimeCore {
1688
1886
  );
1689
1887
  }
1690
1888
  }
1691
- const snapshotHeadId = converted.at(-1)?.id ?? null;
1692
1889
  const snapshotContainsActiveAssistant = converted.some(
1693
1890
  (message) => message.id === activeAssistant?.id,
1694
1891
  );
@@ -1701,9 +1898,17 @@ export class AgUiThreadRuntimeCore {
1701
1898
  converted.push(activeAssistant);
1702
1899
  }
1703
1900
  this.applyExternalMessages(converted);
1704
- if (preservesActiveAssistant) {
1705
- this.recordedHistoryIds.delete(activeAssistant.id);
1706
- this.markPendingAssistantHistory(activeAssistant.id, snapshotHeadId);
1901
+ if (activeAssistant !== undefined) {
1902
+ const activeItem = this.tryGetMessage(activeAssistant.id);
1903
+ if (activeItem) {
1904
+ if (preservesActiveAssistant) {
1905
+ this.snapshotHistoryIds.delete(activeAssistant.id);
1906
+ }
1907
+ this.markPendingAssistantHistory(
1908
+ activeAssistant.id,
1909
+ activeItem.parentId,
1910
+ );
1911
+ }
1707
1912
  }
1708
1913
  } catch (error) {
1709
1914
  this.logger.error?.("[agui] failed to import messages snapshot", error);
@@ -1728,7 +1933,9 @@ export class AgUiThreadRuntimeCore {
1728
1933
  }
1729
1934
 
1730
1935
  private recordHistoryEntry(parentId: string | null, message: ThreadMessage) {
1731
- this.appendHistoryItem(parentId, message);
1936
+ void this.appendHistoryItem(parentId, message)?.catch((error) => {
1937
+ this.logger.error?.("[agui] failed to append history entry", error);
1938
+ });
1732
1939
  }
1733
1940
 
1734
1941
  private markPendingAssistantHistory(
@@ -1740,22 +1947,108 @@ export class AgUiThreadRuntimeCore {
1740
1947
  }
1741
1948
 
1742
1949
  private persistAssistantHistory(messageId: string) {
1743
- if (!this.history) return;
1950
+ const history = this.history;
1951
+ if (!history) return;
1744
1952
  const parentId = this.assistantHistoryParents.get(messageId);
1745
1953
  if (parentId === undefined) return;
1746
1954
  const message = this.tryGetMessage(messageId)?.message;
1747
1955
  if (!message || message.role !== "assistant") return;
1748
1956
  if (!this.isPersistableStatus(message.status)) return;
1957
+ const wasPersisted = this.persistedHistoryIds.has(messageId);
1958
+ const update = history.update;
1959
+ const shouldUpdate =
1960
+ update !== undefined &&
1961
+ (wasPersisted || this.snapshotHistoryIds.has(messageId));
1962
+
1963
+ if (shouldUpdate) {
1964
+ const write = this.chainHistoryWrite(messageId, () =>
1965
+ update.call(history, { parentId, message }),
1966
+ );
1967
+ this.assistantHistoryParents.delete(messageId);
1968
+ void write.then(
1969
+ () => {
1970
+ this.persistedHistoryIds.add(messageId);
1971
+ },
1972
+ (error) => {
1973
+ const pending = this.historyWrites.get(messageId);
1974
+ if (pending === undefined || pending === write) {
1975
+ this.assistantHistoryParents.set(messageId, parentId);
1976
+ }
1977
+ this.logger.error?.("[agui] failed to update history entry", error);
1978
+ },
1979
+ );
1980
+ return;
1981
+ }
1982
+
1983
+ if (wasPersisted) {
1984
+ this.assistantHistoryParents.delete(messageId);
1985
+ return;
1986
+ }
1987
+
1988
+ const write = this.appendHistoryItem(parentId, message);
1989
+ if (!write) return;
1749
1990
  this.assistantHistoryParents.delete(messageId);
1750
- this.appendHistoryItem(parentId, message);
1991
+ void write.then(
1992
+ () => {},
1993
+ (error) => {
1994
+ const pending = this.historyWrites.get(messageId);
1995
+ if (pending === undefined || pending === write) {
1996
+ this.assistantHistoryParents.set(messageId, parentId);
1997
+ }
1998
+ this.logger.error?.("[agui] failed to append history entry", error);
1999
+ },
2000
+ );
1751
2001
  }
1752
2002
 
1753
- private appendHistoryItem(parentId: string | null, message: ThreadMessage) {
1754
- if (!this.history || this.recordedHistoryIds.has(message.id)) return;
1755
- this.recordedHistoryIds.add(message.id);
1756
- void this.history.append({ parentId, message }).catch((error) => {
1757
- this.recordedHistoryIds.delete(message.id);
1758
- this.logger.error?.("[agui] failed to append history entry", error);
1759
- });
2003
+ private appendHistoryItem(
2004
+ parentId: string | null,
2005
+ message: ThreadMessage,
2006
+ ): Promise<void> | undefined {
2007
+ if (!this.history || this.persistedHistoryIds.has(message.id)) return;
2008
+ const pending = this.historyWrites.get(message.id);
2009
+ if (pending) return pending;
2010
+
2011
+ const append = this.history.append.bind(this.history);
2012
+ const write = this.chainHistoryWrite(message.id, () =>
2013
+ append({ parentId, message }),
2014
+ );
2015
+ void write.then(
2016
+ () => {
2017
+ this.persistedHistoryIds.add(message.id);
2018
+ },
2019
+ () => {},
2020
+ );
2021
+ return write;
2022
+ }
2023
+
2024
+ private chainHistoryWrite(
2025
+ id: string,
2026
+ write: () => Promise<void>,
2027
+ ): Promise<void> {
2028
+ const pending = this.historyWrites.get(id);
2029
+ let next: Promise<void>;
2030
+ if (pending) {
2031
+ next = pending.then(write, write);
2032
+ } else {
2033
+ try {
2034
+ next = Promise.resolve(write());
2035
+ } catch (error) {
2036
+ next = Promise.reject(error);
2037
+ }
2038
+ }
2039
+ this.historyWrites.set(id, next);
2040
+ void next.then(
2041
+ () => {
2042
+ if (this.historyWrites.get(id) === next) {
2043
+ this.historyWrites.delete(id);
2044
+ }
2045
+ },
2046
+ () => {
2047
+ if (this.historyWrites.get(id) === next) {
2048
+ this.historyWrites.delete(id);
2049
+ }
2050
+ },
2051
+ );
2052
+ return next;
1760
2053
  }
1761
2054
  }