@convex-dev/agent 0.7.0-alpha.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.
Files changed (61) hide show
  1. package/dist/component/_generated/component.d.ts +5 -0
  2. package/dist/component/_generated/component.d.ts.map +1 -1
  3. package/dist/component/files.d.ts +6 -4
  4. package/dist/component/files.d.ts.map +1 -1
  5. package/dist/component/files.js +53 -42
  6. package/dist/component/files.js.map +1 -1
  7. package/dist/component/messages.d.ts.map +1 -1
  8. package/dist/component/messages.js +7 -3
  9. package/dist/component/messages.js.map +1 -1
  10. package/dist/component/schema.d.ts +58 -6
  11. package/dist/component/schema.d.ts.map +1 -1
  12. package/dist/component/schema.js +12 -2
  13. package/dist/component/schema.js.map +1 -1
  14. package/dist/component/streams.d.ts +10 -1
  15. package/dist/component/streams.d.ts.map +1 -1
  16. package/dist/component/streams.js +72 -17
  17. package/dist/component/streams.js.map +1 -1
  18. package/dist/streaming/materializePersistedUIMessageChunks.d.ts +8 -2
  19. package/dist/streaming/materializePersistedUIMessageChunks.d.ts.map +1 -1
  20. package/dist/streaming/materializePersistedUIMessageChunks.js +34 -3
  21. package/dist/streaming/materializePersistedUIMessageChunks.js.map +1 -1
  22. package/dist/vercel/UIMessages.js +1 -1
  23. package/dist/vercel/UIMessages.js.map +1 -1
  24. package/dist/vercel/client/files.d.ts.map +1 -1
  25. package/dist/vercel/client/files.js +51 -27
  26. package/dist/vercel/client/files.js.map +1 -1
  27. package/dist/vercel/client/streamText.d.ts.map +1 -1
  28. package/dist/vercel/client/streamText.js +2 -0
  29. package/dist/vercel/client/streamText.js.map +1 -1
  30. package/dist/vercel/client/streaming.d.ts +14 -0
  31. package/dist/vercel/client/streaming.d.ts.map +1 -1
  32. package/dist/vercel/client/streaming.js +35 -13
  33. package/dist/vercel/client/streaming.js.map +1 -1
  34. package/dist/vercel/fileMaterialization.d.ts +24 -0
  35. package/dist/vercel/fileMaterialization.d.ts.map +1 -0
  36. package/dist/vercel/fileMaterialization.js +118 -0
  37. package/dist/vercel/fileMaterialization.js.map +1 -0
  38. package/dist/vercel/mapping.d.ts.map +1 -1
  39. package/dist/vercel/mapping.js +7 -1
  40. package/dist/vercel/mapping.js.map +1 -1
  41. package/package.json +4 -4
  42. package/src/component/_generated/component.ts +8 -2
  43. package/src/component/files.test.ts +81 -6
  44. package/src/component/files.ts +69 -41
  45. package/src/component/messages.test.ts +92 -0
  46. package/src/component/messages.ts +11 -3
  47. package/src/component/schema.ts +16 -2
  48. package/src/component/streams.test.ts +109 -0
  49. package/src/component/streams.ts +103 -20
  50. package/src/streaming/materializePersistedUIMessageChunks.test.ts +33 -0
  51. package/src/streaming/materializePersistedUIMessageChunks.ts +37 -0
  52. package/src/vercel/UIMessages.ts +1 -1
  53. package/src/vercel/client/files.test.ts +56 -0
  54. package/src/vercel/client/files.ts +53 -28
  55. package/src/vercel/client/streamText.ts +3 -0
  56. package/src/vercel/client/streaming.test.ts +71 -0
  57. package/src/vercel/client/streaming.ts +50 -16
  58. package/src/vercel/fileMaterialization.ts +174 -0
  59. package/src/vercel/mapping.test.ts +117 -0
  60. package/src/vercel/mapping.ts +15 -1
  61. package/src/vercel/toUIMessages.test.ts +7 -1
@@ -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: deltaValidator,
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
- await ctx.db.insert("streamDeltas", args);
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, ["state"]),
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.scheduler.runAfter(
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: async (ctx, args) => {
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 (stream.state.kind === "finished" && stream.state.cleanupFnId) {
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",
@@ -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 = (await ctx.storage.getUrl(reused.storageId))!;
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
- if (sha256) {
86
- const metadata = await ctx.storage.getMetadata(newStorageId);
87
- if (metadata?.sha256 !== sha256) {
88
- throw new Error("Hash mismatch: " + metadata?.sha256 + " != " + sha256);
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;