@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.
- 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 +183 -39
- package/dist/runtime/AgUiThreadRuntimeCore.js.map +1 -1
- package/dist/runtime/adapter/conversions.d.ts +19 -3
- package/dist/runtime/adapter/conversions.d.ts.map +1 -1
- package/dist/runtime/adapter/conversions.js +197 -36
- package/dist/runtime/adapter/conversions.js.map +1 -1
- package/dist/runtime/adapter/run-aggregator.d.ts +15 -2
- package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
- package/dist/runtime/adapter/run-aggregator.js +62 -10
- package/dist/runtime/adapter/run-aggregator.js.map +1 -1
- package/dist/runtime/adapter/subscriber.d.ts +6 -0
- package/dist/runtime/adapter/subscriber.d.ts.map +1 -1
- package/dist/runtime/adapter/subscriber.js +10 -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 +15 -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/runtime/types.d.ts +5 -0
- package/dist/runtime/types.d.ts.map +1 -1
- package/dist/useAgUiRuntime.js +4 -1
- package/dist/useAgUiRuntime.js.map +1 -1
- package/package.json +7 -7
- package/src/index.ts +8 -2
- package/src/runtime/AgUiThreadRuntimeCore.ts +335 -42
- package/src/runtime/adapter/conversions.content.test.ts +278 -0
- package/src/runtime/adapter/conversions.ts +298 -50
- package/src/runtime/adapter/run-aggregator.approval.test.ts +103 -0
- package/src/runtime/adapter/run-aggregator.ts +96 -5
- package/src/runtime/adapter/subscriber.ts +17 -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 +23 -1
- package/src/runtime/interrupt-internals.ts +35 -0
- package/src/runtime/types.ts +6 -0
- package/src/useAgUiRuntime.approval.test.tsx +885 -0
- package/src/useAgUiRuntime.queue.test.tsx +68 -1
- package/src/useAgUiRuntime.ts +5 -1
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import type { InputContent, RunAgentParameters } from "@ag-ui/client";
|
|
4
4
|
import type {
|
|
5
5
|
ThreadMessageLike as CoreThreadMessageLike,
|
|
6
|
+
PartProviderMetadata,
|
|
6
7
|
ToolCallMessagePartMcpMetadata,
|
|
7
8
|
ToolModelContentPart,
|
|
8
9
|
} from "@assistant-ui/core";
|
|
@@ -17,6 +18,7 @@ import {
|
|
|
17
18
|
AG_UI_METADATA_NAMESPACE,
|
|
18
19
|
A2UI_SURFACE_ACTIVITY_TYPE,
|
|
19
20
|
type AgUiCustomMetadata,
|
|
21
|
+
type AgUiOpaqueReasoning,
|
|
20
22
|
} from "./run-aggregator";
|
|
21
23
|
import {
|
|
22
24
|
applyA2uiOperations,
|
|
@@ -25,6 +27,7 @@ import {
|
|
|
25
27
|
type A2uiSurfaceState,
|
|
26
28
|
} from "@assistant-ui/react-generative-ui/a2ui";
|
|
27
29
|
import type { AgUiInterrupt } from "../types";
|
|
30
|
+
import { projectAgUiToolApprovals } from "./tool-approval";
|
|
28
31
|
import {
|
|
29
32
|
parseMcpToolCallResult,
|
|
30
33
|
readMcpAppResourceUri,
|
|
@@ -39,15 +42,18 @@ type AttachmentLike = {
|
|
|
39
42
|
};
|
|
40
43
|
|
|
41
44
|
type ThreadMessageLike = {
|
|
42
|
-
id
|
|
45
|
+
id?: string;
|
|
43
46
|
role: string;
|
|
44
47
|
content: unknown;
|
|
48
|
+
metadata?: unknown;
|
|
45
49
|
name?: string;
|
|
46
50
|
toolCallId?: string;
|
|
47
51
|
error?: string;
|
|
48
52
|
attachments?: readonly AttachmentLike[];
|
|
49
53
|
};
|
|
50
54
|
|
|
55
|
+
type NormalizedThreadMessageLike = ThreadMessageLike & { id: string };
|
|
56
|
+
|
|
51
57
|
type AgUiToolCall = {
|
|
52
58
|
id: string;
|
|
53
59
|
type: "function";
|
|
@@ -57,11 +63,29 @@ type AgUiToolCall = {
|
|
|
57
63
|
export type AgUiMessage =
|
|
58
64
|
| {
|
|
59
65
|
id: string;
|
|
60
|
-
role:
|
|
66
|
+
role: "user";
|
|
61
67
|
content: string | InputContent[];
|
|
62
68
|
name?: string;
|
|
69
|
+
}
|
|
70
|
+
| {
|
|
71
|
+
id: string;
|
|
72
|
+
role: "assistant";
|
|
73
|
+
content: string;
|
|
74
|
+
name?: string;
|
|
63
75
|
toolCalls?: AgUiToolCall[];
|
|
64
76
|
}
|
|
77
|
+
| {
|
|
78
|
+
id: string;
|
|
79
|
+
role: "system" | "developer";
|
|
80
|
+
content: string;
|
|
81
|
+
name?: string;
|
|
82
|
+
}
|
|
83
|
+
| {
|
|
84
|
+
id: string;
|
|
85
|
+
role: "reasoning";
|
|
86
|
+
content: string;
|
|
87
|
+
encryptedValue?: string;
|
|
88
|
+
}
|
|
65
89
|
| {
|
|
66
90
|
id: string;
|
|
67
91
|
role: "tool";
|
|
@@ -94,6 +118,56 @@ const getString = (record: Record<string, unknown>, key: string) => {
|
|
|
94
118
|
const getToolCallId = (record: Record<string, unknown>) =>
|
|
95
119
|
getString(record, "toolCallId") ?? getString(record, "tool_call_id");
|
|
96
120
|
|
|
121
|
+
const readAgUiNamespace = (providerMetadata: unknown) => {
|
|
122
|
+
if (!isObject(providerMetadata)) return undefined;
|
|
123
|
+
const namespaced = providerMetadata[AG_UI_METADATA_NAMESPACE];
|
|
124
|
+
return isObject(namespaced) ? namespaced : undefined;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const readAgUiReasoningMeta = (providerMetadata: unknown) => {
|
|
128
|
+
const namespaced = readAgUiNamespace(providerMetadata);
|
|
129
|
+
if (!namespaced) return {};
|
|
130
|
+
return {
|
|
131
|
+
encryptedValue: getString(namespaced, "encryptedValue"),
|
|
132
|
+
reasoningId: getString(namespaced, "reasoningId"),
|
|
133
|
+
};
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// AG-UI carries per-item extras in `metadata`; anything else on the item is
|
|
137
|
+
// stripped by its schema. The part's own `filename` wins over a key of the
|
|
138
|
+
// same name in the bag.
|
|
139
|
+
const buildInputMetadata = (
|
|
140
|
+
part: Record<string, unknown>,
|
|
141
|
+
filename?: string | undefined,
|
|
142
|
+
) => {
|
|
143
|
+
const metadata = {
|
|
144
|
+
...readAgUiNamespace(part.providerMetadata),
|
|
145
|
+
...(filename !== undefined && { filename }),
|
|
146
|
+
};
|
|
147
|
+
return Object.keys(metadata).length > 0 ? metadata : undefined;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
// The inverse: `filename` lands on the part's own field, everything else the
|
|
151
|
+
// item carried goes back into the namespace it came from, so a snapshot echo
|
|
152
|
+
// resends what the host attached.
|
|
153
|
+
const readInputMetadata = (
|
|
154
|
+
metadata: unknown,
|
|
155
|
+
): {
|
|
156
|
+
filename?: string | undefined;
|
|
157
|
+
providerMetadata?: PartProviderMetadata | undefined;
|
|
158
|
+
} => {
|
|
159
|
+
if (!isObject(metadata)) return {};
|
|
160
|
+
const { filename: _filename, ...rest } = metadata;
|
|
161
|
+
return {
|
|
162
|
+
filename: getString(metadata, "filename"),
|
|
163
|
+
...(Object.keys(rest).length > 0 && {
|
|
164
|
+
providerMetadata: {
|
|
165
|
+
[AG_UI_METADATA_NAMESPACE]: rest as PartProviderMetadata[string],
|
|
166
|
+
},
|
|
167
|
+
}),
|
|
168
|
+
};
|
|
169
|
+
};
|
|
170
|
+
|
|
97
171
|
function parseJSONText(value: string): unknown {
|
|
98
172
|
if (!value) return value;
|
|
99
173
|
try {
|
|
@@ -198,7 +272,12 @@ function toInputContent(
|
|
|
198
272
|
if (type === "image") {
|
|
199
273
|
const image = getString(part, "image");
|
|
200
274
|
if (image === undefined) return null;
|
|
201
|
-
|
|
275
|
+
const metadata = buildInputMetadata(part, getString(part, "filename"));
|
|
276
|
+
return {
|
|
277
|
+
type: "image",
|
|
278
|
+
source: buildInputSource(image, fallbackMimeType),
|
|
279
|
+
...(metadata && { metadata }),
|
|
280
|
+
};
|
|
202
281
|
}
|
|
203
282
|
|
|
204
283
|
if (type === "file") {
|
|
@@ -211,7 +290,7 @@ function toInputContent(
|
|
|
211
290
|
declaredMimeType,
|
|
212
291
|
getString(part, "sourceType"),
|
|
213
292
|
);
|
|
214
|
-
const metadata =
|
|
293
|
+
const metadata = buildInputMetadata(part, filename);
|
|
215
294
|
switch (mediaTypeForMime(source.mimeType)) {
|
|
216
295
|
case "image":
|
|
217
296
|
return { type: "image", source, ...(metadata && { metadata }) };
|
|
@@ -312,9 +391,7 @@ function toSnapshotAttachments(content: unknown): SnapshotAttachment[] {
|
|
|
312
391
|
const source = inputSourceToString(part.source);
|
|
313
392
|
if (!source) continue;
|
|
314
393
|
|
|
315
|
-
const filename =
|
|
316
|
-
? getString(part.metadata, "filename")
|
|
317
|
-
: undefined;
|
|
394
|
+
const { filename, providerMetadata } = readInputMetadata(part.metadata);
|
|
318
395
|
const id = attachments.length.toString();
|
|
319
396
|
|
|
320
397
|
if (type === "image") {
|
|
@@ -329,6 +406,7 @@ function toSnapshotAttachments(content: unknown): SnapshotAttachment[] {
|
|
|
329
406
|
type: "image",
|
|
330
407
|
image: source.value,
|
|
331
408
|
...(filename !== undefined && { filename }),
|
|
409
|
+
...(providerMetadata && { providerMetadata }),
|
|
332
410
|
},
|
|
333
411
|
],
|
|
334
412
|
});
|
|
@@ -349,6 +427,7 @@ function toSnapshotAttachments(content: unknown): SnapshotAttachment[] {
|
|
|
349
427
|
mimeType,
|
|
350
428
|
...(source.isUrl && { sourceType: "url" as const }),
|
|
351
429
|
...(filename !== undefined && { filename }),
|
|
430
|
+
...(providerMetadata && { providerMetadata }),
|
|
352
431
|
},
|
|
353
432
|
],
|
|
354
433
|
});
|
|
@@ -498,17 +577,71 @@ function readPersistedInterrupts(
|
|
|
498
577
|
return validateInterrupts(namespaced.interrupts);
|
|
499
578
|
}
|
|
500
579
|
|
|
580
|
+
function readOpaqueReasoning(metadata: unknown): AgUiOpaqueReasoning[] {
|
|
581
|
+
if (!isObject(metadata)) return [];
|
|
582
|
+
const custom = metadata.custom;
|
|
583
|
+
if (!isObject(custom)) return [];
|
|
584
|
+
const namespaced = custom[AG_UI_METADATA_NAMESPACE];
|
|
585
|
+
if (!isObject(namespaced)) return [];
|
|
586
|
+
const entries = namespaced.opaqueReasoning;
|
|
587
|
+
if (!Array.isArray(entries)) return [];
|
|
588
|
+
return entries.flatMap((entry) => {
|
|
589
|
+
if (!isObject(entry)) return [];
|
|
590
|
+
const id = getString(entry, "id");
|
|
591
|
+
const encryptedValue = getString(entry, "encryptedValue");
|
|
592
|
+
if (!id?.trim() || !encryptedValue?.trim()) return [];
|
|
593
|
+
return [
|
|
594
|
+
{ id, encryptedValue, ...(entry.after === true ? { after: true } : {}) },
|
|
595
|
+
];
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function withOpaqueReasoning(
|
|
600
|
+
message: CoreThreadMessageLike,
|
|
601
|
+
opaqueReasoning: AgUiOpaqueReasoning[],
|
|
602
|
+
): CoreThreadMessageLike {
|
|
603
|
+
const metadata = isObject(message.metadata) ? message.metadata : {};
|
|
604
|
+
const custom = isObject(metadata.custom) ? metadata.custom : {};
|
|
605
|
+
const namespaced = isObject(custom[AG_UI_METADATA_NAMESPACE])
|
|
606
|
+
? (custom[AG_UI_METADATA_NAMESPACE] as Record<string, unknown>)
|
|
607
|
+
: {};
|
|
608
|
+
return {
|
|
609
|
+
...message,
|
|
610
|
+
metadata: {
|
|
611
|
+
...metadata,
|
|
612
|
+
custom: {
|
|
613
|
+
...custom,
|
|
614
|
+
[AG_UI_METADATA_NAMESPACE]: { ...namespaced, opaqueReasoning },
|
|
615
|
+
},
|
|
616
|
+
},
|
|
617
|
+
} as CoreThreadMessageLike;
|
|
618
|
+
}
|
|
619
|
+
|
|
501
620
|
function toAssistantSnapshotMessage(
|
|
502
621
|
rawMessage: Record<string, unknown>,
|
|
503
622
|
): CoreThreadMessageLike {
|
|
504
623
|
const text = extractText(rawMessage.content);
|
|
505
|
-
const
|
|
624
|
+
const interrupts = readPersistedInterrupts(rawMessage.metadata);
|
|
625
|
+
const restoredToolCalls = extractAssistantToolCalls(rawMessage);
|
|
626
|
+
const approvals = projectAgUiToolApprovals(
|
|
627
|
+
interrupts,
|
|
628
|
+
new Set(
|
|
629
|
+
restoredToolCalls
|
|
630
|
+
.map((part) => part.toolCallId)
|
|
631
|
+
.filter((id): id is string => !!id),
|
|
632
|
+
),
|
|
633
|
+
);
|
|
634
|
+
const toolCallParts = restoredToolCalls.map((part) => {
|
|
635
|
+
const approval = part.toolCallId
|
|
636
|
+
? approvals.get(part.toolCallId)
|
|
637
|
+
: undefined;
|
|
638
|
+
return approval ? { ...part, approval } : part;
|
|
639
|
+
});
|
|
506
640
|
const assistantContent = [
|
|
507
641
|
...(text.length > 0 ? [{ type: "text" as const, text }] : []),
|
|
508
642
|
...toolCallParts,
|
|
509
643
|
];
|
|
510
644
|
const messageName = getString(rawMessage, "name");
|
|
511
|
-
const interrupts = readPersistedInterrupts(rawMessage.metadata);
|
|
512
645
|
return {
|
|
513
646
|
id: getString(rawMessage, "id") ?? generateId(),
|
|
514
647
|
role: "assistant",
|
|
@@ -596,6 +729,11 @@ export function fromAgUiMessages(
|
|
|
596
729
|
const converted: CoreThreadMessageLike[] = [];
|
|
597
730
|
const a2uiBuckets = new Map<string, A2uiState>();
|
|
598
731
|
const a2uiBucketOwnerIndices = new Map<string, number>();
|
|
732
|
+
// A zero-data-retention run carries its payload in encryptedValue with no
|
|
733
|
+
// readable content, so the record has no part to become and rides on the
|
|
734
|
+
// neighbouring message instead of being dropped. The anchor is the index the
|
|
735
|
+
// next pushed message will occupy, which is where it sat on the wire.
|
|
736
|
+
const opaqueReasoning: (AgUiOpaqueReasoning & { anchor: number })[] = [];
|
|
599
737
|
|
|
600
738
|
for (const rawMessage of messages) {
|
|
601
739
|
if (!isObject(rawMessage)) continue;
|
|
@@ -750,15 +888,53 @@ export function fromAgUiMessages(
|
|
|
750
888
|
}
|
|
751
889
|
|
|
752
890
|
if (role === "reasoning") {
|
|
753
|
-
// Gate on showThinking so a cold reload matches the live run: the
|
|
754
|
-
// aggregator never stores reasoning parts when showThinking is false.
|
|
755
|
-
if (!showThinking) continue;
|
|
756
891
|
const text = extractText(rawMessage.content);
|
|
757
|
-
|
|
892
|
+
const encryptedValue = getString(rawMessage, "encryptedValue");
|
|
893
|
+
if (text.trim().length === 0) {
|
|
894
|
+
// showThinking hides reasoning; this record is never rendered anyway,
|
|
895
|
+
// and dropping it would deny the provider the payload it needs to
|
|
896
|
+
// accept the next run.
|
|
897
|
+
const opaqueId = getString(rawMessage, "id");
|
|
898
|
+
if (opaqueId?.trim() && encryptedValue?.trim()) {
|
|
899
|
+
opaqueReasoning.push({
|
|
900
|
+
id: opaqueId,
|
|
901
|
+
encryptedValue,
|
|
902
|
+
anchor: converted.length,
|
|
903
|
+
});
|
|
904
|
+
}
|
|
905
|
+
continue;
|
|
906
|
+
}
|
|
907
|
+
// Gate visible reasoning on showThinking so a cold reload matches the
|
|
908
|
+
// live run: the aggregator never stores reasoning parts when it is off.
|
|
909
|
+
// A signature on a hidden record is still transport state, so it is kept
|
|
910
|
+
// opaque rather than discarded with the text.
|
|
911
|
+
if (!showThinking) {
|
|
912
|
+
const hiddenId = getString(rawMessage, "id");
|
|
913
|
+
if (hiddenId?.trim() && encryptedValue?.trim()) {
|
|
914
|
+
opaqueReasoning.push({
|
|
915
|
+
id: hiddenId,
|
|
916
|
+
encryptedValue,
|
|
917
|
+
anchor: converted.length,
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
continue;
|
|
921
|
+
}
|
|
758
922
|
converted.push({
|
|
759
923
|
id: getString(rawMessage, "id") ?? generateId(),
|
|
760
924
|
role: "assistant",
|
|
761
|
-
content: [
|
|
925
|
+
content: [
|
|
926
|
+
{
|
|
927
|
+
type: "reasoning",
|
|
928
|
+
text,
|
|
929
|
+
...(encryptedValue !== undefined
|
|
930
|
+
? {
|
|
931
|
+
providerMetadata: {
|
|
932
|
+
[AG_UI_METADATA_NAMESPACE]: { encryptedValue },
|
|
933
|
+
},
|
|
934
|
+
}
|
|
935
|
+
: {}),
|
|
936
|
+
},
|
|
937
|
+
],
|
|
762
938
|
});
|
|
763
939
|
continue;
|
|
764
940
|
}
|
|
@@ -768,6 +944,18 @@ export function fromAgUiMessages(
|
|
|
768
944
|
}
|
|
769
945
|
}
|
|
770
946
|
|
|
947
|
+
for (const { anchor, ...entry } of opaqueReasoning) {
|
|
948
|
+
// Nothing followed it on the wire, so it trails the last message instead.
|
|
949
|
+
const trailing = anchor >= converted.length;
|
|
950
|
+
const index = trailing ? converted.length - 1 : anchor;
|
|
951
|
+
const target = converted[index];
|
|
952
|
+
if (!target) continue;
|
|
953
|
+
converted[index] = withOpaqueReasoning(target, [
|
|
954
|
+
...readOpaqueReasoning(target.metadata),
|
|
955
|
+
...(trailing ? [{ ...entry, after: true }] : [entry]),
|
|
956
|
+
]);
|
|
957
|
+
}
|
|
958
|
+
|
|
771
959
|
for (let i = 0; i < converted.length; i++) {
|
|
772
960
|
const message = converted[i]!;
|
|
773
961
|
if (message.role !== "assistant") continue;
|
|
@@ -801,7 +989,7 @@ export function fromAgUiMessages(
|
|
|
801
989
|
}
|
|
802
990
|
|
|
803
991
|
function convertAssistantMessage(
|
|
804
|
-
message:
|
|
992
|
+
message: NormalizedThreadMessageLike,
|
|
805
993
|
converted: AgUiMessage[],
|
|
806
994
|
): void {
|
|
807
995
|
const content = extractText(message.content);
|
|
@@ -821,24 +1009,47 @@ function convertAssistantMessage(
|
|
|
821
1009
|
part,
|
|
822
1010
|
}));
|
|
823
1011
|
|
|
1012
|
+
// An AG-UI assistant record has no reasoning field, so reasoning leaves as
|
|
1013
|
+
// the standalone record it arrived as, ahead of the assistant it belongs to.
|
|
1014
|
+
const shellIsDropped = content.length === 0 && toolCalls.length === 0;
|
|
1015
|
+
let reasoningIndex = 0;
|
|
1016
|
+
for (const part of contentArray) {
|
|
1017
|
+
if (!isObject(part) || part.type !== "reasoning") continue;
|
|
1018
|
+
const text = getString(part, "text") ?? "";
|
|
1019
|
+
if (text.trim().length === 0) continue;
|
|
1020
|
+
const { encryptedValue, reasoningId } = readAgUiReasoningMeta(
|
|
1021
|
+
part.providerMetadata,
|
|
1022
|
+
);
|
|
1023
|
+
converted.push({
|
|
1024
|
+
// The wire id is what a signature was issued against, so it wins over a
|
|
1025
|
+
// synthesized one whenever the run carried it.
|
|
1026
|
+
id: reasoningId?.trim()
|
|
1027
|
+
? reasoningId
|
|
1028
|
+
: shellIsDropped && reasoningIndex === 0
|
|
1029
|
+
? message.id
|
|
1030
|
+
: `${message.id}:reasoning-${reasoningIndex}`,
|
|
1031
|
+
role: "reasoning",
|
|
1032
|
+
content: text,
|
|
1033
|
+
...(encryptedValue !== undefined ? { encryptedValue } : {}),
|
|
1034
|
+
});
|
|
1035
|
+
reasoningIndex++;
|
|
1036
|
+
}
|
|
1037
|
+
|
|
824
1038
|
// Drop assistant messages with no text or tool calls (e.g. an imported
|
|
825
1039
|
// reasoning-only entry) so they are not re-sent as a blank assistant turn.
|
|
826
|
-
if (
|
|
1040
|
+
if (shellIsDropped) {
|
|
827
1041
|
return;
|
|
828
1042
|
}
|
|
829
1043
|
|
|
830
|
-
|
|
1044
|
+
converted.push({
|
|
831
1045
|
id: message.id,
|
|
832
1046
|
role: "assistant",
|
|
833
1047
|
content,
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
assistantMessage.toolCalls = toolCalls.map((entry) => entry.call);
|
|
840
|
-
}
|
|
841
|
-
converted.push(assistantMessage);
|
|
1048
|
+
...(message.name ? { name: message.name } : {}),
|
|
1049
|
+
...(toolCalls.length > 0
|
|
1050
|
+
? { toolCalls: toolCalls.map((entry) => entry.call) }
|
|
1051
|
+
: {}),
|
|
1052
|
+
});
|
|
842
1053
|
|
|
843
1054
|
for (const { id: toolCallId, part } of toolCalls) {
|
|
844
1055
|
if (part.result === undefined) continue;
|
|
@@ -850,36 +1061,30 @@ function convertAssistantMessage(
|
|
|
850
1061
|
? part.result
|
|
851
1062
|
: JSON.stringify(part.result);
|
|
852
1063
|
|
|
853
|
-
|
|
1064
|
+
converted.push({
|
|
854
1065
|
id: part.unstable_toolMessageId ?? `${toolCallId}:tool`,
|
|
855
1066
|
role: "tool",
|
|
856
1067
|
content: resultContent,
|
|
857
1068
|
toolCallId,
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
toolMessage.error = resultContent;
|
|
861
|
-
}
|
|
862
|
-
converted.push(toolMessage);
|
|
1069
|
+
...(part.isError ? { error: resultContent } : {}),
|
|
1070
|
+
});
|
|
863
1071
|
}
|
|
864
1072
|
}
|
|
865
1073
|
|
|
866
1074
|
function convertToolMessage(
|
|
867
|
-
message:
|
|
1075
|
+
message: NormalizedThreadMessageLike,
|
|
868
1076
|
converted: AgUiMessage[],
|
|
869
1077
|
): void {
|
|
870
1078
|
const content = extractText(message.content);
|
|
871
1079
|
const toolCallId = message.toolCallId ?? generateId();
|
|
872
1080
|
|
|
873
|
-
|
|
1081
|
+
converted.push({
|
|
874
1082
|
id: message.id,
|
|
875
1083
|
role: "tool",
|
|
876
1084
|
content,
|
|
877
1085
|
toolCallId,
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
toolMessage.error = message.error;
|
|
881
|
-
}
|
|
882
|
-
converted.push(toolMessage);
|
|
1086
|
+
...(typeof message.error === "string" ? { error: message.error } : {}),
|
|
1087
|
+
});
|
|
883
1088
|
}
|
|
884
1089
|
|
|
885
1090
|
export function toAgUiMessages(
|
|
@@ -887,29 +1092,72 @@ export function toAgUiMessages(
|
|
|
887
1092
|
): AgUiMessage[] {
|
|
888
1093
|
const converted: AgUiMessage[] = [];
|
|
889
1094
|
|
|
890
|
-
for (const
|
|
1095
|
+
for (const rawMessage of messages) {
|
|
1096
|
+
const message: NormalizedThreadMessageLike = {
|
|
1097
|
+
...rawMessage,
|
|
1098
|
+
id: rawMessage.id ?? generateId(),
|
|
1099
|
+
};
|
|
1100
|
+
const opaqueReasoning = readOpaqueReasoning(message.metadata);
|
|
1101
|
+
const toOpaqueRecord = (entry: AgUiOpaqueReasoning): AgUiMessage => ({
|
|
1102
|
+
id: entry.id,
|
|
1103
|
+
role: "reasoning",
|
|
1104
|
+
content: "",
|
|
1105
|
+
encryptedValue: entry.encryptedValue,
|
|
1106
|
+
});
|
|
1107
|
+
for (const entry of opaqueReasoning) {
|
|
1108
|
+
if (entry.after !== true) converted.push(toOpaqueRecord(entry));
|
|
1109
|
+
}
|
|
1110
|
+
const flushTrailingOpaqueReasoning = () => {
|
|
1111
|
+
for (const entry of opaqueReasoning) {
|
|
1112
|
+
if (entry.after === true) converted.push(toOpaqueRecord(entry));
|
|
1113
|
+
}
|
|
1114
|
+
};
|
|
1115
|
+
|
|
891
1116
|
if (message.role === "assistant") {
|
|
892
1117
|
convertAssistantMessage(message, converted);
|
|
1118
|
+
flushTrailingOpaqueReasoning();
|
|
893
1119
|
continue;
|
|
894
1120
|
}
|
|
895
1121
|
|
|
896
1122
|
if (message.role === "tool") {
|
|
897
1123
|
convertToolMessage(message, converted);
|
|
1124
|
+
flushTrailingOpaqueReasoning();
|
|
898
1125
|
continue;
|
|
899
1126
|
}
|
|
900
1127
|
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
message
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
1128
|
+
if (message.role === "user") {
|
|
1129
|
+
converted.push({
|
|
1130
|
+
id: message.id,
|
|
1131
|
+
role: "user",
|
|
1132
|
+
content: buildUserContent(message),
|
|
1133
|
+
...(message.name ? { name: message.name } : {}),
|
|
1134
|
+
});
|
|
1135
|
+
flushTrailingOpaqueReasoning();
|
|
1136
|
+
continue;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
if (message.role === "system" || message.role === "developer") {
|
|
1140
|
+
converted.push({
|
|
1141
|
+
id: message.id,
|
|
1142
|
+
role: message.role,
|
|
1143
|
+
content: extractText(message.content),
|
|
1144
|
+
...(message.name ? { name: message.name } : {}),
|
|
1145
|
+
});
|
|
1146
|
+
flushTrailingOpaqueReasoning();
|
|
1147
|
+
continue;
|
|
911
1148
|
}
|
|
912
|
-
|
|
1149
|
+
|
|
1150
|
+
if (message.role === "reasoning") {
|
|
1151
|
+
converted.push({
|
|
1152
|
+
id: message.id,
|
|
1153
|
+
role: "reasoning",
|
|
1154
|
+
content: extractText(message.content),
|
|
1155
|
+
});
|
|
1156
|
+
flushTrailingOpaqueReasoning();
|
|
1157
|
+
continue;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
flushTrailingOpaqueReasoning();
|
|
913
1161
|
}
|
|
914
1162
|
|
|
915
1163
|
return converted;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import type { ChatModelRunResult } from "@assistant-ui/core";
|
|
3
|
+
import { RunAggregator } from "./run-aggregator";
|
|
4
|
+
import type { AgUiInterrupt } from "../types";
|
|
5
|
+
|
|
6
|
+
const GATE: AgUiInterrupt = {
|
|
7
|
+
id: "int-1",
|
|
8
|
+
reason: "tool_call",
|
|
9
|
+
toolCallId: "tc-1",
|
|
10
|
+
message: "Delete /tmp/a?",
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const noopLogger = {
|
|
14
|
+
debug: vi.fn(),
|
|
15
|
+
info: vi.fn(),
|
|
16
|
+
warn: vi.fn(),
|
|
17
|
+
error: vi.fn(),
|
|
18
|
+
} as any;
|
|
19
|
+
|
|
20
|
+
const gatedAggregator = () => {
|
|
21
|
+
const emitted: ChatModelRunResult[] = [];
|
|
22
|
+
const aggregator = new RunAggregator({
|
|
23
|
+
showThinking: false,
|
|
24
|
+
logger: noopLogger,
|
|
25
|
+
emit: (result) => {
|
|
26
|
+
emitted.push(result);
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
aggregator.handle({ type: "RUN_STARTED", runId: "run-1" });
|
|
31
|
+
aggregator.handle({
|
|
32
|
+
type: "TOOL_CALL_START",
|
|
33
|
+
toolCallId: "tc-1",
|
|
34
|
+
toolCallName: "delete_file",
|
|
35
|
+
});
|
|
36
|
+
aggregator.handle({
|
|
37
|
+
type: "TOOL_CALL_ARGS",
|
|
38
|
+
toolCallId: "tc-1",
|
|
39
|
+
delta: '{"path":"/tmp/a"}',
|
|
40
|
+
});
|
|
41
|
+
aggregator.handle({ type: "TOOL_CALL_END", toolCallId: "tc-1" });
|
|
42
|
+
aggregator.handle({
|
|
43
|
+
type: "RUN_FINISHED",
|
|
44
|
+
runId: "run-1",
|
|
45
|
+
outcome: { type: "interrupt", interrupts: [GATE] },
|
|
46
|
+
} as any);
|
|
47
|
+
|
|
48
|
+
return { aggregator, emitted, last: () => emitted[emitted.length - 1]! };
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const approvalOf = (result: ChatModelRunResult) => {
|
|
52
|
+
const part = (result.content ?? []).find((p) => p.type === "tool-call");
|
|
53
|
+
return (part as { approval?: unknown } | undefined)?.approval;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
describe("RunAggregator tool approval projection", () => {
|
|
57
|
+
it("projects the gate while the run awaits the interrupt", () => {
|
|
58
|
+
const { last } = gatedAggregator();
|
|
59
|
+
|
|
60
|
+
expect(last().status).toMatchObject({
|
|
61
|
+
type: "requires-action",
|
|
62
|
+
reason: "interrupt",
|
|
63
|
+
});
|
|
64
|
+
expect(approvalOf(last())).toMatchObject({ id: "int-1" });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The gate cannot be clicked once the run is unresumable, but the interrupts
|
|
69
|
+
* stay on the message: the bespoke hooks read that payload, and dropping it
|
|
70
|
+
* would take a shipped surface away with the projection.
|
|
71
|
+
*/
|
|
72
|
+
it("drops the gate when the run then errors, keeping the interrupts", () => {
|
|
73
|
+
const { aggregator, last } = gatedAggregator();
|
|
74
|
+
|
|
75
|
+
aggregator.handle({ type: "RUN_ERROR", message: "stream dropped" } as any);
|
|
76
|
+
|
|
77
|
+
expect(last().status).toMatchObject({
|
|
78
|
+
type: "incomplete",
|
|
79
|
+
reason: "error",
|
|
80
|
+
});
|
|
81
|
+
expect(approvalOf(last())).toBeUndefined();
|
|
82
|
+
expect(
|
|
83
|
+
(last().metadata?.custom as Record<string, any> | undefined)?.agui
|
|
84
|
+
?.interrupts,
|
|
85
|
+
).toEqual([GATE]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("drops the gate when the run is then cancelled, keeping the interrupts", () => {
|
|
89
|
+
const { aggregator, last } = gatedAggregator();
|
|
90
|
+
|
|
91
|
+
aggregator.handle({ type: "RUN_CANCELLED" } as any);
|
|
92
|
+
|
|
93
|
+
expect(last().status).toMatchObject({
|
|
94
|
+
type: "incomplete",
|
|
95
|
+
reason: "cancelled",
|
|
96
|
+
});
|
|
97
|
+
expect(approvalOf(last())).toBeUndefined();
|
|
98
|
+
expect(
|
|
99
|
+
(last().metadata?.custom as Record<string, any> | undefined)?.agui
|
|
100
|
+
?.interrupts,
|
|
101
|
+
).toEqual([GATE]);
|
|
102
|
+
});
|
|
103
|
+
});
|