@convex-dev/agent 0.7.1 → 0.7.3

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 (90) 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 +10 -8
  7. package/dist/component/_generated/component.d.ts.map +1 -1
  8. package/dist/component/messages.d.ts +340 -338
  9. package/dist/component/messages.d.ts.map +1 -1
  10. package/dist/component/messages.js +74 -14
  11. package/dist/component/messages.js.map +1 -1
  12. package/dist/component/schema.d.ts +726 -622
  13. package/dist/component/schema.d.ts.map +1 -1
  14. package/dist/component/streams.d.ts +6 -1
  15. package/dist/component/streams.d.ts.map +1 -1
  16. package/dist/component/streams.js +14 -15
  17. package/dist/component/streams.js.map +1 -1
  18. package/dist/component/threads.d.ts.map +1 -1
  19. package/dist/component/threads.js +1 -0
  20. package/dist/component/threads.js.map +1 -1
  21. package/dist/component/vector/index.d.ts +9 -9
  22. package/dist/component/vector/index.d.ts.map +1 -1
  23. package/dist/component/vector/tables.d.ts +5 -5
  24. package/dist/component/vector/tables.d.ts.map +1 -1
  25. package/dist/component/vector/tables.js +1 -1
  26. package/dist/component/vector/tables.js.map +1 -1
  27. package/dist/errors.d.ts +2 -0
  28. package/dist/errors.d.ts.map +1 -0
  29. package/dist/errors.js +106 -0
  30. package/dist/errors.js.map +1 -0
  31. package/dist/validators.d.ts +1952 -1952
  32. package/dist/vercel/client/definePlaygroundAPI.d.ts +132 -132
  33. package/dist/vercel/client/messages.d.ts +50 -32
  34. package/dist/vercel/client/messages.d.ts.map +1 -1
  35. package/dist/vercel/client/messages.js +2 -0
  36. package/dist/vercel/client/messages.js.map +1 -1
  37. package/dist/vercel/client/search.d.ts +475 -32
  38. package/dist/vercel/client/search.d.ts.map +1 -1
  39. package/dist/vercel/client/search.js +34 -9
  40. package/dist/vercel/client/search.js.map +1 -1
  41. package/dist/vercel/client/start.d.ts.map +1 -1
  42. package/dist/vercel/client/start.js +1 -0
  43. package/dist/vercel/client/start.js.map +1 -1
  44. package/dist/vercel/client/streamText.d.ts +1 -2
  45. package/dist/vercel/client/streamText.d.ts.map +1 -1
  46. package/dist/vercel/client/streamText.js +79 -22
  47. package/dist/vercel/client/streamText.js.map +1 -1
  48. package/dist/vercel/client/streaming.d.ts +326 -305
  49. package/dist/vercel/client/streaming.d.ts.map +1 -1
  50. package/dist/vercel/client/streaming.js +140 -25
  51. package/dist/vercel/client/streaming.js.map +1 -1
  52. package/dist/vercel/client/types.d.ts +2 -0
  53. package/dist/vercel/client/types.d.ts.map +1 -1
  54. package/dist/vercel/client/types.js.map +1 -1
  55. package/dist/vercel/client/utils.d.ts +9 -5
  56. package/dist/vercel/client/utils.d.ts.map +1 -1
  57. package/dist/vercel/client/utils.js +13 -13
  58. package/dist/vercel/client/utils.js.map +1 -1
  59. package/dist/vercel/index.d.ts +91 -90
  60. package/dist/vercel/index.d.ts.map +1 -1
  61. package/dist/vercel/index.js +2 -0
  62. package/dist/vercel/index.js.map +1 -1
  63. package/package.json +16 -9
  64. package/src/client/messages.ts +12 -0
  65. package/src/component/_generated/component.ts +10 -0
  66. package/src/component/messages.test.ts +404 -0
  67. package/src/component/messages.ts +94 -12
  68. package/src/component/streams.test.ts +54 -1
  69. package/src/component/streams.ts +20 -17
  70. package/src/component/threads.test.ts +39 -0
  71. package/src/component/threads.ts +1 -0
  72. package/src/component/vector/tables.ts +1 -1
  73. package/src/errors.test.ts +76 -0
  74. package/src/errors.ts +113 -0
  75. package/src/vercel/client/deltaFlush.test.ts +106 -0
  76. package/src/vercel/client/index.test.ts +47 -0
  77. package/src/vercel/client/messages.ts +20 -0
  78. package/src/vercel/client/search.test.ts +183 -8
  79. package/src/vercel/client/search.ts +46 -12
  80. package/src/vercel/client/start.ts +1 -0
  81. package/src/vercel/client/streamText.test.ts +577 -18
  82. package/src/vercel/client/streamText.ts +84 -32
  83. package/src/vercel/client/streaming.integration.test.ts +40 -3
  84. package/src/vercel/client/streaming.test.ts +101 -7
  85. package/src/vercel/client/streaming.throttle.test.ts +144 -0
  86. package/src/vercel/client/streaming.ts +161 -25
  87. package/src/vercel/client/types.ts +2 -0
  88. package/src/vercel/client/utils.test.ts +97 -0
  89. package/src/vercel/client/utils.ts +15 -15
  90. package/src/vercel/index.ts +2 -0
