@assistant-ui/react-ag-ui 0.0.54 → 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 (45) 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 +169 -35
  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 +58 -26
  10. package/dist/runtime/adapter/conversions.js.map +1 -1
  11. package/dist/runtime/adapter/run-aggregator.d.ts +5 -1
  12. package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
  13. package/dist/runtime/adapter/run-aggregator.js +26 -1
  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 +3 -0
  31. package/dist/useAgUiRuntime.js.map +1 -1
  32. package/package.json +7 -7
  33. package/src/index.ts +8 -2
  34. package/src/runtime/AgUiThreadRuntimeCore.ts +314 -41
  35. package/src/runtime/adapter/conversions.content.test.ts +61 -3
  36. package/src/runtime/adapter/conversions.ts +83 -39
  37. package/src/runtime/adapter/run-aggregator.approval.test.ts +103 -0
  38. package/src/runtime/adapter/run-aggregator.ts +37 -1
  39. package/src/runtime/adapter/subscriber.ts +14 -0
  40. package/src/runtime/adapter/tool-approval.test.ts +681 -0
  41. package/src/runtime/adapter/tool-approval.ts +366 -0
  42. package/src/runtime/event-parser.ts +10 -1
  43. package/src/runtime/interrupt-internals.ts +35 -0
  44. package/src/useAgUiRuntime.approval.test.tsx +885 -0
  45. package/src/useAgUiRuntime.ts +4 -0
