@convex-dev/agent
Version:
A agent component for Convex.
567 lines (462 loc) • 17.7 kB
text/typescript
/// <reference types="vite/client" />
import { convexTest } from "convex-test";
import { describe, expect, test, vi } from "vitest";
import { api, internal } from "./_generated/api.js";
import type { Id } from "./_generated/dataModel.js";
import schema from "./schema.js";
import { modules } from "./setup.test.js";
describe("threads", () => {
test("createThread creates a thread with correct data", async () => {
const t = convexTest(schema, modules);
const thread = await t.mutation(api.threads.createThread, {
userId: "testUser",
title: "Test Thread",
summary: "A test thread",
});
expect(thread.userId).toBe("testUser");
expect(thread.title).toBe("Test Thread");
expect(thread.summary).toBe("A test thread");
expect(thread.status).toBe("active");
expect(thread._id).toBeTruthy();
expect(thread._creationTime).toBeTruthy();
});
test("getThread returns thread data", async () => {
const t = convexTest(schema, modules);
const created = await t.mutation(api.threads.createThread, {
userId: "getUser",
title: "Get Thread",
});
const fetched = await t.query(api.threads.getThread, {
threadId: created._id as Id<"threads">,
});
expect(fetched).not.toBeNull();
expect(fetched!._id).toBe(created._id);
expect(fetched!.userId).toBe("getUser");
expect(fetched!.title).toBe("Get Thread");
});
test("getThread returns null for non-existent thread", async () => {
const t = convexTest(schema, modules);
// Create a thread, then delete it to get a valid but non-existent ID
const thread = await t.mutation(api.threads.createThread, {
userId: "tempUser",
title: "Temp Thread",
});
// Delete the thread so it no longer exists
await t.action(api.threads.deleteAllForThreadIdSync, {
threadId: thread._id as Id<"threads">,
});
// Now test with the deleted thread's ID
const result = await t.query(api.threads.getThread, {
threadId: thread._id as Id<"threads">,
});
expect(result).toBeNull();
});
test("listThreadsByUserId returns threads for user", async () => {
const t = convexTest(schema, modules);
// Create threads for different users
const thread1 = await t.mutation(api.threads.createThread, {
userId: "listUser",
title: "Thread 1",
});
const thread2 = await t.mutation(api.threads.createThread, {
userId: "listUser",
title: "Thread 2",
});
const thread3 = await t.mutation(api.threads.createThread, {
userId: "otherUser",
title: "Other Thread",
});
const result = await t.query(api.threads.listThreadsByUserId, {
userId: "listUser",
});
expect(result.page).toHaveLength(2);
expect(result.page.map((t) => t._id)).toContain(thread1._id);
expect(result.page.map((t) => t._id)).toContain(thread2._id);
expect(result.page.map((t) => t._id)).not.toContain(thread3._id);
});
test("listThreadsByUserId pagination works", async () => {
const t = convexTest(schema, modules);
// Create multiple threads
const threads = [];
for (let i = 0; i < 5; i++) {
const thread = await t.mutation(api.threads.createThread, {
userId: "paginationUser",
title: `Thread ${i}`,
});
threads.push(thread);
}
const firstPage = await t.query(api.threads.listThreadsByUserId, {
userId: "paginationUser",
paginationOpts: { cursor: null, numItems: 2 },
});
expect(firstPage.page).toHaveLength(2);
expect(firstPage.isDone).toBe(false);
const secondPage = await t.query(api.threads.listThreadsByUserId, {
userId: "paginationUser",
paginationOpts: { cursor: firstPage.continueCursor, numItems: 2 },
});
expect(secondPage.page).toHaveLength(2);
// Should not have duplicate threads
const firstPageIds = firstPage.page.map((t) => t._id);
const secondPageIds = secondPage.page.map((t) => t._id);
expect(firstPageIds.every((id) => !secondPageIds.includes(id))).toBe(true);
});
test("listThreadsByUserId ordering works", async () => {
const t = convexTest(schema, modules);
// Create threads with slight delays to ensure different creation times
const thread1 = await t.mutation(api.threads.createThread, {
userId: "orderUser",
title: "First Thread",
});
// Small delay to ensure different creation times
await new Promise((resolve) => setTimeout(resolve, 1));
const thread2 = await t.mutation(api.threads.createThread, {
userId: "orderUser",
title: "Second Thread",
});
// Test descending order (default)
const descResult = await t.query(api.threads.listThreadsByUserId, {
userId: "orderUser",
order: "desc",
});
expect(descResult.page[0]._id).toBe(thread2._id);
expect(descResult.page[1]._id).toBe(thread1._id);
// Test ascending order
const ascResult = await t.query(api.threads.listThreadsByUserId, {
userId: "orderUser",
order: "asc",
});
expect(ascResult.page[0]._id).toBe(thread1._id);
expect(ascResult.page[1]._id).toBe(thread2._id);
});
test("updateThread updates thread fields", async () => {
const t = convexTest(schema, modules);
const thread = await t.mutation(api.threads.createThread, {
userId: "updateUser",
title: "Original Title",
summary: "Original Summary",
});
const updated = await t.mutation(api.threads.updateThread, {
threadId: thread._id as Id<"threads">,
patch: {
title: "Updated Title",
summary: "Updated Summary",
status: "archived",
},
});
expect(updated.title).toBe("Updated Title");
expect(updated.summary).toBe("Updated Summary");
expect(updated.status).toBe("archived");
expect(updated._id).toBe(thread._id);
});
test("updateThread with partial patch works", async () => {
const t = convexTest(schema, modules);
const thread = await t.mutation(api.threads.createThread, {
userId: "partialUser",
title: "Original Title",
summary: "Original Summary",
});
const updated = await t.mutation(api.threads.updateThread, {
threadId: thread._id as Id<"threads">,
patch: {
title: "New Title Only",
},
});
expect(updated.title).toBe("New Title Only");
expect(updated.summary).toBe("Original Summary"); // Unchanged
expect(updated.status).toBe("active"); // Unchanged
});
test("updateThread throws error for non-existent thread", async () => {
const t = convexTest(schema, modules);
// Create a thread, then delete it to get a valid but non-existent ID
const thread = await t.mutation(api.threads.createThread, {
userId: "tempUser",
title: "Temp Thread",
});
// Delete the thread so it no longer exists
await t.action(api.threads.deleteAllForThreadIdSync, {
threadId: thread._id as Id<"threads">,
});
// Now test updating the deleted thread
await expect(
t.mutation(api.threads.updateThread, {
threadId: thread._id as Id<"threads">,
patch: { title: "New Title" },
}),
).rejects.toThrow();
});
test("deleteAllForThreadIdSync deletes thread and all related data", async () => {
const t = convexTest(schema, modules);
// Create a thread with messages
const thread = await t.mutation(api.threads.createThread, {
userId: "deleteUser",
title: "Thread to Delete",
});
// Add messages to the thread
await t.mutation(api.messages.addMessages, {
threadId: thread._id as Id<"threads">,
messages: [
{ message: { role: "user" as const, content: "Hello" } },
{ message: { role: "assistant" as const, content: "Hi there" } },
{ message: { role: "user" as const, content: "How are you?" } },
],
});
// Verify data exists before deletion
const beforeMessages = await t.query(api.messages.listMessagesByThreadId, {
threadId: thread._id as Id<"threads">,
order: "desc",
paginationOpts: { cursor: null, numItems: 10 },
});
expect(beforeMessages.page).toHaveLength(3);
const beforeThread = await t.query(api.threads.getThread, {
threadId: thread._id as Id<"threads">,
});
expect(beforeThread).not.toBeNull();
// Delete the thread synchronously
await t.action(api.threads.deleteAllForThreadIdSync, {
threadId: thread._id as Id<"threads">,
});
// Verify thread is deleted
const afterThread = await t.query(api.threads.getThread, {
threadId: thread._id as Id<"threads">,
});
expect(afterThread).toBeNull();
// Verify messages are deleted (this will return empty page since thread is gone)
const afterMessages = await t.query(api.messages.listMessagesByThreadId, {
threadId: thread._id as Id<"threads">,
order: "desc",
paginationOpts: { cursor: null, numItems: 10 },
});
expect(afterMessages.page).toHaveLength(0);
});
test("deleteAllForThreadIdAsync deletes thread asynchronously", async () => {
// Enable fake timers for scheduled function testing
vi.useFakeTimers();
const t = convexTest(schema, modules);
// Create a thread with messages
const thread = await t.mutation(api.threads.createThread, {
userId: "asyncDeleteUser",
title: "Async Thread to Delete",
});
await t.mutation(api.messages.addMessages, {
threadId: thread._id as Id<"threads">,
messages: Array.from({ length: 100 }, (_, i) => ({
message: { role: "user" as const, content: `Message ${i}` },
})),
});
// Start async deletion
const result = await t.mutation(api.threads.deleteAllForThreadIdAsync, {
threadId: thread._id as Id<"threads">,
});
// If there's more work to do, advance timers and wait for scheduled functions
if (!result.isDone) {
// Run all pending timers to trigger scheduled functions
vi.runAllTimers();
// Wait for all scheduled functions to complete
await t.finishInProgressScheduledFunctions();
}
// Verify thread is deleted
const afterThread = await t.query(api.threads.getThread, {
threadId: thread._id as Id<"threads">,
});
expect(afterThread).toBeNull();
// Verify streams are deleted
const afterStreams = await t.query(api.streams.list, {
threadId: thread._id as Id<"threads">,
});
expect(afterStreams).toHaveLength(0);
// Reset to normal timers
vi.useRealTimers();
});
test("deletePageForThreadId handles pagination correctly", async () => {
const t = convexTest(schema, modules);
// Create a thread with many messages to test pagination
const thread = await t.mutation(api.threads.createThread, {
userId: "paginationDeleteUser",
title: "Pagination Delete Thread",
});
// Add many messages to force pagination
const messages = [];
for (let i = 0; i < 150; i++) {
messages.push({
message: { role: "user" as const, content: `Message ${i}` },
});
}
await t.mutation(api.messages.addMessages, {
threadId: thread._id as Id<"threads">,
messages,
});
// Delete first page
const result1 = await t.mutation(internal.threads._deletePageForThreadId, {
threadId: thread._id as Id<"threads">,
});
expect(result1.isDone).toBe(false);
expect(result1.cursor).toBeTruthy();
// Continue deleting pages until done
let currentResult = result1;
let iterations = 0;
while (!currentResult.isDone && iterations < 10) {
currentResult = await t.mutation(
internal.threads._deletePageForThreadId,
{
threadId: thread._id as Id<"threads">,
cursor: currentResult.cursor,
},
);
iterations++;
}
// Thread should be deleted when done
expect(currentResult.isDone).toBe(true);
const afterThread = await t.query(api.threads.getThread, {
threadId: thread._id as Id<"threads">,
});
expect(afterThread).toBeNull();
});
test("deletePageForThreadId with custom limit works", async () => {
const t = convexTest(schema, modules);
// Create a thread with messages
const thread = await t.mutation(api.threads.createThread, {
userId: "limitUser",
title: "Limit Test Thread",
});
// Add messages
const messages = [];
for (let i = 0; i < 50; i++) {
messages.push({
message: { role: "user" as const, content: `Message ${i}` },
});
}
await t.mutation(api.messages.addMessages, {
threadId: thread._id as Id<"threads">,
messages,
});
// Delete with custom limit
const result = await t.mutation(internal.threads._deletePageForThreadId, {
threadId: thread._id as Id<"threads">,
limit: 25,
});
// Should still have messages left with this limit
expect(result.isDone).toBe(false);
expect(result.cursor).toBeTruthy();
});
test("deleteAllForThreadIdAsync with multiple scheduled iterations", async () => {
// Enable fake timers for scheduled function testing
vi.useFakeTimers();
const t = convexTest(schema, modules);
// Create a thread with many messages to force multiple scheduling iterations
const thread = await t.mutation(api.threads.createThread, {
userId: "multiIterDeleteUser",
title: "Multi Iter Delete Thread",
});
// Add many messages to force pagination and multiple scheduled iterations
const messages = [];
for (let i = 0; i < 300; i++) {
messages.push({
message: { role: "user" as const, content: `Message ${i}` },
});
}
await t.mutation(api.messages.addMessages, {
threadId: thread._id as Id<"threads">,
messages,
});
// Start async deletion
const result = await t.mutation(api.threads.deleteAllForThreadIdAsync, {
threadId: thread._id as Id<"threads">,
});
// Should not be done immediately with this many messages
expect(result.isDone).toBe(false);
// Use finishAllScheduledFunctions to handle recursive scheduling
await t.finishAllScheduledFunctions(vi.runAllTimers);
// Verify thread is deleted
const afterThread = await t.query(api.threads.getThread, {
threadId: thread._id as Id<"threads">,
});
expect(afterThread).toBeNull();
// Reset to normal timers
vi.useRealTimers();
});
test("deleteAllForThreadIdSync handles thread with no messages", async () => {
const t = convexTest(schema, modules);
// Create a thread with no messages
const thread = await t.mutation(api.threads.createThread, {
userId: "emptyUser",
title: "Empty Thread",
});
// Delete should work without errors
await expect(
t.action(api.threads.deleteAllForThreadIdSync, {
threadId: thread._id as Id<"threads">,
}),
).resolves.not.toThrow();
// Thread should be deleted
const afterThread = await t.query(api.threads.getThread, {
threadId: thread._id as Id<"threads">,
});
expect(afterThread).toBeNull();
});
test("deleteAllForThreadIdSync handles non-existent thread", async () => {
const t = convexTest(schema, modules);
// Create a thread, then delete it to get a valid but non-existent ID
const thread = await t.mutation(api.threads.createThread, {
userId: "tempUser",
title: "Temp Thread",
});
// Delete the thread so it no longer exists
await t.action(api.threads.deleteAllForThreadIdSync, {
threadId: thread._id as Id<"threads">,
});
// Try to delete the already-deleted thread - should not throw
await expect(
t.action(api.threads.deleteAllForThreadIdSync, {
threadId: thread._id as Id<"threads">,
}),
).resolves.not.toThrow();
});
test("thread creation sets status to active by default", async () => {
const t = convexTest(schema, modules);
const thread = await t.mutation(api.threads.createThread, {
userId: "statusUser",
title: "Status Test Thread",
});
expect(thread.status).toBe("active");
});
test("multiple threads for same user work correctly", async () => {
const t = convexTest(schema, modules);
const threads = [];
for (let i = 0; i < 3; i++) {
const thread = await t.mutation(api.threads.createThread, {
userId: "multiThreadUser",
title: `Thread ${i + 1}`,
});
threads.push(thread);
}
const result = await t.query(api.threads.listThreadsByUserId, {
userId: "multiThreadUser",
});
expect(result.page).toHaveLength(3);
expect(result.page.map((t) => t.title)).toContain("Thread 1");
expect(result.page.map((t) => t.title)).toContain("Thread 2");
expect(result.page.map((t) => t.title)).toContain("Thread 3");
});
test("threads with different users are isolated", async () => {
const t = convexTest(schema, modules);
const thread1 = await t.mutation(api.threads.createThread, {
userId: "user1",
title: "User 1 Thread",
});
const thread2 = await t.mutation(api.threads.createThread, {
userId: "user2",
title: "User 2 Thread",
});
const user1Threads = await t.query(api.threads.listThreadsByUserId, {
userId: "user1",
});
const user2Threads = await t.query(api.threads.listThreadsByUserId, {
userId: "user2",
});
expect(user1Threads.page).toHaveLength(1);
expect(user1Threads.page[0]._id).toBe(thread1._id);
expect(user2Threads.page).toHaveLength(1);
expect(user2Threads.page[0]._id).toBe(thread2._id);
});
});