@@ -164,6 +164,249 @@ describe("agent", () => {
164
164
  });
165
165
  });
166
166
 
167
+ test("an explicit order starts, appends, and avoids later order collisions", async () => {
168
+ const t = convexTest(schema, modules);
169
+ const thread = await t.mutation(api.threads.createThread, {
170
+ userId: "test",
171
+ });
172
+ await t.mutation(api.messages.addMessages, {
173
+ threadId: thread._id as Id<"threads">,
174
+ messages: [
175
+ { message: { role: "user", content: "hello" } },
176
+ { message: { role: "assistant", content: "agent reply" } },
177
+ ],
178
+ });
179
+
180
+ const { messages: firstHumanReply } = await t.mutation(
181
+ api.messages.addMessages,
182
+ {
183
+ threadId: thread._id as Id<"threads">,
184
+ order: 1,
185
+ agentName: "human:Alex",
186
+ messages: [{ message: { role: "assistant", content: "human reply" } }],
187
+ },
188
+ );
189
+ expect(firstHumanReply[0]).toMatchObject({
190
+ order: 1,
191
+ stepOrder: 0,
192
+ agentName: "human:Alex",
193
+ });
194
+
195
+ const { messages: secondHumanReply } = await t.mutation(
196
+ api.messages.addMessages,
197
+ {
198
+ threadId: thread._id as Id<"threads">,
199
+ order: 1,
200
+ agentName: "human:Sam",
201
+ messages: [{ message: { role: "assistant", content: "follow-up" } }],
202
+ },
203
+ );
204
+ expect(secondHumanReply[0]).toMatchObject({
205
+ order: 1,
206
+ stepOrder: 1,
207
+ agentName: "human:Sam",
208
+ });
209
+
210
+ const { messages: backdatedBatch } = await t.mutation(
211
+ api.messages.addMessages,
212
+ {
213
+ threadId: thread._id as Id<"threads">,
214
+ order: 0,
215
+ messages: [
216
+ { message: { role: "assistant", content: "backdated reply" } },
217
+ { message: { role: "user", content: "new user turn" } },
218
+ ],
219
+ },
220
+ );
221
+ expect(
222
+ backdatedBatch.map(({ order, stepOrder }) => [order, stepOrder]),
223
+ ).toEqual([
224
+ [0, 2],
225
+ [2, 0],
226
+ ]);
227
+ });
228
+
229
+ test("concurrent saves to an explicit order receive distinct step orders", async () => {
230
+ const t = convexTest(schema, modules);
231
+ const thread = await t.mutation(api.threads.createThread, {
232
+ userId: "test",
233
+ });
234
+ const saveReply = (agentName: string) =>
235
+ t.mutation(api.messages.addMessages, {
236
+ threadId: thread._id as Id<"threads">,
237
+ order: 3,
238
+ agentName,
239
+ messages: [
240
+ { message: { role: "assistant" as const, content: agentName } },
241
+ ],
242
+ });
243
+
244
+ const replies = await Promise.all([saveReply("Alex"), saveReply("Sam")]);
245
+
246
+ expect(
247
+ replies
248
+ .map(({ messages }) => messages[0].stepOrder)
249
+ .sort((a, b) => a - b),
250
+ ).toEqual([0, 1]);
251
+ });
252
+
253
+ test("next order is allocated after the latest message", async () => {
254
+ const t = convexTest(schema, modules);
255
+ const thread = await t.mutation(api.threads.createThread, {
256
+ userId: "test",
257
+ });
258
+ await t.mutation(api.messages.addMessages, {
259
+ threadId: thread._id as Id<"threads">,
260
+ messages: [{ message: { role: "user", content: "hello" } }],
261
+ });
262
+
263
+ const { messages } = await t.mutation(api.messages.addMessages, {
264
+ threadId: thread._id as Id<"threads">,
265
+ order: "next",
266
+ messages: [{ message: { role: "assistant", content: "separate reply" } }],
267
+ });
268
+
269
+ expect(messages[0]).toMatchObject({ order: 1, stepOrder: 0 });
270
+ });
271
+
272
+ test("concurrent next orders receive distinct orders", async () => {
273
+ const t = convexTest(schema, modules);
274
+ const thread = await t.mutation(api.threads.createThread, {
275
+ userId: "test",
276
+ });
277
+ await t.mutation(api.messages.addMessages, {
278
+ threadId: thread._id as Id<"threads">,
279
+ messages: [{ message: { role: "user", content: "hello" } }],
280
+ });
281
+ const saveReply = (agentName: string) =>
282
+ t.mutation(api.messages.addMessages, {
283
+ threadId: thread._id as Id<"threads">,
284
+ order: "next",
285
+ agentName,
286
+ messages: [
287
+ { message: { role: "assistant" as const, content: agentName } },
288
+ ],
289
+ });
290
+
291
+ const replies = await Promise.all([saveReply("Alex"), saveReply("Sam")]);
292
+
293
+ expect(
294
+ replies
295
+ .map(({ messages }) => messages[0])
296
+ .sort((a, b) => a.order - b.order)
297
+ .map(({ order, stepOrder }) => [order, stepOrder]),
298
+ ).toEqual([
299
+ [1, 0],
300
+ [2, 0],
301
+ ]);
302
+ });
303
+
304
+ test("an explicit order ahead of the thread places the batch there", async () => {
305
+ const t = convexTest(schema, modules);
306
+ const thread = await t.mutation(api.threads.createThread, {
307
+ userId: "test",
308
+ });
309
+ await t.mutation(api.messages.addMessages, {
310
+ threadId: thread._id as Id<"threads">,
311
+ messages: [{ message: { role: "user", content: "hello" } }],
312
+ });
313
+
314
+ const { messages } = await t.mutation(api.messages.addMessages, {
315
+ threadId: thread._id as Id<"threads">,
316
+ order: 5,
317
+ messages: [
318
+ { message: { role: "assistant", content: "imported reply" } },
319
+ { message: { role: "user", content: "imported question" } },
320
+ ],
321
+ });
322
+ expect(messages.map(({ order, stepOrder }) => [order, stepOrder])).toEqual([
323
+ [5, 0],
324
+ [6, 0],
325
+ ]);
326
+ });
327
+
328
+ test("an explicit order cannot conflict with another placement argument", async () => {
329
+ const t = convexTest(schema, modules);
330
+ const thread = await t.mutation(api.threads.createThread, {
331
+ userId: "test",
332
+ });
333
+ const { messages } = await t.mutation(api.messages.addMessages, {
334
+ threadId: thread._id as Id<"threads">,
335
+ messages: [{ message: { role: "user", content: "hello" } }],
336
+ });
337
+
338
+ await expect(
339
+ t.mutation(api.messages.addMessages, {
340
+ threadId: thread._id as Id<"threads">,
341
+ order: 1,
342
+ promptMessageId: messages[0]._id as Id<"messages">,
343
+ messages: [{ message: { role: "assistant", content: "reply" } }],
344
+ }),
345
+ ).rejects.toThrow("order and promptMessageId cannot both be provided");
346
+
347
+ await expect(
348
+ t.mutation(api.messages.addMessages, {
349
+ threadId: thread._id as Id<"threads">,
350
+ order: "next",
351
+ promptMessageId: messages[0]._id as Id<"messages">,
352
+ messages: [{ message: { role: "assistant", content: "reply" } }],
353
+ }),
354
+ ).rejects.toThrow("order and promptMessageId cannot both be provided");
355
+
356
+ for (const order of [-1, 1.5, Number.MAX_SAFE_INTEGER]) {
357
+ await expect(
358
+ t.mutation(api.messages.addMessages, {
359
+ threadId: thread._id as Id<"threads">,
360
+ order,
361
+ messages: [{ message: { role: "assistant", content: "reply" } }],
362
+ }),
363
+ ).rejects.toThrow("order must be a non-negative safe integer");
364
+ }
365
+ });
366
+
367
+ test("derived message positions cannot exceed safe integers", async () => {
368
+ const t = convexTest(schema, modules);
369
+ const thread = await t.mutation(api.threads.createThread, {
370
+ userId: "test",
371
+ });
372
+ const { messages } = await t.mutation(api.messages.addMessages, {
373
+ threadId: thread._id as Id<"threads">,
374
+ order: 1,
375
+ messages: [{ message: { role: "assistant", content: "reply" } }],
376
+ });
377
+ const messageId = messages[0]._id as Id<"messages">;
378
+
379
+ await t.run(async (ctx) => {
380
+ await ctx.db.patch("messages", messageId, {
381
+ stepOrder: Number.MAX_SAFE_INTEGER,
382
+ });
383
+ });
384
+ await expect(
385
+ t.mutation(api.messages.addMessages, {
386
+ threadId: thread._id as Id<"threads">,
387
+ order: 1,
388
+ messages: [{ message: { role: "assistant", content: "follow-up" } }],
389
+ }),
390
+ ).rejects.toThrow(
391
+ "stepOrder cannot be incremented past Number.MAX_SAFE_INTEGER",
392
+ );
393
+
394
+ await t.run(async (ctx) => {
395
+ await ctx.db.patch("messages", messageId, {
396
+ order: Number.MAX_SAFE_INTEGER,
397
+ stepOrder: 0,
398
+ });
399
+ });
400
+ await expect(
401
+ t.mutation(api.messages.addMessages, {
402
+ threadId: thread._id as Id<"threads">,
403
+ messages: [{ message: { role: "user", content: "new turn" } }],
404
+ }),
405
+ ).rejects.toThrow(
406
+ "order cannot be incremented past Number.MAX_SAFE_INTEGER",
407
+ );
408
+ });
409
+
167
410
  test("order is incremented for user messages on to addMessages for the same promptMessageId", async () => {
168
411
  const t = convexTest(schema, modules);
169
412
  const thread = await t.mutation(api.threads.createThread, {
@@ -1017,3 +1260,164 @@ describe("agent", () => {
1017
1260
  });
1018
1261
  });
1019
1262
  });
