@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.
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 +1 -1
  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
@@ -204,6 +204,12 @@ export class DeltaStreamer<T> {
204
204
  throttleMs: number;
205
205
  onAsyncAbort: (reason: string) => Promise<void>;
206
206
  compress: ((parts: T[]) => T[]) | null;
207
+ materialize:
208
+ | ((parts: T[]) => Promise<{
209
+ parts: T[];
210
+ fileRefs: Array<{ url: string; fileId: string }>;
211
+ }>)
212
+ | null;
207
213
  };
208
214
  #nextParts: T[] = [];
209
215
  #latestWrite: number = 0;
@@ -223,6 +229,10 @@ export class DeltaStreamer<T> {
223
229
  onAsyncAbort: (reason: string) => Promise<void>;
224
230
  abortSignal: AbortSignal | undefined;
225
231
  compress: ((parts: T[]) => T[]) | null;
232
+ materialize?: (parts: T[]) => Promise<{
233
+ parts: T[];
234
+ fileRefs: Array<{ url: string; fileId: string }>;
235
+ }>;
226
236
  },
227
237
  public readonly metadata: {
228
238
  threadId: string;
@@ -240,6 +250,7 @@ export class DeltaStreamer<T> {
240
250
  throttleMs: config.throttleMs ?? DEFAULT_STREAMING_OPTIONS.throttleMs,
241
251
  onAsyncAbort: config.onAsyncAbort,
242
252
  compress: config.compress,
253
+ materialize: config.materialize ?? null,
243
254
  };
244
255
  this.#nextParts = [];
245
256
  this.abortController = new AbortController();
@@ -300,8 +311,18 @@ export class DeltaStreamer<T> {
300
311
  }
301
312
 
302
313
  public async consumeStream(stream: AsyncIterableStream<T>) {
303
- for await (const chunk of stream) {
304
- await this.addParts([chunk]);
314
+ try {
315
+ for await (const chunk of stream) {
316
+ await this.addParts([chunk]);
317
+ }
318
+ } catch (error) {
319
+ // A provider can throw while responding to an abort. Join the durable
320
+ // abort transition here, outside the active delta writer, before
321
+ // preserving the provider error for the caller.
322
+ await this.#abort(
323
+ error instanceof Error ? error.message : "stream consumption failed",
324
+ ).catch(() => {});
325
+ throw error;
305
326
  }
306
327
  // Skip finish if it will be handled externally (atomically with message save)
307
328
  // or if the stream was aborted (e.g., due to a failed delta write).
@@ -335,21 +356,19 @@ export class DeltaStreamer<T> {
335
356
  if (this.abortController.signal.aborted) {
336
357
  return;
337
358
  }
338
- const delta = this.#createDelta();
339
- if (!delta) {
340
- return;
341
- }
342
- this.#latestWrite = Date.now();
343
359
  let success: boolean;
344
360
  try {
361
+ const delta = await this.#createDelta();
362
+ if (!delta) {
363
+ return;
364
+ }
365
+ this.#latestWrite = Date.now();
345
366
  success = await this.ctx.runMutation(
346
367
  this.component.streams.addDelta,
347
368
  delta,
348
369
  );
349
370
  } catch (e) {
350
- await this.#abortDelta(
351
- e instanceof Error ? e.message : "unknown error",
352
- );
371
+ await this.#abortDelta(e instanceof Error ? e.message : "unknown error");
353
372
  return;
354
373
  }
355
374
  if (!success) {
@@ -375,21 +394,36 @@ export class DeltaStreamer<T> {
375
394
  }
376
395
  }
377
396
 
378
- #createDelta(): StreamDelta | undefined {
397
+ async #createDelta(): Promise<
398
+ | (StreamDelta & { fileRefs?: Array<{ url: string; fileId: string }> })
399
+ | undefined
400
+ > {
379
401
  if (this.#nextParts.length === 0) {
380
402
  return undefined;
381
403
  }
382
404
  const start = this.#cursor;
383
- const end = start + this.#nextParts.length;
405
+ const pendingParts = this.#nextParts;
406
+ const end = start + pendingParts.length;
384
407
  this.#cursor = end;
385
- const parts = this.config.compress
386
- ? this.config.compress(this.#nextParts)
387
- : this.#nextParts;
388
408
  this.#nextParts = [];
409
+ const materialized = this.config.materialize
410
+ ? await this.config.materialize(pendingParts)
411
+ : { parts: pendingParts, fileRefs: [] };
412
+ const parts = this.config.compress
413
+ ? this.config.compress(materialized.parts)
414
+ : materialized.parts;
389
415
  if (!this.streamId) {
390
416
  throw new Error("Creating a delta before the stream is created");
391
417
  }
392
- return { streamId: this.streamId, start, end, parts };
418
+ return {
419
+ streamId: this.streamId,
420
+ start,
421
+ end,
422
+ parts,
423
+ ...(materialized.fileRefs.length > 0
424
+ ? { fileRefs: materialized.fileRefs }
425
+ : {}),
426
+ };
393
427
  }
394
428
 
395
429
  public async finish() {
@@ -0,0 +1,174 @@
1
+ import type { UIMessageChunk } from "ai";
2
+ import { MAX_FILE_SIZE, storeFile } from "./client/files.js";
3
+ import type { ActionCtx, AgentComponent, MutationCtx } from "./client/types.js";
4
+
5
+ export type MaterializedFileRef = { url: string; fileId: string };
6
+
7
+ /**
8
+ * Replaces oversized inline files in persisted UI chunks with storage URLs.
9
+ * Recovery uses the accompanying URL-to-file ownership references to attach
10
+ * the stored files to the durable messages it creates.
11
+ */
12
+ export async function materializeUIMessageChunkFiles(
13
+ ctx: ActionCtx,
14
+ component: AgentComponent,
15
+ parts: readonly UIMessageChunk[],
16
+ ): Promise<{ parts: UIMessageChunk[]; fileRefs: MaterializedFileRef[] }> {
17
+ const fileRefs: MaterializedFileRef[] = [];
18
+ const materialized = await Promise.all(
19
+ parts.map(async (part): Promise<UIMessageChunk> => {
20
+ if (part.type === "tool-output-available") {
21
+ const result = await materializeCanonicalToolResultContentFiles(
22
+ ctx,
23
+ component,
24
+ part.output,
25
+ );
26
+ fileRefs.push(...result.fileRefs);
27
+ return { ...part, output: result.output };
28
+ }
29
+ if (part.type !== "file" && part.type !== "reasoning-file") {
30
+ return { ...part };
31
+ }
32
+ const file = await materializeInlineFile(
33
+ ctx,
34
+ component,
35
+ part.url,
36
+ part.mediaType,
37
+ );
38
+ if (!file) {
39
+ return { ...part };
40
+ }
41
+ fileRefs.push({ url: file.url, fileId: file.fileId });
42
+ return { ...part, url: file.url };
43
+ }),
44
+ );
45
+ return { parts: materialized, fileRefs };
46
+ }
47
+
48
+ /**
49
+ * Materializes files in the AI SDK's canonical tool-result content output.
50
+ * Other tool outputs are application-defined and intentionally remain opaque.
51
+ */
52
+ export async function materializeCanonicalToolResultContentFiles(
53
+ ctx: ActionCtx | MutationCtx,
54
+ component: AgentComponent,
55
+ output: unknown,
56
+ ): Promise<{ output: unknown; fileRefs: MaterializedFileRef[] }> {
57
+ if (!isCanonicalToolResultContent(output)) {
58
+ return { output, fileRefs: [] };
59
+ }
60
+ const fileRefs: MaterializedFileRef[] = [];
61
+ const value = await Promise.all(
62
+ output.value.map(async (part): Promise<unknown> => {
63
+ if (!isCanonicalToolResultFile(part)) return part;
64
+ const file = await materializeInlineFile(
65
+ ctx,
66
+ component,
67
+ part.data.type === "url"
68
+ ? part.data.url
69
+ : part.data.type === "data"
70
+ ? part.data.data
71
+ : new TextEncoder().encode(part.data.text),
72
+ part.mediaType,
73
+ part.filename,
74
+ );
75
+ if (!file) return part;
76
+ fileRefs.push({ url: file.url, fileId: file.fileId });
77
+ return {
78
+ ...part,
79
+ data: { type: "url", url: file.url },
80
+ };
81
+ }),
82
+ );
83
+ return { output: { ...output, value }, fileRefs };
84
+ }
85
+
86
+ async function materializeInlineFile(
87
+ ctx: ActionCtx | MutationCtx,
88
+ component: AgentComponent,
89
+ data: unknown,
90
+ mediaType: string,
91
+ filename?: string,
92
+ ) {
93
+ const bytes = decodeInlineFileData(data);
94
+ if (!bytes) return undefined;
95
+ if (bytes.byteLength <= MAX_FILE_SIZE) return undefined;
96
+ const blobBytes = bytes.buffer.slice(
97
+ bytes.byteOffset,
98
+ bytes.byteOffset + bytes.byteLength,
99
+ ) as ArrayBuffer;
100
+ const { file } = await storeFile(
101
+ ctx,
102
+ component,
103
+ new Blob([blobBytes], {
104
+ type: mediaType || "application/octet-stream",
105
+ }),
106
+ filename ? { filename } : {},
107
+ );
108
+ return file;
109
+ }
110
+
111
+ function isCanonicalToolResultContent(
112
+ value: unknown,
113
+ ): value is { type: "content"; value: unknown[] } {
114
+ return (
115
+ isRecord(value) && value.type === "content" && Array.isArray(value.value)
116
+ );
117
+ }
118
+
119
+ function isCanonicalToolResultFile(value: unknown): value is {
120
+ type: "file";
121
+ data:
122
+ | { type: "url"; url: string }
123
+ | { type: "data"; data: unknown }
124
+ | { type: "text"; text: string };
125
+ mediaType: string;
126
+ filename?: string;
127
+ } {
128
+ return (
129
+ isRecord(value) &&
130
+ value.type === "file" &&
131
+ typeof value.mediaType === "string" &&
132
+ isRecord(value.data) &&
133
+ ((value.data.type === "url" && typeof value.data.url === "string") ||
134
+ value.data.type === "data" ||
135
+ (value.data.type === "text" && typeof value.data.text === "string"))
136
+ );
137
+ }
138
+
139
+ function isRecord(value: unknown): value is Record<string, unknown> {
140
+ return typeof value === "object" && value !== null && !Array.isArray(value);
141
+ }
142
+
143
+ function decodeInlineFileData(value: unknown): Uint8Array | undefined {
144
+ if (typeof value === "string") {
145
+ if (!value.startsWith("data:")) {
146
+ // Remote and already-stored files are references, not inline payloads.
147
+ if (/^[a-z][a-z0-9+.-]*:/i.test(value)) return undefined;
148
+ return decodeBase64(value);
149
+ }
150
+ const separator = value.indexOf(",");
151
+ if (separator === -1) return undefined;
152
+ const metadata = value.slice(5, separator);
153
+ const payload = value.slice(separator + 1);
154
+ try {
155
+ return metadata.includes(";base64")
156
+ ? decodeBase64(payload)
157
+ : new TextEncoder().encode(decodeURIComponent(payload));
158
+ } catch {
159
+ return undefined;
160
+ }
161
+ }
162
+ if (value instanceof Uint8Array) return value;
163
+ if (value instanceof ArrayBuffer) return new Uint8Array(value);
164
+ return undefined;
165
+ }
166
+
167
+ function decodeBase64(value: string): Uint8Array | undefined {
168
+ try {
169
+ const binary = atob(value);
170
+ return Uint8Array.from(binary, (character) => character.charCodeAt(0));
171
+ } catch {
172
+ return undefined;
173
+ }
174
+ }
@@ -465,6 +465,123 @@ describe("mapping", () => {
465
465
  );
466
466
  });
467
467
 
468
+ test("materializes oversized canonical tool-result files", async () => {
469
+ const bytes = new Uint8Array(1024 * 65).fill(1);
470
+ const ctx = {
471
+ runAction: async () => undefined,
472
+ runMutation: async () => ({
473
+ fileId: "file-123",
474
+ storageId: "storage-123",
475
+ }),
476
+ storage: {
477
+ getUrl: async () => "https://example.com/file",
478
+ },
479
+ } as unknown as ActionCtx;
480
+ const result = {
481
+ type: "tool-result" as const,
482
+ toolCallId: "call-1",
483
+ toolName: "render",
484
+ output: {
485
+ type: "content" as const,
486
+ value: [
487
+ {
488
+ type: "file" as const,
489
+ data: { type: "data" as const, data: bytes },
490
+ mediaType: "application/octet-stream",
491
+ filename: "result.bin",
492
+ },
493
+ ],
494
+ },
495
+ } satisfies ToolResultPart;
496
+
497
+ const { content, fileIds } = await serializeContent(
498
+ ctx,
499
+ api as unknown as AgentComponent,
500
+ [result],
501
+ );
502
+
503
+ expect(fileIds).toEqual(["file-123"]);
504
+ expect(content).toMatchObject([
505
+ {
506
+ type: "tool-result",
507
+ output: {
508
+ type: "content",
509
+ value: [
510
+ {
511
+ type: "file",
512
+ data: { type: "url", url: "https://example.com/file" },
513
+ },
514
+ ],
515
+ },
516
+ },
517
+ ]);
518
+
519
+ const opaque = { attachment: "data:application/octet-stream,opaque" };
520
+ await expect(
521
+ serializeContent(ctx, api as unknown as AgentComponent, [
522
+ {
523
+ type: "tool-result",
524
+ toolCallId: "call-2",
525
+ toolName: "render",
526
+ result: opaque,
527
+ } satisfies Infer<typeof vToolResultPart>,
528
+ ]),
529
+ ).resolves.toMatchObject({
530
+ content: [{ output: { type: "json", value: opaque } }],
531
+ });
532
+ });
533
+
534
+ test("materializes oversized canonical tool-result text files", async () => {
535
+ const ctx = {
536
+ runAction: async () => undefined,
537
+ runMutation: async () => ({
538
+ fileId: "file-123",
539
+ storageId: "storage-123",
540
+ }),
541
+ storage: {
542
+ getUrl: async () => "https://example.com/file",
543
+ },
544
+ } as unknown as ActionCtx;
545
+ const result = {
546
+ type: "tool-result" as const,
547
+ toolCallId: "call-1",
548
+ toolName: "render",
549
+ output: {
550
+ type: "content" as const,
551
+ value: [
552
+ {
553
+ type: "file" as const,
554
+ data: { type: "text" as const, text: "x".repeat(1024 * 65) },
555
+ mediaType: "text/plain",
556
+ filename: "result.txt",
557
+ },
558
+ ],
559
+ },
560
+ } satisfies ToolResultPart;
561
+
562
+ const { content, fileIds } = await serializeContent(
563
+ ctx,
564
+ api as unknown as AgentComponent,
565
+ [result],
566
+ );
567
+
568
+ expect(fileIds).toEqual(["file-123"]);
569
+ expect(content).toMatchObject([
570
+ {
571
+ type: "tool-result",
572
+ output: {
573
+ type: "content",
574
+ value: [
575
+ {
576
+ type: "file",
577
+ data: { type: "url", url: "https://example.com/file" },
578
+ },
579
+ ],
580
+ },
581
+ },
582
+ ]);
583
+ });
584
+
468
585
  test("sanity: fileIds are not returned for small files", async () => {
469
586
  const arr = new Uint8Array([1, 2, 3, 4, 5]);
470
587
  const ab = arr.buffer.slice(
@@ -44,6 +44,7 @@ import {
44
44
  import type { ActionCtx, AgentComponent } from "./client/types.js";
45
45
  import type { MutationCtx } from "./client/types.js";
46
46
  import { MAX_FILE_SIZE, storeFile } from "./client/files.js";
47
+ import { materializeCanonicalToolResultContentFiles } from "./fileMaterialization.js";
47
48
  import type { Infer } from "convex/values";
48
49
  import {
49
50
  convertUint8ArrayToBase64,
@@ -466,7 +467,20 @@ export async function serializeContent(
466
467
  } satisfies Infer<typeof vToolCallPart>;
467
468
  }
468
469
  case "tool-result": {
469
- return serializeToolResult(part, metadata);
470
+ const output =
471
+ "output" in part && part.output !== undefined
472
+ ? part.output
473
+ : normalizeToolOutput("result" in part ? part.result : undefined);
474
+ const materialized = await materializeCanonicalToolResultContentFiles(
475
+ ctx,
476
+ component,
477
+ output,
478
+ );
479
+ fileIds.push(...materialized.fileRefs.map((ref) => ref.fileId));
480
+ return serializeToolResult(
481
+ { ...part, output: materialized.output } as ToolResultPart,
482
+ metadata,
483
+ );
470
484
  }
471
485
  case "reasoning": {
472
486
  return {
@@ -26,13 +26,19 @@ describe("toUIMessages", () => {
26
26
  content: "Hello!",
27
27
  },
28
28
  text: "Hello!",
29
+ providerMetadata: { testProvider: { traceId: "trace-123" } },
29
30
  }),
30
31
  ];
31
32
  const uiMessages = toUIMessages(messages);
32
33
  expect(uiMessages).toHaveLength(1);
33
34
  expect(uiMessages[0].role).toBe("user");
34
35
  expect(uiMessages[0].text).toBe("Hello!");
35
- expect(uiMessages[0].parts[0]).toEqual({ type: "text", text: "Hello!" });
36
+ expect(uiMessages[0].parts[0]).toEqual({
37
+ type: "text",
38
+ text: "Hello!",
39
+ state: "done",
40
+ providerMetadata: { testProvider: { traceId: "trace-123" } },
41
+ });
36
42
  });
37
43
 
38
44
  it("handles assistant message", () => {