@convex-dev/agent 0.7.0 → 0.7.2
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/README.md +5 -8
- package/dist/client/messages.d.ts +10 -0
- package/dist/client/messages.d.ts.map +1 -1
- package/dist/client/messages.js +1 -0
- package/dist/client/messages.js.map +1 -1
- package/dist/component/_generated/component.d.ts +15 -8
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/files.d.ts +6 -4
- package/dist/component/files.d.ts.map +1 -1
- package/dist/component/files.js +53 -42
- package/dist/component/files.js.map +1 -1
- package/dist/component/messages.d.ts +340 -338
- package/dist/component/messages.d.ts.map +1 -1
- package/dist/component/messages.js +80 -16
- package/dist/component/messages.js.map +1 -1
- package/dist/component/schema.d.ts +782 -626
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/schema.js +12 -2
- package/dist/component/schema.js.map +1 -1
- package/dist/component/streams.d.ts +16 -2
- package/dist/component/streams.d.ts.map +1 -1
- package/dist/component/streams.js +86 -30
- package/dist/component/streams.js.map +1 -1
- package/dist/component/vector/index.d.ts +9 -9
- package/dist/component/vector/index.d.ts.map +1 -1
- package/dist/component/vector/tables.d.ts +5 -5
- package/dist/component/vector/tables.d.ts.map +1 -1
- package/dist/component/vector/tables.js +1 -1
- package/dist/component/vector/tables.js.map +1 -1
- package/dist/errors.d.ts +2 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +106 -0
- package/dist/errors.js.map +1 -0
- package/dist/streaming/materializePersistedUIMessageChunks.d.ts +8 -2
- package/dist/streaming/materializePersistedUIMessageChunks.d.ts.map +1 -1
- package/dist/streaming/materializePersistedUIMessageChunks.js +34 -3
- package/dist/streaming/materializePersistedUIMessageChunks.js.map +1 -1
- package/dist/validators.d.ts +1952 -1952
- package/dist/vercel/UIMessages.js +1 -1
- package/dist/vercel/UIMessages.js.map +1 -1
- package/dist/vercel/client/definePlaygroundAPI.d.ts +132 -132
- package/dist/vercel/client/files.d.ts.map +1 -1
- package/dist/vercel/client/files.js +51 -27
- package/dist/vercel/client/files.js.map +1 -1
- package/dist/vercel/client/messages.d.ts +50 -32
- package/dist/vercel/client/messages.d.ts.map +1 -1
- package/dist/vercel/client/messages.js +2 -0
- package/dist/vercel/client/messages.js.map +1 -1
- package/dist/vercel/client/search.d.ts +475 -32
- package/dist/vercel/client/search.d.ts.map +1 -1
- package/dist/vercel/client/search.js +34 -9
- package/dist/vercel/client/search.js.map +1 -1
- package/dist/vercel/client/start.d.ts.map +1 -1
- package/dist/vercel/client/start.js +1 -0
- package/dist/vercel/client/start.js.map +1 -1
- package/dist/vercel/client/streamText.d.ts +1 -2
- package/dist/vercel/client/streamText.d.ts.map +1 -1
- package/dist/vercel/client/streamText.js +49 -17
- package/dist/vercel/client/streamText.js.map +1 -1
- package/dist/vercel/client/streaming.d.ts +318 -301
- package/dist/vercel/client/streaming.d.ts.map +1 -1
- package/dist/vercel/client/streaming.js +56 -21
- package/dist/vercel/client/streaming.js.map +1 -1
- package/dist/vercel/client/types.d.ts +2 -0
- package/dist/vercel/client/types.d.ts.map +1 -1
- package/dist/vercel/client/types.js.map +1 -1
- package/dist/vercel/client/utils.d.ts +9 -5
- package/dist/vercel/client/utils.d.ts.map +1 -1
- package/dist/vercel/client/utils.js +13 -13
- package/dist/vercel/client/utils.js.map +1 -1
- package/dist/vercel/fileMaterialization.d.ts +24 -0
- package/dist/vercel/fileMaterialization.d.ts.map +1 -0
- package/dist/vercel/fileMaterialization.js +118 -0
- package/dist/vercel/fileMaterialization.js.map +1 -0
- package/dist/vercel/index.d.ts +91 -90
- package/dist/vercel/index.d.ts.map +1 -1
- package/dist/vercel/index.js +2 -0
- package/dist/vercel/index.js.map +1 -1
- package/dist/vercel/mapping.d.ts.map +1 -1
- package/dist/vercel/mapping.js +7 -1
- package/dist/vercel/mapping.js.map +1 -1
- package/package.json +16 -9
- package/src/client/messages.ts +12 -0
- package/src/component/_generated/component.ts +18 -2
- package/src/component/files.test.ts +81 -6
- package/src/component/files.ts +69 -41
- package/src/component/messages.test.ts +496 -0
- package/src/component/messages.ts +105 -15
- package/src/component/schema.ts +16 -2
- package/src/component/streams.test.ts +109 -0
- package/src/component/streams.ts +123 -35
- package/src/component/vector/tables.ts +1 -1
- package/src/errors.test.ts +76 -0
- package/src/errors.ts +113 -0
- package/src/streaming/materializePersistedUIMessageChunks.test.ts +33 -0
- package/src/streaming/materializePersistedUIMessageChunks.ts +37 -0
- package/src/vercel/UIMessages.ts +1 -1
- package/src/vercel/client/files.test.ts +56 -0
- package/src/vercel/client/files.ts +53 -28
- package/src/vercel/client/index.test.ts +47 -0
- package/src/vercel/client/messages.ts +20 -0
- package/src/vercel/client/search.test.ts +183 -8
- package/src/vercel/client/search.ts +46 -12
- package/src/vercel/client/start.ts +1 -0
- package/src/vercel/client/streamText.test.ts +113 -17
- package/src/vercel/client/streamText.ts +55 -28
- package/src/vercel/client/streaming.test.ts +142 -7
- package/src/vercel/client/streaming.ts +73 -19
- package/src/vercel/client/types.ts +2 -0
- package/src/vercel/client/utils.test.ts +97 -0
- package/src/vercel/client/utils.ts +15 -15
- package/src/vercel/fileMaterialization.ts +174 -0
- package/src/vercel/index.ts +2 -0
- package/src/vercel/mapping.test.ts +117 -0
- package/src/vercel/mapping.ts +15 -1
- package/src/vercel/toUIMessages.test.ts +7 -1
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { APICallError } from "@ai-sdk/provider";
|
|
2
|
+
import { describe, expect, test } from "vitest";
|
|
3
|
+
import { errorToString } from "./errors.js";
|
|
4
|
+
|
|
5
|
+
describe("errorToString", () => {
|
|
6
|
+
test("preserves provider error classifications", () => {
|
|
7
|
+
const details = {
|
|
8
|
+
error: {
|
|
9
|
+
code: "invalid_prompt",
|
|
10
|
+
message: "Invalid prompt: flagged by policy",
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
const apiError = new APICallError({
|
|
14
|
+
message: "Invalid prompt: flagged by policy",
|
|
15
|
+
url: "https://api.example.test",
|
|
16
|
+
requestBodyValues: {},
|
|
17
|
+
statusCode: 400,
|
|
18
|
+
data: details,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
expect(errorToString(details)).toBe(
|
|
22
|
+
"invalid_prompt: Invalid prompt: flagged by policy",
|
|
23
|
+
);
|
|
24
|
+
expect(errorToString(apiError)).toBe(
|
|
25
|
+
"invalid_prompt: Invalid prompt: flagged by policy",
|
|
26
|
+
);
|
|
27
|
+
expect(errorToString(new Error())).toBe("Error");
|
|
28
|
+
expect(errorToString(new TypeError())).toBe("TypeError");
|
|
29
|
+
const systemError = Object.assign(new Error("socket hang up"), {
|
|
30
|
+
code: "ECONNRESET",
|
|
31
|
+
});
|
|
32
|
+
expect(errorToString(systemError)).toBe("socket hang up");
|
|
33
|
+
const codeOnly = Object.assign(new Error("Request failed"), {
|
|
34
|
+
data: { code: "rate_limit" },
|
|
35
|
+
});
|
|
36
|
+
expect(errorToString(codeOnly)).toBe("rate_limit: Request failed");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("serializes objects without mistaking shared values for cycles", () => {
|
|
40
|
+
const shared = { detail: "provider disconnected" };
|
|
41
|
+
const circular: Record<string, unknown> = { shared };
|
|
42
|
+
circular.self = circular;
|
|
43
|
+
|
|
44
|
+
expect(errorToString({ x: shared, y: shared })).toBe(
|
|
45
|
+
'{"x":{"detail":"provider disconnected"},"y":{"detail":"provider disconnected"}}',
|
|
46
|
+
);
|
|
47
|
+
expect(errorToString(circular)).toBe(
|
|
48
|
+
'{"shared":{"detail":"provider disconnected"},"self":"[Circular]"}',
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("bounds stored error text without splitting surrogate pairs", () => {
|
|
53
|
+
const serialized = errorToString(`${"x".repeat(1022)}😀tail`);
|
|
54
|
+
|
|
55
|
+
expect(serialized.length).toBeLessThanOrEqual(1024);
|
|
56
|
+
expect(serialized.endsWith("x…")).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("does not throw when Error properties are hostile accessors", () => {
|
|
60
|
+
const error = new Error();
|
|
61
|
+
Object.defineProperties(error, {
|
|
62
|
+
message: {
|
|
63
|
+
get() {
|
|
64
|
+
throw new Error("message getter failed");
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
name: {
|
|
68
|
+
get() {
|
|
69
|
+
throw new Error("name getter failed");
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
expect(errorToString(error)).toBe("Unknown error");
|
|
75
|
+
});
|
|
76
|
+
});
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
const MAX_ERROR_LENGTH = 1024;
|
|
2
|
+
|
|
3
|
+
export function errorToString(error: unknown): string {
|
|
4
|
+
return truncateError(describeError(error));
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function describeError(error: unknown): string {
|
|
8
|
+
if (typeof error === "string") return error;
|
|
9
|
+
if (error instanceof Error) {
|
|
10
|
+
const message = property(error, "message");
|
|
11
|
+
if (typeof message !== "string" || message.length === 0) {
|
|
12
|
+
const name = property(error, "name");
|
|
13
|
+
return typeof name === "string" && name.length > 0
|
|
14
|
+
? name
|
|
15
|
+
: safeString(error);
|
|
16
|
+
}
|
|
17
|
+
const nested = errorDetails(
|
|
18
|
+
property(error, "error") ?? property(error, "data"),
|
|
19
|
+
);
|
|
20
|
+
return (
|
|
21
|
+
formatDetails({
|
|
22
|
+
message: nested.message ?? message,
|
|
23
|
+
code: nested.code,
|
|
24
|
+
}) ?? message
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const details = formatDetails(errorDetails(error));
|
|
29
|
+
if (details) return details;
|
|
30
|
+
|
|
31
|
+
if (error && typeof error === "object") {
|
|
32
|
+
try {
|
|
33
|
+
const ancestors: object[] = [];
|
|
34
|
+
const serialized = JSON.stringify(error, function (_key, value: unknown) {
|
|
35
|
+
if (typeof value === "bigint") return value.toString();
|
|
36
|
+
if (!value || typeof value !== "object") return value;
|
|
37
|
+
while (ancestors.length > 0 && ancestors.at(-1) !== this) {
|
|
38
|
+
ancestors.pop();
|
|
39
|
+
}
|
|
40
|
+
if (ancestors.includes(value)) return "[Circular]";
|
|
41
|
+
ancestors.push(value);
|
|
42
|
+
return value;
|
|
43
|
+
});
|
|
44
|
+
if (serialized) return serialized;
|
|
45
|
+
} catch {
|
|
46
|
+
return safeString(error);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return safeString(error);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function safeString(error: unknown): string {
|
|
54
|
+
try {
|
|
55
|
+
return String(error);
|
|
56
|
+
} catch {
|
|
57
|
+
return "Unknown error";
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function errorDetails(error: unknown): { message?: string; code?: string } {
|
|
62
|
+
let current = error;
|
|
63
|
+
let message: string | undefined;
|
|
64
|
+
let code: string | undefined;
|
|
65
|
+
for (let depth = 0; depth < 3; depth++) {
|
|
66
|
+
if (typeof current === "string") {
|
|
67
|
+
message ??= current;
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
if (!current || typeof current !== "object") break;
|
|
71
|
+
|
|
72
|
+
const currentMessage = property(current, "message");
|
|
73
|
+
if (typeof currentMessage === "string" && currentMessage.length > 0) {
|
|
74
|
+
message ??= currentMessage;
|
|
75
|
+
}
|
|
76
|
+
const currentCode = property(current, "code");
|
|
77
|
+
if (typeof currentCode === "string" || typeof currentCode === "number") {
|
|
78
|
+
code ??= String(currentCode);
|
|
79
|
+
}
|
|
80
|
+
if (message && code) break;
|
|
81
|
+
current = property(current, "error") ?? property(current, "data");
|
|
82
|
+
}
|
|
83
|
+
return { message, code };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function property(value: object, key: string): unknown {
|
|
87
|
+
try {
|
|
88
|
+
return (value as Record<string, unknown>)[key];
|
|
89
|
+
} catch {
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function formatDetails({
|
|
95
|
+
message,
|
|
96
|
+
code,
|
|
97
|
+
}: {
|
|
98
|
+
message?: string;
|
|
99
|
+
code?: string;
|
|
100
|
+
}): string | undefined {
|
|
101
|
+
if (message && code) {
|
|
102
|
+
return message.startsWith(`${code}:`) ? message : `${code}: ${message}`;
|
|
103
|
+
}
|
|
104
|
+
return message ?? code;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function truncateError(error: string): string {
|
|
108
|
+
if (error.length <= MAX_ERROR_LENGTH) return error;
|
|
109
|
+
let truncated = error.slice(0, MAX_ERROR_LENGTH - 1);
|
|
110
|
+
const last = truncated.charCodeAt(truncated.length - 1);
|
|
111
|
+
if (last >= 0xd800 && last <= 0xdbff) truncated = truncated.slice(0, -1);
|
|
112
|
+
return `${truncated}…`;
|
|
113
|
+
}
|
|
@@ -51,6 +51,39 @@ describe("projectPersistedUIMessageChunks", () => {
|
|
|
51
51
|
expect(validate(vMessageWithMetadataInternal, actual[1])).toBe(true);
|
|
52
52
|
});
|
|
53
53
|
|
|
54
|
+
it("attaches materialized canonical tool-result files during recovery", () => {
|
|
55
|
+
const url = "https://files.example/tool-result";
|
|
56
|
+
const actual = projectPersistedUIMessageChunks(
|
|
57
|
+
stream,
|
|
58
|
+
[
|
|
59
|
+
{
|
|
60
|
+
type: "tool-input-available",
|
|
61
|
+
toolCallId: "call-1",
|
|
62
|
+
toolName: "render",
|
|
63
|
+
input: {},
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
type: "tool-output-available",
|
|
67
|
+
toolCallId: "call-1",
|
|
68
|
+
output: {
|
|
69
|
+
type: "content",
|
|
70
|
+
value: [
|
|
71
|
+
{
|
|
72
|
+
type: "file",
|
|
73
|
+
data: { type: "url", url },
|
|
74
|
+
mediaType: "application/octet-stream",
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
{ status: "success" },
|
|
81
|
+
[{ url, fileId: "file-1" }],
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
expect(actual[1]).toMatchObject({ fileIds: ["file-1"] });
|
|
85
|
+
});
|
|
86
|
+
|
|
54
87
|
it("keeps persisted sources on the step that produced them", () => {
|
|
55
88
|
const chunks = [
|
|
56
89
|
{ type: "start-step" },
|
|
@@ -33,6 +33,7 @@ export function projectPersistedUIMessageChunks(
|
|
|
33
33
|
stream: StreamMessage,
|
|
34
34
|
chunks: readonly unknown[],
|
|
35
35
|
metadata: PersistedStreamMetadata,
|
|
36
|
+
fileRefs: readonly { url: string; fileId: string }[] = [],
|
|
36
37
|
): MessageWithMetadataInternal[] {
|
|
37
38
|
if (stream.format !== "UIMessageChunk") {
|
|
38
39
|
throw new Error(
|
|
@@ -60,6 +61,7 @@ export function projectPersistedUIMessageChunks(
|
|
|
60
61
|
reduced.state.parts,
|
|
61
62
|
stream,
|
|
62
63
|
metadata,
|
|
64
|
+
fileRefs,
|
|
63
65
|
);
|
|
64
66
|
}
|
|
65
67
|
|
|
@@ -91,6 +93,7 @@ export function projectPersistedUIMessageChunkParts(
|
|
|
91
93
|
parts: PersistedUIMessagePart[],
|
|
92
94
|
stream: StreamMessage,
|
|
93
95
|
metadata: PersistedStreamMetadata,
|
|
96
|
+
fileRefs: readonly { url: string; fileId: string }[] = [],
|
|
94
97
|
): MessageWithMetadataInternal[] {
|
|
95
98
|
const blocks: PersistedUIMessagePart[][] = [];
|
|
96
99
|
let block: PersistedUIMessagePart[] = [];
|
|
@@ -267,6 +270,7 @@ export function projectPersistedUIMessageChunkParts(
|
|
|
267
270
|
const hasToolCall =
|
|
268
271
|
message.role === "tool" ||
|
|
269
272
|
content.some((part) => part.type === "tool-call");
|
|
273
|
+
const fileIds = referencedFileIds(message, fileRefs);
|
|
270
274
|
return {
|
|
271
275
|
message,
|
|
272
276
|
status: metadata.status,
|
|
@@ -284,6 +288,9 @@ export function projectPersistedUIMessageChunkParts(
|
|
|
284
288
|
)
|
|
285
289
|
.map((part) => part.text)
|
|
286
290
|
.join(" "),
|
|
291
|
+
...(fileIds.length > 0
|
|
292
|
+
? { fileIds: fileIds as MessageWithMetadataInternal["fileIds"] }
|
|
293
|
+
: {}),
|
|
287
294
|
...(metadata.error !== undefined ? { error: metadata.error } : {}),
|
|
288
295
|
} satisfies MessageWithMetadataInternal;
|
|
289
296
|
});
|
|
@@ -316,6 +323,36 @@ function projectSources(parts: PersistedUIMessagePart[]) {
|
|
|
316
323
|
);
|
|
317
324
|
}
|
|
318
325
|
|
|
326
|
+
function referencedFileIds(
|
|
327
|
+
message: Message,
|
|
328
|
+
fileRefs: readonly { url: string; fileId: string }[],
|
|
329
|
+
) {
|
|
330
|
+
if (typeof message.content === "string") return [];
|
|
331
|
+
const urls = new Set<string>();
|
|
332
|
+
for (const part of message.content) {
|
|
333
|
+
if (part.type === "file") {
|
|
334
|
+
if (typeof part.data === "string") urls.add(part.data);
|
|
335
|
+
} else if (part.type === "reasoning-file") {
|
|
336
|
+
if ("url" in part && part.url) urls.add(part.url);
|
|
337
|
+
} else if (part.type === "tool-result" && part.output?.type === "content") {
|
|
338
|
+
for (const outputPart of part.output.value) {
|
|
339
|
+
if (
|
|
340
|
+
outputPart.type === "file" &&
|
|
341
|
+
outputPart.data.type === "url" &&
|
|
342
|
+
typeof outputPart.data.url === "string"
|
|
343
|
+
) {
|
|
344
|
+
urls.add(outputPart.data.url);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return [
|
|
350
|
+
...new Set(
|
|
351
|
+
fileRefs.filter((ref) => urls.has(ref.url)).map((ref) => ref.fileId),
|
|
352
|
+
),
|
|
353
|
+
];
|
|
354
|
+
}
|
|
355
|
+
|
|
319
356
|
function toolResult(
|
|
320
357
|
part: PersistedToolPart,
|
|
321
358
|
mode: "normal" | "error-text" | "error-json" | "execution-denied",
|
package/src/vercel/UIMessages.ts
CHANGED
|
@@ -344,7 +344,7 @@ function createUserUIMessage<
|
|
|
344
344
|
|
|
345
345
|
const parts: UIMessage<METADATA, DATA_PARTS, TOOLS>["parts"] = [];
|
|
346
346
|
if (text && !nonStringContent.length) {
|
|
347
|
-
parts.push({ type: "text", text });
|
|
347
|
+
parts.push({ type: "text", text, ...partCommon });
|
|
348
348
|
}
|
|
349
349
|
for (const contentPart of nonStringContent) {
|
|
350
350
|
switch (contentPart.type) {
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
2
|
+
|
|
3
|
+
import { describe, expect, test } from "vitest";
|
|
4
|
+
import { storeFile } from "./files.js";
|
|
5
|
+
|
|
6
|
+
describe("storeFile", () => {
|
|
7
|
+
test("throws a clear error when a reused file is missing from storage", async () => {
|
|
8
|
+
const ctx = {
|
|
9
|
+
runAction: async () => null,
|
|
10
|
+
runMutation: async () => ({
|
|
11
|
+
fileId: "existing-file",
|
|
12
|
+
storageId: "existing-storage",
|
|
13
|
+
}),
|
|
14
|
+
storage: {
|
|
15
|
+
getUrl: async () => null,
|
|
16
|
+
},
|
|
17
|
+
} as unknown as Parameters<typeof storeFile>[0];
|
|
18
|
+
const component = {
|
|
19
|
+
files: { useExistingFile: {}, addFile: {} },
|
|
20
|
+
} as unknown as Parameters<typeof storeFile>[1];
|
|
21
|
+
|
|
22
|
+
await expect(storeFile(ctx, component, new Blob(["x"]))).rejects.toThrow(
|
|
23
|
+
"File not found in storage: existing-storage",
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("cleans its losing blob before an existing URL read fails", async () => {
|
|
28
|
+
const deleted: string[] = [];
|
|
29
|
+
let mutationCount = 0;
|
|
30
|
+
const ctx = {
|
|
31
|
+
runAction: async () => null,
|
|
32
|
+
runMutation: async () => {
|
|
33
|
+
mutationCount++;
|
|
34
|
+
return mutationCount === 1
|
|
35
|
+
? null
|
|
36
|
+
: { fileId: "existing-file", storageId: "existing-storage" };
|
|
37
|
+
},
|
|
38
|
+
storage: {
|
|
39
|
+
store: async () => "new-storage",
|
|
40
|
+
getMetadata: async () => null,
|
|
41
|
+
getUrl: async () => null,
|
|
42
|
+
delete: async (storageId: string) => {
|
|
43
|
+
deleted.push(storageId);
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
} as unknown as Parameters<typeof storeFile>[0];
|
|
47
|
+
const component = {
|
|
48
|
+
files: { useExistingFile: {}, addFile: {} },
|
|
49
|
+
} as unknown as Parameters<typeof storeFile>[1];
|
|
50
|
+
|
|
51
|
+
await expect(storeFile(ctx, component, new Blob(["x"]))).rejects.toThrow(
|
|
52
|
+
"File not found in storage: existing-storage",
|
|
53
|
+
);
|
|
54
|
+
expect(deleted).toEqual(["new-storage"]);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -67,9 +67,13 @@ export async function storeFile(
|
|
|
67
67
|
const reused = await ctx.runMutation(component.files.useExistingFile, {
|
|
68
68
|
hash,
|
|
69
69
|
filename,
|
|
70
|
+
mediaType: blob.type || undefined,
|
|
70
71
|
});
|
|
71
72
|
if (reused) {
|
|
72
|
-
const url =
|
|
73
|
+
const url = await ctx.storage.getUrl(reused.storageId);
|
|
74
|
+
if (!url) {
|
|
75
|
+
throw new Error(`File not found in storage: ${reused.storageId}`);
|
|
76
|
+
}
|
|
73
77
|
return {
|
|
74
78
|
...getParts(url, blob.type, filename),
|
|
75
79
|
file: {
|
|
@@ -82,35 +86,56 @@ export async function storeFile(
|
|
|
82
86
|
};
|
|
83
87
|
}
|
|
84
88
|
const newStorageId = await ctx.storage.store(blob);
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}
|
|
91
|
-
const { fileId, storageId } = await ctx.runMutation(component.files.addFile, {
|
|
92
|
-
storageId: newStorageId,
|
|
93
|
-
hash,
|
|
94
|
-
filename,
|
|
95
|
-
mediaType: blob.type,
|
|
96
|
-
});
|
|
97
|
-
const url = (await ctx.storage.getUrl(storageId as Id<"_storage">))!;
|
|
98
|
-
if (storageId !== newStorageId) {
|
|
99
|
-
// We're re-using another file's storageId
|
|
100
|
-
// Because we try to reuse the file above, this should be very very rare
|
|
101
|
-
// and only in the case of racing to check then store the file.
|
|
89
|
+
let newStorageRegistered = false;
|
|
90
|
+
let cleanupAttempted = false;
|
|
91
|
+
const cleanupNewStorage = async () => {
|
|
92
|
+
if (cleanupAttempted) return;
|
|
93
|
+
cleanupAttempted = true;
|
|
102
94
|
await ctx.storage.delete(newStorageId);
|
|
103
|
-
}
|
|
104
|
-
return {
|
|
105
|
-
...getParts(url, blob.type, filename),
|
|
106
|
-
file: {
|
|
107
|
-
url,
|
|
108
|
-
fileId,
|
|
109
|
-
storageId: storageId as Id<"_storage">,
|
|
110
|
-
hash,
|
|
111
|
-
filename,
|
|
112
|
-
},
|
|
113
95
|
};
|
|
96
|
+
try {
|
|
97
|
+
if (sha256) {
|
|
98
|
+
const metadata = await ctx.storage.getMetadata(newStorageId);
|
|
99
|
+
if (metadata?.sha256 !== sha256) {
|
|
100
|
+
throw new Error("Hash mismatch: " + metadata?.sha256 + " != " + sha256);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const { fileId, storageId } = await ctx.runMutation(
|
|
104
|
+
component.files.addFile,
|
|
105
|
+
{
|
|
106
|
+
storageId: newStorageId,
|
|
107
|
+
hash,
|
|
108
|
+
filename,
|
|
109
|
+
mediaType: blob.type,
|
|
110
|
+
},
|
|
111
|
+
);
|
|
112
|
+
newStorageRegistered = storageId === newStorageId;
|
|
113
|
+
// A competing request can win after useExistingFile but before addFile.
|
|
114
|
+
// Delete our losing blob before the existing file's URL is read, so a
|
|
115
|
+
// failed getUrl cannot leave the raw object orphaned.
|
|
116
|
+
if (!newStorageRegistered) {
|
|
117
|
+
await cleanupNewStorage();
|
|
118
|
+
}
|
|
119
|
+
const url = await ctx.storage.getUrl(storageId as Id<"_storage">);
|
|
120
|
+
if (!url) {
|
|
121
|
+
throw new Error(`File not found in storage: ${storageId}`);
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
...getParts(url, blob.type, filename),
|
|
125
|
+
file: {
|
|
126
|
+
url,
|
|
127
|
+
fileId,
|
|
128
|
+
storageId: storageId as Id<"_storage">,
|
|
129
|
+
hash,
|
|
130
|
+
filename,
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
} catch (error) {
|
|
134
|
+
if (!newStorageRegistered && !cleanupAttempted) {
|
|
135
|
+
await cleanupNewStorage().catch(() => {});
|
|
136
|
+
}
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
114
139
|
}
|
|
115
140
|
|
|
116
141
|
/**
|
|
@@ -4,6 +4,8 @@ import {
|
|
|
4
4
|
createThread,
|
|
5
5
|
createTool,
|
|
6
6
|
filterOutOrphanedToolMessages,
|
|
7
|
+
saveMessage,
|
|
8
|
+
toUIMessages,
|
|
7
9
|
type MessageDoc,
|
|
8
10
|
} from "../index.js";
|
|
9
11
|
import type { DataModelFromSchemaDefinition } from "convex/server";
|
|
@@ -458,6 +460,51 @@ describe("Agent message operations", () => {
|
|
|
458
460
|
expect(messages.length).toBe(2);
|
|
459
461
|
expect(messages[1]._id).toBeDefined();
|
|
460
462
|
});
|
|
463
|
+
|
|
464
|
+
test("saveMessage can place a standalone assistant message on a new order", async () => {
|
|
465
|
+
const t = initConvexTest(schema);
|
|
466
|
+
const threadId = await t.run(async (ctx) =>
|
|
467
|
+
createThread(ctx, components.agent, { userId: "operator-test" }),
|
|
468
|
+
);
|
|
469
|
+
const { message: agentReply } = await t.run(async (ctx) =>
|
|
470
|
+
agent.saveMessage(ctx, {
|
|
471
|
+
threadId,
|
|
472
|
+
message: {
|
|
473
|
+
role: "assistant",
|
|
474
|
+
content: [
|
|
475
|
+
{
|
|
476
|
+
type: "tool-call",
|
|
477
|
+
toolCallId: "handoff-1",
|
|
478
|
+
toolName: "handoff",
|
|
479
|
+
input: {},
|
|
480
|
+
},
|
|
481
|
+
],
|
|
482
|
+
},
|
|
483
|
+
}),
|
|
484
|
+
);
|
|
485
|
+
const { message: operatorReply } = await t.run(async (ctx) =>
|
|
486
|
+
saveMessage(ctx, components.agent, {
|
|
487
|
+
threadId,
|
|
488
|
+
order: "next",
|
|
489
|
+
agentName: "human:Alex",
|
|
490
|
+
message: { role: "assistant", content: "Operator reply" },
|
|
491
|
+
}),
|
|
492
|
+
);
|
|
493
|
+
const uiMessages = toUIMessages([agentReply, operatorReply]);
|
|
494
|
+
|
|
495
|
+
expect(agentReply).toMatchObject({ order: 0, stepOrder: 0 });
|
|
496
|
+
expect(operatorReply).toMatchObject({
|
|
497
|
+
order: 1,
|
|
498
|
+
stepOrder: 0,
|
|
499
|
+
agentName: "human:Alex",
|
|
500
|
+
});
|
|
501
|
+
expect(uiMessages).toHaveLength(2);
|
|
502
|
+
expect(uiMessages[1]).toMatchObject({
|
|
503
|
+
order: 1,
|
|
504
|
+
agentName: "human:Alex",
|
|
505
|
+
text: "Operator reply",
|
|
506
|
+
});
|
|
507
|
+
});
|
|
461
508
|
});
|
|
462
509
|
|
|
463
510
|
describe("Agent text/object generation", () => {
|
|
@@ -11,6 +11,7 @@ import { toUIMessages, type UIMessage } from "../UIMessages.js";
|
|
|
11
11
|
import {
|
|
12
12
|
listMessages,
|
|
13
13
|
saveMessages as saveCanonicalMessages,
|
|
14
|
+
type MessageOrder,
|
|
14
15
|
} from "../../client/messages.js";
|
|
15
16
|
import type {
|
|
16
17
|
AgentComponent,
|
|
@@ -36,6 +37,15 @@ export async function listUIMessages(
|
|
|
36
37
|
export type SaveMessagesArgs = {
|
|
37
38
|
threadId: string;
|
|
38
39
|
userId?: string | null;
|
|
40
|
+
/**
|
|
41
|
+
* Save the first message at this order. Pass `"next"` to allocate a new
|
|
42
|
+
* order after the current latest message. If the numeric order already
|
|
43
|
+
* contains messages, the message is appended at the next stepOrder.
|
|
44
|
+
* Numeric orders must be non-negative safe integers less than
|
|
45
|
+
* Number.MAX_SAFE_INTEGER.
|
|
46
|
+
* Cannot be combined with promptMessageId or pendingMessageId.
|
|
47
|
+
*/
|
|
48
|
+
order?: MessageOrder;
|
|
39
49
|
/**
|
|
40
50
|
* The message that these messages are in response to. They will be
|
|
41
51
|
* the same "order" as this message, at increasing stepOrder(s).
|
|
@@ -86,6 +96,7 @@ export async function saveMessages(
|
|
|
86
96
|
userId: args.userId ?? undefined,
|
|
87
97
|
agentName: args.agentName,
|
|
88
98
|
promptMessageId: args.promptMessageId,
|
|
99
|
+
order: args.order,
|
|
89
100
|
pendingMessageId: args.pendingMessageId,
|
|
90
101
|
embeddings: args.embeddings,
|
|
91
102
|
messages: serialized.map(({ message }) => message),
|
|
@@ -105,6 +116,14 @@ export async function saveMessages(
|
|
|
105
116
|
export type SaveMessageArgs = {
|
|
106
117
|
threadId: string;
|
|
107
118
|
userId?: string | null;
|
|
119
|
+
/**
|
|
120
|
+
* Save the message at this order. Pass `"next"` to allocate a new order
|
|
121
|
+
* after the current latest message. If the numeric order already contains
|
|
122
|
+
* messages, the message is appended at the next stepOrder. Numeric orders
|
|
123
|
+
* must be non-negative safe integers less than Number.MAX_SAFE_INTEGER.
|
|
124
|
+
* Cannot be combined with promptMessageId or pendingMessageId.
|
|
125
|
+
*/
|
|
126
|
+
order?: MessageOrder;
|
|
108
127
|
/**
|
|
109
128
|
* The message that these messages are in response to. They will be
|
|
110
129
|
* the same "order" as this message, at increasing stepOrder(s).
|
|
@@ -169,6 +188,7 @@ export async function saveMessage(
|
|
|
169
188
|
userId: args.userId ?? undefined,
|
|
170
189
|
agentName: args.agentName,
|
|
171
190
|
promptMessageId: args.promptMessageId,
|
|
191
|
+
order: args.order,
|
|
172
192
|
pendingMessageId: args.pendingMessageId,
|
|
173
193
|
messages:
|
|
174
194
|
args.prompt !== undefined
|