1263
+
1264
+ describe("late saves racing a failed pending message (issue #320)", () => {
1265
+ const PROVIDER_ERROR = "invalid_prompt: Invalid prompt: flagged by policy.";
1266
+
1267
+ test("keeps the first durable failure authoritative", async () => {
1268
+ const t = initConvexTest();
1269
+ const thread = await t.mutation(api.threads.createThread, {
1270
+ userId: "u1",
1271
+ });
1272
+ const threadId = thread._id as Id<"threads">;
1273
+
1274
+ const { messages: seeded } = await t.mutation(api.messages.addMessages, {
1275
+ threadId,
1276
+ messages: [
1277
+ { message: { role: "user", content: "hello" } },
1278
+ { message: { role: "assistant", content: [] }, status: "pending" },
1279
+ ],
1280
+ });
1281
+ const pending = seeded.at(-1)!;
1282
+ expect(pending.status).toBe("pending");
1283
+
1284
+ const streamId = await t.mutation(api.streams.create, {
1285
+ threadId,
1286
+ order: pending.order,
1287
+ stepOrder: pending.stepOrder,
1288
+ format: "UIMessageChunk",
1289
+ });
1290
+
1291
+ await t.mutation(api.messages.finalizeMessage, {
1292
+ messageId: pending._id as Id<"messages">,
1293
+ result: { status: "failed", error: PROVIDER_ERROR },
1294
+ });
1295
+ await t.mutation(api.streams.abort, { streamId, reason: PROVIDER_ERROR });
1296
+
1297
+ const { messages: late } = await t.mutation(api.messages.addMessages, {
1298
+ threadId,
1299
+ pendingMessageId: pending._id as Id<"messages">,
1300
+ finishStreamId: streamId,
1301
+ failPendingSteps: false,
1302
+ messages: [
1303
+ { message: { role: "assistant", content: "partial response" } },
1304
+ ],
1305
+ });
1306
+
1307
+ const assistants = (
1308
+ await t.run(async (ctx) =>
1309
+ ctx.db
1310
+ .query("messages")
1311
+ .withIndex("threadId_status_tool_order_stepOrder", (q) =>
1312
+ q.eq("threadId", threadId),
1313
+ )
1314
+ .collect(),
1315
+ )
1316
+ ).filter((message) => message.message?.role === "assistant");
1317
+
1318
+ expect(late).toHaveLength(1);
1319
+ expect(assistants).toHaveLength(1);
1320
+ expect(assistants[0]!._id).toBe(pending._id);
1321
+ expect(assistants[0]!.status).toBe("failed");
1322
+ expect(assistants[0]!.error).toBe(PROVIDER_ERROR);
1323
+ expect(assistants[0]!.text).toBe("partial response");
1324
+
1325
+ const stream = await t.run((ctx) =>
1326
+ ctx.db.get("streamingMessages", streamId),
1327
+ );
1328
+ expect(stream?.state.kind).toBe("aborted");
1329
+ });
1330
+ });
1331
+
1332
+ describe("deleting a message aborts generation writing to it (issue #300)", () => {
1333
+ test("a stream at the deleted order is aborted", async () => {
1334
+ const t = initConvexTest();
1335
+ const thread = await t.mutation(api.threads.createThread, { userId: "u" });
1336
+ const threadId = thread._id as Id<"threads">;
1337
+
1338
+ const { messages } = await t.mutation(api.messages.addMessages, {
1339
+ threadId,
1340
+ messages: [{ message: { role: "user", content: "hello" } }],
1341
+ });
1342
+ const prompt = messages[0];
1343
+
1344
+ await t.mutation(api.streams.create, {
1345
+ threadId,
1346
+ order: prompt.order,
1347
+ stepOrder: prompt.stepOrder + 1,
1348
+ userId: "u",
1349
+ agentName: "a",
1350
+ model: "m",
1351
+ provider: "p",
1352
+ format: "UIMessageChunk",
1353
+ });
1354
+
1355
+ await t.mutation(api.messages.deleteByIds, {
1356
+ messageIds: [prompt._id as Id<"messages">],
1357
+ });
1358
+
1359
+ const streaming = await t.query(api.streams.list, {
1360
+ threadId,
1361
+ statuses: ["streaming"],
1362
+ });
1363
+ const aborted = await t.query(api.streams.list, {
1364
+ threadId,
1365
+ statuses: ["aborted"],
1366
+ });
1367
+ expect(streaming).toHaveLength(0);
1368
+ expect(aborted).toHaveLength(1);
1369
+ });
1370
+ });
1371
+
1372
+ describe("abandoning a save whose prompt was deleted (issue #300)", () => {
1373
+ test("abandons instead of throwing when the caller opts in", async () => {
1374
+ const t = initConvexTest();
1375
+ const thread = await t.mutation(api.threads.createThread, { userId: "u" });
1376
+ const threadId = thread._id as Id<"threads">;
1377
+
1378
+ const { messages } = await t.mutation(api.messages.addMessages, {
1379
+ threadId,
1380
+ messages: [{ message: { role: "user", content: "hello" } }],
1381
+ });
1382
+ const promptMessageId = messages[0]._id as Id<"messages">;
1383
+
1384
+ await t.mutation(api.messages.deleteByIds, { messageIds: [promptMessageId] });
1385
+
1386
+ const saved = await t.mutation(api.messages.addMessages, {
1387
+ threadId,
1388
+ promptMessageId,
1389
+ abandonIfPromptMissing: true,
1390
+ messages: [{ message: { role: "assistant", content: "answer" } }],
1391
+ });
1392
+ expect(saved.messages).toEqual([]);
1393
+
1394
+ // Nothing was grafted onto the thread.
1395
+ const all = await t.query(api.messages.listMessagesByThreadId, {
1396
+ threadId,
1397
+ order: "asc",
1398
+ paginationOpts: { cursor: null, numItems: 10 },
1399
+ });
1400
+ expect(all.page).toHaveLength(0);
1401
+ });
1402
+
1403
+ test("still throws for a caller that did not opt in", async () => {
1404
+ const t = initConvexTest();
1405
+ const thread = await t.mutation(api.threads.createThread, { userId: "u" });
1406
+ const threadId = thread._id as Id<"threads">;
1407
+
1408
+ const { messages } = await t.mutation(api.messages.addMessages, {
1409
+ threadId,
1410
+ messages: [{ message: { role: "user", content: "hello" } }],
1411
+ });
1412
+ const promptMessageId = messages[0]._id as Id<"messages">;
1413
+ await t.mutation(api.messages.deleteByIds, { messageIds: [promptMessageId] });
1414
+
1415
+ await expect(
1416
+ t.mutation(api.messages.addMessages, {
1417
+ threadId,
1418
+ promptMessageId,
1419
+ messages: [{ message: { role: "assistant", content: "answer" } }],
1420
+ }),
1421
+ ).rejects.toThrow("not found");
1422
+ });
1423
+ });
@@ -44,6 +44,7 @@ import {
44
44
  getStreamingMessagesWithMetadata,
45
45
  finishHandler,
46
46
  releaseStreamFileOwnershipByIds,
47
+ abortStreamsAtOrder,
47
48
  } from "./streams.js";
