@convex-dev/agent 0.1.8-alpha.2 → 0.1.8

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.
@@ -0,0 +1,560 @@
1
+ /// <reference types="vite/client" />
2
+
3
+ import { convexTest } from "convex-test";
4
+ import { describe, expect, test, vi } from "vitest";
5
+ import { api, internal } from "./_generated/api.js";
6
+ import type { Id } from "./_generated/dataModel.js";
7
+ import schema from "./schema.js";
8
+ import { modules } from "./setup.test.js";
9
+
10
+ describe("threads", () => {
11
+ test("createThread creates a thread with correct data", async () => {
12
+ const t = convexTest(schema, modules);
13
+
14
+ const thread = await t.mutation(api.threads.createThread, {
15
+ userId: "testUser",
16
+ title: "Test Thread",
17
+ summary: "A test thread",
18
+ });
19
+
20
+ expect(thread.userId).toBe("testUser");
21
+ expect(thread.title).toBe("Test Thread");
22
+ expect(thread.summary).toBe("A test thread");
23
+ expect(thread.status).toBe("active");
24
+ expect(thread._id).toBeTruthy();
25
+ expect(thread._creationTime).toBeTruthy();
26
+ });
27
+
28
+ test("getThread returns thread data", async () => {
29
+ const t = convexTest(schema, modules);
30
+
31
+ const created = await t.mutation(api.threads.createThread, {
32
+ userId: "getUser",
33
+ title: "Get Thread",
34
+ });
35
+
36
+ const fetched = await t.query(api.threads.getThread, {
37
+ threadId: created._id as Id<"threads">,
38
+ });
39
+
40
+ expect(fetched).not.toBeNull();
41
+ expect(fetched!._id).toBe(created._id);
42
+ expect(fetched!.userId).toBe("getUser");
43
+ expect(fetched!.title).toBe("Get Thread");
44
+ });
45
+
46
+ test("getThread returns null for non-existent thread", async () => {
47
+ const t = convexTest(schema, modules);
48
+
49
+ // Create a thread, then delete it to get a valid but non-existent ID
50
+ const thread = await t.mutation(api.threads.createThread, {
51
+ userId: "tempUser",
52
+ title: "Temp Thread",
53
+ });
54
+
55
+ // Delete the thread so it no longer exists
56
+ await t.action(api.threads.deleteAllForThreadIdSync, {
57
+ threadId: thread._id as Id<"threads">,
58
+ });
59
+
60
+ // Now test with the deleted thread's ID
61
+ const result = await t.query(api.threads.getThread, {
62
+ threadId: thread._id as Id<"threads">,
63
+ });
64
+
65
+ expect(result).toBeNull();
66
+ });
67
+
68
+ test("listThreadsByUserId returns threads for user", async () => {
69
+ const t = convexTest(schema, modules);
70
+
71
+ // Create threads for different users
72
+ const thread1 = await t.mutation(api.threads.createThread, {
73
+ userId: "listUser",
74
+ title: "Thread 1",
75
+ });
76
+ const thread2 = await t.mutation(api.threads.createThread, {
77
+ userId: "listUser",
78
+ title: "Thread 2",
79
+ });
80
+ const thread3 = await t.mutation(api.threads.createThread, {
81
+ userId: "otherUser",
82
+ title: "Other Thread",
83
+ });
84
+
85
+ const result = await t.query(api.threads.listThreadsByUserId, {
86
+ userId: "listUser",
87
+ });
88
+
89
+ expect(result.page).toHaveLength(2);
90
+ expect(result.page.map((t) => t._id)).toContain(thread1._id);
91
+ expect(result.page.map((t) => t._id)).toContain(thread2._id);
92
+ expect(result.page.map((t) => t._id)).not.toContain(thread3._id);
93
+ });
94
+
95
+ test("listThreadsByUserId pagination works", async () => {
96
+ const t = convexTest(schema, modules);
97
+
98
+ // Create multiple threads
99
+ const threads = [];
100
+ for (let i = 0; i < 5; i++) {
101
+ const thread = await t.mutation(api.threads.createThread, {
102
+ userId: "paginationUser",
103
+ title: `Thread ${i}`,
104
+ });
105
+ threads.push(thread);
106
+ }
107
+
108
+ const firstPage = await t.query(api.threads.listThreadsByUserId, {
109
+ userId: "paginationUser",
110
+ paginationOpts: { cursor: null, numItems: 2 },
111
+ });
112
+
113
+ expect(firstPage.page).toHaveLength(2);
114
+ expect(firstPage.isDone).toBe(false);
115
+
116
+ const secondPage = await t.query(api.threads.listThreadsByUserId, {
117
+ userId: "paginationUser",
118
+ paginationOpts: { cursor: firstPage.continueCursor, numItems: 2 },
119
+ });
120
+
121
+ expect(secondPage.page).toHaveLength(2);
122
+ // Should not have duplicate threads
123
+ const firstPageIds = firstPage.page.map((t) => t._id);
124
+ const secondPageIds = secondPage.page.map((t) => t._id);
125
+ expect(firstPageIds.every((id) => !secondPageIds.includes(id))).toBe(true);
126
+ });
127
+
128
+ test("listThreadsByUserId ordering works", async () => {
129
+ const t = convexTest(schema, modules);
130
+
131
+ // Create threads with slight delays to ensure different creation times
132
+ const thread1 = await t.mutation(api.threads.createThread, {
133
+ userId: "orderUser",
134
+ title: "First Thread",
135
+ });
136
+
137
+ // Small delay to ensure different creation times
138
+ await new Promise((resolve) => setTimeout(resolve, 1));
139
+
140
+ const thread2 = await t.mutation(api.threads.createThread, {
141
+ userId: "orderUser",
142
+ title: "Second Thread",
143
+ });
144
+
145
+ // Test descending order (default)
146
+ const descResult = await t.query(api.threads.listThreadsByUserId, {
147
+ userId: "orderUser",
148
+ order: "desc",
149
+ });
150
+
151
+ expect(descResult.page[0]._id).toBe(thread2._id);
152
+ expect(descResult.page[1]._id).toBe(thread1._id);
153
+
154
+ // Test ascending order
155
+ const ascResult = await t.query(api.threads.listThreadsByUserId, {
156
+ userId: "orderUser",
157
+ order: "asc",
158
+ });
159
+
160
+ expect(ascResult.page[0]._id).toBe(thread1._id);
161
+ expect(ascResult.page[1]._id).toBe(thread2._id);
162
+ });
163
+
164
+ test("updateThread updates thread fields", async () => {
165
+ const t = convexTest(schema, modules);
166
+
167
+ const thread = await t.mutation(api.threads.createThread, {
168
+ userId: "updateUser",
169
+ title: "Original Title",
170
+ summary: "Original Summary",
171
+ });
172
+
173
+ const updated = await t.mutation(api.threads.updateThread, {
174
+ threadId: thread._id as Id<"threads">,
175
+ patch: {
176
+ title: "Updated Title",
177
+ summary: "Updated Summary",
178
+ status: "archived",
179
+ },
180
+ });
181
+
182
+ expect(updated.title).toBe("Updated Title");
183
+ expect(updated.summary).toBe("Updated Summary");
184
+ expect(updated.status).toBe("archived");
185
+ expect(updated._id).toBe(thread._id);
186
+ });
187
+
188
+ test("updateThread with partial patch works", async () => {
189
+ const t = convexTest(schema, modules);
190
+
191
+ const thread = await t.mutation(api.threads.createThread, {
192
+ userId: "partialUser",
193
+ title: "Original Title",
194
+ summary: "Original Summary",
195
+ });
196
+
197
+ const updated = await t.mutation(api.threads.updateThread, {
198
+ threadId: thread._id as Id<"threads">,
199
+ patch: {
200
+ title: "New Title Only",
201
+ },
202
+ });
203
+
204
+ expect(updated.title).toBe("New Title Only");
205
+ expect(updated.summary).toBe("Original Summary"); // Unchanged
206
+ expect(updated.status).toBe("active"); // Unchanged
207
+ });
208
+
209
+ test("updateThread throws error for non-existent thread", async () => {
210
+ const t = convexTest(schema, modules);
211
+
212
+ // Create a thread, then delete it to get a valid but non-existent ID
213
+ const thread = await t.mutation(api.threads.createThread, {
214
+ userId: "tempUser",
215
+ title: "Temp Thread",
216
+ });
217
+
218
+ // Delete the thread so it no longer exists
219
+ await t.action(api.threads.deleteAllForThreadIdSync, {
220
+ threadId: thread._id as Id<"threads">,
221
+ });
222
+
223
+ // Now test updating the deleted thread
224
+ await expect(
225
+ t.mutation(api.threads.updateThread, {
226
+ threadId: thread._id as Id<"threads">,
227
+ patch: { title: "New Title" },
228
+ })
229
+ ).rejects.toThrow();
230
+ });
231
+
232
+ test("deleteAllForThreadIdSync deletes thread and all related data", async () => {
233
+ const t = convexTest(schema, modules);
234
+
235
+ // Create a thread with messages
236
+ const thread = await t.mutation(api.threads.createThread, {
237
+ userId: "deleteUser",
238
+ title: "Thread to Delete",
239
+ });
240
+
241
+ // Add messages to the thread
242
+ await t.mutation(api.messages.addMessages, {
243
+ threadId: thread._id as Id<"threads">,
244
+ messages: [
245
+ { message: { role: "user" as const, content: "Hello" } },
246
+ { message: { role: "assistant" as const, content: "Hi there" } },
247
+ { message: { role: "user" as const, content: "How are you?" } },
248
+ ],
249
+ });
250
+
251
+ // Verify data exists before deletion
252
+ const beforeMessages = await t.query(api.messages.listMessagesByThreadId, {
253
+ threadId: thread._id as Id<"threads">,
254
+ order: "desc",
255
+ paginationOpts: { cursor: null, numItems: 10 },
256
+ });
257
+ expect(beforeMessages.page).toHaveLength(3);
258
+
259
+ const beforeThread = await t.query(api.threads.getThread, {
260
+ threadId: thread._id as Id<"threads">,
261
+ });
262
+ expect(beforeThread).not.toBeNull();
263
+
264
+ // Delete the thread synchronously
265
+ await t.action(api.threads.deleteAllForThreadIdSync, {
266
+ threadId: thread._id as Id<"threads">,
267
+ });
268
+
269
+ // Verify thread is deleted
270
+ const afterThread = await t.query(api.threads.getThread, {
271
+ threadId: thread._id as Id<"threads">,
272
+ });
273
+ expect(afterThread).toBeNull();
274
+
275
+ // Verify messages are deleted (this will return empty page since thread is gone)
276
+ const afterMessages = await t.query(api.messages.listMessagesByThreadId, {
277
+ threadId: thread._id as Id<"threads">,
278
+ order: "desc",
279
+ paginationOpts: { cursor: null, numItems: 10 },
280
+ });
281
+ expect(afterMessages.page).toHaveLength(0);
282
+ });
283
+
284
+ test("deleteAllForThreadIdAsync deletes thread asynchronously", async () => {
285
+ // Enable fake timers for scheduled function testing
286
+ vi.useFakeTimers();
287
+
288
+ const t = convexTest(schema, modules);
289
+
290
+ // Create a thread with messages
291
+ const thread = await t.mutation(api.threads.createThread, {
292
+ userId: "asyncDeleteUser",
293
+ title: "Async Thread to Delete",
294
+ });
295
+
296
+ await t.mutation(api.messages.addMessages, {
297
+ threadId: thread._id as Id<"threads">,
298
+ messages: [
299
+ { message: { role: "user" as const, content: "Test message" } },
300
+ ],
301
+ });
302
+
303
+ // Start async deletion
304
+ const result = await t.mutation(api.threads.deleteAllForThreadIdAsync, {
305
+ threadId: thread._id as Id<"threads">,
306
+ });
307
+
308
+ // If there's more work to do, advance timers and wait for scheduled functions
309
+ if (!result.isDone) {
310
+ // Run all pending timers to trigger scheduled functions
311
+ vi.runAllTimers();
312
+
313
+ // Wait for all scheduled functions to complete
314
+ await t.finishInProgressScheduledFunctions();
315
+ }
316
+
317
+ // Verify thread is deleted
318
+ const afterThread = await t.query(api.threads.getThread, {
319
+ threadId: thread._id as Id<"threads">,
320
+ });
321
+ expect(afterThread).toBeNull();
322
+
323
+ // Reset to normal timers
324
+ vi.useRealTimers();
325
+ });
326
+
327
+ test("deletePageForThreadId handles pagination correctly", async () => {
328
+ const t = convexTest(schema, modules);
329
+
330
+ // Create a thread with many messages to test pagination
331
+ const thread = await t.mutation(api.threads.createThread, {
332
+ userId: "paginationDeleteUser",
333
+ title: "Pagination Delete Thread",
334
+ });
335
+
336
+ // Add many messages to force pagination
337
+ const messages = [];
338
+ for (let i = 0; i < 150; i++) {
339
+ messages.push({
340
+ message: { role: "user" as const, content: `Message ${i}` },
341
+ });
342
+ }
343
+ await t.mutation(api.messages.addMessages, {
344
+ threadId: thread._id as Id<"threads">,
345
+ messages,
346
+ });
347
+
348
+ // Delete first page
349
+ const result1 = await t.mutation(internal.threads._deletePageForThreadId, {
350
+ threadId: thread._id as Id<"threads">,
351
+ });
352
+
353
+ expect(result1.isDone).toBe(false);
354
+ expect(result1.cursor).toBeTruthy();
355
+
356
+ // Continue deleting pages until done
357
+ let currentResult = result1;
358
+ let iterations = 0;
359
+ while (!currentResult.isDone && iterations < 10) {
360
+ currentResult = await t.mutation(
361
+ internal.threads._deletePageForThreadId,
362
+ {
363
+ threadId: thread._id as Id<"threads">,
364
+ cursor: currentResult.cursor,
365
+ }
366
+ );
367
+ iterations++;
368
+ }
369
+
370
+ // Thread should be deleted when done
371
+ expect(currentResult.isDone).toBe(true);
372
+ const afterThread = await t.query(api.threads.getThread, {
373
+ threadId: thread._id as Id<"threads">,
374
+ });
375
+ expect(afterThread).toBeNull();
376
+ });
377
+
378
+ test("deletePageForThreadId with custom limit works", async () => {
379
+ const t = convexTest(schema, modules);
380
+
381
+ // Create a thread with messages
382
+ const thread = await t.mutation(api.threads.createThread, {
383
+ userId: "limitUser",
384
+ title: "Limit Test Thread",
385
+ });
386
+
387
+ // Add messages
388
+ const messages = [];
389
+ for (let i = 0; i < 50; i++) {
390
+ messages.push({
391
+ message: { role: "user" as const, content: `Message ${i}` },
392
+ });
393
+ }
394
+ await t.mutation(api.messages.addMessages, {
395
+ threadId: thread._id as Id<"threads">,
396
+ messages,
397
+ });
398
+
399
+ // Delete with custom limit
400
+ const result = await t.mutation(internal.threads._deletePageForThreadId, {
401
+ threadId: thread._id as Id<"threads">,
402
+ limit: 25,
403
+ });
404
+
405
+ // Should still have messages left with this limit
406
+ expect(result.isDone).toBe(false);
407
+ expect(result.cursor).toBeTruthy();
408
+ });
409
+
410
+ test("deleteAllForThreadIdAsync with multiple scheduled iterations", async () => {
411
+ // Enable fake timers for scheduled function testing
412
+ vi.useFakeTimers();
413
+
414
+ const t = convexTest(schema, modules);
415
+
416
+ // Create a thread with many messages to force multiple scheduling iterations
417
+ const thread = await t.mutation(api.threads.createThread, {
418
+ userId: "multiIterDeleteUser",
419
+ title: "Multi Iter Delete Thread",
420
+ });
421
+
422
+ // Add many messages to force pagination and multiple scheduled iterations
423
+ const messages = [];
424
+ for (let i = 0; i < 300; i++) {
425
+ messages.push({
426
+ message: { role: "user" as const, content: `Message ${i}` },
427
+ });
428
+ }
429
+ await t.mutation(api.messages.addMessages, {
430
+ threadId: thread._id as Id<"threads">,
431
+ messages,
432
+ });
433
+
434
+ // Start async deletion
435
+ const result = await t.mutation(api.threads.deleteAllForThreadIdAsync, {
436
+ threadId: thread._id as Id<"threads">,
437
+ });
438
+
439
+ // Should not be done immediately with this many messages
440
+ expect(result.isDone).toBe(false);
441
+
442
+ // Use finishAllScheduledFunctions to handle recursive scheduling
443
+ await t.finishAllScheduledFunctions(vi.runAllTimers);
444
+
445
+ // Verify thread is deleted
446
+ const afterThread = await t.query(api.threads.getThread, {
447
+ threadId: thread._id as Id<"threads">,
448
+ });
449
+ expect(afterThread).toBeNull();
450
+
451
+ // Reset to normal timers
452
+ vi.useRealTimers();
453
+ });
454
+
455
+ test("deleteAllForThreadIdSync handles thread with no messages", async () => {
456
+ const t = convexTest(schema, modules);
457
+
458
+ // Create a thread with no messages
459
+ const thread = await t.mutation(api.threads.createThread, {
460
+ userId: "emptyUser",
461
+ title: "Empty Thread",
462
+ });
463
+
464
+ // Delete should work without errors
465
+ await expect(
466
+ t.action(api.threads.deleteAllForThreadIdSync, {
467
+ threadId: thread._id as Id<"threads">,
468
+ })
469
+ ).resolves.not.toThrow();
470
+
471
+ // Thread should be deleted
472
+ const afterThread = await t.query(api.threads.getThread, {
473
+ threadId: thread._id as Id<"threads">,
474
+ });
475
+ expect(afterThread).toBeNull();
476
+ });
477
+
478
+ test("deleteAllForThreadIdSync handles non-existent thread", async () => {
479
+ const t = convexTest(schema, modules);
480
+
481
+ // Create a thread, then delete it to get a valid but non-existent ID
482
+ const thread = await t.mutation(api.threads.createThread, {
483
+ userId: "tempUser",
484
+ title: "Temp Thread",
485
+ });
486
+
487
+ // Delete the thread so it no longer exists
488
+ await t.action(api.threads.deleteAllForThreadIdSync, {
489
+ threadId: thread._id as Id<"threads">,
490
+ });
491
+
492
+ // Try to delete the already-deleted thread - should not throw
493
+ await expect(
494
+ t.action(api.threads.deleteAllForThreadIdSync, {
495
+ threadId: thread._id as Id<"threads">,
496
+ })
497
+ ).resolves.not.toThrow();
498
+ });
499
+
500
+ test("thread creation sets status to active by default", async () => {
501
+ const t = convexTest(schema, modules);
502
+
503
+ const thread = await t.mutation(api.threads.createThread, {
504
+ userId: "statusUser",
505
+ title: "Status Test Thread",
506
+ });
507
+
508
+ expect(thread.status).toBe("active");
509
+ });
510
+
511
+ test("multiple threads for same user work correctly", async () => {
512
+ const t = convexTest(schema, modules);
513
+
514
+ const threads = [];
515
+ for (let i = 0; i < 3; i++) {
516
+ const thread = await t.mutation(api.threads.createThread, {
517
+ userId: "multiThreadUser",
518
+ title: `Thread ${i + 1}`,
519
+ });
520
+ threads.push(thread);
521
+ }
522
+
523
+ const result = await t.query(api.threads.listThreadsByUserId, {
524
+ userId: "multiThreadUser",
525
+ });
526
+
527
+ expect(result.page).toHaveLength(3);
528
+ expect(result.page.map((t) => t.title)).toContain("Thread 1");
529
+ expect(result.page.map((t) => t.title)).toContain("Thread 2");
530
+ expect(result.page.map((t) => t.title)).toContain("Thread 3");
531
+ });
532
+
533
+ test("threads with different users are isolated", async () => {
534
+ const t = convexTest(schema, modules);
535
+
536
+ const thread1 = await t.mutation(api.threads.createThread, {
537
+ userId: "user1",
538
+ title: "User 1 Thread",
539
+ });
540
+
541
+ const thread2 = await t.mutation(api.threads.createThread, {
542
+ userId: "user2",
543
+ title: "User 2 Thread",
544
+ });
545
+
546
+ const user1Threads = await t.query(api.threads.listThreadsByUserId, {
547
+ userId: "user1",
548
+ });
549
+
550
+ const user2Threads = await t.query(api.threads.listThreadsByUserId, {
551
+ userId: "user2",
552
+ });
553
+
554
+ expect(user1Threads.page).toHaveLength(1);
555
+ expect(user1Threads.page[0]._id).toBe(thread1._id);
556
+
557
+ expect(user2Threads.page).toHaveLength(1);
558
+ expect(user2Threads.page[0]._id).toBe(thread2._id);
559
+ });
560
+ });
@@ -159,9 +159,11 @@ export const deleteAllForThreadIdAsync = mutation({
159
159
  });
160
160
  } else {
161
161
  // Kick off the streams deletion
162
- await ctx.scheduler.runAfter(0, api.threads.deleteAllForThreadIdSync, {
163
- threadId: args.threadId,
164
- });
162
+ await ctx.scheduler.runAfter(
163
+ 0,
164
+ api.streams.deleteAllStreamsForThreadIdSync,
165
+ { threadId: args.threadId }
166
+ );
165
167
  }
166
168
  return result;
167
169
  },
@@ -183,7 +185,10 @@ async function deletePageForThreadIdHandler(
183
185
  });
184
186
  await Promise.all(messages.page.map((m) => deleteMessage(ctx, m)));
185
187
  if (messages.isDone) {
186
- await ctx.db.delete(args.threadId);
188
+ const thread = await ctx.db.get(args.threadId);
189
+ if (thread) {
190
+ await ctx.db.delete(args.threadId);
191
+ }
187
192
  }
188
193
  return {
189
194
  cursor: messages.continueCursor,