@@ -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
@@ -148,6 +155,7 @@ export class AgUiThreadRuntimeCore {
148
155
  private onError: ((error: Error) => void) | undefined;
149
156
  private onCancel: (() => void) | undefined;
150
157
  private readonly notifyUpdate: () => void;
158
+ private readonly reportedErrors = new WeakSet<object>();
151
159
 
152
160
  private runtime: AssistantRuntime | undefined;
153
161
  private readonly repository = new MessageRepository();
@@ -162,7 +170,9 @@ export class AgUiThreadRuntimeCore {
162
170
  private history: ThreadHistoryAdapter | undefined;
163
171
  private lastRunConfig: RunConfig | undefined;
164
172
  private readonly assistantHistoryParents = new Map<string, string | null>();
165
- 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>>();
166
176
  private _isLoading = false;
167
177
  private _loadPromise: Promise<void> | undefined;
168
178
  private pendingResumeMessageId: string | null = null;
@@ -198,6 +208,9 @@ export class AgUiThreadRuntimeCore {
198
208
 
199
209
  detachRuntime() {
200
210
  this.runtime = undefined;
211
+ void this.cancel().catch((error) => {
212
+ this.logger.error("[agui] failed to cancel run during teardown", error);
213
+ });
201
214
  }
202
215
 
203
216
  getMessages(): readonly ThreadMessage[] {
@@ -526,34 +539,142 @@ export class AgUiThreadRuntimeCore {
526
539
  );
527
540
  }
528
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 {
529
590
  const now = Date.now();
530
- for (const interrupt of pending.interrupts) {
591
+ for (const interrupt of interrupts) {
531
592
  if (!interrupt.expiresAt) continue;
532
593
  const expiry = new Date(interrupt.expiresAt).getTime();
533
594
  if (Number.isNaN(expiry)) {
534
595
  throw new Error(
535
- `[agui] submitInterruptResponses: interrupt ${interrupt.id} has malformed expiresAt "${interrupt.expiresAt}"`,
596
+ `[agui] ${method}: interrupt ${interrupt.id} has malformed expiresAt "${interrupt.expiresAt}"`,
536
597
  );
537
598
  }
538
599
  if (expiry <= now) {
539
600
  throw new Error(
540
- `[agui] submitInterruptResponses: interrupt ${interrupt.id} expired at ${interrupt.expiresAt}`,
601
+ `[agui] ${method}: interrupt ${interrupt.id} expired at ${interrupt.expiresAt}`,
541
602
  );
542
603
  }
543
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
+ }
544
619
 
545
- const resume: AgUiResumeEntry[] = openIds.map((id) =>
546
- 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
+ ),
547
632
  );
633
+ const isGated = [...gated.values()].some(
634
+ (approval) => approval.id === options.approvalId,
635
+ );
636
+ if (!isGated) {
637
+ throw new Error(
638
+ `[agui] respondToToolApproval: no pending tool-call interrupt for approval id "${options.approvalId}"`,
639
+ );
640
+ }
548
641
 
549
- 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) {
550
658
  throw new Error(
551
- "[agui] submitInterruptResponses: a run is already in progress",
659
+ `[agui] respondToToolApproval: approval "${options.approvalId}" is already decided`,
552
660
  );
553
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;
554
676
 
555
- this.clearPendingInterrupts(pending.messageId);
556
- await this.startRun(pending.messageId, this.lastRunConfig, resume);
677
+ await this.resumeWithResponses(pending.messageId, resume);
557
678
  }
558
679
 
559
680
  async steerAway(
@@ -595,7 +716,7 @@ export class AgUiThreadRuntimeCore {
595
716
  }
596
717
 
597
718
  const normalized = this.toAppendMessage(message);
598
- this.clearPendingInterrupts(pending.messageId);
719
+ this.clearPendingInterrupts(pending.messageId, resume);
599
720
  const threadMessageId = this.appendEntry(normalized);
600
721
  await this.startRun(threadMessageId, normalized.runConfig, resume);
601
722
  }
@@ -677,7 +798,10 @@ export class AgUiThreadRuntimeCore {
677
798
  } as AppendMessage;
678
799
  }
679
800
 
680
- private clearPendingInterrupts(messageId: string): void {
801
+ private clearPendingInterrupts(
802
+ messageId: string,
803
+ resume: readonly AgUiResumeEntry[],
804
+ ): void {
681
805
  const touched = this.updateMessage(messageId, (message) => {
682
806
  if (message.role !== "assistant") return message;
683
807
  const assistant = message as ThreadAssistantMessage;
@@ -699,6 +823,7 @@ export class AgUiThreadRuntimeCore {
699
823
  }
700
824
  return {
701
825
  ...assistant,
826
+ content: withSettledToolApprovals(assistant.content, resume),
702
827
  status: { type: "complete" as const, reason: "unknown" as const },
703
828
  metadata: { ...assistant.metadata, custom: newCustom },
704
829
  };
@@ -890,9 +1015,9 @@ export class AgUiThreadRuntimeCore {
890
1015
  this.resetRepositoryHead(lastAppliedId);
891
1016
  }
892
1017
 
893
- this.recordedHistoryIds.clear();
1018
+ this.snapshotHistoryIds.clear();
894
1019
  for (const { message } of this.getMessageRepository().messages) {
895
- this.recordedHistoryIds.add(message.id);
1020
+ this.snapshotHistoryIds.add(message.id);
896
1021
  }
897
1022
  this.notifyUpdate();
898
1023
  }
@@ -954,9 +1079,10 @@ export class AgUiThreadRuntimeCore {
954
1079
  }
955
1080
 
956
1081
  this.assistantHistoryParents.clear();
957
- this.recordedHistoryIds.clear();
1082
+ this.snapshotHistoryIds.clear();
1083
+ this.persistedHistoryIds.clear();
958
1084
  for (const { message } of loaded.messages) {
959
- this.recordedHistoryIds.add(message.id);
1085
+ this.persistedHistoryIds.add(message.id);
960
1086
  }
961
1087
  this.notifyUpdate();
962
1088
  }
@@ -999,14 +1125,20 @@ export class AgUiThreadRuntimeCore {
999
1125
  ? (this.tryGetMessages(parentId) ?? this.repository.getMessages())
1000
1126
  : this.repository.getMessages()),
1001
1127
  ];
1128
+ const runStartMessageIds = new Set(
1129
+ this.getMessageRepository().messages.map(({ message }) => message.id),
1130
+ );
1002
1131
 
1003
1132
  this.pendingError = null;
1004
1133
  const assistantParentId = parent ? parentId : this.repository.headId;
1005
1134
  let assistantMessageId: string | undefined;
1006
1135
  // A snapshot the preserve gate declines still evicts the in-flight
1007
1136
  // 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.
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.
1010
1142
  let assistantCollided = false;
1011
1143
  const ensureAssistant = (allowRecreate = false): string => {
1012
1144
  const cached = assistantMessageId;
@@ -1014,12 +1146,20 @@ export class AgUiThreadRuntimeCore {
1014
1146
  if (cached !== undefined && (assistantCollided || !allowRecreate)) {
1015
1147
  return cached;
1016
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.
1017
1157
  const parentId =
1018
- cached === undefined &&
1158
+ shouldUseSelectedParent &&
1019
1159
  assistantParentId &&
1020
1160
  this.hasMessage(assistantParentId)
1021
1161
  ? assistantParentId
1022
- : this.repository.headId;
1162
+ : repositoryHeadId;
1023
1163
  const created = this.insertAssistantPlaceholder(parentId, cached);
1024
1164
  assistantMessageId = created;
1025
1165
  this.markPendingAssistantHistory(created, parentId);
@@ -1029,10 +1169,11 @@ export class AgUiThreadRuntimeCore {
1029
1169
  if (shouldEagerlyInsertAssistant) ensureAssistant();
1030
1170
 
1031
1171
  const applyUpdate = (update: ChatModelRunResult) => {
1032
- const hasContent =
1033
- Array.isArray(update.content) && update.content.length > 0;
1172
+ const hasStreamContent =
1173
+ Array.isArray(update.content) &&
1174
+ update.content.some((part) => part.type !== "data");
1034
1175
  const resolved = this.updateAssistantMessage(
1035
- ensureAssistant(hasContent),
1176
+ ensureAssistant(hasStreamContent),
1036
1177
  update,
1037
1178
  );
1038
1179
  if (resolved !== assistantMessageId) {
@@ -1047,8 +1188,21 @@ export class AgUiThreadRuntimeCore {
1047
1188
  onServerMessageId: (serverId) => {
1048
1189
  const placeholder = ensureAssistant(true);
1049
1190
  if (placeholder === serverId) return;
1050
- 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) {
1051
1201
  assistantMessageId = serverId;
1202
+ if (adoptsVisibleCollision) {
1203
+ const parentId = this.tryGetMessage(serverId)?.parentId ?? null;
1204
+ this.markPendingAssistantHistory(serverId, parentId);
1205
+ }
1052
1206
  } else {
1053
1207
  assistantCollided = true;
1054
1208
  }
@@ -1134,6 +1288,7 @@ export class AgUiThreadRuntimeCore {
1134
1288
 
1135
1289
  if (this.pendingError) {
1136
1290
  const err = this.pendingError;
1291
+ this.reportedErrors.add(err);
1137
1292
  this.pendingError = null;
1138
1293
  this.pendingResumeMessageId = null;
1139
1294
  this.pendingA2uiResume = false;
@@ -1364,10 +1519,33 @@ export class AgUiThreadRuntimeCore {
1364
1519
  }
1365
1520
  }
1366
1521
 
1367
- if (this.recordedHistoryIds.has(oldId)) {
1368
- 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);
1369
1537
  if (!collidesWithExisting) {
1370
- 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);
1371
1549
  }
1372
1550
  }
1373
1551
 
@@ -1708,7 +1886,6 @@ export class AgUiThreadRuntimeCore {
1708
1886
  );
1709
1887
  }
1710
1888
  }
1711
- const snapshotHeadId = converted.at(-1)?.id ?? null;
1712
1889
  const snapshotContainsActiveAssistant = converted.some(
1713
1890
  (message) => message.id === activeAssistant?.id,
1714
1891
  );
@@ -1721,9 +1898,17 @@ export class AgUiThreadRuntimeCore {
1721
1898
  converted.push(activeAssistant);
1722
1899
  }
1723
1900
  this.applyExternalMessages(converted);
1724
- if (preservesActiveAssistant) {
1725
- this.recordedHistoryIds.delete(activeAssistant.id);
1726
- 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
+ }
1727
1912
  }
1728
1913
  } catch (error) {
1729
1914
  this.logger.error?.("[agui] failed to import messages snapshot", error);
@@ -1748,7 +1933,9 @@ export class AgUiThreadRuntimeCore {
1748
1933
  }
1749
1934
 
1750
1935
  private recordHistoryEntry(parentId: string | null, message: ThreadMessage) {
1751
- this.appendHistoryItem(parentId, message);
1936
+ void this.appendHistoryItem(parentId, message)?.catch((error) => {
1937
+ this.logger.error?.("[agui] failed to append history entry", error);
1938
+ });
1752
1939
  }
1753
1940
 
1754
1941
  private markPendingAssistantHistory(
@@ -1760,22 +1947,108 @@ export class AgUiThreadRuntimeCore {
1760
1947
  }
1761
1948
 
1762
1949
  private persistAssistantHistory(messageId: string) {
1763
- if (!this.history) return;
1950
+ const history = this.history;
1951
+ if (!history) return;
1764
1952
  const parentId = this.assistantHistoryParents.get(messageId);
1765
1953
  if (parentId === undefined) return;
1766
1954
  const message = this.tryGetMessage(messageId)?.message;
1767
1955
  if (!message || message.role !== "assistant") return;
1768
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;
1769
1990
  this.assistantHistoryParents.delete(messageId);
1770
- 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
+ );
1771
2001
  }
1772
2002
 
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
- });
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;
1780
2053
  }
1781
2054
  }
@@ -1,6 +1,15 @@
1
- import { describe, expect, it } from "vitest";
2
- import { UserMessageSchema } from "@ag-ui/client";
3
- import { fromAgUiMessages, toAgUiMessages } from "./conversions";
1
+ import { describe, expect, expectTypeOf, it } from "vitest";
2
+ import {
3
+ MessageSchema,
4
+ UserMessageSchema,
5
+ type Message as AgUiWireMessage,
6
+ } from "@ag-ui/client";
7
+ import type { AppendMessage } from "@assistant-ui/core";
8
+ import {
9
+ fromAgUiMessages,
10
+ toAgUiMessages,
11
+ type AgUiMessage,
12
+ } from "./conversions";
4
13
 
5
14
  type Message = Parameters<typeof toAgUiMessages>[0][number];
6
15
 
@@ -20,7 +29,56 @@ const contentOf = (message: Message) => {
20
29
  return (converted as { content: unknown }).content;
21
30
  };
22
31
 
32
+ const appendMessage = (): AppendMessage => ({
33
+ role: "user",
34
+ content: [{ type: "text", text: "Hi" }],
35
+ attachments: [],
36
+ createdAt: new Date(),
37
+ parentId: null,
38
+ sourceId: null,
39
+ runConfig: undefined,
40
+ metadata: { custom: {} },
41
+ });
42
+
23
43
  describe("toAgUiMessages content metadata", () => {
44
+ it("emits AgUiMessage values assignable to AG-UI Message", () => {
45
+ expectTypeOf<AgUiMessage>().toExtend<AgUiWireMessage>();
46
+ });
47
+
48
+ it("emits records MessageSchema accepts", () => {
49
+ const converted = toAgUiMessages([
50
+ { id: "u-1", role: "user", content: "Hi" },
51
+ { id: "a-1", role: "assistant", content: "Hello" },
52
+ { id: "s-1", role: "system", content: "Be brief" },
53
+ { id: "t-1", role: "tool", content: "ok", toolCallId: "c-1" },
54
+ { id: "r-1", role: "reasoning", content: "hmm" },
55
+ ]);
56
+
57
+ expect(converted.map((message) => MessageSchema.parse(message))).toEqual(
58
+ converted,
59
+ );
60
+ });
61
+
62
+ it("normalizes an AppendMessage without an id", () => {
63
+ const converted = toAgUiMessages([appendMessage()])[0];
64
+ if (!converted) throw new Error("expected a converted message");
65
+
66
+ expect(UserMessageSchema.parse(converted)).toMatchObject({
67
+ role: "user",
68
+ content: "Hi",
69
+ });
70
+ expect(converted.id).toEqual(expect.any(String));
71
+ });
72
+
73
+ it("preserves a caller-supplied id", () => {
74
+ const converted = toAgUiMessages([
75
+ { ...appendMessage(), id: "stable-user-1" },
76
+ ])[0];
77
+ if (!converted) throw new Error("expected a converted message");
78
+
79
+ expect(UserMessageSchema.parse(converted).id).toBe("stable-user-1");
80
+ });
81
+
24
82
  it("carries a part's agui provider metadata onto an image item", () => {
25
83
  expect(
26
84
  contentOf(