@assistant-ui/react-ag-ui 0.0.53 → 0.0.54
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/runtime/AgUiThreadRuntimeCore.d.ts.map +1 -1
- package/dist/runtime/AgUiThreadRuntimeCore.js +14 -4
- package/dist/runtime/AgUiThreadRuntimeCore.js.map +1 -1
- package/dist/runtime/adapter/conversions.d.ts +6 -0
- package/dist/runtime/adapter/conversions.d.ts.map +1 -1
- package/dist/runtime/adapter/conversions.js +139 -10
- package/dist/runtime/adapter/conversions.js.map +1 -1
- package/dist/runtime/adapter/run-aggregator.d.ts +10 -1
- package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
- package/dist/runtime/adapter/run-aggregator.js +36 -9
- 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 +1 -0
- package/dist/runtime/adapter/subscriber.js.map +1 -1
- package/dist/runtime/event-parser.js +13 -0
- package/dist/runtime/event-parser.js.map +1 -1
- package/dist/runtime/types.d.ts +5 -0
- package/dist/runtime/types.d.ts.map +1 -1
- package/dist/useAgUiRuntime.js +1 -1
- package/dist/useAgUiRuntime.js.map +1 -1
- package/package.json +4 -4
- package/src/runtime/AgUiThreadRuntimeCore.ts +21 -1
- package/src/runtime/adapter/conversions.content.test.ts +220 -0
- package/src/runtime/adapter/conversions.ts +215 -11
- package/src/runtime/adapter/run-aggregator.ts +59 -4
- package/src/runtime/adapter/subscriber.ts +3 -0
- package/src/runtime/event-parser.ts +13 -0
- package/src/runtime/types.ts +6 -0
- package/src/useAgUiRuntime.queue.test.tsx +68 -1
- package/src/useAgUiRuntime.ts +1 -1
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { UserMessageSchema } from "@ag-ui/client";
|
|
3
|
+
import { fromAgUiMessages, toAgUiMessages } from "./conversions";
|
|
4
|
+
|
|
5
|
+
type Message = Parameters<typeof toAgUiMessages>[0][number];
|
|
6
|
+
|
|
7
|
+
const userMessage = (
|
|
8
|
+
content: unknown,
|
|
9
|
+
attachments?: Message["attachments"],
|
|
10
|
+
): Message =>
|
|
11
|
+
({
|
|
12
|
+
id: "u1",
|
|
13
|
+
role: "user",
|
|
14
|
+
content,
|
|
15
|
+
...(attachments && { attachments }),
|
|
16
|
+
}) as Message;
|
|
17
|
+
|
|
18
|
+
const contentOf = (message: Message) => {
|
|
19
|
+
const [converted] = toAgUiMessages([message]);
|
|
20
|
+
return (converted as { content: unknown }).content;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
describe("toAgUiMessages content metadata", () => {
|
|
24
|
+
it("carries a part's agui provider metadata onto an image item", () => {
|
|
25
|
+
expect(
|
|
26
|
+
contentOf(
|
|
27
|
+
userMessage([
|
|
28
|
+
{
|
|
29
|
+
type: "image",
|
|
30
|
+
image: "https://example.com/cat.png",
|
|
31
|
+
providerMetadata: { agui: { file_id: "f_1" } },
|
|
32
|
+
},
|
|
33
|
+
]),
|
|
34
|
+
),
|
|
35
|
+
).toEqual([
|
|
36
|
+
{
|
|
37
|
+
type: "image",
|
|
38
|
+
source: { type: "url", value: "https://example.com/cat.png" },
|
|
39
|
+
metadata: { file_id: "f_1" },
|
|
40
|
+
},
|
|
41
|
+
]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("preserves a direct image part's filename through a snapshot round trip", () => {
|
|
45
|
+
const sent = toAgUiMessages([
|
|
46
|
+
userMessage([
|
|
47
|
+
{
|
|
48
|
+
type: "image",
|
|
49
|
+
image: "https://example.com/cat.png",
|
|
50
|
+
filename: "cat.png",
|
|
51
|
+
},
|
|
52
|
+
]),
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
expect((sent[0] as { content: unknown }).content).toEqual([
|
|
56
|
+
{
|
|
57
|
+
type: "image",
|
|
58
|
+
source: { type: "url", value: "https://example.com/cat.png" },
|
|
59
|
+
metadata: { filename: "cat.png" },
|
|
60
|
+
},
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
const rebuilt = fromAgUiMessages(sent as never);
|
|
64
|
+
const attachment = (rebuilt[0] as unknown as { attachments: unknown[] })
|
|
65
|
+
.attachments[0];
|
|
66
|
+
|
|
67
|
+
expect(attachment).toMatchObject({
|
|
68
|
+
name: "cat.png",
|
|
69
|
+
content: [{ type: "image", filename: "cat.png" }],
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("merges a file part's metadata with its filename", () => {
|
|
74
|
+
expect(
|
|
75
|
+
contentOf(
|
|
76
|
+
userMessage([
|
|
77
|
+
{
|
|
78
|
+
type: "file",
|
|
79
|
+
data: "https://example.com/spec.pdf",
|
|
80
|
+
mimeType: "application/pdf",
|
|
81
|
+
filename: "spec.pdf",
|
|
82
|
+
providerMetadata: { agui: { file_id: "f_2" } },
|
|
83
|
+
},
|
|
84
|
+
]),
|
|
85
|
+
),
|
|
86
|
+
).toEqual([
|
|
87
|
+
{
|
|
88
|
+
type: "document",
|
|
89
|
+
source: {
|
|
90
|
+
type: "url",
|
|
91
|
+
value: "https://example.com/spec.pdf",
|
|
92
|
+
mimeType: "application/pdf",
|
|
93
|
+
},
|
|
94
|
+
metadata: { file_id: "f_2", filename: "spec.pdf" },
|
|
95
|
+
},
|
|
96
|
+
]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("keeps the part's own filename over one in the metadata", () => {
|
|
100
|
+
expect(
|
|
101
|
+
contentOf(
|
|
102
|
+
userMessage([
|
|
103
|
+
{
|
|
104
|
+
type: "file",
|
|
105
|
+
data: "https://example.com/spec.pdf",
|
|
106
|
+
mimeType: "application/pdf",
|
|
107
|
+
filename: "spec.pdf",
|
|
108
|
+
providerMetadata: { agui: { filename: "renamed.pdf" } },
|
|
109
|
+
},
|
|
110
|
+
]),
|
|
111
|
+
),
|
|
112
|
+
).toMatchObject([{ metadata: { filename: "spec.pdf" } }]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("carries metadata from an attachment's content part", () => {
|
|
116
|
+
expect(
|
|
117
|
+
contentOf(
|
|
118
|
+
userMessage([], [
|
|
119
|
+
{
|
|
120
|
+
name: "spec.pdf",
|
|
121
|
+
contentType: "application/pdf",
|
|
122
|
+
content: [
|
|
123
|
+
{
|
|
124
|
+
type: "file",
|
|
125
|
+
data: "https://example.com/spec.pdf",
|
|
126
|
+
mimeType: "application/pdf",
|
|
127
|
+
providerMetadata: { agui: { file_id: "f_3" } },
|
|
128
|
+
},
|
|
129
|
+
],
|
|
130
|
+
},
|
|
131
|
+
] as Message["attachments"]),
|
|
132
|
+
),
|
|
133
|
+
).toMatchObject([{ type: "document", metadata: { file_id: "f_3" } }]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("ignores metadata namespaced for another provider", () => {
|
|
137
|
+
expect(
|
|
138
|
+
contentOf(
|
|
139
|
+
userMessage([
|
|
140
|
+
{
|
|
141
|
+
type: "image",
|
|
142
|
+
image: "https://example.com/cat.png",
|
|
143
|
+
providerMetadata: { openai: { file_id: "f_4" } },
|
|
144
|
+
},
|
|
145
|
+
]),
|
|
146
|
+
),
|
|
147
|
+
).toEqual([
|
|
148
|
+
{
|
|
149
|
+
type: "image",
|
|
150
|
+
source: { type: "url", value: "https://example.com/cat.png" },
|
|
151
|
+
},
|
|
152
|
+
]);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("emits no metadata channel for text, which the schema has no field for", () => {
|
|
156
|
+
expect(
|
|
157
|
+
contentOf(
|
|
158
|
+
userMessage([
|
|
159
|
+
{
|
|
160
|
+
type: "text",
|
|
161
|
+
text: "hello",
|
|
162
|
+
providerMetadata: { agui: { file_id: "f_5" } },
|
|
163
|
+
},
|
|
164
|
+
{ type: "image", image: "https://example.com/cat.png" },
|
|
165
|
+
]),
|
|
166
|
+
),
|
|
167
|
+
).toEqual([
|
|
168
|
+
{ type: "text", text: "hello" },
|
|
169
|
+
{
|
|
170
|
+
type: "image",
|
|
171
|
+
source: { type: "url", value: "https://example.com/cat.png" },
|
|
172
|
+
},
|
|
173
|
+
]);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("stays valid against the upstream user message schema", () => {
|
|
177
|
+
const [converted] = toAgUiMessages([
|
|
178
|
+
userMessage([
|
|
179
|
+
{
|
|
180
|
+
type: "file",
|
|
181
|
+
data: "https://example.com/spec.pdf",
|
|
182
|
+
mimeType: "application/pdf",
|
|
183
|
+
filename: "spec.pdf",
|
|
184
|
+
providerMetadata: { agui: { file_id: "f_6", nested: { a: 1 } } },
|
|
185
|
+
},
|
|
186
|
+
]),
|
|
187
|
+
]);
|
|
188
|
+
|
|
189
|
+
expect(
|
|
190
|
+
UserMessageSchema.safeParse(JSON.parse(JSON.stringify(converted)))
|
|
191
|
+
.success,
|
|
192
|
+
).toBe(true);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("survives a snapshot round trip", () => {
|
|
196
|
+
const sent = toAgUiMessages([
|
|
197
|
+
userMessage([
|
|
198
|
+
{
|
|
199
|
+
type: "file",
|
|
200
|
+
data: "https://example.com/spec.pdf",
|
|
201
|
+
mimeType: "application/pdf",
|
|
202
|
+
filename: "spec.pdf",
|
|
203
|
+
providerMetadata: { agui: { file_id: "f_7" } },
|
|
204
|
+
},
|
|
205
|
+
]),
|
|
206
|
+
]);
|
|
207
|
+
|
|
208
|
+
const rebuilt = fromAgUiMessages(sent as never);
|
|
209
|
+
const attachment = (rebuilt[0] as unknown as { attachments: unknown[] })
|
|
210
|
+
.attachments[0] as { content: unknown[] };
|
|
211
|
+
|
|
212
|
+
expect(attachment.content[0]).toMatchObject({
|
|
213
|
+
type: "file",
|
|
214
|
+
filename: "spec.pdf",
|
|
215
|
+
providerMetadata: { agui: { file_id: "f_7" } },
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
expect(toAgUiMessages(rebuilt as never)).toEqual(sent);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
@@ -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,
|
|
@@ -42,6 +44,7 @@ type ThreadMessageLike = {
|
|
|
42
44
|
id: string;
|
|
43
45
|
role: string;
|
|
44
46
|
content: unknown;
|
|
47
|
+
metadata?: unknown;
|
|
45
48
|
name?: string;
|
|
46
49
|
toolCallId?: string;
|
|
47
50
|
error?: string;
|
|
@@ -62,6 +65,12 @@ export type AgUiMessage =
|
|
|
62
65
|
name?: string;
|
|
63
66
|
toolCalls?: AgUiToolCall[];
|
|
64
67
|
}
|
|
68
|
+
| {
|
|
69
|
+
id: string;
|
|
70
|
+
role: "reasoning";
|
|
71
|
+
content: string;
|
|
72
|
+
encryptedValue?: string;
|
|
73
|
+
}
|
|
65
74
|
| {
|
|
66
75
|
id: string;
|
|
67
76
|
role: "tool";
|
|
@@ -94,6 +103,56 @@ const getString = (record: Record<string, unknown>, key: string) => {
|
|
|
94
103
|
const getToolCallId = (record: Record<string, unknown>) =>
|
|
95
104
|
getString(record, "toolCallId") ?? getString(record, "tool_call_id");
|
|
96
105
|
|
|
106
|
+
const readAgUiNamespace = (providerMetadata: unknown) => {
|
|
107
|
+
if (!isObject(providerMetadata)) return undefined;
|
|
108
|
+
const namespaced = providerMetadata[AG_UI_METADATA_NAMESPACE];
|
|
109
|
+
return isObject(namespaced) ? namespaced : undefined;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const readAgUiReasoningMeta = (providerMetadata: unknown) => {
|
|
113
|
+
const namespaced = readAgUiNamespace(providerMetadata);
|
|
114
|
+
if (!namespaced) return {};
|
|
115
|
+
return {
|
|
116
|
+
encryptedValue: getString(namespaced, "encryptedValue"),
|
|
117
|
+
reasoningId: getString(namespaced, "reasoningId"),
|
|
118
|
+
};
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// AG-UI carries per-item extras in `metadata`; anything else on the item is
|
|
122
|
+
// stripped by its schema. The part's own `filename` wins over a key of the
|
|
123
|
+
// same name in the bag.
|
|
124
|
+
const buildInputMetadata = (
|
|
125
|
+
part: Record<string, unknown>,
|
|
126
|
+
filename?: string | undefined,
|
|
127
|
+
) => {
|
|
128
|
+
const metadata = {
|
|
129
|
+
...readAgUiNamespace(part.providerMetadata),
|
|
130
|
+
...(filename !== undefined && { filename }),
|
|
131
|
+
};
|
|
132
|
+
return Object.keys(metadata).length > 0 ? metadata : undefined;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
// The inverse: `filename` lands on the part's own field, everything else the
|
|
136
|
+
// item carried goes back into the namespace it came from, so a snapshot echo
|
|
137
|
+
// resends what the host attached.
|
|
138
|
+
const readInputMetadata = (
|
|
139
|
+
metadata: unknown,
|
|
140
|
+
): {
|
|
141
|
+
filename?: string | undefined;
|
|
142
|
+
providerMetadata?: PartProviderMetadata | undefined;
|
|
143
|
+
} => {
|
|
144
|
+
if (!isObject(metadata)) return {};
|
|
145
|
+
const { filename: _filename, ...rest } = metadata;
|
|
146
|
+
return {
|
|
147
|
+
filename: getString(metadata, "filename"),
|
|
148
|
+
...(Object.keys(rest).length > 0 && {
|
|
149
|
+
providerMetadata: {
|
|
150
|
+
[AG_UI_METADATA_NAMESPACE]: rest as PartProviderMetadata[string],
|
|
151
|
+
},
|
|
152
|
+
}),
|
|
153
|
+
};
|
|
154
|
+
};
|
|
155
|
+
|
|
97
156
|
function parseJSONText(value: string): unknown {
|
|
98
157
|
if (!value) return value;
|
|
99
158
|
try {
|
|
@@ -198,7 +257,12 @@ function toInputContent(
|
|
|
198
257
|
if (type === "image") {
|
|
199
258
|
const image = getString(part, "image");
|
|
200
259
|
if (image === undefined) return null;
|
|
201
|
-
|
|
260
|
+
const metadata = buildInputMetadata(part, getString(part, "filename"));
|
|
261
|
+
return {
|
|
262
|
+
type: "image",
|
|
263
|
+
source: buildInputSource(image, fallbackMimeType),
|
|
264
|
+
...(metadata && { metadata }),
|
|
265
|
+
};
|
|
202
266
|
}
|
|
203
267
|
|
|
204
268
|
if (type === "file") {
|
|
@@ -211,7 +275,7 @@ function toInputContent(
|
|
|
211
275
|
declaredMimeType,
|
|
212
276
|
getString(part, "sourceType"),
|
|
213
277
|
);
|
|
214
|
-
const metadata =
|
|
278
|
+
const metadata = buildInputMetadata(part, filename);
|
|
215
279
|
switch (mediaTypeForMime(source.mimeType)) {
|
|
216
280
|
case "image":
|
|
217
281
|
return { type: "image", source, ...(metadata && { metadata }) };
|
|
@@ -312,9 +376,7 @@ function toSnapshotAttachments(content: unknown): SnapshotAttachment[] {
|
|
|
312
376
|
const source = inputSourceToString(part.source);
|
|
313
377
|
if (!source) continue;
|
|
314
378
|
|
|
315
|
-
const filename =
|
|
316
|
-
? getString(part.metadata, "filename")
|
|
317
|
-
: undefined;
|
|
379
|
+
const { filename, providerMetadata } = readInputMetadata(part.metadata);
|
|
318
380
|
const id = attachments.length.toString();
|
|
319
381
|
|
|
320
382
|
if (type === "image") {
|
|
@@ -329,6 +391,7 @@ function toSnapshotAttachments(content: unknown): SnapshotAttachment[] {
|
|
|
329
391
|
type: "image",
|
|
330
392
|
image: source.value,
|
|
331
393
|
...(filename !== undefined && { filename }),
|
|
394
|
+
...(providerMetadata && { providerMetadata }),
|
|
332
395
|
},
|
|
333
396
|
],
|
|
334
397
|
});
|
|
@@ -349,6 +412,7 @@ function toSnapshotAttachments(content: unknown): SnapshotAttachment[] {
|
|
|
349
412
|
mimeType,
|
|
350
413
|
...(source.isUrl && { sourceType: "url" as const }),
|
|
351
414
|
...(filename !== undefined && { filename }),
|
|
415
|
+
...(providerMetadata && { providerMetadata }),
|
|
352
416
|
},
|
|
353
417
|
],
|
|
354
418
|
});
|
|
@@ -498,6 +562,46 @@ function readPersistedInterrupts(
|
|
|
498
562
|
return validateInterrupts(namespaced.interrupts);
|
|
499
563
|
}
|
|
500
564
|
|
|
565
|
+
function readOpaqueReasoning(metadata: unknown): AgUiOpaqueReasoning[] {
|
|
566
|
+
if (!isObject(metadata)) return [];
|
|
567
|
+
const custom = metadata.custom;
|
|
568
|
+
if (!isObject(custom)) return [];
|
|
569
|
+
const namespaced = custom[AG_UI_METADATA_NAMESPACE];
|
|
570
|
+
if (!isObject(namespaced)) return [];
|
|
571
|
+
const entries = namespaced.opaqueReasoning;
|
|
572
|
+
if (!Array.isArray(entries)) return [];
|
|
573
|
+
return entries.flatMap((entry) => {
|
|
574
|
+
if (!isObject(entry)) return [];
|
|
575
|
+
const id = getString(entry, "id");
|
|
576
|
+
const encryptedValue = getString(entry, "encryptedValue");
|
|
577
|
+
if (!id?.trim() || !encryptedValue?.trim()) return [];
|
|
578
|
+
return [
|
|
579
|
+
{ id, encryptedValue, ...(entry.after === true ? { after: true } : {}) },
|
|
580
|
+
];
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function withOpaqueReasoning(
|
|
585
|
+
message: CoreThreadMessageLike,
|
|
586
|
+
opaqueReasoning: AgUiOpaqueReasoning[],
|
|
587
|
+
): CoreThreadMessageLike {
|
|
588
|
+
const metadata = isObject(message.metadata) ? message.metadata : {};
|
|
589
|
+
const custom = isObject(metadata.custom) ? metadata.custom : {};
|
|
590
|
+
const namespaced = isObject(custom[AG_UI_METADATA_NAMESPACE])
|
|
591
|
+
? (custom[AG_UI_METADATA_NAMESPACE] as Record<string, unknown>)
|
|
592
|
+
: {};
|
|
593
|
+
return {
|
|
594
|
+
...message,
|
|
595
|
+
metadata: {
|
|
596
|
+
...metadata,
|
|
597
|
+
custom: {
|
|
598
|
+
...custom,
|
|
599
|
+
[AG_UI_METADATA_NAMESPACE]: { ...namespaced, opaqueReasoning },
|
|
600
|
+
},
|
|
601
|
+
},
|
|
602
|
+
} as CoreThreadMessageLike;
|
|
603
|
+
}
|
|
604
|
+
|
|
501
605
|
function toAssistantSnapshotMessage(
|
|
502
606
|
rawMessage: Record<string, unknown>,
|
|
503
607
|
): CoreThreadMessageLike {
|
|
@@ -596,6 +700,11 @@ export function fromAgUiMessages(
|
|
|
596
700
|
const converted: CoreThreadMessageLike[] = [];
|
|
597
701
|
const a2uiBuckets = new Map<string, A2uiState>();
|
|
598
702
|
const a2uiBucketOwnerIndices = new Map<string, number>();
|
|
703
|
+
// A zero-data-retention run carries its payload in encryptedValue with no
|
|
704
|
+
// readable content, so the record has no part to become and rides on the
|
|
705
|
+
// neighbouring message instead of being dropped. The anchor is the index the
|
|
706
|
+
// next pushed message will occupy, which is where it sat on the wire.
|
|
707
|
+
const opaqueReasoning: (AgUiOpaqueReasoning & { anchor: number })[] = [];
|
|
599
708
|
|
|
600
709
|
for (const rawMessage of messages) {
|
|
601
710
|
if (!isObject(rawMessage)) continue;
|
|
@@ -750,15 +859,53 @@ export function fromAgUiMessages(
|
|
|
750
859
|
}
|
|
751
860
|
|
|
752
861
|
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
862
|
const text = extractText(rawMessage.content);
|
|
757
|
-
|
|
863
|
+
const encryptedValue = getString(rawMessage, "encryptedValue");
|
|
864
|
+
if (text.trim().length === 0) {
|
|
865
|
+
// showThinking hides reasoning; this record is never rendered anyway,
|
|
866
|
+
// and dropping it would deny the provider the payload it needs to
|
|
867
|
+
// accept the next run.
|
|
868
|
+
const opaqueId = getString(rawMessage, "id");
|
|
869
|
+
if (opaqueId?.trim() && encryptedValue?.trim()) {
|
|
870
|
+
opaqueReasoning.push({
|
|
871
|
+
id: opaqueId,
|
|
872
|
+
encryptedValue,
|
|
873
|
+
anchor: converted.length,
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
continue;
|
|
877
|
+
}
|
|
878
|
+
// Gate visible reasoning on showThinking so a cold reload matches the
|
|
879
|
+
// live run: the aggregator never stores reasoning parts when it is off.
|
|
880
|
+
// A signature on a hidden record is still transport state, so it is kept
|
|
881
|
+
// opaque rather than discarded with the text.
|
|
882
|
+
if (!showThinking) {
|
|
883
|
+
const hiddenId = getString(rawMessage, "id");
|
|
884
|
+
if (hiddenId?.trim() && encryptedValue?.trim()) {
|
|
885
|
+
opaqueReasoning.push({
|
|
886
|
+
id: hiddenId,
|
|
887
|
+
encryptedValue,
|
|
888
|
+
anchor: converted.length,
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
continue;
|
|
892
|
+
}
|
|
758
893
|
converted.push({
|
|
759
894
|
id: getString(rawMessage, "id") ?? generateId(),
|
|
760
895
|
role: "assistant",
|
|
761
|
-
content: [
|
|
896
|
+
content: [
|
|
897
|
+
{
|
|
898
|
+
type: "reasoning",
|
|
899
|
+
text,
|
|
900
|
+
...(encryptedValue !== undefined
|
|
901
|
+
? {
|
|
902
|
+
providerMetadata: {
|
|
903
|
+
[AG_UI_METADATA_NAMESPACE]: { encryptedValue },
|
|
904
|
+
},
|
|
905
|
+
}
|
|
906
|
+
: {}),
|
|
907
|
+
},
|
|
908
|
+
],
|
|
762
909
|
});
|
|
763
910
|
continue;
|
|
764
911
|
}
|
|
@@ -768,6 +915,18 @@ export function fromAgUiMessages(
|
|
|
768
915
|
}
|
|
769
916
|
}
|
|
770
917
|
|
|
918
|
+
for (const { anchor, ...entry } of opaqueReasoning) {
|
|
919
|
+
// Nothing followed it on the wire, so it trails the last message instead.
|
|
920
|
+
const trailing = anchor >= converted.length;
|
|
921
|
+
const index = trailing ? converted.length - 1 : anchor;
|
|
922
|
+
const target = converted[index];
|
|
923
|
+
if (!target) continue;
|
|
924
|
+
converted[index] = withOpaqueReasoning(target, [
|
|
925
|
+
...readOpaqueReasoning(target.metadata),
|
|
926
|
+
...(trailing ? [{ ...entry, after: true }] : [entry]),
|
|
927
|
+
]);
|
|
928
|
+
}
|
|
929
|
+
|
|
771
930
|
for (let i = 0; i < converted.length; i++) {
|
|
772
931
|
const message = converted[i]!;
|
|
773
932
|
if (message.role !== "assistant") continue;
|
|
@@ -821,9 +980,35 @@ function convertAssistantMessage(
|
|
|
821
980
|
part,
|
|
822
981
|
}));
|
|
823
982
|
|
|
983
|
+
// An AG-UI assistant record has no reasoning field, so reasoning leaves as
|
|
984
|
+
// the standalone record it arrived as, ahead of the assistant it belongs to.
|
|
985
|
+
const shellIsDropped = content.length === 0 && toolCalls.length === 0;
|
|
986
|
+
let reasoningIndex = 0;
|
|
987
|
+
for (const part of contentArray) {
|
|
988
|
+
if (!isObject(part) || part.type !== "reasoning") continue;
|
|
989
|
+
const text = getString(part, "text") ?? "";
|
|
990
|
+
if (text.trim().length === 0) continue;
|
|
991
|
+
const { encryptedValue, reasoningId } = readAgUiReasoningMeta(
|
|
992
|
+
part.providerMetadata,
|
|
993
|
+
);
|
|
994
|
+
converted.push({
|
|
995
|
+
// The wire id is what a signature was issued against, so it wins over a
|
|
996
|
+
// synthesized one whenever the run carried it.
|
|
997
|
+
id: reasoningId?.trim()
|
|
998
|
+
? reasoningId
|
|
999
|
+
: shellIsDropped && reasoningIndex === 0
|
|
1000
|
+
? message.id
|
|
1001
|
+
: `${message.id}:reasoning-${reasoningIndex}`,
|
|
1002
|
+
role: "reasoning",
|
|
1003
|
+
content: text,
|
|
1004
|
+
...(encryptedValue !== undefined ? { encryptedValue } : {}),
|
|
1005
|
+
});
|
|
1006
|
+
reasoningIndex++;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
824
1009
|
// Drop assistant messages with no text or tool calls (e.g. an imported
|
|
825
1010
|
// reasoning-only entry) so they are not re-sent as a blank assistant turn.
|
|
826
|
-
if (
|
|
1011
|
+
if (shellIsDropped) {
|
|
827
1012
|
return;
|
|
828
1013
|
}
|
|
829
1014
|
|
|
@@ -888,13 +1073,31 @@ export function toAgUiMessages(
|
|
|
888
1073
|
const converted: AgUiMessage[] = [];
|
|
889
1074
|
|
|
890
1075
|
for (const message of messages) {
|
|
1076
|
+
const opaqueReasoning = readOpaqueReasoning(message.metadata);
|
|
1077
|
+
const toOpaqueRecord = (entry: AgUiOpaqueReasoning): AgUiMessage => ({
|
|
1078
|
+
id: entry.id,
|
|
1079
|
+
role: "reasoning",
|
|
1080
|
+
content: "",
|
|
1081
|
+
encryptedValue: entry.encryptedValue,
|
|
1082
|
+
});
|
|
1083
|
+
for (const entry of opaqueReasoning) {
|
|
1084
|
+
if (entry.after !== true) converted.push(toOpaqueRecord(entry));
|
|
1085
|
+
}
|
|
1086
|
+
const flushTrailingOpaqueReasoning = () => {
|
|
1087
|
+
for (const entry of opaqueReasoning) {
|
|
1088
|
+
if (entry.after === true) converted.push(toOpaqueRecord(entry));
|
|
1089
|
+
}
|
|
1090
|
+
};
|
|
1091
|
+
|
|
891
1092
|
if (message.role === "assistant") {
|
|
892
1093
|
convertAssistantMessage(message, converted);
|
|
1094
|
+
flushTrailingOpaqueReasoning();
|
|
893
1095
|
continue;
|
|
894
1096
|
}
|
|
895
1097
|
|
|
896
1098
|
if (message.role === "tool") {
|
|
897
1099
|
convertToolMessage(message, converted);
|
|
1100
|
+
flushTrailingOpaqueReasoning();
|
|
898
1101
|
continue;
|
|
899
1102
|
}
|
|
900
1103
|
|
|
@@ -910,6 +1113,7 @@ export function toAgUiMessages(
|
|
|
910
1113
|
genericMessage.name = message.name;
|
|
911
1114
|
}
|
|
912
1115
|
converted.push(genericMessage);
|
|
1116
|
+
flushTrailingOpaqueReasoning();
|
|
913
1117
|
}
|
|
914
1118
|
|
|
915
1119
|
return converted;
|
|
@@ -20,8 +20,15 @@ import type { Logger } from "../logger";
|
|
|
20
20
|
|
|
21
21
|
export const AG_UI_METADATA_NAMESPACE = "agui";
|
|
22
22
|
|
|
23
|
+
export type AgUiOpaqueReasoning = {
|
|
24
|
+
id: string;
|
|
25
|
+
encryptedValue: string;
|
|
26
|
+
after?: boolean;
|
|
27
|
+
};
|
|
28
|
+
|
|
23
29
|
export type AgUiCustomMetadata = {
|
|
24
30
|
interrupts?: AgUiInterrupt[];
|
|
31
|
+
opaqueReasoning?: AgUiOpaqueReasoning[];
|
|
25
32
|
};
|
|
26
33
|
|
|
27
34
|
type Emit = (update: ChatModelRunResult) => void;
|
|
@@ -77,6 +84,9 @@ export class RunAggregator {
|
|
|
77
84
|
>();
|
|
78
85
|
private activeTextMessageId: string | undefined;
|
|
79
86
|
private readonly reasoningParts = new Map<string, string>(); // key → buffer
|
|
87
|
+
private readonly reasoningSignatures = new Map<string, string>();
|
|
88
|
+
private readonly reasoningMessageIds = new Map<string, string>();
|
|
89
|
+
private readonly anonymousReasoningKeys = new Set<string>();
|
|
80
90
|
private activeReasoningKey: string | undefined;
|
|
81
91
|
private reasoningPartCounter = 0;
|
|
82
92
|
private readonly toolCalls = new Map<string, ToolCallState>();
|
|
@@ -111,6 +121,9 @@ export class RunAggregator {
|
|
|
111
121
|
case "RUN_STARTED": {
|
|
112
122
|
this.clearTextParts();
|
|
113
123
|
this.reasoningParts.clear();
|
|
124
|
+
this.reasoningSignatures.clear();
|
|
125
|
+
this.reasoningMessageIds.clear();
|
|
126
|
+
this.anonymousReasoningKeys.clear();
|
|
114
127
|
this.activeReasoningKey = undefined;
|
|
115
128
|
this.reasoningPartCounter = 0;
|
|
116
129
|
this.toolCalls.clear();
|
|
@@ -200,8 +213,25 @@ export class RunAggregator {
|
|
|
200
213
|
case "REASONING_MESSAGE_START":
|
|
201
214
|
this.handleReasoningStart(
|
|
202
215
|
"messageId" in event ? event.messageId : undefined,
|
|
216
|
+
event.type === "REASONING_MESSAGE_START",
|
|
203
217
|
);
|
|
204
218
|
break;
|
|
219
|
+
case "REASONING_ENCRYPTED_VALUE":
|
|
220
|
+
if (event.subtype === "message") {
|
|
221
|
+
// entityId names any message, not necessarily a reasoning one, so an
|
|
222
|
+
// unmatched id may only claim a block that has no id to contradict it.
|
|
223
|
+
const active = this.activeReasoningKey;
|
|
224
|
+
const key = this.reasoningParts.has(event.entityId)
|
|
225
|
+
? event.entityId
|
|
226
|
+
: active !== undefined && this.anonymousReasoningKeys.has(active)
|
|
227
|
+
? active
|
|
228
|
+
: undefined;
|
|
229
|
+
if (key !== undefined) {
|
|
230
|
+
this.reasoningSignatures.set(key, event.encryptedValue);
|
|
231
|
+
this.emit();
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
break;
|
|
205
235
|
case "THINKING_TEXT_MESSAGE_CONTENT":
|
|
206
236
|
this.handleReasoningContent(event.delta);
|
|
207
237
|
this.totalChunks++;
|
|
@@ -211,6 +241,7 @@ export class RunAggregator {
|
|
|
211
241
|
this.handleReasoningContent(
|
|
212
242
|
event.delta,
|
|
213
243
|
"messageId" in event ? event.messageId : undefined,
|
|
244
|
+
true,
|
|
214
245
|
);
|
|
215
246
|
this.totalChunks++;
|
|
216
247
|
this.recordFirstToken();
|
|
@@ -589,7 +620,19 @@ export class RunAggregator {
|
|
|
589
620
|
if (this.showThinking) {
|
|
590
621
|
const buffer = this.reasoningParts.get(part.key) ?? "";
|
|
591
622
|
if (buffer.length > 0 || this.activeReasoningKey === part.key) {
|
|
592
|
-
|
|
623
|
+
const encryptedValue = this.reasoningSignatures.get(part.key);
|
|
624
|
+
const reasoningId = this.reasoningMessageIds.get(part.key);
|
|
625
|
+
const meta = {
|
|
626
|
+
...(reasoningId !== undefined ? { reasoningId } : {}),
|
|
627
|
+
...(encryptedValue !== undefined ? { encryptedValue } : {}),
|
|
628
|
+
};
|
|
629
|
+
snapshot.push({
|
|
630
|
+
type: "reasoning",
|
|
631
|
+
text: buffer,
|
|
632
|
+
...(Object.keys(meta).length > 0
|
|
633
|
+
? { providerMetadata: { [AG_UI_METADATA_NAMESPACE]: meta } }
|
|
634
|
+
: {}),
|
|
635
|
+
} as const);
|
|
593
636
|
}
|
|
594
637
|
}
|
|
595
638
|
continue;
|
|
@@ -698,12 +741,20 @@ export class RunAggregator {
|
|
|
698
741
|
};
|
|
699
742
|
}
|
|
700
743
|
|
|
701
|
-
private handleReasoningStart(messageId?: string): void {
|
|
744
|
+
private handleReasoningStart(messageId?: string, isMessageId = false): void {
|
|
702
745
|
if (!this.showThinking) return;
|
|
703
746
|
// A reasoning block acts as a boundary: anonymous text arriving after it
|
|
704
747
|
// should be a new part, not appended to any pre-reasoning text.
|
|
705
748
|
this.activeTextMessageId = undefined;
|
|
706
749
|
const key = messageId ?? `__auto-reasoning-${++this.reasoningPartCounter}`;
|
|
750
|
+
// Two different questions: which id may be replayed as a ReasoningMessage.id
|
|
751
|
+
// (only the message-scoped aliases carry one), and which block an unmatched
|
|
752
|
+
// signature may claim (any block opened without an id at all).
|
|
753
|
+
if (messageId === undefined) {
|
|
754
|
+
this.anonymousReasoningKeys.add(key);
|
|
755
|
+
} else if (isMessageId) {
|
|
756
|
+
this.reasoningMessageIds.set(key, messageId);
|
|
757
|
+
}
|
|
707
758
|
if (!this.reasoningParts.has(key)) {
|
|
708
759
|
this.reasoningParts.set(key, "");
|
|
709
760
|
this.partOrder.push({ kind: "reasoning", key });
|
|
@@ -712,11 +763,15 @@ export class RunAggregator {
|
|
|
712
763
|
this.emit();
|
|
713
764
|
}
|
|
714
765
|
|
|
715
|
-
private handleReasoningContent(
|
|
766
|
+
private handleReasoningContent(
|
|
767
|
+
delta: string,
|
|
768
|
+
messageId?: string,
|
|
769
|
+
isMessageId = false,
|
|
770
|
+
): void {
|
|
716
771
|
if (!this.showThinking || !delta) return;
|
|
717
772
|
if (!this.activeReasoningKey) {
|
|
718
773
|
// Content arrived without a preceding START — create the slot lazily.
|
|
719
|
-
this.handleReasoningStart(messageId);
|
|
774
|
+
this.handleReasoningStart(messageId, isMessageId);
|
|
720
775
|
}
|
|
721
776
|
const key = this.activeReasoningKey;
|
|
722
777
|
if (!key) return;
|