@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.
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/runtime/AgUiThreadRuntimeCore.d.ts +23 -2
- package/dist/runtime/AgUiThreadRuntimeCore.d.ts.map +1 -1
- package/dist/runtime/AgUiThreadRuntimeCore.js +169 -35
- package/dist/runtime/AgUiThreadRuntimeCore.js.map +1 -1
- package/dist/runtime/adapter/conversions.d.ts +13 -3
- package/dist/runtime/adapter/conversions.d.ts.map +1 -1
- package/dist/runtime/adapter/conversions.js +58 -26
- package/dist/runtime/adapter/conversions.js.map +1 -1
- package/dist/runtime/adapter/run-aggregator.d.ts +5 -1
- package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
- package/dist/runtime/adapter/run-aggregator.js +26 -1
- package/dist/runtime/adapter/run-aggregator.js.map +1 -1
- package/dist/runtime/adapter/subscriber.d.ts +3 -0
- package/dist/runtime/adapter/subscriber.d.ts.map +1 -1
- package/dist/runtime/adapter/subscriber.js +9 -0
- package/dist/runtime/adapter/subscriber.js.map +1 -1
- package/dist/runtime/adapter/tool-approval.d.ts +56 -0
- package/dist/runtime/adapter/tool-approval.d.ts.map +1 -0
- package/dist/runtime/adapter/tool-approval.js +244 -0
- package/dist/runtime/adapter/tool-approval.js.map +1 -0
- package/dist/runtime/event-parser.d.ts.map +1 -1
- package/dist/runtime/event-parser.js +2 -1
- package/dist/runtime/event-parser.js.map +1 -1
- package/dist/runtime/interrupt-internals.d.ts +8 -0
- package/dist/runtime/interrupt-internals.d.ts.map +1 -0
- package/dist/runtime/interrupt-internals.js +25 -0
- package/dist/runtime/interrupt-internals.js.map +1 -0
- package/dist/useAgUiRuntime.js +3 -0
- package/dist/useAgUiRuntime.js.map +1 -1
- package/package.json +7 -7
- package/src/index.ts +8 -2
- package/src/runtime/AgUiThreadRuntimeCore.ts +314 -41
- package/src/runtime/adapter/conversions.content.test.ts +61 -3
- package/src/runtime/adapter/conversions.ts +83 -39
- package/src/runtime/adapter/run-aggregator.approval.test.ts +103 -0
- package/src/runtime/adapter/run-aggregator.ts +37 -1
- package/src/runtime/adapter/subscriber.ts +14 -0
- package/src/runtime/adapter/tool-approval.test.ts +681 -0
- package/src/runtime/adapter/tool-approval.ts +366 -0
- package/src/runtime/event-parser.ts +10 -1
- package/src/runtime/interrupt-internals.ts +35 -0
- package/src/useAgUiRuntime.approval.test.tsx +885 -0
- 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
|
|
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
|
|
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]
|
|
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]
|
|
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
|
-
|
|
546
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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(
|
|
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.
|
|
1018
|
+
this.snapshotHistoryIds.clear();
|
|
894
1019
|
for (const { message } of this.getMessageRepository().messages) {
|
|
895
|
-
this.
|
|
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.
|
|
1082
|
+
this.snapshotHistoryIds.clear();
|
|
1083
|
+
this.persistedHistoryIds.clear();
|
|
958
1084
|
for (const { message } of loaded.messages) {
|
|
959
|
-
this.
|
|
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
|
|
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
|
-
|
|
1158
|
+
shouldUseSelectedParent &&
|
|
1019
1159
|
assistantParentId &&
|
|
1020
1160
|
this.hasMessage(assistantParentId)
|
|
1021
1161
|
? assistantParentId
|
|
1022
|
-
:
|
|
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
|
|
1033
|
-
Array.isArray(update.content) &&
|
|
1172
|
+
const hasStreamContent =
|
|
1173
|
+
Array.isArray(update.content) &&
|
|
1174
|
+
update.content.some((part) => part.type !== "data");
|
|
1034
1175
|
const resolved = this.updateAssistantMessage(
|
|
1035
|
-
ensureAssistant(
|
|
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
|
-
|
|
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
|
-
|
|
1368
|
-
|
|
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.
|
|
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 (
|
|
1725
|
-
this.
|
|
1726
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
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 {
|
|
3
|
-
|
|
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(
|