48
49
  import { partial } from "convex-helpers/validators";
49
50
 
@@ -65,21 +66,45 @@ export async function deleteMessage(
65
66
  }
66
67
  }
67
68
 
69
+ /**
70
+ * Deleting a message strands any generation still writing to its order, which
71
+ * would otherwise only surface as a missing-parent failure when that generation
72
+ * finalizes. Aborting the stream lets the in-flight run stop on its own.
73
+ */
74
+ async function abortStreamsForDeleted(
75
+ ctx: MutationCtx,
76
+ deleted: (Doc<"messages"> | null)[],
77
+ ) {
78
+ const seen = new Set<string>();
79
+ for (const message of deleted) {
80
+ if (!message) continue;
81
+ const key = `${message.threadId}:${message.order}`;
82
+ if (seen.has(key)) continue;
83
+ seen.add(key);
84
+ await abortStreamsAtOrder(ctx, {
85
+ threadId: message.threadId,
86
+ order: message.order,
87
+ reason: "Message deleted",
88
+ });
89
+ }
90
+ }
91
+
68
92
  export const deleteByIds = mutation({
69
93
  args: { messageIds: v.array(v.id("messages")) },
70
94
  returns: v.array(v.id("messages")),
71
95
  handler: async (ctx, args) => {
72
- const deletedMessageIds = await Promise.all(
96
+ const deleted = await Promise.all(
73
97
  args.messageIds.map(async (id) => {
74
98
  const message = await ctx.db.get("messages", id);
75
99
  if (message) {
76
100
  await deleteMessage(ctx, message);
77
- return id;
101
+ return message;
78
102
  }
79
103
  return null;
80
104
  }),
81
105
  );
82
- return deletedMessageIds.filter((id) => id !== null);
106
+ await abortStreamsForDeleted(ctx, deleted);
107
+ return deleted.filter((m) => m !== null).map((m) => m._id);
83
108
  },
84
109
  });
85
110
 
@@ -145,6 +170,7 @@ export const deleteByOrder = mutation({
145
170
  })
146
171
  .take(64);
147
172
  await Promise.all(messages.map((m) => deleteMessage(ctx, m)));
173
+ await abortStreamsForDeleted(ctx, messages);
148
174
  return {
149
175
  isDone: messages.length < 64,
150
176
  lastOrder: messages.at(-1)?.order,
@@ -157,6 +183,13 @@ const addMessagesArgs = {
157
183
  userId: v.optional(v.string()),
158
184
  threadId: v.id("threads"),
159
185
  promptMessageId: v.optional(v.id("messages")),
186
+ /**
187
+ * For saves that belong to a run anchored on promptMessageId: if that
188
+ * message is gone the run is obsolete, so abandon the save instead of
189
+ * throwing. A caller passing an id that never existed still gets an error.
190
+ */
191
+ abandonIfPromptMissing: v.optional(v.boolean()),
192
+ order: v.optional(v.union(v.number(), v.literal("next"))),
160
193
  agentName: v.optional(v.string()),
161
194
  messages: v.array(vMessageWithMetadataInternal),
162
195
  embeddings: v.optional(vMessageEmbeddingsWithDimension),
@@ -175,6 +208,15 @@ export const addMessages = mutation({
175
208
  handler: addMessagesHandler,
176
209
  returns: v.object({ messages: v.array(vMessageDoc) }),
177
210
  });
211
+
212
+ function incrementMessagePosition(value: number, field: "order" | "stepOrder") {
213
+ assert(
214
+ Number.isSafeInteger(value) && value < Number.MAX_SAFE_INTEGER,
215
+ `${field} cannot be incremented past Number.MAX_SAFE_INTEGER`,
216
+ );
217
+ return value + 1;
218
+ }
219
+
178
220
  async function addMessagesHandler(
179
221
  ctx: MutationCtx,
180
222
  args: ObjectType<typeof addMessagesArgs>,
@@ -193,12 +235,30 @@ async function addMessagesHandler(
193
235
  finishStreamId,
194
236
  messages,
195
237
  promptMessageId,
238
+ abandonIfPromptMissing,
239
+ order: requestedOrder,
196
240
  pendingMessageId,
197
241
  hideFromUserIdSearch,
198
242
  ...rest
199
243
  } = args;
200
244
  const promptMessage =
201
245
  promptMessageId && (await ctx.db.get("messages", promptMessageId));
246
+ assert(
247
+ requestedOrder === undefined ||
248
+ requestedOrder === "next" ||
249
+ (Number.isSafeInteger(requestedOrder) &&
250
+ requestedOrder >= 0 &&
251
+ requestedOrder < Number.MAX_SAFE_INTEGER),
252
+ "order must be a non-negative safe integer less than Number.MAX_SAFE_INTEGER",
253
+ );
254
+ assert(
255
+ requestedOrder === undefined || !promptMessageId,
256
+ "order and promptMessageId cannot both be provided",
257
+ );
258
+ assert(
259
+ requestedOrder === undefined || !pendingMessageId,
260
+ "order and pendingMessageId cannot both be provided",
261
+ );
202
262
  if (failPendingSteps) {
203
263
  assert(args.threadId, "threadId is required to fail pending steps");
204
264
  const pendingMessages = await ctx.db
@@ -228,7 +288,21 @@ async function addMessagesHandler(
228
288
  let order, stepOrder;
229
289
  let fail = false;
230
290
  let error: string | undefined;
231
- if (promptMessageId) {
291
+ const startsAtNextOrder = requestedOrder === "next";
292
+ const explicitOrder =
293
+ typeof requestedOrder === "number" ? requestedOrder : undefined;
294
+ if (startsAtNextOrder) {
295
+ const maxMessage = await getMaxMessage(ctx, threadId);
296
+ order = incrementMessagePosition(maxMessage?.order ?? -1, "order");
297
+ stepOrder = -1;
298
+ } else if (explicitOrder !== undefined) {
299
+ order = explicitOrder;
300
+ const maxMessage = await getMaxMessage(ctx, threadId, order);
301
+ stepOrder = maxMessage?.stepOrder ?? -1;
302
+ } else if (promptMessageId) {
303
+ if (!promptMessage && abandonIfPromptMissing) {
304
+ return { messages: [] };
305
+ }
232
306
  assert(promptMessage, `Parent message ${promptMessageId} not found`);
233
307
  if (promptMessage.status === "failed") {
234
308
  fail = true;
@@ -289,8 +363,8 @@ async function addMessagesHandler(
289
363
  if (pendingMessage.status === "failed") {
290
364
  fail = true;
291
365
  error =
292
- `Trying to update a message that failed: ${pendingMessageId}, ` +
293
- `error: ${pendingMessage.error ?? error}`;
366
+ pendingMessage.error ??
367
+ `Trying to update a message that failed: ${pendingMessageId}`;
294
368
  messageDoc.status = "failed";
295
369
  messageDoc.error = error;
296
370
  }
@@ -309,20 +383,28 @@ async function addMessagesHandler(
309
383
  toReturn.push((await ctx.db.get("messages", pendingMessage._id))!);
310
384
  continue;
311
385
  }
312
- if (message.message.role === "user") {
313
- if (promptMessage && promptMessage.order === order) {
314
- // see if there's a later message than the parent message order
386
+ if ((startsAtNextOrder || explicitOrder !== undefined) && i === 0) {
387
+ stepOrder = incrementMessagePosition(stepOrder, "stepOrder");
388
+ } else if (message.message.role === "user") {
389
+ if (
390
+ (explicitOrder !== undefined && order === explicitOrder) ||
391
+ (promptMessage && promptMessage.order === order)
392
+ ) {
393
+ // Avoid colliding with a later order when saving from an older one.
315
394
  const maxMessage = await getMaxMessage(ctx, threadId);
316
- order = (maxMessage?.order ?? order) + 1;
395
+ order = incrementMessagePosition(
396
+ Math.max(maxMessage?.order ?? order, order),
397
+ "order",
398
+ );
317
399
  } else {
318
- order++;
400
+ order = incrementMessagePosition(order, "order");
319
401
  }
320
402
  stepOrder = 0;
321
403
  } else {
322
404
  if (order < 0) {
323
405
  order = 0;
324
406
  }
325
- stepOrder++;
407
+ stepOrder = incrementMessagePosition(stepOrder, "stepOrder");
326
408
  }
327
409
  const messageId = await ctx.db.insert("messages", {
328
410
  ...messageDoc,
@@ -1,10 +1,12 @@
1
1
  /// <reference types="vite/client" />
2
2
 
3
- import { describe, expect, test } from "vitest";
3
+ import { afterEach, describe, expect, test, vi } from "vitest";
4
4
  import { api } from "./_generated/api.js";
5
5
  import type { Id } from "./_generated/dataModel.js";
6
6
  import { initConvexTest } from "./setup.test.js";
7
7
 
8
+ afterEach(() => vi.useRealTimers());
9
+
8
10
  async function seedStream(t: ReturnType<typeof initConvexTest>) {
9
11
  const thread = await t.mutation(api.threads.createThread, {
10
12
  userId: "stream-files",
@@ -106,4 +108,55 @@ describe("streams", () => {
106
108
  expect(stream?.state.kind).toBe("finished");
107
109
  expect(stream?.fileRefs).toBeUndefined();
108
110
  });
111
+
112
+ test("bulk deletion preserves global stream order across status lanes", async () => {
113
+ vi.useFakeTimers();
114
+
115
+ const t = initConvexTest();
116
+ const thread = await t.mutation(api.threads.createThread, {
117
+ userId: "stream-cleanup",
118
+ });
119
+ const threadId = thread._id as Id<"threads">;
120
+ const earlyStreamId = await t.run(async (ctx) => {
121
+ const streamId = await ctx.db.insert("streamingMessages", {
122
+ threadId,
123
+ order: 1,
124
+ stepOrder: 0,
125
+ format: "UIMessageChunk",
126
+ state: { kind: "streaming", lastHeartbeat: Date.now() },
127
+ });
128
+ await ctx.db.insert("streamingMessages", {
129
+ threadId,
130
+ order: 9,
131
+ stepOrder: 0,
132
+ format: "UIMessageChunk",
133
+ state: { kind: "aborted", reason: "retained regeneration" },
134
+ });
135
+ await ctx.db.insert("streamDeltas", {
136
+ streamId,
137
+ start: 0,
138
+ end: 1,
139
+ parts: [{ type: "text-start", id: "text" }],
140
+ });
141
+ return streamId;
142
+ });
143
+
144
+ await t.mutation(api.streams.deleteAllStreamsForThreadIdAsync, {
145
+ threadId,
146
+ });
147
+ await t.finishAllScheduledFunctions(vi.runAllTimers);
148
+
149
+ expect(
150
+ await t.query(api.streams.list, {
151
+ threadId,
152
+ statuses: ["streaming", "finished", "aborted"],
153
+ }),
154
+ ).toEqual([]);
155
+ expect(
156
+ await t.query(api.streams.listDeltas, {
157
+ threadId,
158
+ cursors: [{ streamId: earlyStreamId, cursor: 0 }],
159
+ }),
160
+ ).toEqual([]);
161
+ });
109
162
  });