@convex-dev/agent 0.6.3 → 0.7.0-alpha.0
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/MIGRATION.md +60 -116
- package/dist/client/messages.d.ts +1 -398
- package/dist/client/messages.d.ts.map +1 -1
- package/dist/client/messages.js +3 -47
- package/dist/client/messages.js.map +1 -1
- package/dist/client/types.d.ts +1 -455
- package/dist/client/types.d.ts.map +1 -1
- package/dist/component/_generated/component.d.ts +805 -45
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/apiKeys.js +5 -5
- package/dist/component/apiKeys.js.map +1 -1
- package/dist/component/files.d.ts.map +1 -1
- package/dist/component/files.js +13 -11
- package/dist/component/files.js.map +1 -1
- package/dist/component/messages.d.ts +1252 -201
- package/dist/component/messages.d.ts.map +1 -1
- package/dist/component/messages.js +83 -47
- package/dist/component/messages.js.map +1 -1
- package/dist/component/schema.d.ts +2593 -332
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/streams.d.ts +7 -1
- package/dist/component/streams.d.ts.map +1 -1
- package/dist/component/streams.js +46 -47
- package/dist/component/streams.js.map +1 -1
- package/dist/component/threads.d.ts.map +1 -1
- package/dist/component/threads.js +8 -9
- package/dist/component/threads.js.map +1 -1
- package/dist/component/users.js +2 -2
- package/dist/component/users.js.map +1 -1
- package/dist/component/vector/index.d.ts.map +1 -1
- package/dist/component/vector/index.js +14 -8
- package/dist/component/vector/index.js.map +1 -1
- package/dist/react/index.d.ts +5 -5
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +5 -5
- package/dist/react/index.js.map +1 -1
- package/dist/shared.d.ts +19 -7
- package/dist/shared.d.ts.map +1 -1
- package/dist/shared.js +4 -4
- package/dist/shared.js.map +1 -1
- package/dist/streaming/materializePersistedUIMessageChunks.d.ts +16 -0
- package/dist/streaming/materializePersistedUIMessageChunks.d.ts.map +1 -0
- package/dist/streaming/materializePersistedUIMessageChunks.js +302 -0
- package/dist/streaming/materializePersistedUIMessageChunks.js.map +1 -0
- package/dist/streaming/persistedUIMessageChunks.d.ts +106 -0
- package/dist/streaming/persistedUIMessageChunks.d.ts.map +1 -0
- package/dist/streaming/persistedUIMessageChunks.js +475 -0
- package/dist/streaming/persistedUIMessageChunks.js.map +1 -0
- package/dist/validators.d.ts +10026 -1055
- package/dist/validators.d.ts.map +1 -1
- package/dist/validators.js +71 -9
- package/dist/validators.js.map +1 -1
- package/dist/{UIMessages.d.ts → vercel/UIMessages.d.ts} +1 -1
- package/dist/vercel/UIMessages.d.ts.map +1 -0
- package/dist/{UIMessages.js → vercel/UIMessages.js} +63 -1
- package/dist/vercel/UIMessages.js.map +1 -0
- package/dist/{client → vercel/client}/createTool.d.ts +23 -16
- package/dist/vercel/client/createTool.d.ts.map +1 -0
- package/dist/{client → vercel/client}/createTool.js +2 -0
- package/dist/vercel/client/createTool.js.map +1 -0
- package/dist/{client → vercel/client}/definePlaygroundAPI.d.ts +593 -134
- package/dist/{client → vercel/client}/definePlaygroundAPI.d.ts.map +1 -1
- package/dist/{client → vercel/client}/definePlaygroundAPI.js +13 -9
- package/dist/vercel/client/definePlaygroundAPI.js.map +1 -0
- package/dist/{client → vercel/client}/files.d.ts +2 -2
- package/dist/vercel/client/files.d.ts.map +1 -0
- package/dist/vercel/client/files.js.map +1 -0
- package/dist/vercel/client/messages.d.ts +542 -0
- package/dist/vercel/client/messages.d.ts.map +1 -0
- package/dist/vercel/client/messages.js +66 -0
- package/dist/vercel/client/messages.js.map +1 -0
- package/dist/{client → vercel/client}/mockModel.d.ts +17 -17
- package/dist/vercel/client/mockModel.d.ts.map +1 -0
- package/dist/{client → vercel/client}/mockModel.js +12 -13
- package/dist/vercel/client/mockModel.js.map +1 -0
- package/dist/{client → vercel/client}/saveInputMessages.d.ts +2 -2
- package/dist/vercel/client/saveInputMessages.d.ts.map +1 -0
- package/dist/vercel/client/saveInputMessages.js.map +1 -0
- package/dist/{client → vercel/client}/search.d.ts +119 -22
- package/dist/vercel/client/search.d.ts.map +1 -0
- package/dist/{client → vercel/client}/search.js +2 -2
- package/dist/vercel/client/search.js.map +1 -0
- package/dist/{client → vercel/client}/start.d.ts +26 -11
- package/dist/vercel/client/start.d.ts.map +1 -0
- package/dist/{client → vercel/client}/start.js +64 -22
- package/dist/vercel/client/start.js.map +1 -0
- package/dist/{client → vercel/client}/streamText.d.ts +13 -12
- package/dist/vercel/client/streamText.d.ts.map +1 -0
- package/dist/{client → vercel/client}/streamText.js +43 -9
- package/dist/vercel/client/streamText.js.map +1 -0
- package/dist/{client → vercel/client}/streaming.d.ts +1410 -183
- package/dist/vercel/client/streaming.d.ts.map +1 -0
- package/dist/{client → vercel/client}/streaming.js +120 -52
- package/dist/vercel/client/streaming.js.map +1 -0
- package/dist/vercel/client/types.d.ts +456 -0
- package/dist/vercel/client/types.d.ts.map +1 -0
- package/dist/vercel/client/types.js +3 -0
- package/dist/vercel/client/types.js.map +1 -0
- package/dist/{client → vercel/client}/utils.d.ts +3 -2
- package/dist/vercel/client/utils.d.ts.map +1 -0
- package/dist/{client → vercel/client}/utils.js +2 -2
- package/dist/vercel/client/utils.js.map +1 -0
- package/dist/vercel/deltas.d.ts +31 -0
- package/dist/vercel/deltas.d.ts.map +1 -0
- package/dist/vercel/deltas.js +525 -0
- package/dist/vercel/deltas.js.map +1 -0
- package/dist/{client → vercel}/index.d.ts +484 -97
- package/dist/vercel/index.d.ts.map +1 -0
- package/dist/{client → vercel}/index.js +65 -38
- package/dist/vercel/index.js.map +1 -0
- package/dist/{mapping.d.ts → vercel/mapping.d.ts} +5 -13
- package/dist/vercel/mapping.d.ts.map +1 -0
- package/dist/{mapping.js → vercel/mapping.js} +411 -112
- package/dist/vercel/mapping.js.map +1 -0
- package/dist/{react → vercel/react}/optimisticallySendMessage.d.ts +2 -2
- package/dist/vercel/react/optimisticallySendMessage.d.ts.map +1 -0
- package/dist/vercel/react/optimisticallySendMessage.js.map +1 -0
- package/dist/{react → vercel/react}/types.d.ts +1 -1
- package/dist/vercel/react/types.d.ts.map +1 -0
- package/dist/{react → vercel/react}/types.js.map +1 -1
- package/dist/{react → vercel/react}/useDeltaStreams.d.ts +1 -1
- package/dist/vercel/react/useDeltaStreams.d.ts.map +1 -0
- package/dist/{react → vercel/react}/useDeltaStreams.js +1 -1
- package/dist/vercel/react/useDeltaStreams.js.map +1 -0
- package/dist/{react → vercel/react}/useStreamingUIMessages.d.ts +1 -1
- package/dist/vercel/react/useStreamingUIMessages.d.ts.map +1 -0
- package/dist/vercel/react/useStreamingUIMessages.js +112 -0
- package/dist/vercel/react/useStreamingUIMessages.js.map +1 -0
- package/dist/{react → vercel/react}/useThreadMessages.d.ts +1 -1
- package/dist/vercel/react/useThreadMessages.d.ts.map +1 -0
- package/dist/{react → vercel/react}/useThreadMessages.js +1 -1
- package/dist/vercel/react/useThreadMessages.js.map +1 -0
- package/dist/{react → vercel/react}/useUIMessages.d.ts +2 -1
- package/dist/vercel/react/useUIMessages.d.ts.map +1 -0
- package/dist/{react → vercel/react}/useUIMessages.js +8 -4
- package/dist/vercel/react/useUIMessages.js.map +1 -0
- package/package.json +30 -22
- package/src/client/messages.ts +5 -107
- package/src/client/types.ts +0 -621
- package/src/component/_generated/component.ts +1089 -48
- package/src/component/apiKeys.ts +5 -5
- package/src/component/files.test.ts +1 -1
- package/src/component/files.ts +14 -12
- package/src/component/messages.test.ts +273 -1
- package/src/component/messages.ts +99 -57
- package/src/component/streams.ts +80 -64
- package/src/component/threads.ts +12 -9
- package/src/component/users.ts +2 -2
- package/src/component/vector/index.ts +14 -7
- package/src/eslintConfig.test.ts +44 -0
- package/src/react/index.ts +8 -5
- package/src/shared.ts +26 -36
- package/src/streaming/materializePersistedUIMessageChunks.test.ts +513 -0
- package/src/streaming/materializePersistedUIMessageChunks.ts +398 -0
- package/src/streaming/persistedUIMessageChunks.test.ts +205 -0
- package/src/streaming/persistedUIMessageChunks.ts +658 -0
- package/src/validators.test.ts +4 -1
- package/src/validators.ts +89 -8
- package/src/{UIMessages.combineUIMessages.test.ts → vercel/UIMessages.combineUIMessages.test.ts} +4 -1
- package/src/{UIMessages.ts → vercel/UIMessages.ts} +96 -5
- package/src/{client → vercel/client}/approval.test.ts +48 -19
- package/src/{client → vercel/client}/createTool.ts +63 -23
- package/src/{client → vercel/client}/definePlaygroundAPI.ts +44 -29
- package/src/{client → vercel/client}/files.ts +2 -2
- package/src/{client → vercel/client}/index.test.ts +162 -3
- package/src/vercel/client/messages.ts +182 -0
- package/src/{client → vercel/client}/mockModel.ts +38 -39
- package/src/{client → vercel/client}/saveInputMessages.test.ts +1 -1
- package/src/{client → vercel/client}/saveInputMessages.ts +7 -4
- package/src/{client → vercel/client}/search.test.ts +3 -3
- package/src/{client → vercel/client}/search.ts +4 -4
- package/src/{client → vercel/client}/setup.test.ts +6 -3
- package/src/vercel/client/start.test.ts +14 -0
- package/src/{client → vercel/client}/start.ts +147 -36
- package/src/vercel/client/streamText.test.ts +223 -0
- package/src/{client → vercel/client}/streamText.ts +102 -31
- package/src/{client → vercel/client}/streaming.integration.test.ts +40 -115
- package/src/{client → vercel/client}/streaming.test.ts +178 -4
- package/src/{client → vercel/client}/streaming.ts +114 -52
- package/src/vercel/client/types.test.ts +13 -0
- package/src/vercel/client/types.ts +679 -0
- package/src/{client → vercel/client}/utils.ts +13 -7
- package/src/vercel/deltas.test.ts +898 -0
- package/src/vercel/deltas.ts +633 -0
- package/src/{fromUIMessages.test.ts → vercel/fromUIMessages.test.ts} +17 -1
- package/src/{client → vercel}/index.ts +169 -62
- package/src/vercel/mapping.test.ts +965 -0
- package/src/{mapping.ts → vercel/mapping.ts} +501 -131
- package/src/{react → vercel/react}/optimisticallySendMessage.ts +2 -2
- package/src/{react → vercel/react}/types.ts +1 -1
- package/src/{react → vercel/react}/useDeltaStreams.ts +6 -2
- package/src/{react → vercel/react}/useStreamingUIMessages.ts +67 -35
- package/src/{react → vercel/react}/useThreadMessages.ts +2 -2
- package/src/{react → vercel/react}/useUIMessages.test.ts +80 -1
- package/src/{react → vercel/react}/useUIMessages.ts +13 -5
- package/src/{toUIMessages.test.ts → vercel/toUIMessages.test.ts} +49 -2
- package/dist/UIMessages.d.ts.map +0 -1
- package/dist/UIMessages.js.map +0 -1
- package/dist/client/createTool.d.ts.map +0 -1
- package/dist/client/createTool.js.map +0 -1
- package/dist/client/definePlaygroundAPI.js.map +0 -1
- package/dist/client/files.d.ts.map +0 -1
- package/dist/client/files.js.map +0 -1
- package/dist/client/index.d.ts.map +0 -1
- package/dist/client/index.js.map +0 -1
- package/dist/client/mockModel.d.ts.map +0 -1
- package/dist/client/mockModel.js.map +0 -1
- package/dist/client/saveInputMessages.d.ts.map +0 -1
- package/dist/client/saveInputMessages.js.map +0 -1
- package/dist/client/search.d.ts.map +0 -1
- package/dist/client/search.js.map +0 -1
- package/dist/client/start.d.ts.map +0 -1
- package/dist/client/start.js.map +0 -1
- package/dist/client/streamText.d.ts.map +0 -1
- package/dist/client/streamText.js.map +0 -1
- package/dist/client/streaming.d.ts.map +0 -1
- package/dist/client/streaming.js.map +0 -1
- package/dist/client/utils.d.ts.map +0 -1
- package/dist/client/utils.js.map +0 -1
- package/dist/deltas.d.ts +0 -43
- package/dist/deltas.d.ts.map +0 -1
- package/dist/deltas.js +0 -446
- package/dist/deltas.js.map +0 -1
- package/dist/mapping.d.ts.map +0 -1
- package/dist/mapping.js.map +0 -1
- package/dist/react/optimisticallySendMessage.d.ts.map +0 -1
- package/dist/react/optimisticallySendMessage.js.map +0 -1
- package/dist/react/types.d.ts.map +0 -1
- package/dist/react/useDeltaStreams.d.ts.map +0 -1
- package/dist/react/useDeltaStreams.js.map +0 -1
- package/dist/react/useStreamingUIMessages.d.ts.map +0 -1
- package/dist/react/useStreamingUIMessages.js +0 -92
- package/dist/react/useStreamingUIMessages.js.map +0 -1
- package/dist/react/useThreadMessages.d.ts.map +0 -1
- package/dist/react/useThreadMessages.js.map +0 -1
- package/dist/react/useUIMessages.d.ts.map +0 -1
- package/dist/react/useUIMessages.js.map +0 -1
- package/src/client/streamText.test.ts +0 -102
- package/src/deltas.test.ts +0 -626
- package/src/deltas.ts +0 -569
- package/src/mapping.test.ts +0 -402
- /package/dist/{client → vercel/client}/files.js +0 -0
- /package/dist/{client → vercel/client}/saveInputMessages.js +0 -0
- /package/dist/{react → vercel/react}/optimisticallySendMessage.js +0 -0
- /package/dist/{react → vercel/react}/types.js +0 -0
- /package/src/{UIMessages.test.ts → vercel/UIMessages.test.ts} +0 -0
|
@@ -0,0 +1,965 @@
|
|
|
1
|
+
import { describe, test, expect, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
guessMimeType,
|
|
4
|
+
serializeDataOrUrl,
|
|
5
|
+
toModelMessageDataOrUrl,
|
|
6
|
+
serializeMessage,
|
|
7
|
+
serializeResponseMessages,
|
|
8
|
+
toModelMessage,
|
|
9
|
+
serializeContent,
|
|
10
|
+
toModelMessageContent,
|
|
11
|
+
toUIFilePart,
|
|
12
|
+
autoDenyUnresolvedApprovals,
|
|
13
|
+
serializeWarnings,
|
|
14
|
+
} from "./mapping.js";
|
|
15
|
+
import { api } from "../component/_generated/api.js";
|
|
16
|
+
import type { AgentComponent, ActionCtx } from "./client/types.js";
|
|
17
|
+
import { vMessage, vToolResultPart } from "../validators.js";
|
|
18
|
+
import fs from "fs";
|
|
19
|
+
import path from "path";
|
|
20
|
+
import type { SerializedContent } from "./mapping.js";
|
|
21
|
+
import { validate } from "convex-helpers/validators";
|
|
22
|
+
import type {
|
|
23
|
+
FilePart,
|
|
24
|
+
ModelMessage,
|
|
25
|
+
StepResult,
|
|
26
|
+
ToolResultPart,
|
|
27
|
+
ToolSet,
|
|
28
|
+
CallWarning,
|
|
29
|
+
} from "ai";
|
|
30
|
+
import type { Infer } from "convex/values";
|
|
31
|
+
import { mockModel } from "./client/mockModel.js";
|
|
32
|
+
|
|
33
|
+
const testAssetsDir = path.join(__dirname, "../../test-assets");
|
|
34
|
+
const testFiles = [
|
|
35
|
+
"book.svg",
|
|
36
|
+
"bump.jpeg",
|
|
37
|
+
"stack.png",
|
|
38
|
+
"favicon.ico",
|
|
39
|
+
"convex-logo.svg",
|
|
40
|
+
"stack-light@3x.webp",
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
function fileToArrayBuffer(filePath: string): ArrayBuffer {
|
|
44
|
+
const buf = fs.readFileSync(filePath);
|
|
45
|
+
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe("mapping", () => {
|
|
49
|
+
test("serializes every AI SDK 7 warning discriminant", () => {
|
|
50
|
+
const warnings: CallWarning[] = [
|
|
51
|
+
{ type: "unsupported", feature: "feature", details: "details" },
|
|
52
|
+
{ type: "compatibility", feature: "feature", details: "details" },
|
|
53
|
+
{
|
|
54
|
+
type: "deprecated",
|
|
55
|
+
setting: "setting",
|
|
56
|
+
message: "message",
|
|
57
|
+
},
|
|
58
|
+
{ type: "other", message: "message" },
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
expect(serializeWarnings(warnings)).toEqual(warnings);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("infers correct mimeType for all test-assets", () => {
|
|
65
|
+
const expected: { [key: string]: string } = {
|
|
66
|
+
"book.svg": "image/svg+xml", // <svg
|
|
67
|
+
"bump.jpeg": "image/jpeg",
|
|
68
|
+
"stack.png": "image/png",
|
|
69
|
+
"favicon.ico": "application/octet-stream", // fallback for ico
|
|
70
|
+
"convex-logo.svg": "image/svg+xml", // <?xm
|
|
71
|
+
"stack-light@3x.webp": "image/webp",
|
|
72
|
+
"cat.gif": "image/gif",
|
|
73
|
+
};
|
|
74
|
+
for (const file of testFiles) {
|
|
75
|
+
const ab = fileToArrayBuffer(path.join(testAssetsDir, file));
|
|
76
|
+
const mime = guessMimeType(ab);
|
|
77
|
+
expect(mime).toBe(expected[file]);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("turns Uint8Array into ArrayBuffer and round-trips", () => {
|
|
82
|
+
const arr = new Uint8Array([1, 2, 3, 4, 5]);
|
|
83
|
+
// serializeDataOrUrl should return the same ArrayBuffer
|
|
84
|
+
const ser = serializeDataOrUrl(arr);
|
|
85
|
+
expect(ser).toBeInstanceOf(ArrayBuffer);
|
|
86
|
+
expect(new Uint8Array(ser as ArrayBuffer)).toEqual(arr);
|
|
87
|
+
// toModelMessageDataOrUrl should return the same ArrayBuffer
|
|
88
|
+
const deser = toModelMessageDataOrUrl(ser);
|
|
89
|
+
expect(deser).toBeInstanceOf(ArrayBuffer);
|
|
90
|
+
expect(new Uint8Array(deser as ArrayBuffer)).toEqual(arr);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("round-trip serialize/deserialize message", async () => {
|
|
94
|
+
const message = {
|
|
95
|
+
role: "user" as const,
|
|
96
|
+
content: "hello world",
|
|
97
|
+
providerOptions: {},
|
|
98
|
+
};
|
|
99
|
+
// Fake ctx and component
|
|
100
|
+
const ctx = {
|
|
101
|
+
runAction: async () => undefined,
|
|
102
|
+
runMutation: async () => undefined,
|
|
103
|
+
storage: {
|
|
104
|
+
store: async () => "storageId",
|
|
105
|
+
getUrl: async () => "https://example.com/file",
|
|
106
|
+
delete: async () => undefined,
|
|
107
|
+
},
|
|
108
|
+
} as unknown as ActionCtx;
|
|
109
|
+
const component = api as unknown as AgentComponent;
|
|
110
|
+
const { message: ser } = await serializeMessage(ctx, component, message);
|
|
111
|
+
// Use is for type validation
|
|
112
|
+
expect(validate(vMessage, ser)).toBeTruthy();
|
|
113
|
+
const round = toModelMessage(ser);
|
|
114
|
+
expect(round).toEqual(message);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("tool output round-trips", async () => {
|
|
118
|
+
const toolResult = {
|
|
119
|
+
type: "tool-result" as const,
|
|
120
|
+
toolCallId: "tool-call-id",
|
|
121
|
+
toolName: "tool-name",
|
|
122
|
+
output: {
|
|
123
|
+
type: "text",
|
|
124
|
+
value: "hello world",
|
|
125
|
+
},
|
|
126
|
+
} satisfies ToolResultPart;
|
|
127
|
+
const [result] = toModelMessageContent([toolResult]);
|
|
128
|
+
expect(result).toMatchObject(toolResult);
|
|
129
|
+
const {
|
|
130
|
+
content: [roundtrip],
|
|
131
|
+
} = await serializeContent({} as ActionCtx, {} as AgentComponent, [
|
|
132
|
+
result as ToolResultPart,
|
|
133
|
+
]);
|
|
134
|
+
expect(roundtrip).toMatchObject(toolResult);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("tool results get normalized to output", async () => {
|
|
138
|
+
const toolResult = {
|
|
139
|
+
type: "tool-result" as const,
|
|
140
|
+
toolCallId: "tool-call-id",
|
|
141
|
+
toolName: "tool-name",
|
|
142
|
+
result: "hello world",
|
|
143
|
+
} satisfies Infer<typeof vToolResultPart>;
|
|
144
|
+
const expected = {
|
|
145
|
+
type: "tool-result",
|
|
146
|
+
toolCallId: "tool-call-id",
|
|
147
|
+
toolName: "tool-name",
|
|
148
|
+
output: {
|
|
149
|
+
type: "text",
|
|
150
|
+
value: "hello world",
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
const [deserialized] = toModelMessageContent([toolResult]);
|
|
154
|
+
expect(deserialized).toMatchObject(expected);
|
|
155
|
+
const {
|
|
156
|
+
content: [serialized],
|
|
157
|
+
} = await serializeContent({} as ActionCtx, {} as AgentComponent, [
|
|
158
|
+
toolResult,
|
|
159
|
+
]);
|
|
160
|
+
expect(serialized).toMatchObject(expected);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("legacy result JSON with a type field is not treated as SDK output", async () => {
|
|
164
|
+
const weather = { type: "weather", temperature: 72 };
|
|
165
|
+
const toolResult = {
|
|
166
|
+
type: "tool-result" as const,
|
|
167
|
+
toolCallId: "tool-call-id",
|
|
168
|
+
toolName: "weather",
|
|
169
|
+
result: weather,
|
|
170
|
+
} satisfies Infer<typeof vToolResultPart>;
|
|
171
|
+
const expected = { type: "json", value: weather };
|
|
172
|
+
|
|
173
|
+
const [deserialized] = toModelMessageContent([
|
|
174
|
+
toolResult,
|
|
175
|
+
]) as ToolResultPart[];
|
|
176
|
+
expect(deserialized.output).toEqual(expected);
|
|
177
|
+
const { content } = await serializeContent(
|
|
178
|
+
{} as ActionCtx,
|
|
179
|
+
{} as AgentComponent,
|
|
180
|
+
[toolResult],
|
|
181
|
+
);
|
|
182
|
+
expect((content[0] as Infer<typeof vToolResultPart>).output).toEqual(
|
|
183
|
+
expected,
|
|
184
|
+
);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test.each([
|
|
188
|
+
[
|
|
189
|
+
"tagged data",
|
|
190
|
+
{
|
|
191
|
+
type: "file",
|
|
192
|
+
data: { type: "data", data: new Uint8Array([1, 2]) },
|
|
193
|
+
mediaType: "application/octet-stream",
|
|
194
|
+
},
|
|
195
|
+
{ type: "data" },
|
|
196
|
+
],
|
|
197
|
+
[
|
|
198
|
+
"tagged URL",
|
|
199
|
+
{
|
|
200
|
+
type: "file",
|
|
201
|
+
data: { type: "url", url: new URL("https://example.com/file") },
|
|
202
|
+
mediaType: "application/pdf",
|
|
203
|
+
},
|
|
204
|
+
{ type: "url", url: new URL("https://example.com/file") },
|
|
205
|
+
],
|
|
206
|
+
[
|
|
207
|
+
"tagged text",
|
|
208
|
+
{
|
|
209
|
+
type: "file",
|
|
210
|
+
data: { type: "text", text: "hello" },
|
|
211
|
+
mediaType: "text/plain",
|
|
212
|
+
},
|
|
213
|
+
{ type: "text", text: "hello" },
|
|
214
|
+
],
|
|
215
|
+
[
|
|
216
|
+
"tagged reference",
|
|
217
|
+
{
|
|
218
|
+
type: "file",
|
|
219
|
+
data: { type: "reference", reference: { openai: "file-1" } },
|
|
220
|
+
mediaType: "application/pdf",
|
|
221
|
+
},
|
|
222
|
+
{ type: "reference", reference: { openai: "file-1" } },
|
|
223
|
+
],
|
|
224
|
+
[
|
|
225
|
+
"legacy file-data",
|
|
226
|
+
{
|
|
227
|
+
type: "file-data",
|
|
228
|
+
data: "AQI=",
|
|
229
|
+
mediaType: "application/octet-stream",
|
|
230
|
+
},
|
|
231
|
+
{ type: "data", data: "AQI=" },
|
|
232
|
+
],
|
|
233
|
+
[
|
|
234
|
+
"legacy file-url",
|
|
235
|
+
{
|
|
236
|
+
type: "file-url",
|
|
237
|
+
url: "https://example.com/file",
|
|
238
|
+
mediaType: "application/pdf",
|
|
239
|
+
},
|
|
240
|
+
{ type: "url", url: new URL("https://example.com/file") },
|
|
241
|
+
],
|
|
242
|
+
[
|
|
243
|
+
"legacy file ID",
|
|
244
|
+
{ type: "file-id", fileId: "file-1" },
|
|
245
|
+
{ type: "file-id", fileId: "file-1" },
|
|
246
|
+
],
|
|
247
|
+
[
|
|
248
|
+
"provider-keyed legacy file ID",
|
|
249
|
+
{ type: "file-id", fileId: { openai: "file-1" } },
|
|
250
|
+
{ type: "reference", reference: { openai: "file-1" } },
|
|
251
|
+
],
|
|
252
|
+
[
|
|
253
|
+
"legacy file reference",
|
|
254
|
+
{ type: "file-reference", providerReference: { openai: "file-1" } },
|
|
255
|
+
{ type: "reference", reference: { openai: "file-1" } },
|
|
256
|
+
],
|
|
257
|
+
[
|
|
258
|
+
"legacy image data",
|
|
259
|
+
{ type: "image-data", data: "AQI=", mediaType: "image/png" },
|
|
260
|
+
{ type: "data", data: "AQI=" },
|
|
261
|
+
],
|
|
262
|
+
[
|
|
263
|
+
"legacy image URL",
|
|
264
|
+
{ type: "image-url", url: "https://example.com/image" },
|
|
265
|
+
{ type: "url", url: new URL("https://example.com/image") },
|
|
266
|
+
],
|
|
267
|
+
[
|
|
268
|
+
"legacy image ID",
|
|
269
|
+
{ type: "image-file-id", fileId: "image-1" },
|
|
270
|
+
{ type: "image-file-id", fileId: "image-1" },
|
|
271
|
+
],
|
|
272
|
+
[
|
|
273
|
+
"provider-keyed legacy image ID",
|
|
274
|
+
{ type: "image-file-id", fileId: { openai: "image-1" } },
|
|
275
|
+
{ type: "reference", reference: { openai: "image-1" } },
|
|
276
|
+
],
|
|
277
|
+
[
|
|
278
|
+
"legacy image reference",
|
|
279
|
+
{
|
|
280
|
+
type: "image-file-reference",
|
|
281
|
+
providerReference: { openai: "image-1" },
|
|
282
|
+
},
|
|
283
|
+
{ type: "reference", reference: { openai: "image-1" } },
|
|
284
|
+
],
|
|
285
|
+
])("round-trips AI SDK 7 tool-result %s", async (_name, value, expected) => {
|
|
286
|
+
const result = {
|
|
287
|
+
type: "tool-result",
|
|
288
|
+
toolCallId: "call-1",
|
|
289
|
+
toolName: "lookup",
|
|
290
|
+
output: { type: "content", value: [value] },
|
|
291
|
+
} as ToolResultPart;
|
|
292
|
+
const { content } = await serializeContent(
|
|
293
|
+
{} as ActionCtx,
|
|
294
|
+
{} as AgentComponent,
|
|
295
|
+
[result],
|
|
296
|
+
);
|
|
297
|
+
const [restored] = toModelMessageContent(content) as ToolResultPart[];
|
|
298
|
+
const restoredPart = (restored.output as { value: unknown[] }).value[0]!;
|
|
299
|
+
if ("fileId" in expected) {
|
|
300
|
+
expect(restoredPart).toMatchObject(expected);
|
|
301
|
+
} else {
|
|
302
|
+
expect((restoredPart as { data: unknown }).data).toMatchObject(expected);
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test("deserializes persisted legacy media tool output to a canonical file", () => {
|
|
307
|
+
const [restored] = toModelMessageContent([
|
|
308
|
+
{
|
|
309
|
+
type: "tool-result",
|
|
310
|
+
toolCallId: "call-1",
|
|
311
|
+
toolName: "render",
|
|
312
|
+
output: {
|
|
313
|
+
type: "content",
|
|
314
|
+
value: [{ type: "media", data: "AQI=", mediaType: "image/png" }],
|
|
315
|
+
},
|
|
316
|
+
},
|
|
317
|
+
]) as ToolResultPart[];
|
|
318
|
+
expect(restored.output).toEqual({
|
|
319
|
+
type: "content",
|
|
320
|
+
value: [
|
|
321
|
+
{
|
|
322
|
+
type: "file",
|
|
323
|
+
data: { type: "data", data: "AQI=" },
|
|
324
|
+
mediaType: "image/png",
|
|
325
|
+
},
|
|
326
|
+
],
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test("reasoning files persist tagged data and URLs, and reject missing data", async () => {
|
|
331
|
+
const { content } = await serializeContent(
|
|
332
|
+
{} as ActionCtx,
|
|
333
|
+
{} as AgentComponent,
|
|
334
|
+
[
|
|
335
|
+
{
|
|
336
|
+
type: "reasoning-file",
|
|
337
|
+
data: { type: "url", url: new URL("https://example.com/reasoning") },
|
|
338
|
+
mediaType: "text/plain",
|
|
339
|
+
},
|
|
340
|
+
{
|
|
341
|
+
type: "reasoning-file",
|
|
342
|
+
data: { type: "data", data: new Uint8Array([1, 2]) },
|
|
343
|
+
mediaType: "application/octet-stream",
|
|
344
|
+
},
|
|
345
|
+
{ type: "custom", kind: "provider.annotation" },
|
|
346
|
+
] as ModelMessage["content"],
|
|
347
|
+
);
|
|
348
|
+
expect(content).toMatchObject([
|
|
349
|
+
{ type: "reasoning-file", url: "https://example.com/reasoning" },
|
|
350
|
+
{ type: "reasoning-file", data: expect.any(ArrayBuffer) },
|
|
351
|
+
{ type: "custom", kind: "provider.annotation" },
|
|
352
|
+
]);
|
|
353
|
+
expect(toModelMessageContent(content)).toMatchObject([
|
|
354
|
+
{
|
|
355
|
+
type: "reasoning-file",
|
|
356
|
+
data: { type: "url", url: new URL("https://example.com/reasoning") },
|
|
357
|
+
},
|
|
358
|
+
{
|
|
359
|
+
type: "reasoning-file",
|
|
360
|
+
data: { type: "data", data: expect.any(ArrayBuffer) },
|
|
361
|
+
},
|
|
362
|
+
{ type: "custom", kind: "provider.annotation" },
|
|
363
|
+
]);
|
|
364
|
+
expect(
|
|
365
|
+
(await serializeContent({} as ActionCtx, {} as AgentComponent, content))
|
|
366
|
+
.content,
|
|
367
|
+
).toEqual(content);
|
|
368
|
+
expect(() =>
|
|
369
|
+
toModelMessageContent([
|
|
370
|
+
{ type: "reasoning-file", mediaType: "text/plain" },
|
|
371
|
+
] as SerializedContent),
|
|
372
|
+
).toThrow("reasoning-file requires data or url");
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
test("reserializing persisted provider fields preserves them", async () => {
|
|
376
|
+
const content: SerializedContent = [
|
|
377
|
+
{ type: "reasoning", text: "private", signature: "signed" },
|
|
378
|
+
{
|
|
379
|
+
type: "tool-result",
|
|
380
|
+
toolCallId: "provider-call",
|
|
381
|
+
toolName: "search",
|
|
382
|
+
providerExecuted: true,
|
|
383
|
+
output: { type: "text", value: "found" },
|
|
384
|
+
},
|
|
385
|
+
];
|
|
386
|
+
await expect(
|
|
387
|
+
serializeContent({} as ActionCtx, {} as AgentComponent, content),
|
|
388
|
+
).resolves.toEqual({ content, fileIds: undefined });
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test.each([
|
|
392
|
+
[
|
|
393
|
+
"ArrayBuffer",
|
|
394
|
+
{
|
|
395
|
+
type: "file",
|
|
396
|
+
data: new Uint8Array([1, 2]).buffer,
|
|
397
|
+
mediaType: "application/octet-stream",
|
|
398
|
+
},
|
|
399
|
+
{ url: "data:application/octet-stream;base64,AQI=" },
|
|
400
|
+
],
|
|
401
|
+
[
|
|
402
|
+
"tagged data",
|
|
403
|
+
{
|
|
404
|
+
type: "file",
|
|
405
|
+
data: { type: "data", data: new Uint8Array([1, 2]) },
|
|
406
|
+
mediaType: "application/octet-stream",
|
|
407
|
+
},
|
|
408
|
+
{ url: "data:application/octet-stream;base64,AQI=" },
|
|
409
|
+
],
|
|
410
|
+
[
|
|
411
|
+
"provider reference",
|
|
412
|
+
{
|
|
413
|
+
type: "file",
|
|
414
|
+
data: { type: "reference", reference: { openai: "file-1" } },
|
|
415
|
+
mediaType: "application/pdf",
|
|
416
|
+
},
|
|
417
|
+
{ url: "", providerReference: { openai: "file-1" } },
|
|
418
|
+
],
|
|
419
|
+
])("renders file UI part for %s", (_name, part, expected) => {
|
|
420
|
+
expect(toUIFilePart(part as FilePart)).toMatchObject(expected);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
test("saving files returns fileIds when too big", async () => {
|
|
424
|
+
// Make a big file
|
|
425
|
+
const bigArr = new Uint8Array(1024 * 65).fill(1);
|
|
426
|
+
const ab = bigArr.buffer.slice(
|
|
427
|
+
bigArr.byteOffset,
|
|
428
|
+
bigArr.byteOffset + bigArr.byteLength,
|
|
429
|
+
);
|
|
430
|
+
let called = false;
|
|
431
|
+
const ctx = {
|
|
432
|
+
runAction: async () => undefined,
|
|
433
|
+
runMutation: async (_fn: unknown, _args: unknown) => {
|
|
434
|
+
called = true;
|
|
435
|
+
return { fileId: "file-123", storageId: "storage-123" };
|
|
436
|
+
},
|
|
437
|
+
storage: {
|
|
438
|
+
store: async () => "storageId",
|
|
439
|
+
getUrl: async () => "https://example.com/file",
|
|
440
|
+
delete: async () => undefined,
|
|
441
|
+
},
|
|
442
|
+
} as unknown as ActionCtx;
|
|
443
|
+
const component = api as unknown as AgentComponent;
|
|
444
|
+
const content = [
|
|
445
|
+
{
|
|
446
|
+
type: "file" as const,
|
|
447
|
+
data: ab,
|
|
448
|
+
filename: "bigfile.bin",
|
|
449
|
+
mimeType: "application/octet-stream",
|
|
450
|
+
providerOptions: {},
|
|
451
|
+
},
|
|
452
|
+
];
|
|
453
|
+
const { content: ser, fileIds } = await serializeContent(
|
|
454
|
+
ctx,
|
|
455
|
+
component,
|
|
456
|
+
content,
|
|
457
|
+
);
|
|
458
|
+
expect(called).toBe(true);
|
|
459
|
+
expect(fileIds).toEqual(["file-123"]);
|
|
460
|
+
// Should have replaced data with a URL
|
|
461
|
+
const serArr = ser as SerializedContent;
|
|
462
|
+
expect(typeof (serArr as { data: unknown }[])[0].data).toBe("string");
|
|
463
|
+
expect((serArr as { data: unknown }[])[0].data as string).toMatch(
|
|
464
|
+
/^https?:\/\//,
|
|
465
|
+
);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
test("sanity: fileIds are not returned for small files", async () => {
|
|
469
|
+
const arr = new Uint8Array([1, 2, 3, 4, 5]);
|
|
470
|
+
const ab = arr.buffer.slice(
|
|
471
|
+
arr.byteOffset,
|
|
472
|
+
arr.byteOffset + arr.byteLength,
|
|
473
|
+
);
|
|
474
|
+
const ctx = {
|
|
475
|
+
runAction: async () => undefined,
|
|
476
|
+
runMutation: async () => ({
|
|
477
|
+
fileId: "file-123",
|
|
478
|
+
storageId: "storage-123",
|
|
479
|
+
}),
|
|
480
|
+
storage: {
|
|
481
|
+
store: async () => "storageId",
|
|
482
|
+
getUrl: async () => "https://example.com/file",
|
|
483
|
+
delete: async () => undefined,
|
|
484
|
+
},
|
|
485
|
+
} as unknown as ActionCtx;
|
|
486
|
+
const component = api as unknown as AgentComponent;
|
|
487
|
+
const content = [
|
|
488
|
+
{
|
|
489
|
+
type: "file" as const,
|
|
490
|
+
data: ab,
|
|
491
|
+
filename: "smallfile.bin",
|
|
492
|
+
mimeType: "application/octet-stream",
|
|
493
|
+
providerOptions: {},
|
|
494
|
+
},
|
|
495
|
+
];
|
|
496
|
+
const { fileIds } = await serializeContent(ctx, component, content);
|
|
497
|
+
expect(fileIds).toBeUndefined();
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
test("tool-approval-request is preserved after serialization", async () => {
|
|
501
|
+
const approvalRequest = {
|
|
502
|
+
type: "tool-approval-request" as const,
|
|
503
|
+
approvalId: "approval-123",
|
|
504
|
+
toolCallId: "tool-call-456",
|
|
505
|
+
};
|
|
506
|
+
const { content } = await serializeContent(
|
|
507
|
+
{} as ActionCtx,
|
|
508
|
+
{} as AgentComponent,
|
|
509
|
+
[approvalRequest],
|
|
510
|
+
);
|
|
511
|
+
expect(content).toHaveLength(1);
|
|
512
|
+
expect((content as unknown[])[0]).toMatchObject(approvalRequest);
|
|
513
|
+
expect(toModelMessageContent(content)).toMatchObject([approvalRequest]);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
test("tool-approval-response with approved: true is preserved", async () => {
|
|
517
|
+
const approvalResponse = {
|
|
518
|
+
type: "tool-approval-response" as const,
|
|
519
|
+
approvalId: "approval-123",
|
|
520
|
+
approved: true,
|
|
521
|
+
reason: "User approved",
|
|
522
|
+
};
|
|
523
|
+
const { content } = await serializeContent(
|
|
524
|
+
{} as ActionCtx,
|
|
525
|
+
{} as AgentComponent,
|
|
526
|
+
[approvalResponse],
|
|
527
|
+
);
|
|
528
|
+
expect(content).toHaveLength(1);
|
|
529
|
+
expect((content as unknown[])[0]).toMatchObject(approvalResponse);
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
test("tool-approval-response with approved: false is preserved", async () => {
|
|
533
|
+
const approvalResponse = {
|
|
534
|
+
type: "tool-approval-response" as const,
|
|
535
|
+
approvalId: "approval-123",
|
|
536
|
+
approved: false,
|
|
537
|
+
reason: "User denied",
|
|
538
|
+
providerExecuted: false,
|
|
539
|
+
};
|
|
540
|
+
const { content } = await serializeContent(
|
|
541
|
+
{} as ActionCtx,
|
|
542
|
+
{} as AgentComponent,
|
|
543
|
+
[approvalResponse],
|
|
544
|
+
);
|
|
545
|
+
expect(content).toHaveLength(1);
|
|
546
|
+
expect((content as unknown[])[0]).toMatchObject(approvalResponse);
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
test("stored reasoning-file URLs survive serialization", async () => {
|
|
550
|
+
const reasoningFile: SerializedContent = [
|
|
551
|
+
{
|
|
552
|
+
type: "reasoning-file",
|
|
553
|
+
url: "https://example.com/reasoning",
|
|
554
|
+
mediaType: "text/plain",
|
|
555
|
+
},
|
|
556
|
+
];
|
|
557
|
+
const { content } = await serializeContent(
|
|
558
|
+
{} as ActionCtx,
|
|
559
|
+
{} as AgentComponent,
|
|
560
|
+
reasoningFile,
|
|
561
|
+
);
|
|
562
|
+
expect(content).toEqual(reasoningFile);
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
describe("serializeResponseMessages", () => {
|
|
566
|
+
const ctx = {
|
|
567
|
+
runAction: async () => undefined,
|
|
568
|
+
runMutation: async () => undefined,
|
|
569
|
+
storage: {
|
|
570
|
+
store: async () => "storageId",
|
|
571
|
+
getUrl: async () => "https://example.com/file",
|
|
572
|
+
delete: async () => undefined,
|
|
573
|
+
},
|
|
574
|
+
} as unknown as ActionCtx;
|
|
575
|
+
const component = api as unknown as AgentComponent;
|
|
576
|
+
|
|
577
|
+
const step0Messages: ModelMessage[] = [
|
|
578
|
+
{
|
|
579
|
+
role: "assistant",
|
|
580
|
+
content: [
|
|
581
|
+
{
|
|
582
|
+
type: "tool-call",
|
|
583
|
+
toolCallId: "c1",
|
|
584
|
+
toolName: "search",
|
|
585
|
+
input: {},
|
|
586
|
+
},
|
|
587
|
+
],
|
|
588
|
+
},
|
|
589
|
+
{
|
|
590
|
+
role: "tool",
|
|
591
|
+
content: [
|
|
592
|
+
{
|
|
593
|
+
type: "tool-result",
|
|
594
|
+
toolCallId: "c1",
|
|
595
|
+
toolName: "search",
|
|
596
|
+
output: { type: "text", value: "ok" },
|
|
597
|
+
},
|
|
598
|
+
],
|
|
599
|
+
},
|
|
600
|
+
];
|
|
601
|
+
const step1Messages: ModelMessage[] = [
|
|
602
|
+
{ role: "assistant", content: [{ type: "text", text: "thinking" }] },
|
|
603
|
+
];
|
|
604
|
+
const step2Messages: ModelMessage[] = [
|
|
605
|
+
{
|
|
606
|
+
role: "assistant",
|
|
607
|
+
content: [
|
|
608
|
+
{
|
|
609
|
+
type: "tool-call",
|
|
610
|
+
toolCallId: "c2",
|
|
611
|
+
toolName: "search",
|
|
612
|
+
input: {},
|
|
613
|
+
},
|
|
614
|
+
],
|
|
615
|
+
},
|
|
616
|
+
{
|
|
617
|
+
role: "tool",
|
|
618
|
+
content: [
|
|
619
|
+
{
|
|
620
|
+
type: "tool-result",
|
|
621
|
+
toolCallId: "c2",
|
|
622
|
+
toolName: "search",
|
|
623
|
+
output: { type: "text", value: "done" },
|
|
624
|
+
},
|
|
625
|
+
],
|
|
626
|
+
},
|
|
627
|
+
];
|
|
628
|
+
|
|
629
|
+
const makeStep = (messages: ModelMessage[]): StepResult<ToolSet> =>
|
|
630
|
+
({
|
|
631
|
+
content: [],
|
|
632
|
+
text: "",
|
|
633
|
+
reasoning: [],
|
|
634
|
+
reasoningText: undefined,
|
|
635
|
+
files: [],
|
|
636
|
+
sources: [],
|
|
637
|
+
toolCalls: [],
|
|
638
|
+
staticToolCalls: [],
|
|
639
|
+
dynamicToolCalls: [],
|
|
640
|
+
toolResults: [],
|
|
641
|
+
staticToolResults: [],
|
|
642
|
+
dynamicToolResults: [],
|
|
643
|
+
finishReason: "stop",
|
|
644
|
+
rawFinishReason: undefined,
|
|
645
|
+
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
|
|
646
|
+
warnings: undefined,
|
|
647
|
+
request: {},
|
|
648
|
+
response: {
|
|
649
|
+
id: "resp",
|
|
650
|
+
timestamp: new Date(),
|
|
651
|
+
modelId: "test",
|
|
652
|
+
messages,
|
|
653
|
+
},
|
|
654
|
+
providerMetadata: undefined,
|
|
655
|
+
}) as unknown as StepResult<ToolSet>;
|
|
656
|
+
|
|
657
|
+
const contentTypes = (msg: { content: unknown }): string[] => {
|
|
658
|
+
const c = msg.content;
|
|
659
|
+
if (!Array.isArray(c)) return ["text"];
|
|
660
|
+
return c.map((p: { type?: string }) => p.type ?? "?");
|
|
661
|
+
};
|
|
662
|
+
|
|
663
|
+
test("explicitly provided empty response messages stay empty", async () => {
|
|
664
|
+
const res = await serializeResponseMessages(
|
|
665
|
+
ctx,
|
|
666
|
+
component,
|
|
667
|
+
makeStep([]),
|
|
668
|
+
undefined,
|
|
669
|
+
[],
|
|
670
|
+
);
|
|
671
|
+
expect(res.messages).toEqual([]);
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
test("serializes all response messages for a step", async () => {
|
|
675
|
+
const res = await serializeResponseMessages(
|
|
676
|
+
ctx,
|
|
677
|
+
component,
|
|
678
|
+
makeStep(step0Messages),
|
|
679
|
+
undefined,
|
|
680
|
+
step0Messages,
|
|
681
|
+
);
|
|
682
|
+
expect(res.messages).toHaveLength(2);
|
|
683
|
+
expect(res.messages[0].message.role).toBe("assistant");
|
|
684
|
+
expect(contentTypes(res.messages[0].message)).toEqual(["tool-call"]);
|
|
685
|
+
expect(res.messages[1].message.role).toBe("tool");
|
|
686
|
+
expect(contentTypes(res.messages[1].message)).toEqual(["tool-result"]);
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
test("serializes the text response from a later step", async () => {
|
|
690
|
+
const res = await serializeResponseMessages(
|
|
691
|
+
ctx,
|
|
692
|
+
component,
|
|
693
|
+
makeStep(step1Messages),
|
|
694
|
+
undefined,
|
|
695
|
+
step1Messages,
|
|
696
|
+
);
|
|
697
|
+
expect(res.messages).toHaveLength(1);
|
|
698
|
+
expect(res.messages[0].message.role).toBe("assistant");
|
|
699
|
+
expect(contentTypes(res.messages[0].message)).toEqual(["text"]);
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
test("persists the model that generated an SDK 7 step over the fallback", async () => {
|
|
703
|
+
const routedModel = mockModel({
|
|
704
|
+
provider: "routed-provider",
|
|
705
|
+
modelId: "routed-model",
|
|
706
|
+
});
|
|
707
|
+
const step = {
|
|
708
|
+
...makeStep(step1Messages),
|
|
709
|
+
model: routedModel,
|
|
710
|
+
} as StepResult<ToolSet>;
|
|
711
|
+
|
|
712
|
+
const res = await serializeResponseMessages(
|
|
713
|
+
ctx,
|
|
714
|
+
component,
|
|
715
|
+
step,
|
|
716
|
+
{ provider: "fallback-provider", model: "fallback-model" },
|
|
717
|
+
step1Messages,
|
|
718
|
+
);
|
|
719
|
+
|
|
720
|
+
expect(res.messages[0]).toMatchObject({
|
|
721
|
+
model: "routed-model",
|
|
722
|
+
provider: "routed-provider",
|
|
723
|
+
});
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
test("serializes a tool-call and tool-result from the same step", async () => {
|
|
727
|
+
const res = await serializeResponseMessages(
|
|
728
|
+
ctx,
|
|
729
|
+
component,
|
|
730
|
+
makeStep(step2Messages),
|
|
731
|
+
undefined,
|
|
732
|
+
step2Messages,
|
|
733
|
+
);
|
|
734
|
+
expect(res.messages).toHaveLength(2);
|
|
735
|
+
expect(res.messages[0].message.role).toBe("assistant");
|
|
736
|
+
expect(contentTypes(res.messages[0].message)).toEqual(["tool-call"]);
|
|
737
|
+
expect(res.messages[1].message.role).toBe("tool");
|
|
738
|
+
expect(contentTypes(res.messages[1].message)).toEqual(["tool-result"]);
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
test("preserves text, a tool call, and its result from one step", async () => {
|
|
742
|
+
const stepMessages: ModelMessage[] = [
|
|
743
|
+
{
|
|
744
|
+
role: "assistant",
|
|
745
|
+
content: [{ type: "text", text: "Let me check..." }],
|
|
746
|
+
},
|
|
747
|
+
{
|
|
748
|
+
role: "assistant",
|
|
749
|
+
content: [
|
|
750
|
+
{
|
|
751
|
+
type: "tool-call",
|
|
752
|
+
toolCallId: "c3",
|
|
753
|
+
toolName: "search",
|
|
754
|
+
input: {},
|
|
755
|
+
},
|
|
756
|
+
],
|
|
757
|
+
},
|
|
758
|
+
{
|
|
759
|
+
role: "tool",
|
|
760
|
+
content: [
|
|
761
|
+
{
|
|
762
|
+
type: "tool-result",
|
|
763
|
+
toolCallId: "c3",
|
|
764
|
+
toolName: "search",
|
|
765
|
+
output: { type: "text", value: "done" },
|
|
766
|
+
},
|
|
767
|
+
],
|
|
768
|
+
},
|
|
769
|
+
];
|
|
770
|
+
const res = await serializeResponseMessages(
|
|
771
|
+
ctx,
|
|
772
|
+
component,
|
|
773
|
+
makeStep(stepMessages),
|
|
774
|
+
undefined,
|
|
775
|
+
stepMessages,
|
|
776
|
+
);
|
|
777
|
+
expect(res.messages).toHaveLength(3);
|
|
778
|
+
expect(res.messages[0].message.role).toBe("assistant");
|
|
779
|
+
expect(contentTypes(res.messages[0].message)).toEqual(["text"]);
|
|
780
|
+
expect(res.messages[1].message.role).toBe("assistant");
|
|
781
|
+
expect(contentTypes(res.messages[1].message)).toEqual(["tool-call"]);
|
|
782
|
+
expect(res.messages[2].message.role).toBe("tool");
|
|
783
|
+
expect(contentTypes(res.messages[2].message)).toEqual(["tool-result"]);
|
|
784
|
+
});
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
describe("autoDenyUnresolvedApprovals", () => {
|
|
788
|
+
test("returns messages unchanged when no unresolved approvals", () => {
|
|
789
|
+
const messages = [
|
|
790
|
+
{ role: "user" as const, content: "hello" },
|
|
791
|
+
{
|
|
792
|
+
role: "assistant" as const,
|
|
793
|
+
content: [
|
|
794
|
+
{ type: "tool-call", toolCallId: "tc1", toolName: "a", input: {} },
|
|
795
|
+
{
|
|
796
|
+
type: "tool-approval-request",
|
|
797
|
+
approvalId: "ap1",
|
|
798
|
+
toolCallId: "tc1",
|
|
799
|
+
},
|
|
800
|
+
],
|
|
801
|
+
},
|
|
802
|
+
{
|
|
803
|
+
role: "tool" as const,
|
|
804
|
+
content: [
|
|
805
|
+
{
|
|
806
|
+
type: "tool-approval-response",
|
|
807
|
+
approvalId: "ap1",
|
|
808
|
+
approved: true,
|
|
809
|
+
},
|
|
810
|
+
],
|
|
811
|
+
},
|
|
812
|
+
] as any;
|
|
813
|
+
|
|
814
|
+
const result = autoDenyUnresolvedApprovals(messages);
|
|
815
|
+
expect(result).toBe(messages); // same reference, no changes
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
test("injects synthetic denial for a single unresolved approval", () => {
|
|
819
|
+
const messages = [
|
|
820
|
+
{ role: "user" as const, content: "hello" },
|
|
821
|
+
{
|
|
822
|
+
role: "assistant" as const,
|
|
823
|
+
content: [
|
|
824
|
+
{ type: "tool-call", toolCallId: "tc1", toolName: "a", input: {} },
|
|
825
|
+
{
|
|
826
|
+
type: "tool-approval-request",
|
|
827
|
+
approvalId: "ap1",
|
|
828
|
+
toolCallId: "tc1",
|
|
829
|
+
},
|
|
830
|
+
],
|
|
831
|
+
},
|
|
832
|
+
{ role: "user" as const, content: "new message" },
|
|
833
|
+
] as any;
|
|
834
|
+
|
|
835
|
+
const result = autoDenyUnresolvedApprovals(messages);
|
|
836
|
+
expect(result).toHaveLength(4); // original 3 + 1 synthetic tool message
|
|
837
|
+
// Synthetic denial should be inserted right after the assistant message (index 1)
|
|
838
|
+
expect(result[2].role).toBe("tool");
|
|
839
|
+
const denialContent = result[2].content as any[];
|
|
840
|
+
expect(denialContent).toHaveLength(1);
|
|
841
|
+
expect(denialContent[0].type).toBe("tool-approval-response");
|
|
842
|
+
expect(denialContent[0].approvalId).toBe("ap1");
|
|
843
|
+
expect(denialContent[0].approved).toBe(false);
|
|
844
|
+
expect(denialContent[0].reason).toBe(
|
|
845
|
+
"auto-denied: new generation started",
|
|
846
|
+
);
|
|
847
|
+
// The new user message should follow
|
|
848
|
+
expect(result[3].role).toBe("user");
|
|
849
|
+
expect(result[3].content).toBe("new message");
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
test("groups multiple unresolved approvals from the same step into a single synthetic message", () => {
|
|
853
|
+
const messages = [
|
|
854
|
+
{
|
|
855
|
+
role: "assistant" as const,
|
|
856
|
+
content: [
|
|
857
|
+
{ type: "tool-call", toolCallId: "tc1", toolName: "a", input: {} },
|
|
858
|
+
{ type: "tool-call", toolCallId: "tc2", toolName: "b", input: {} },
|
|
859
|
+
{
|
|
860
|
+
type: "tool-approval-request",
|
|
861
|
+
approvalId: "ap1",
|
|
862
|
+
toolCallId: "tc1",
|
|
863
|
+
},
|
|
864
|
+
{
|
|
865
|
+
type: "tool-approval-request",
|
|
866
|
+
approvalId: "ap2",
|
|
867
|
+
toolCallId: "tc2",
|
|
868
|
+
},
|
|
869
|
+
],
|
|
870
|
+
},
|
|
871
|
+
] as any;
|
|
872
|
+
|
|
873
|
+
const result = autoDenyUnresolvedApprovals(messages);
|
|
874
|
+
expect(result).toHaveLength(2); // assistant + 1 synthetic tool message
|
|
875
|
+
expect(result[1].role).toBe("tool");
|
|
876
|
+
const denialContent = result[1].content as any[];
|
|
877
|
+
expect(denialContent).toHaveLength(2);
|
|
878
|
+
expect(denialContent[0].approvalId).toBe("ap1");
|
|
879
|
+
expect(denialContent[0].approved).toBe(false);
|
|
880
|
+
expect(denialContent[1].approvalId).toBe("ap2");
|
|
881
|
+
expect(denialContent[1].approved).toBe(false);
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
test("only auto-denies unresolved approvals, leaves resolved ones alone", () => {
|
|
885
|
+
const messages = [
|
|
886
|
+
{
|
|
887
|
+
role: "assistant" as const,
|
|
888
|
+
content: [
|
|
889
|
+
{ type: "tool-call", toolCallId: "tc1", toolName: "a", input: {} },
|
|
890
|
+
{ type: "tool-call", toolCallId: "tc2", toolName: "b", input: {} },
|
|
891
|
+
{
|
|
892
|
+
type: "tool-approval-request",
|
|
893
|
+
approvalId: "ap1",
|
|
894
|
+
toolCallId: "tc1",
|
|
895
|
+
},
|
|
896
|
+
{
|
|
897
|
+
type: "tool-approval-request",
|
|
898
|
+
approvalId: "ap2",
|
|
899
|
+
toolCallId: "tc2",
|
|
900
|
+
},
|
|
901
|
+
],
|
|
902
|
+
},
|
|
903
|
+
{
|
|
904
|
+
role: "tool" as const,
|
|
905
|
+
content: [
|
|
906
|
+
{
|
|
907
|
+
type: "tool-approval-response",
|
|
908
|
+
approvalId: "ap1",
|
|
909
|
+
approved: true,
|
|
910
|
+
},
|
|
911
|
+
],
|
|
912
|
+
},
|
|
913
|
+
{ role: "user" as const, content: "next question" },
|
|
914
|
+
] as any;
|
|
915
|
+
|
|
916
|
+
const result = autoDenyUnresolvedApprovals(messages);
|
|
917
|
+
// Should inject a denial for ap2 (unresolved) after the assistant message
|
|
918
|
+
expect(result).toHaveLength(4); // assistant + existing tool + synthetic denial + user
|
|
919
|
+
// The synthetic denial is inserted after the assistant (index 0)
|
|
920
|
+
expect(result[0].role).toBe("assistant");
|
|
921
|
+
expect(result[1].role).toBe("tool"); // synthetic denial for ap2
|
|
922
|
+
const denialContent = result[1].content as any[];
|
|
923
|
+
expect(denialContent).toHaveLength(1);
|
|
924
|
+
expect(denialContent[0].approvalId).toBe("ap2");
|
|
925
|
+
expect(denialContent[0].approved).toBe(false);
|
|
926
|
+
// Original tool message (ap1 response) follows
|
|
927
|
+
expect(result[2].role).toBe("tool");
|
|
928
|
+
const originalToolContent = result[2].content as any[];
|
|
929
|
+
expect(originalToolContent[0].approvalId).toBe("ap1");
|
|
930
|
+
expect(originalToolContent[0].approved).toBe(true);
|
|
931
|
+
// User message last
|
|
932
|
+
expect(result[3].role).toBe("user");
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
test("emits console.warn for each auto-denied approval", () => {
|
|
936
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
937
|
+
const messages = [
|
|
938
|
+
{
|
|
939
|
+
role: "assistant" as const,
|
|
940
|
+
content: [
|
|
941
|
+
{ type: "tool-call", toolCallId: "tc1", toolName: "a", input: {} },
|
|
942
|
+
{ type: "tool-call", toolCallId: "tc2", toolName: "b", input: {} },
|
|
943
|
+
{
|
|
944
|
+
type: "tool-approval-request",
|
|
945
|
+
approvalId: "ap1",
|
|
946
|
+
toolCallId: "tc1",
|
|
947
|
+
},
|
|
948
|
+
{
|
|
949
|
+
type: "tool-approval-request",
|
|
950
|
+
approvalId: "ap2",
|
|
951
|
+
toolCallId: "tc2",
|
|
952
|
+
},
|
|
953
|
+
],
|
|
954
|
+
},
|
|
955
|
+
] as any;
|
|
956
|
+
|
|
957
|
+
autoDenyUnresolvedApprovals(messages);
|
|
958
|
+
|
|
959
|
+
expect(warnSpy).toHaveBeenCalledTimes(2);
|
|
960
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("ap1"));
|
|
961
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("ap2"));
|
|
962
|
+
warnSpy.mockRestore();
|
|
963
|
+
});
|
|
964
|
+
});
|
|
965
|
+
});
|