@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.
Files changed (116) hide show
  1. package/README.md +5 -8
  2. package/dist/client/messages.d.ts +10 -0
  3. package/dist/client/messages.d.ts.map +1 -1
  4. package/dist/client/messages.js +1 -0
  5. package/dist/client/messages.js.map +1 -1
  6. package/dist/component/_generated/component.d.ts +15 -8
  7. package/dist/component/_generated/component.d.ts.map +1 -1
  8. package/dist/component/files.d.ts +6 -4
  9. package/dist/component/files.d.ts.map +1 -1
  10. package/dist/component/files.js +53 -42
  11. package/dist/component/files.js.map +1 -1
  12. package/dist/component/messages.d.ts +340 -338
  13. package/dist/component/messages.d.ts.map +1 -1
  14. package/dist/component/messages.js +80 -16
  15. package/dist/component/messages.js.map +1 -1
  16. package/dist/component/schema.d.ts +782 -626
  17. package/dist/component/schema.d.ts.map +1 -1
  18. package/dist/component/schema.js +12 -2
  19. package/dist/component/schema.js.map +1 -1
  20. package/dist/component/streams.d.ts +16 -2
  21. package/dist/component/streams.d.ts.map +1 -1
  22. package/dist/component/streams.js +86 -30
  23. package/dist/component/streams.js.map +1 -1
  24. package/dist/component/vector/index.d.ts +9 -9
  25. package/dist/component/vector/index.d.ts.map +1 -1
  26. package/dist/component/vector/tables.d.ts +5 -5
  27. package/dist/component/vector/tables.d.ts.map +1 -1
  28. package/dist/component/vector/tables.js +1 -1
  29. package/dist/component/vector/tables.js.map +1 -1
  30. package/dist/errors.d.ts +2 -0
  31. package/dist/errors.d.ts.map +1 -0
  32. package/dist/errors.js +106 -0
  33. package/dist/errors.js.map +1 -0
  34. package/dist/streaming/materializePersistedUIMessageChunks.d.ts +8 -2
  35. package/dist/streaming/materializePersistedUIMessageChunks.d.ts.map +1 -1
  36. package/dist/streaming/materializePersistedUIMessageChunks.js +34 -3
  37. package/dist/streaming/materializePersistedUIMessageChunks.js.map +1 -1
  38. package/dist/validators.d.ts +1952 -1952
  39. package/dist/vercel/UIMessages.js +1 -1
  40. package/dist/vercel/UIMessages.js.map +1 -1
  41. package/dist/vercel/client/definePlaygroundAPI.d.ts +132 -132
  42. package/dist/vercel/client/files.d.ts.map +1 -1
  43. package/dist/vercel/client/files.js +51 -27
  44. package/dist/vercel/client/files.js.map +1 -1
  45. package/dist/vercel/client/messages.d.ts +50 -32
  46. package/dist/vercel/client/messages.d.ts.map +1 -1
  47. package/dist/vercel/client/messages.js +2 -0
  48. package/dist/vercel/client/messages.js.map +1 -1
  49. package/dist/vercel/client/search.d.ts +475 -32
  50. package/dist/vercel/client/search.d.ts.map +1 -1
  51. package/dist/vercel/client/search.js +34 -9
  52. package/dist/vercel/client/search.js.map +1 -1
  53. package/dist/vercel/client/start.d.ts.map +1 -1
  54. package/dist/vercel/client/start.js +1 -0
  55. package/dist/vercel/client/start.js.map +1 -1
  56. package/dist/vercel/client/streamText.d.ts +1 -2
  57. package/dist/vercel/client/streamText.d.ts.map +1 -1
  58. package/dist/vercel/client/streamText.js +49 -17
  59. package/dist/vercel/client/streamText.js.map +1 -1
  60. package/dist/vercel/client/streaming.d.ts +318 -301
  61. package/dist/vercel/client/streaming.d.ts.map +1 -1
  62. package/dist/vercel/client/streaming.js +56 -21
  63. package/dist/vercel/client/streaming.js.map +1 -1
  64. package/dist/vercel/client/types.d.ts +2 -0
  65. package/dist/vercel/client/types.d.ts.map +1 -1
  66. package/dist/vercel/client/types.js.map +1 -1
  67. package/dist/vercel/client/utils.d.ts +9 -5
  68. package/dist/vercel/client/utils.d.ts.map +1 -1
  69. package/dist/vercel/client/utils.js +13 -13
  70. package/dist/vercel/client/utils.js.map +1 -1
  71. package/dist/vercel/fileMaterialization.d.ts +24 -0
  72. package/dist/vercel/fileMaterialization.d.ts.map +1 -0
  73. package/dist/vercel/fileMaterialization.js +118 -0
  74. package/dist/vercel/fileMaterialization.js.map +1 -0
  75. package/dist/vercel/index.d.ts +91 -90
  76. package/dist/vercel/index.d.ts.map +1 -1
  77. package/dist/vercel/index.js +2 -0
  78. package/dist/vercel/index.js.map +1 -1
  79. package/dist/vercel/mapping.d.ts.map +1 -1
  80. package/dist/vercel/mapping.js +7 -1
  81. package/dist/vercel/mapping.js.map +1 -1
  82. package/package.json +16 -9
  83. package/src/client/messages.ts +12 -0
  84. package/src/component/_generated/component.ts +18 -2
  85. package/src/component/files.test.ts +81 -6
  86. package/src/component/files.ts +69 -41
  87. package/src/component/messages.test.ts +496 -0
  88. package/src/component/messages.ts +105 -15
  89. package/src/component/schema.ts +16 -2
  90. package/src/component/streams.test.ts +109 -0
  91. package/src/component/streams.ts +123 -35
  92. package/src/component/vector/tables.ts +1 -1
  93. package/src/errors.test.ts +76 -0
  94. package/src/errors.ts +113 -0
  95. package/src/streaming/materializePersistedUIMessageChunks.test.ts +33 -0
  96. package/src/streaming/materializePersistedUIMessageChunks.ts +37 -0
  97. package/src/vercel/UIMessages.ts +1 -1
  98. package/src/vercel/client/files.test.ts +56 -0
  99. package/src/vercel/client/files.ts +53 -28
  100. package/src/vercel/client/index.test.ts +47 -0
  101. package/src/vercel/client/messages.ts +20 -0
  102. package/src/vercel/client/search.test.ts +183 -8
  103. package/src/vercel/client/search.ts +46 -12
  104. package/src/vercel/client/start.ts +1 -0
  105. package/src/vercel/client/streamText.test.ts +113 -17
  106. package/src/vercel/client/streamText.ts +55 -28
  107. package/src/vercel/client/streaming.test.ts +142 -7
  108. package/src/vercel/client/streaming.ts +73 -19
  109. package/src/vercel/client/types.ts +2 -0
  110. package/src/vercel/client/utils.test.ts +97 -0
  111. package/src/vercel/client/utils.ts +15 -15
  112. package/src/vercel/fileMaterialization.ts +174 -0
  113. package/src/vercel/index.ts +2 -0
  114. package/src/vercel/mapping.test.ts +117 -0
  115. package/src/vercel/mapping.ts +15 -1
  116. 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",
@@ -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
  /**
@@ -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