@convex-dev/agent 0.1.14-alpha.1 → 0.1.14-alpha.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.
@@ -152,14 +152,14 @@ describe("agent", () => {
152
152
  messages: [{ message: { role: "user", content: "hello" } }],
153
153
  });
154
154
  const messageId = messages[0]._id as Id<"messages">;
155
-
155
+
156
156
  const updatedMessage = await t.mutation(api.messages.updateMessage, {
157
157
  messageId,
158
158
  patch: {
159
159
  message: { role: "user", content: "updated content" },
160
160
  },
161
161
  });
162
-
162
+
163
163
  expect(updatedMessage.message).toEqual({
164
164
  role: "user",
165
165
  content: "updated content",
@@ -177,10 +177,10 @@ describe("agent", () => {
177
177
  pending: true,
178
178
  });
179
179
  const messageId = messages[0]._id as Id<"messages">;
180
-
180
+
181
181
  // Initial status should be pending
182
182
  expect(messages[0].status).toBe("pending");
183
-
183
+
184
184
  // Update to success
185
185
  const updatedMessage = await t.mutation(api.messages.updateMessage, {
186
186
  messageId,
@@ -188,7 +188,7 @@ describe("agent", () => {
188
188
  status: "success",
189
189
  },
190
190
  });
191
-
191
+
192
192
  expect(updatedMessage.status).toBe("success");
193
193
  });
194
194
 
@@ -203,7 +203,7 @@ describe("agent", () => {
203
203
  pending: true,
204
204
  });
205
205
  const messageId = messages[0]._id as Id<"messages">;
206
-
206
+
207
207
  const updatedMessage = await t.mutation(api.messages.updateMessage, {
208
208
  messageId,
209
209
  patch: {
@@ -211,7 +211,7 @@ describe("agent", () => {
211
211
  error: "Something went wrong",
212
212
  },
213
213
  });
214
-
214
+
215
215
  expect(updatedMessage.status).toBe("failed");
216
216
  expect(updatedMessage.error).toBe("Something went wrong");
217
217
  });
@@ -223,22 +223,24 @@ describe("agent", () => {
223
223
  });
224
224
  const { messages } = await t.mutation(api.messages.addMessages, {
225
225
  threadId: thread._id as Id<"threads">,
226
- messages: [{
227
- message: {
228
- role: "assistant",
229
- content: [
230
- {
231
- type: "tool-call",
232
- args: { a: 1 },
233
- toolCallId: "1",
234
- toolName: "tool",
235
- },
236
- ],
226
+ messages: [
227
+ {
228
+ message: {
229
+ role: "assistant",
230
+ content: [
231
+ {
232
+ type: "tool-call",
233
+ args: { a: 1 },
234
+ toolCallId: "1",
235
+ toolName: "tool",
236
+ },
237
+ ],
238
+ },
237
239
  },
238
- }],
240
+ ],
239
241
  });
240
242
  const messageId = messages[0]._id as Id<"messages">;
241
-
243
+
242
244
  const updatedMessage = await t.mutation(api.messages.updateMessage, {
243
245
  messageId,
244
246
  patch: {
@@ -255,7 +257,7 @@ describe("agent", () => {
255
257
  },
256
258
  },
257
259
  });
258
-
260
+
259
261
  expect(updatedMessage.message).toEqual({
260
262
  role: "assistant",
261
263
  content: [
@@ -271,7 +273,7 @@ describe("agent", () => {
271
273
 
272
274
  test("updateMessage throws error for non-existent message", async () => {
273
275
  const t = convexTest(schema, modules);
274
-
276
+
275
277
  await expect(
276
278
  t.mutation(api.messages.updateMessage, {
277
279
  messageId: "invalidId" as Id<"messages">,
@@ -281,4 +283,229 @@ describe("agent", () => {
281
283
  })
282
284
  ).rejects.toThrow();
283
285
  });
286
+
287
+ test("deleteByIds deletes existing messages and returns their IDs", async () => {
288
+ const t = convexTest(schema, modules);
289
+ const thread = await t.mutation(api.threads.createThread, {
290
+ userId: "test",
291
+ });
292
+ const { messages } = await t.mutation(api.messages.addMessages, {
293
+ threadId: thread._id as Id<"threads">,
294
+ messages: [
295
+ { message: { role: "user", content: "hello" } },
296
+ { message: { role: "assistant", content: "world" } },
297
+ { message: { role: "user", content: "test" } },
298
+ ],
299
+ });
300
+
301
+ const messageIds = messages.map((m) => m._id as Id<"messages">);
302
+ const deletedIds = await t.mutation(api.messages.deleteByIds, {
303
+ messageIds: [messageIds[0], messageIds[2]], // Delete first and third messages
304
+ });
305
+
306
+ expect(deletedIds).toEqual([messageIds[0], messageIds[2]]);
307
+
308
+ // Verify messages are actually deleted
309
+ const remainingMessages = await t.query(
310
+ api.messages.listMessagesByThreadId,
311
+ {
312
+ threadId: thread._id as Id<"threads">,
313
+ order: "asc",
314
+ }
315
+ );
316
+ expect(remainingMessages.page).toHaveLength(1);
317
+ expect(remainingMessages.page[0]._id).toBe(messageIds[1]);
318
+ });
319
+
320
+ test("deleteByIds handles non-existent message IDs gracefully", async () => {
321
+ const t = convexTest(schema, modules);
322
+ const thread = await t.mutation(api.threads.createThread, {
323
+ userId: "test",
324
+ });
325
+ const { messages } = await t.mutation(api.messages.addMessages, {
326
+ threadId: thread._id as Id<"threads">,
327
+ messages: [{ message: { role: "user", content: "hello" } }],
328
+ });
329
+
330
+ const messageId = messages[0]._id as Id<"messages">;
331
+ const validDeletedIds = await t.mutation(api.messages.deleteByIds, {
332
+ messageIds: [messageId],
333
+ });
334
+ expect(validDeletedIds).toEqual([messageId]);
335
+
336
+ const deletedIds = await t.mutation(api.messages.deleteByIds, {
337
+ messageIds: [messageId],
338
+ });
339
+
340
+ // Should only return the valid ID that was actually deleted
341
+ expect(deletedIds).toEqual([]);
342
+
343
+ // Verify the valid message was deleted
344
+ const remainingMessages = await t.query(
345
+ api.messages.listMessagesByThreadId,
346
+ {
347
+ threadId: thread._id as Id<"threads">,
348
+ order: "asc",
349
+ }
350
+ );
351
+ expect(remainingMessages.page).toHaveLength(0);
352
+ });
353
+
354
+ test("deleteByOrder deletes messages within specified order range", async () => {
355
+ const t = convexTest(schema, modules);
356
+ const thread = await t.mutation(api.threads.createThread, {
357
+ userId: "test",
358
+ });
359
+
360
+ // Create multiple rounds of messages with different orders
361
+ const { messages: round1 } = await t.mutation(api.messages.addMessages, {
362
+ threadId: thread._id as Id<"threads">,
363
+ messages: [
364
+ { message: { role: "user", content: "message order 0, step 0" } },
365
+ { message: { role: "assistant", content: "message order 0, step 1" } },
366
+ ],
367
+ });
368
+
369
+ const { messages: round2 } = await t.mutation(api.messages.addMessages, {
370
+ threadId: thread._id as Id<"threads">,
371
+ messages: [
372
+ { message: { role: "user", content: "message order 1, step 0" } },
373
+ { message: { role: "assistant", content: "message order 1, step 1" } },
374
+ ],
375
+ });
376
+
377
+ const { messages: round3 } = await t.mutation(api.messages.addMessages, {
378
+ threadId: thread._id as Id<"threads">,
379
+ messages: [
380
+ { message: { role: "user", content: "message order 2, step 0" } },
381
+ ],
382
+ });
383
+
384
+ // Delete messages in order range 0 to 1 (exclusive)
385
+ const result = await t.mutation(api.messages.deleteByOrder, {
386
+ threadId: thread._id as Id<"threads">,
387
+ startOrder: 0,
388
+ endOrder: 1,
389
+ });
390
+
391
+ expect(result.isDone).toBe(true);
392
+ expect(result.lastOrder).toBe(0);
393
+ expect(result.lastStepOrder).toBe(1);
394
+
395
+ // Verify only messages from order 0 were deleted
396
+ const remainingMessages = await t.query(
397
+ api.messages.listMessagesByThreadId,
398
+ {
399
+ threadId: thread._id as Id<"threads">,
400
+ order: "asc",
401
+ }
402
+ );
403
+
404
+ expect(remainingMessages.page).toHaveLength(3); // Should have messages from order 1 and 2
405
+ expect(remainingMessages.page.map((m) => m.order)).toEqual([1, 1, 2]);
406
+ });
407
+
408
+ test("deleteByOrder handles step order boundaries correctly", async () => {
409
+ const t = convexTest(schema, modules);
410
+ const thread = await t.mutation(api.threads.createThread, {
411
+ userId: "test",
412
+ });
413
+
414
+ // Create messages with the same order but different step orders
415
+ const { messages } = await t.mutation(api.messages.addMessages, {
416
+ threadId: thread._id as Id<"threads">,
417
+ messages: [
418
+ { message: { role: "user", content: "step 0" } },
419
+ { message: { role: "assistant", content: "step 1" } },
420
+ { message: { role: "user", content: "step 2" } },
421
+ { message: { role: "assistant", content: "step 3" } },
422
+ ],
423
+ });
424
+
425
+ // Delete messages from step 1 to step 3 (exclusive)
426
+ const result = await t.mutation(api.messages.deleteByOrder, {
427
+ threadId: thread._id as Id<"threads">,
428
+ startOrder: 0,
429
+ startStepOrder: 1,
430
+ endOrder: 0,
431
+ endStepOrder: 3,
432
+ });
433
+
434
+ expect(result.isDone).toBe(true);
435
+ expect(result.lastOrder).toBe(0);
436
+ expect(result.lastStepOrder).toBe(2);
437
+
438
+ // Verify only step 1 and 2 were deleted (step 3 is excluded by upperBoundInclusive: false)
439
+ const remainingMessages = await t.query(
440
+ api.messages.listMessagesByThreadId,
441
+ {
442
+ threadId: thread._id as Id<"threads">,
443
+ order: "asc",
444
+ }
445
+ );
446
+
447
+ expect(remainingMessages.page).toHaveLength(2);
448
+ expect(remainingMessages.page.map((m) => m.stepOrder)).toEqual([0, 3]);
449
+ });
450
+
451
+ test("deleteByOrder returns isDone false when batch limit is reached", async () => {
452
+ const t = convexTest(schema, modules);
453
+ const thread = await t.mutation(api.threads.createThread, {
454
+ userId: "test",
455
+ });
456
+
457
+ // This test would be more realistic with 65+ messages, but for test efficiency
458
+ // we'll just verify the basic structure works with fewer messages
459
+ const { messages } = await t.mutation(api.messages.addMessages, {
460
+ threadId: thread._id as Id<"threads">,
461
+ messages: [
462
+ { message: { role: "user", content: "message 1" } },
463
+ { message: { role: "assistant", content: "message 2" } },
464
+ ],
465
+ });
466
+
467
+ const result = await t.mutation(api.messages.deleteByOrder, {
468
+ threadId: thread._id as Id<"threads">,
469
+ startOrder: 0,
470
+ endOrder: 2,
471
+ });
472
+
473
+ // With only 2 messages, should be done
474
+ expect(result.isDone).toBe(true);
475
+ expect(result.lastOrder).toBe(0);
476
+ expect(result.lastStepOrder).toBe(1);
477
+ });
478
+
479
+ test("deleteByOrder handles empty result set", async () => {
480
+ const t = convexTest(schema, modules);
481
+ const thread = await t.mutation(api.threads.createThread, {
482
+ userId: "test",
483
+ });
484
+
485
+ const { messages } = await t.mutation(api.messages.addMessages, {
486
+ threadId: thread._id as Id<"threads">,
487
+ messages: [{ message: { role: "user", content: "message order 0" } }],
488
+ });
489
+
490
+ // Try to delete from a range that doesn't exist
491
+ const result = await t.mutation(api.messages.deleteByOrder, {
492
+ threadId: thread._id as Id<"threads">,
493
+ startOrder: 5,
494
+ endOrder: 10,
495
+ });
496
+
497
+ expect(result.isDone).toBe(true);
498
+ expect(result.lastOrder).toBeUndefined();
499
+ expect(result.lastStepOrder).toBeUndefined();
500
+
501
+ // Verify original message is still there
502
+ const remainingMessages = await t.query(
503
+ api.messages.listMessagesByThreadId,
504
+ {
505
+ threadId: thread._id as Id<"threads">,
506
+ order: "asc",
507
+ }
508
+ );
509
+ expect(remainingMessages.page).toHaveLength(1);
510
+ });
284
511
  });
@@ -39,6 +39,7 @@ import {
39
39
  type VectorTableId,
40
40
  vVectorId,
41
41
  } from "./vector/tables.js";
42
+ import { changeRefcount } from "./files.js";
42
43
 
43
44
  /** @deprecated Use *.threads.listMessagesByThreadId instead. */
44
45
  export const listThreadsByUserId = _listThreadsByUserId;
@@ -61,31 +62,28 @@ export async function deleteMessage(
61
62
  if (messageDoc.embeddingId) {
62
63
  await ctx.db.delete(messageDoc.embeddingId);
63
64
  }
64
- for (const fileId of messageDoc.fileIds ?? []) {
65
- if (!fileId) continue;
66
- const file = await ctx.db.get(fileId);
67
- if (file) {
68
- await ctx.db.patch(fileId, { refcount: file.refcount - 1 });
69
- }
65
+ if (messageDoc.fileIds) {
66
+ await changeRefcount(ctx, messageDoc.fileIds, []);
70
67
  }
71
68
  }
72
69
 
73
- export const deleteMessages = mutation({
70
+ export const deleteByIds = mutation({
74
71
  args: {
75
72
  messageIds: v.array(v.id("messages")),
76
73
  },
77
- returns: v.null(),
74
+ returns: v.array(v.id("messages")),
78
75
  handler: async (ctx, args) => {
79
- await Promise.all(
76
+ const deletedMessageIds = await Promise.all(
80
77
  args.messageIds.map(async (id) => {
81
78
  const message = await ctx.db.get(id);
82
79
  if (message) {
83
80
  await deleteMessage(ctx, message);
84
- } else {
85
- console.warn(`Message ${id} not found when trying to delete`);
81
+ return id;
86
82
  }
83
+ return null;
87
84
  })
88
85
  );
86
+ return deletedMessageIds.filter((id) => id !== null);
89
87
  },
90
88
  });
91
89
 
@@ -93,6 +91,46 @@ export const messageStatuses = vMessageDoc.fields.status.members.map(
93
91
  (m) => m.value
94
92
  );
95
93
 
94
+ export const deleteByOrder = mutation({
95
+ args: {
96
+ threadId: v.id("threads"),
97
+ startOrder: v.number(),
98
+ startStepOrder: v.optional(v.number()),
99
+ endOrder: v.number(),
100
+ endStepOrder: v.optional(v.number()),
101
+ },
102
+ returns: v.object({
103
+ isDone: v.boolean(),
104
+ lastOrder: v.optional(v.number()),
105
+ lastStepOrder: v.optional(v.number()),
106
+ }),
107
+ handler: async (ctx, args) => {
108
+ const messages = await orderedMessagesStream(
109
+ ctx,
110
+ args.threadId,
111
+ "asc",
112
+ args.startOrder
113
+ )
114
+ .narrow({
115
+ lowerBound: args.startStepOrder
116
+ ? [args.startOrder, args.startStepOrder]
117
+ : [args.startOrder],
118
+ lowerBoundInclusive: true,
119
+ upperBound: args.endStepOrder
120
+ ? [args.endOrder, args.endStepOrder]
121
+ : [args.endOrder],
122
+ upperBoundInclusive: false,
123
+ })
124
+ .take(64);
125
+ await Promise.all(messages.map((m) => deleteMessage(ctx, m)));
126
+ return {
127
+ isDone: messages.length < 64,
128
+ lastOrder: messages[messages.length - 1]?.order,
129
+ lastStepOrder: messages[messages.length - 1]?.stepOrder,
130
+ };
131
+ },
132
+ });
133
+
96
134
  const addMessagesArgs = {
97
135
  userId: v.optional(v.string()),
98
136
  threadId: v.id("threads"),
@@ -201,11 +239,8 @@ async function addMessagesHandler(
201
239
  // id: messageId,
202
240
  // });
203
241
  // }
204
- for (const fileId of message.fileIds ?? []) {
205
- if (!fileId) continue;
206
- await ctx.db.patch(fileId, {
207
- refcount: (await ctx.db.get(fileId))!.refcount + 1,
208
- });
242
+ if (message.fileIds) {
243
+ await changeRefcount(ctx, [], message.fileIds);
209
244
  }
210
245
  // TODO: delete the associated stream data for the order/stepOrder
211
246
  toReturn.push((await ctx.db.get(messageId))!);
@@ -291,6 +326,7 @@ export const updateMessage = mutation({
291
326
  messageId: v.id("messages"),
292
327
  patch: v.object({
293
328
  message: v.optional(vMessageDoc.fields.message),
329
+ fileIds: v.optional(v.array(v.id("files"))),
294
330
  status: v.optional(vMessageStatus),
295
331
  error: v.optional(v.string()),
296
332
  }),
@@ -300,6 +336,10 @@ export const updateMessage = mutation({
300
336
  const message = await ctx.db.get(args.messageId);
301
337
  assert(message, `Message ${args.messageId} not found`);
302
338
 
339
+ if (args.patch.fileIds) {
340
+ await changeRefcount(ctx, message.fileIds ?? [], args.patch.fileIds);
341
+ }
342
+
303
343
  const patch: Partial<Doc<"messages">> = {
304
344
  ...args.patch,
305
345
  };
@@ -0,0 +1 @@
1
+ {"version":"3.2.4","results":[[":client/index.test.ts",{"duration":60.55858325958252,"failed":false}],[":component/messages.test.ts",{"duration":27.41837501525879,"failed":false}]]}