@convex-dev/agent 0.7.0 → 0.7.1
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/component/_generated/component.d.ts +5 -0
- 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.map +1 -1
- package/dist/component/messages.js +7 -3
- package/dist/component/messages.js.map +1 -1
- package/dist/component/schema.d.ts +58 -6
- 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 +10 -1
- package/dist/component/streams.d.ts.map +1 -1
- package/dist/component/streams.js +72 -17
- package/dist/component/streams.js.map +1 -1
- 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/vercel/UIMessages.js +1 -1
- package/dist/vercel/UIMessages.js.map +1 -1
- 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/streamText.d.ts.map +1 -1
- package/dist/vercel/client/streamText.js +2 -0
- package/dist/vercel/client/streamText.js.map +1 -1
- package/dist/vercel/client/streaming.d.ts +14 -0
- package/dist/vercel/client/streaming.d.ts.map +1 -1
- package/dist/vercel/client/streaming.js +35 -13
- package/dist/vercel/client/streaming.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/mapping.d.ts.map +1 -1
- package/dist/vercel/mapping.js +7 -1
- package/dist/vercel/mapping.js.map +1 -1
- package/package.json +1 -1
- package/src/component/_generated/component.ts +8 -2
- package/src/component/files.test.ts +81 -6
- package/src/component/files.ts +69 -41
- package/src/component/messages.test.ts +92 -0
- package/src/component/messages.ts +11 -3
- package/src/component/schema.ts +16 -2
- package/src/component/streams.test.ts +109 -0
- package/src/component/streams.ts +103 -20
- 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/streamText.ts +3 -0
- package/src/vercel/client/streaming.test.ts +71 -0
- package/src/vercel/client/streaming.ts +50 -16
- package/src/vercel/fileMaterialization.ts +174 -0
- package/src/vercel/mapping.test.ts +117 -0
- package/src/vercel/mapping.ts +15 -1
- package/src/vercel/toUIMessages.test.ts +7 -1
package/src/component/streams.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
getPersistedUIMessageChunkParts,
|
|
26
26
|
projectPersistedUIMessageChunks,
|
|
27
27
|
} from "../streaming/materializePersistedUIMessageChunks.js";
|
|
28
|
+
import { changeRefcount } from "./files.js";
|
|
28
29
|
|
|
29
30
|
const SECOND = 1000;
|
|
30
31
|
const MINUTE = 60 * SECOND;
|
|
@@ -35,9 +36,16 @@ const TIMEOUT_INTERVAL = 10 * MINUTE;
|
|
|
35
36
|
const DELETE_STREAM_DELAY = MINUTE * 5; // 5 minutes
|
|
36
37
|
|
|
37
38
|
const deltaValidator = schema.tables.streamDeltas.validator;
|
|
39
|
+
const streamFileRefValidator = v.object({
|
|
40
|
+
url: v.string(),
|
|
41
|
+
fileId: v.id("files"),
|
|
42
|
+
});
|
|
38
43
|
|
|
39
44
|
export const addDelta = mutation({
|
|
40
|
-
args:
|
|
45
|
+
args: {
|
|
46
|
+
...deltaValidator.fields,
|
|
47
|
+
fileRefs: v.optional(v.array(streamFileRefValidator)),
|
|
48
|
+
},
|
|
41
49
|
returns: v.boolean(),
|
|
42
50
|
handler: async (ctx, args) => {
|
|
43
51
|
const stream = await ctx.db.get("streamingMessages", args.streamId);
|
|
@@ -48,7 +56,30 @@ export const addDelta = mutation({
|
|
|
48
56
|
if (stream.state.kind !== "streaming") {
|
|
49
57
|
return false;
|
|
50
58
|
}
|
|
51
|
-
|
|
59
|
+
const { fileRefs, ...delta } = args;
|
|
60
|
+
if (fileRefs?.length) {
|
|
61
|
+
const previous = stream.fileRefs ?? [];
|
|
62
|
+
// The persisted chunks refer to files by URL. Several URLs can still
|
|
63
|
+
// resolve to one durable file, but a URL must resolve to exactly one.
|
|
64
|
+
const refsByUrl = new Map(previous.map((ref) => [ref.url, ref] as const));
|
|
65
|
+
for (const ref of fileRefs) {
|
|
66
|
+
const existing = refsByUrl.get(ref.url);
|
|
67
|
+
if (existing && existing.fileId !== ref.fileId) {
|
|
68
|
+
throw new Error(`Stream file URL maps to multiple files: ${ref.url}`);
|
|
69
|
+
}
|
|
70
|
+
refsByUrl.set(ref.url, ref);
|
|
71
|
+
}
|
|
72
|
+
const next = [...refsByUrl.values()];
|
|
73
|
+
await changeRefcount(
|
|
74
|
+
ctx,
|
|
75
|
+
previous.map(({ fileId }) => fileId),
|
|
76
|
+
next.map(({ fileId }) => fileId),
|
|
77
|
+
);
|
|
78
|
+
await ctx.db.patch("streamingMessages", args.streamId, {
|
|
79
|
+
fileRefs: next,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
await ctx.db.insert("streamDeltas", delta);
|
|
52
83
|
await heartbeatStream(ctx, { streamId: args.streamId });
|
|
53
84
|
return true;
|
|
54
85
|
},
|
|
@@ -89,7 +120,10 @@ export const listDeltas = query({
|
|
|
89
120
|
});
|
|
90
121
|
|
|
91
122
|
export const create = mutation({
|
|
92
|
-
args: omit(schema.tables.streamingMessages.validator.fields, [
|
|
123
|
+
args: omit(schema.tables.streamingMessages.validator.fields, [
|
|
124
|
+
"state",
|
|
125
|
+
"fileRefs",
|
|
126
|
+
]),
|
|
93
127
|
returns: v.id("streamingMessages"),
|
|
94
128
|
handler: async (ctx, args) => {
|
|
95
129
|
const state = { kind: "streaming" as const, lastHeartbeat: Date.now() };
|
|
@@ -212,8 +246,9 @@ async function abortById(
|
|
|
212
246
|
return false;
|
|
213
247
|
}
|
|
214
248
|
await cleanupTimeoutFn(ctx, stream);
|
|
249
|
+
const cleanupFnId = await scheduleStreamDeletion(ctx, args.streamId);
|
|
215
250
|
await ctx.db.patch("streamingMessages", args.streamId, {
|
|
216
|
-
state: { kind: "aborted", reason: args.reason },
|
|
251
|
+
state: { kind: "aborted", reason: args.reason, cleanupFnId },
|
|
217
252
|
});
|
|
218
253
|
return true;
|
|
219
254
|
}
|
|
@@ -264,11 +299,7 @@ export async function finishHandler(
|
|
|
264
299
|
return;
|
|
265
300
|
}
|
|
266
301
|
await cleanupTimeoutFn(ctx, stream);
|
|
267
|
-
const cleanupFnId = await ctx.
|
|
268
|
-
DELETE_STREAM_DELAY,
|
|
269
|
-
api.streams.deleteStreamAsync,
|
|
270
|
-
{ streamId: args.streamId },
|
|
271
|
-
);
|
|
302
|
+
const cleanupFnId = await scheduleStreamDeletion(ctx, args.streamId);
|
|
272
303
|
await ctx.db.patch("streamingMessages", args.streamId, {
|
|
273
304
|
state: { kind: "finished", endedAt: Date.now(), cleanupFnId },
|
|
274
305
|
});
|
|
@@ -282,6 +313,29 @@ export const heartbeat = mutation({
|
|
|
282
313
|
handler: heartbeatStream,
|
|
283
314
|
});
|
|
284
315
|
|
|
316
|
+
async function releaseStreamFileOwnership(
|
|
317
|
+
ctx: MutationCtx,
|
|
318
|
+
stream: Doc<"streamingMessages">,
|
|
319
|
+
) {
|
|
320
|
+
if (!stream.fileRefs?.length) return;
|
|
321
|
+
await changeRefcount(
|
|
322
|
+
ctx,
|
|
323
|
+
stream.fileRefs.map(({ fileId }) => fileId),
|
|
324
|
+
[],
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function scheduleStreamDeletion(
|
|
329
|
+
ctx: MutationCtx,
|
|
330
|
+
streamId: Id<"streamingMessages">,
|
|
331
|
+
) {
|
|
332
|
+
return ctx.scheduler.runAfter(
|
|
333
|
+
DELETE_STREAM_DELAY,
|
|
334
|
+
api.streams.deleteStreamAsync,
|
|
335
|
+
{ streamId },
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
285
339
|
async function heartbeatStream(
|
|
286
340
|
ctx: MutationCtx,
|
|
287
341
|
args: { streamId: Id<"streamingMessages"> },
|
|
@@ -325,18 +379,24 @@ async function heartbeatStream(
|
|
|
325
379
|
export const timeoutStream = internalMutation({
|
|
326
380
|
args: { streamId: v.id("streamingMessages") },
|
|
327
381
|
returns: v.null(),
|
|
328
|
-
handler:
|
|
329
|
-
const stream = await ctx.db.get("streamingMessages", args.streamId);
|
|
330
|
-
if (!stream || stream.state.kind !== "streaming") {
|
|
331
|
-
console.warn("Stream not found", args.streamId);
|
|
332
|
-
return;
|
|
333
|
-
}
|
|
334
|
-
await ctx.db.patch("streamingMessages", args.streamId, {
|
|
335
|
-
state: { kind: "aborted", reason: "timeout" },
|
|
336
|
-
});
|
|
337
|
-
},
|
|
382
|
+
handler: timeoutStreamHandler,
|
|
338
383
|
});
|
|
339
384
|
|
|
385
|
+
export async function timeoutStreamHandler(
|
|
386
|
+
ctx: MutationCtx,
|
|
387
|
+
args: { streamId: Id<"streamingMessages"> },
|
|
388
|
+
) {
|
|
389
|
+
const stream = await ctx.db.get("streamingMessages", args.streamId);
|
|
390
|
+
if (!stream || stream.state.kind !== "streaming") {
|
|
391
|
+
console.warn("Stream not found", args.streamId);
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
const cleanupFnId = await scheduleStreamDeletion(ctx, args.streamId);
|
|
395
|
+
await ctx.db.patch("streamingMessages", args.streamId, {
|
|
396
|
+
state: { kind: "aborted", reason: "timeout", cleanupFnId },
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
|
|
340
400
|
async function deletePageForStreamId(
|
|
341
401
|
ctx: MutationCtx,
|
|
342
402
|
args: { streamId: Id<"streamingMessages">; cursor?: string },
|
|
@@ -354,8 +414,12 @@ async function deletePageForStreamId(
|
|
|
354
414
|
if (deltas.isDone) {
|
|
355
415
|
const stream = await ctx.db.get("streamingMessages", args.streamId);
|
|
356
416
|
if (stream) {
|
|
417
|
+
await releaseStreamFileOwnership(ctx, stream);
|
|
357
418
|
await cleanupTimeoutFn(ctx, stream);
|
|
358
|
-
if (
|
|
419
|
+
if (
|
|
420
|
+
(stream.state.kind === "finished" || stream.state.kind === "aborted") &&
|
|
421
|
+
stream.state.cleanupFnId
|
|
422
|
+
) {
|
|
359
423
|
const scheduledFunction = await ctx.db.system.get(
|
|
360
424
|
"_scheduled_functions",
|
|
361
425
|
stream.state.cleanupFnId,
|
|
@@ -370,6 +434,18 @@ async function deletePageForStreamId(
|
|
|
370
434
|
return deltas;
|
|
371
435
|
}
|
|
372
436
|
|
|
437
|
+
export async function releaseStreamFileOwnershipByIds(
|
|
438
|
+
ctx: MutationCtx,
|
|
439
|
+
streamIds: Id<"streamingMessages">[],
|
|
440
|
+
) {
|
|
441
|
+
for (const streamId of new Set(streamIds)) {
|
|
442
|
+
const stream = await ctx.db.get("streamingMessages", streamId);
|
|
443
|
+
if (!stream?.fileRefs?.length) continue;
|
|
444
|
+
await releaseStreamFileOwnership(ctx, stream);
|
|
445
|
+
await ctx.db.patch("streamingMessages", streamId, { fileRefs: undefined });
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
373
449
|
export async function deleteStreamsPageForThreadId(
|
|
374
450
|
ctx: MutationCtx,
|
|
375
451
|
args: { threadId: Id<"threads">; streamOrder?: number; deltaCursor?: string },
|
|
@@ -544,6 +620,7 @@ export async function getStreamingMessagesWithMetadata(
|
|
|
544
620
|
streamId: Id<"streamingMessages">;
|
|
545
621
|
reason: string;
|
|
546
622
|
}>;
|
|
623
|
+
streamsToRelease: Id<"streamingMessages">[];
|
|
547
624
|
}> {
|
|
548
625
|
// See if there are any streaming messages for this order
|
|
549
626
|
const streamingMessages = await getStreamingMessages(
|
|
@@ -570,8 +647,10 @@ export async function getStreamingMessagesWithMetadata(
|
|
|
570
647
|
streamMessage,
|
|
571
648
|
parts,
|
|
572
649
|
metadata,
|
|
650
|
+
streamingMessage.fileRefs,
|
|
573
651
|
).slice(numToSkip),
|
|
574
652
|
failure: undefined,
|
|
653
|
+
streamToRelease: streamingMessage._id,
|
|
575
654
|
};
|
|
576
655
|
} catch (error) {
|
|
577
656
|
return {
|
|
@@ -580,6 +659,7 @@ export async function getStreamingMessagesWithMetadata(
|
|
|
580
659
|
streamId: streamingMessage._id,
|
|
581
660
|
reason: error instanceof Error ? error.message : String(error),
|
|
582
661
|
},
|
|
662
|
+
streamToRelease: undefined,
|
|
583
663
|
};
|
|
584
664
|
}
|
|
585
665
|
}),
|
|
@@ -589,5 +669,8 @@ export async function getStreamingMessagesWithMetadata(
|
|
|
589
669
|
materializationFailures: materializedStreams.flatMap(({ failure }) =>
|
|
590
670
|
failure ? [failure] : [],
|
|
591
671
|
),
|
|
672
|
+
streamsToRelease: materializedStreams.flatMap(({ streamToRelease }) =>
|
|
673
|
+
streamToRelease ? [streamToRelease] : [],
|
|
674
|
+
),
|
|
592
675
|
};
|
|
593
676
|
}
|
|
@@ -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
|
/**
|
|
@@ -25,6 +25,7 @@ import { startGeneration } from "./start.js";
|
|
|
25
25
|
import type { Agent } from "../index.js";
|
|
26
26
|
import { getModelName, getProviderName } from "../../shared.js";
|
|
27
27
|
import { errorToString, willContinue } from "./utils.js";
|
|
28
|
+
import { materializeUIMessageChunkFiles } from "../fileMaterialization.js";
|
|
28
29
|
|
|
29
30
|
/** Finish every abort cleanup path before surfacing an internal failure. */
|
|
30
31
|
export async function runAbortCleanup(cleanup: {
|
|
@@ -152,6 +153,8 @@ export async function streamText<
|
|
|
152
153
|
: undefined,
|
|
153
154
|
onAsyncAbort: call.fail,
|
|
154
155
|
compress: compressUIMessageChunks,
|
|
156
|
+
materialize: (parts) =>
|
|
157
|
+
materializeUIMessageChunkFiles(ctx, component, parts),
|
|
155
158
|
abortSignal: args.abortSignal,
|
|
156
159
|
},
|
|
157
160
|
{
|
|
@@ -249,6 +249,49 @@ describe("DeltaStreamer", () => {
|
|
|
249
249
|
);
|
|
250
250
|
});
|
|
251
251
|
|
|
252
|
+
test("waits for signal cleanup when the source throws", async () => {
|
|
253
|
+
let resolveDelta!: (value: boolean) => void;
|
|
254
|
+
const deltaWrite = new Promise<boolean>((resolve) => {
|
|
255
|
+
resolveDelta = resolve;
|
|
256
|
+
});
|
|
257
|
+
const runMutation = vi
|
|
258
|
+
.fn()
|
|
259
|
+
.mockResolvedValueOnce("stream-1")
|
|
260
|
+
.mockImplementationOnce(() => deltaWrite)
|
|
261
|
+
.mockResolvedValueOnce(undefined);
|
|
262
|
+
const abortController = new AbortController();
|
|
263
|
+
const streamer = new DeltaStreamer<string>(
|
|
264
|
+
components.agent,
|
|
265
|
+
{ runMutation } as unknown as MutationCtx,
|
|
266
|
+
{ ...defaultTestOptions, abortSignal: abortController.signal },
|
|
267
|
+
{ ...testMetadata, threadId },
|
|
268
|
+
);
|
|
269
|
+
const sourceError = new Error("provider aborted");
|
|
270
|
+
const source = {
|
|
271
|
+
async *[Symbol.asyncIterator]() {
|
|
272
|
+
yield "chunk";
|
|
273
|
+
abortController.abort();
|
|
274
|
+
throw sourceError;
|
|
275
|
+
},
|
|
276
|
+
} as unknown as Parameters<typeof streamer.consumeStream>[0];
|
|
277
|
+
|
|
278
|
+
const consuming = streamer.consumeStream(source);
|
|
279
|
+
let settled = false;
|
|
280
|
+
void consuming.catch(() => {
|
|
281
|
+
settled = true;
|
|
282
|
+
});
|
|
283
|
+
await vi.waitFor(() => expect(runMutation).toHaveBeenCalledTimes(2));
|
|
284
|
+
expect(settled).toBe(false);
|
|
285
|
+
|
|
286
|
+
resolveDelta(true);
|
|
287
|
+
await expect(consuming).rejects.toBe(sourceError);
|
|
288
|
+
expect(runMutation).toHaveBeenNthCalledWith(
|
|
289
|
+
3,
|
|
290
|
+
components.agent.streams.abort,
|
|
291
|
+
{ streamId: "stream-1", reason: "abortSignal" },
|
|
292
|
+
);
|
|
293
|
+
});
|
|
294
|
+
|
|
252
295
|
test("aborts the component stream when a delta write fails", async () => {
|
|
253
296
|
const runMutation = vi
|
|
254
297
|
.fn()
|
|
@@ -279,6 +322,34 @@ describe("DeltaStreamer", () => {
|
|
|
279
322
|
);
|
|
280
323
|
});
|
|
281
324
|
|
|
325
|
+
test("aborts the component stream when file materialization fails", async () => {
|
|
326
|
+
const materializationFailure = new Error("storage failed");
|
|
327
|
+
const runMutation = vi
|
|
328
|
+
.fn()
|
|
329
|
+
.mockResolvedValueOnce("stream-1")
|
|
330
|
+
.mockResolvedValueOnce(undefined);
|
|
331
|
+
const streamer = new DeltaStreamer<string>(
|
|
332
|
+
components.agent,
|
|
333
|
+
{ runMutation } as unknown as MutationCtx,
|
|
334
|
+
{
|
|
335
|
+
...defaultTestOptions,
|
|
336
|
+
onAsyncAbort: async () => {},
|
|
337
|
+
materialize: async () => {
|
|
338
|
+
throw materializationFailure;
|
|
339
|
+
},
|
|
340
|
+
},
|
|
341
|
+
{ ...testMetadata, threadId },
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
await streamer.addParts(["A"]);
|
|
345
|
+
await expect(streamer.finish()).resolves.toBeUndefined();
|
|
346
|
+
expect(runMutation).toHaveBeenNthCalledWith(
|
|
347
|
+
2,
|
|
348
|
+
components.agent.streams.abort,
|
|
349
|
+
{ streamId: "stream-1", reason: "storage failed" },
|
|
350
|
+
);
|
|
351
|
+
});
|
|
352
|
+
|
|
282
353
|
test("surfaces pending-message cleanup failure after aborting the stream", async () => {
|
|
283
354
|
const pendingMessageFailure = new Error("pending message cleanup failed");
|
|
284
355
|
let resolveComponentAbort!: () => void;
|