import { beforeEach, describe, expect, it, vi } from "vitest"; import { ORPCError } from "@orpc/client"; const dbMock = { select: vi.fn(), insert: vi.fn(), update: vi.fn(), delete: vi.fn(), transaction: vi.fn(async (callback: (tx: typeof dbMock) => Promise) => callback(dbMock)), }; const clearActiveAgentRunIfCurrentMock = vi.fn(); const claimActiveAgentRunMock = vi.fn(); const storageServiceMock = { delete: vi.fn(), write: vi.fn(), read: vi.fn(), }; const resumeServiceMock = { getById: vi.fn(), patch: vi.fn(), patchInTransaction: vi.fn(), notifyResumePatched: vi.fn(), }; const aiProvidersServiceMock = { getRunnableById: vi.fn(), getDefaultRunnable: vi.fn(), markUsed: vi.fn(), }; vi.mock("@reactive-resume/db/client", () => ({ db: dbMock })); vi.mock("@reactive-resume/db/schema", () => ({ agentThread: { id: "agent_threads.id", userId: "agent_threads.user_id", deletedAt: "agent_threads.deleted_at", archivedAt: "agent_threads.archived_at", status: "agent_threads.status", activeRunId: "agent_threads.active_run_id", activeStreamId: "agent_threads.active_stream_id", activeRunStartedAt: "agent_threads.active_run_started_at", aiProviderId: "agent_threads.ai_provider_id", workingResumeId: "agent_threads.working_resume_id", sourceResumeId: "agent_threads.source_resume_id", title: "agent_threads.title", lastMessageAt: "agent_threads.last_message_at", createdAt: "agent_threads.created_at", updatedAt: "agent_threads.updated_at", }, agentMessage: { id: "agent_messages.id", threadId: "agent_messages.thread_id", userId: "agent_messages.user_id", role: "agent_messages.role", status: "agent_messages.status", sequence: "agent_messages.sequence", uiMessage: "agent_messages.ui_message", }, agentAction: { id: "agent_actions.id", threadId: "agent_actions.thread_id", userId: "agent_actions.user_id", resumeId: "agent_actions.resume_id", kind: "agent_actions.kind", status: "agent_actions.status", appliedUpdatedAt: "agent_actions.applied_updated_at", createdAt: "agent_actions.created_at", }, agentAttachment: { id: "agent_attachments.id", threadId: "agent_attachments.thread_id", userId: "agent_attachments.user_id", messageId: "agent_attachments.message_id", storageKey: "agent_attachments.storage_key", filename: "agent_attachments.filename", mediaType: "agent_attachments.media_type", size: "agent_attachments.size", createdAt: "agent_attachments.created_at", }, resume: { name: "resume.name", id: "resume.id", userId: "resume.user_id", slug: "resume.slug" }, aiProvider: { label: "ai_provider.label", id: "ai_provider.id" }, })); vi.mock("drizzle-orm", () => ({ and: (...conditions: unknown[]) => ({ type: "and", conditions }), asc: (value: unknown) => ({ type: "asc", value }), count: () => ({ type: "count" }), desc: (value: unknown) => ({ type: "desc", value }), eq: (left: unknown, right: unknown) => ({ type: "eq", left, right }), gte: (left: unknown, right: unknown) => ({ type: "gte", left, right }), inArray: (left: unknown, values: unknown[]) => ({ type: "inArray", left, values }), isNull: (value: unknown) => ({ type: "isNull", value }), max: (value: unknown) => ({ type: "max", value }), sql: () => ({ type: "sql" }), })); vi.mock("ai", () => ({ convertToModelMessages: vi.fn(), stepCountIs: vi.fn(), ToolLoopAgent: vi.fn(), })); vi.mock("../ai/service", () => ({ getAgentModel: vi.fn() })); vi.mock("../ai/credentials", () => ({ assertAgentEnvironment: vi.fn() })); vi.mock("../ai-providers/service", () => ({ aiProvidersService: aiProvidersServiceMock })); vi.mock("../resume/service", () => ({ resumeService: resumeServiceMock })); vi.mock("../storage/service", () => ({ getStorageService: vi.fn(() => storageServiceMock), inferContentType: vi.fn(), })); vi.mock("./resume", () => ({ buildAgentDraftResumeName: vi.fn(), buildUniqueAgentDraftSlug: vi.fn(), })); vi.mock("./runs", () => ({ claimActiveAgentRun: claimActiveAgentRunMock, clearActiveAgentRunIfCurrent: clearActiveAgentRunIfCurrentMock, })); vi.mock("./streams", () => ({ agentStreamLifecycle: { create: vi.fn(), resume: vi.fn() }, })); vi.mock("./tools", () => ({ buildAgentInstructions: vi.fn(), buildAgentTools: vi.fn(() => ({})), })); vi.mock("@reactive-resume/schema/resume/default", () => ({ defaultResumeData: {} })); vi.mock("@reactive-resume/utils/string", () => ({ generateId: () => "test-id" })); vi.mock("@orpc/server", () => ({ streamToEventIterator: vi.fn() })); beforeEach(() => { for (const mock of Object.values(dbMock)) mock.mockReset(); dbMock.transaction.mockImplementation(async (callback: (tx: typeof dbMock) => Promise) => callback(dbMock)); clearActiveAgentRunIfCurrentMock.mockReset(); claimActiveAgentRunMock.mockReset(); for (const mock of Object.values(storageServiceMock)) mock.mockReset(); for (const mock of Object.values(resumeServiceMock)) mock.mockReset(); for (const mock of Object.values(aiProvidersServiceMock)) mock.mockReset(); }); function buildArchivedThread(overrides: Record = {}) { return { id: "thread-1", userId: "user-1", aiProviderId: "provider-1", workingResumeId: "resume-1", sourceResumeId: null, title: "Archived thread", status: "archived", activeRunId: null, activeStreamId: null, activeRunStartedAt: null, lastMessageAt: new Date("2026-05-01T00:00:00.000Z"), archivedAt: new Date("2026-05-02T00:00:00.000Z"), deletedAt: null, createdAt: new Date("2026-04-01T00:00:00.000Z"), updatedAt: new Date("2026-05-02T00:00:00.000Z"), ...overrides, }; } function buildActiveThread(overrides: Record = {}) { return buildArchivedThread({ status: "active", title: "Active thread", activeRunId: null, activeStreamId: null, archivedAt: null, ...overrides, }); } function buildAttachment(overrides: Record = {}) { return { id: "attachment-1", userId: "user-1", threadId: "thread-1", messageId: null, storageKey: "uploads/user-1/agent/thread-1/attachment-1-note.txt", filename: "note.txt", mediaType: "text/plain", size: 5, createdAt: new Date("2026-05-01T00:00:00.000Z"), ...overrides, }; } function selectLimitResult(rows: unknown[]) { const limit = vi.fn(async () => rows); const where = vi.fn(() => ({ limit })); const from = vi.fn(() => ({ where })); return { from }; } function selectWhereResult(rows: unknown[]) { const where = vi.fn(async () => rows); const from = vi.fn(() => ({ where })); return { from }; } function selectOrderByResult(rows: unknown[]) { const orderBy = vi.fn(async () => rows); const where = vi.fn(() => ({ orderBy })); const from = vi.fn(() => ({ where })); return { from }; } function selectWhereOrderByLimitResult(rows: unknown[]) { const limit = vi.fn(async () => rows); const orderBy = vi.fn(() => ({ limit })); const where = vi.fn(() => ({ orderBy })); const from = vi.fn(() => ({ where })); return { from }; } describe("agentService.threads.get", () => { beforeEach(() => { vi.clearAllMocks(); }); it("returns isReadOnly: true when the thread is archived, even if the resume and provider are present", async () => { const archivedThread = buildArchivedThread(); // First select call is `getThread` (limit), then three select calls for messages/actions/attachments (orderBy). const threadSelect = () => { const limit = vi.fn(async () => [archivedThread]); const where = vi.fn(() => ({ limit })); const from = vi.fn(() => ({ where })); return { from }; }; const emptyListSelect = () => { const orderBy = vi.fn(async () => []); const where = vi.fn(() => ({ orderBy })); const from = vi.fn(() => ({ where })); return { from }; }; dbMock.select .mockImplementationOnce(threadSelect) .mockImplementationOnce(emptyListSelect) .mockImplementationOnce(emptyListSelect) .mockImplementationOnce(emptyListSelect); resumeServiceMock.getById.mockResolvedValue({ id: "resume-1", name: "Resume", data: {}, updatedAt: new Date(), }); const { agentService } = await import("./service"); const result = await agentService.threads.get({ id: "thread-1", userId: "user-1" }); expect(result.isReadOnly).toBe(true); expect(result.thread.status).toBe("archived"); expect(result.resume).toEqual(expect.objectContaining({ id: "resume-1" })); }); }); describe("buildAttachmentModelParts", () => { beforeEach(() => { vi.clearAllMocks(); }); it("converts readable, image, supported binary, and unsupported attachments into model parts", async () => { const { buildAttachmentModelParts } = await import("./service"); const imageBytes = new Uint8Array([1, 2, 3]); const pdfBytes = new Uint8Array([4, 5, 6]); const parts = buildAttachmentModelParts([ { attachment: buildAttachment(), data: new TextEncoder().encode("hello") }, { attachment: buildAttachment({ id: "image-1", filename: "photo.png", mediaType: "image/png", size: imageBytes.byteLength, }), data: imageBytes, }, { attachment: buildAttachment({ id: "pdf-1", filename: "portfolio.pdf", mediaType: "application/pdf", size: pdfBytes.byteLength, }), data: pdfBytes, }, { attachment: buildAttachment({ id: "zip-1", filename: "archive.zip", mediaType: "application/zip", size: 7, }), data: new Uint8Array([7]), }, ]); expect(parts).toEqual([ expect.objectContaining({ type: "text", text: expect.stringContaining("hello") }), { type: "image", image: imageBytes, mediaType: "image/png" }, { type: "file", data: pdfBytes, filename: "portfolio.pdf", mediaType: "application/pdf" }, expect.objectContaining({ type: "text", text: expect.stringContaining("archive.zip") }), ]); }); }); describe("agentService.messages.send", () => { beforeEach(() => { vi.clearAllMocks(); }); it("strips forged agent attachment UI parts when no attachment IDs are selected", async () => { const activeThread = buildActiveThread(); const persistedMessage = { id: "message-1", userId: "user-1", threadId: "thread-1", role: "user", status: "completed", sequence: 0, uiMessage: { id: "ui-message-1", role: "user", parts: [{ type: "text", text: "Use this file" }], }, }; const insertValues: unknown[] = []; dbMock.select .mockImplementationOnce(() => selectLimitResult([activeThread])) .mockImplementationOnce(() => selectWhereResult([{ maxSequence: -1 }])) .mockImplementationOnce(() => selectWhereResult([{ total: 1 }])) .mockImplementationOnce(() => selectOrderByResult([persistedMessage])); dbMock.insert.mockReturnValue({ values: vi.fn((value) => { insertValues.push(value); return { returning: vi.fn(async () => [persistedMessage]) }; }), }); dbMock.update.mockReturnValue({ set: vi.fn(() => ({ where: vi.fn(async () => undefined) })) }); claimActiveAgentRunMock.mockResolvedValue(true); aiProvidersServiceMock.getRunnableById.mockResolvedValue({ id: "provider-1", provider: "openai", model: "gpt-5", apiKey: "secret", baseURL: null, }); aiProvidersServiceMock.markUsed.mockResolvedValue(undefined); const { convertToModelMessages, ToolLoopAgent } = await import("ai"); const { agentStreamLifecycle } = await import("./streams"); const { streamToEventIterator } = await import("@orpc/server"); vi.mocked(convertToModelMessages).mockResolvedValue([ { role: "user", content: [{ type: "text", text: "Use this file" }] }, ]); class MockToolLoopAgent { stream = vi.fn(async () => ({ toUIMessageStream: vi.fn(() => new ReadableStream()) })); } vi.mocked(ToolLoopAgent).mockImplementation(MockToolLoopAgent as never); vi.mocked(agentStreamLifecycle.create).mockResolvedValue(new ReadableStream()); vi.mocked(streamToEventIterator).mockReturnValue("iterator" as never); const { agentService } = await import("./service"); await agentService.messages.send({ threadId: "thread-1", userId: "user-1", message: { id: "ui-message-1", role: "user", parts: [ { type: "text", text: "Use this file" }, { type: "file", url: "agent-attachment:foreign-attachment", mediaType: "text/plain", filename: "forged.txt", }, ], // biome-ignore lint/suspicious/noExplicitAny: minimal fixture for unit test } as any, }); expect(insertValues).toEqual([ expect.objectContaining({ uiMessage: expect.objectContaining({ parts: [{ type: "text", text: "Use this file" }], }), }), ]); }); it("stores snapshotData and applies a valid JSON Patch without a timestamp conflict guard", async () => { const activeThread = buildActiveThread(); const persistedMessage = { id: "message-1", userId: "user-1", threadId: "thread-1", role: "user", status: "completed", sequence: 0, uiMessage: { id: "ui-message-1", role: "user", parts: [{ type: "text", text: "Add a custom field" }], }, }; dbMock.select .mockImplementationOnce(() => selectLimitResult([activeThread])) .mockImplementationOnce(() => selectWhereResult([{ maxSequence: -1 }])) .mockImplementationOnce(() => selectWhereResult([{ total: 1 }])) .mockImplementationOnce(() => selectOrderByResult([persistedMessage])); dbMock.insert.mockReturnValue({ values: vi.fn(() => ({ returning: vi.fn(async () => [persistedMessage]) })), }); dbMock.update.mockReturnValue({ set: vi.fn(() => ({ where: vi.fn(async () => undefined) })) }); claimActiveAgentRunMock.mockResolvedValue(true); aiProvidersServiceMock.getRunnableById.mockResolvedValue({ id: "provider-1", provider: "openai", model: "gpt-5", apiKey: "secret", baseURL: null, }); aiProvidersServiceMock.markUsed.mockResolvedValue(undefined); const { convertToModelMessages, ToolLoopAgent } = await import("ai"); const { agentStreamLifecycle } = await import("./streams"); const { buildAgentTools } = await import("./tools"); const { streamToEventIterator } = await import("@orpc/server"); vi.mocked(convertToModelMessages).mockResolvedValue([ { role: "user", content: [{ type: "text", text: "Add a custom field" }] }, ]); class MockToolLoopAgent { stream = vi.fn(async () => ({ toUIMessageStream: vi.fn(() => new ReadableStream()) })); } vi.mocked(ToolLoopAgent).mockImplementation(MockToolLoopAgent as never); vi.mocked(agentStreamLifecycle.create).mockResolvedValue(new ReadableStream()); vi.mocked(streamToEventIterator).mockReturnValue("iterator" as never); const { agentService } = await import("./service"); await agentService.messages.send({ threadId: "thread-1", userId: "user-1", message: { id: "ui-message-1", role: "user", parts: [{ type: "text", text: "Add a custom field" }], // biome-ignore lint/suspicious/noExplicitAny: minimal fixture for unit test } as any, }); const beforeData = { basics: { customFields: [] } }; const beforeUpdatedAt = new Date("2026-05-01T00:00:00.000Z"); const patchedUpdatedAt = new Date("2026-05-02T00:00:00.000Z"); const operations = [ { op: "add", path: "/basics/customFields/-", value: { id: "field-1", icon: "phosphor", text: "x", link: "" } }, ]; const insertValues: unknown[] = []; resumeServiceMock.getById.mockResolvedValue({ data: beforeData, updatedAt: beforeUpdatedAt }); resumeServiceMock.patchInTransaction.mockResolvedValue({ id: "resume-1", updatedAt: patchedUpdatedAt }); dbMock.insert.mockReturnValue({ values: vi.fn((value) => { insertValues.push(value); return { returning: vi.fn(async () => [ { id: "action-1", ...value, messageId: null, revertedAt: null, revertMessage: null, createdAt: patchedUpdatedAt, updatedAt: patchedUpdatedAt, }, ]), }; }), }); const toolConfig = vi.mocked(buildAgentTools).mock.calls.at(-1)?.[0]; // biome-ignore lint/suspicious/noExplicitAny: captured mocked tool config has intentionally loose handler types const result = await (toolConfig as any).handlers.applyResumePatch({ title: "Append field", operations }); expect(resumeServiceMock.patchInTransaction).toHaveBeenCalledWith(dbMock, { id: "resume-1", userId: "user-1", operations, }); expect(insertValues).toContainEqual( expect.objectContaining({ operations, snapshotData: beforeData, baseUpdatedAt: beforeUpdatedAt, appliedUpdatedAt: patchedUpdatedAt, }), ); expect(result).toEqual(expect.objectContaining({ actionId: "action-1", resumeId: "resume-1" })); }); it("persists canonical attachment UI parts, links selected attachments, and appends server-read model parts", async () => { const activeThread = buildActiveThread(); const attachment = buildAttachment({ filename: "canonical.txt", mediaType: "text/plain", size: 5, }); const persistedMessage = { id: "message-1", userId: "user-1", threadId: "thread-1", role: "user", status: "completed", sequence: 0, uiMessage: { id: "ui-message-1", role: "user", parts: [ { type: "text", text: "Use this file" }, { type: "file", url: "agent-attachment:attachment-1", mediaType: "text/plain", filename: "canonical.txt", }, ], }, }; const insertValues: unknown[] = []; const updateSets: unknown[] = []; dbMock.select .mockImplementationOnce(() => selectLimitResult([activeThread])) .mockImplementationOnce(() => selectWhereResult([attachment])) .mockImplementationOnce(() => selectWhereResult([{ maxSequence: -1 }])) .mockImplementationOnce(() => selectWhereResult([{ total: 1 }])) .mockImplementationOnce(() => selectOrderByResult([persistedMessage])); dbMock.insert.mockReturnValue({ values: vi.fn((value) => { insertValues.push(value); return { returning: vi.fn(async () => [persistedMessage]) }; }), }); dbMock.update.mockImplementation(() => ({ set: vi.fn((value) => { updateSets.push(value); return { where: vi.fn(() => ({ returning: vi.fn(async () => [{ id: "attachment-1" }]), })), }; }), })); claimActiveAgentRunMock.mockResolvedValue(true); aiProvidersServiceMock.getRunnableById.mockResolvedValue({ id: "provider-1", provider: "openai", model: "gpt-5", apiKey: "secret", baseURL: null, }); aiProvidersServiceMock.markUsed.mockResolvedValue(undefined); storageServiceMock.read.mockResolvedValue({ data: new TextEncoder().encode("hello"), contentType: "text/plain" }); const { convertToModelMessages, ToolLoopAgent } = await import("ai"); const { agentStreamLifecycle } = await import("./streams"); const { streamToEventIterator } = await import("@orpc/server"); const streamMock = vi.fn(async () => ({ toUIMessageStream: vi.fn(() => new ReadableStream()), })); vi.mocked(convertToModelMessages).mockResolvedValue([ { role: "user", content: [{ type: "text", text: "Use this file" }] }, ]); class MockToolLoopAgent { stream = streamMock; } vi.mocked(ToolLoopAgent).mockImplementation(MockToolLoopAgent as never); vi.mocked(agentStreamLifecycle.create).mockResolvedValue(new ReadableStream()); vi.mocked(streamToEventIterator).mockReturnValue("iterator" as never); const { agentService } = await import("./service"); await agentService.messages.send({ threadId: "thread-1", userId: "user-1", message: { id: "ui-message-1", role: "user", parts: [ { type: "text", text: "Use this file" }, { type: "file", url: "agent-attachment:attachment-1", mediaType: "application/octet-stream", filename: "forged-name.bin", }, ], // biome-ignore lint/suspicious/noExplicitAny: minimal fixture for unit test } as any, attachmentIds: ["attachment-1"], }); expect(insertValues).toEqual([ expect.objectContaining({ uiMessage: expect.objectContaining({ parts: [ { type: "text", text: "Use this file" }, { type: "file", url: "agent-attachment:attachment-1", mediaType: "text/plain", filename: "canonical.txt", }, ], }), }), ]); expect(updateSets).toContainEqual({ messageId: "message-1" }); expect(streamMock).toHaveBeenCalledWith( expect.objectContaining({ messages: [ { role: "user", content: [ { type: "text", text: "Use this file" }, expect.objectContaining({ type: "text", text: expect.stringContaining("hello") }), ], }, ], }), ); }); it("merges an answered ask-user-question tool result into the existing assistant message", async () => { const activeThread = buildActiveThread(); const userMessage = { id: "message-user-1", userId: "user-1", threadId: "thread-1", role: "user", status: "completed", sequence: 0, uiMessage: { id: "ui-user-1", role: "user", parts: [{ type: "text", text: "Change the name" }], }, }; const unansweredAssistantMessage = { id: "message-assistant-1", userId: "user-1", threadId: "thread-1", role: "assistant", status: "completed", sequence: 1, uiMessage: { id: "ui-assistant-1", role: "assistant", parts: [ { type: "tool-ask_user_question", toolCallId: "call-1", state: "input-available", input: { question: "How broadly should I rename?", choices: ["Only change the main resume header name"], }, }, ], }, }; const answeredAssistantMessage = { ...unansweredAssistantMessage, uiMessage: { ...unansweredAssistantMessage.uiMessage, parts: [ { type: "tool-ask_user_question", toolCallId: "call-1", state: "output-available", input: { question: "How broadly should I rename?", choices: ["Only change the main resume header name"], }, output: "Only change the main resume header name", callProviderMetadata: { openai: { itemId: "fc_duplicate_item" } }, resultProviderMetadata: { openai: { itemId: "fc_duplicate_item" } }, }, ], }, }; const answeredAssistantModelInput = { ...answeredAssistantMessage.uiMessage, parts: [ { type: "tool-ask_user_question", toolCallId: "call-1", state: "output-available", input: { question: "How broadly should I rename?", choices: ["Only change the main resume header name"], }, output: "Only change the main resume header name", }, ], }; const updateSets: unknown[] = []; dbMock.select .mockImplementationOnce(() => selectLimitResult([activeThread])) .mockImplementationOnce(() => selectOrderByResult([userMessage, unansweredAssistantMessage])) .mockImplementationOnce(() => selectOrderByResult([userMessage, answeredAssistantMessage])); dbMock.update.mockImplementation(() => ({ set: vi.fn((value) => { updateSets.push(value); return { where: vi.fn(async () => undefined) }; }), })); claimActiveAgentRunMock.mockResolvedValue(true); aiProvidersServiceMock.getRunnableById.mockResolvedValue({ id: "provider-1", provider: "openai", model: "gpt-5", apiKey: "secret", baseURL: null, }); aiProvidersServiceMock.markUsed.mockResolvedValue(undefined); const { convertToModelMessages, ToolLoopAgent } = await import("ai"); const { agentStreamLifecycle } = await import("./streams"); const { streamToEventIterator } = await import("@orpc/server"); vi.mocked(convertToModelMessages).mockResolvedValue([ { role: "user", content: [{ type: "text", text: "Change the name" }] }, { role: "assistant", content: [{ type: "tool-call", toolCallId: "call-1", toolName: "ask_user_question", input: {} }], }, { role: "tool", content: [ { type: "tool-result", toolCallId: "call-1", toolName: "ask_user_question", output: "Only change the main resume header name", }, ], }, ] as never); class MockToolLoopAgent { stream = vi.fn(async () => ({ toUIMessageStream: vi.fn(() => new ReadableStream()) })); } vi.mocked(ToolLoopAgent).mockImplementation(MockToolLoopAgent as never); vi.mocked(agentStreamLifecycle.create).mockResolvedValue(new ReadableStream()); vi.mocked(streamToEventIterator).mockReturnValue("iterator" as never); const { agentService } = await import("./service"); await agentService.messages.send({ threadId: "thread-1", userId: "user-1", message: { id: "ui-assistant-1", role: "assistant", parts: [ { type: "tool-ask_user_question", toolCallId: "call-1", state: "output-available", output: "Only change the main resume header name", }, ], // biome-ignore lint/suspicious/noExplicitAny: minimal fixture for unit test } as any, }); expect(dbMock.insert).not.toHaveBeenCalled(); expect(updateSets).toContainEqual( expect.objectContaining({ uiMessage: expect.objectContaining({ parts: [ expect.objectContaining({ type: "tool-ask_user_question", toolCallId: "call-1", state: "output-available", output: "Only change the main resume header name", }), ], }), }), ); expect(convertToModelMessages).toHaveBeenCalledWith([userMessage.uiMessage, answeredAssistantModelInput]); }); it("repairs legacy user-answer messages that followed an unresolved ask-user-question tool call", async () => { const activeThread = buildActiveThread(); const firstUserMessage = { id: "message-user-1", userId: "user-1", threadId: "thread-1", role: "user", status: "completed", sequence: 0, uiMessage: { id: "ui-user-1", role: "user", parts: [{ type: "text", text: "Change the name" }], }, }; const unresolvedAssistantMessage = { id: "message-assistant-1", userId: "user-1", threadId: "thread-1", role: "assistant", status: "completed", sequence: 1, uiMessage: { id: "ui-assistant-1", role: "assistant", parts: [ { type: "tool-ask_user_question", toolCallId: "call-legacy", state: "input-available", input: { question: "How broadly should I rename?", choices: ["Only change the main resume header name"], }, }, ], }, }; const legacyAnswerMessage = { id: "message-user-2", userId: "user-1", threadId: "thread-1", role: "user", status: "completed", sequence: 2, uiMessage: { id: "ui-user-2", role: "user", parts: [{ type: "text", text: "Only change the main resume header name" }], }, }; const retryMessage = { id: "message-user-3", userId: "user-1", threadId: "thread-1", role: "user", status: "completed", sequence: 3, uiMessage: { id: "ui-user-3", role: "user", parts: [{ type: "text", text: "Retry" }], }, }; const updateSets: unknown[] = []; dbMock.select .mockImplementationOnce(() => selectLimitResult([activeThread])) .mockImplementationOnce(() => selectWhereResult([{ maxSequence: 2 }])) .mockImplementationOnce(() => selectWhereResult([{ total: 4 }])) .mockImplementationOnce(() => selectOrderByResult([firstUserMessage, unresolvedAssistantMessage, legacyAnswerMessage, retryMessage]), ); dbMock.insert.mockReturnValue({ values: vi.fn(() => ({ returning: vi.fn(async () => [retryMessage]) })), }); dbMock.update.mockImplementation(() => ({ set: vi.fn((value) => { updateSets.push(value); return { where: vi.fn(async () => undefined) }; }), })); claimActiveAgentRunMock.mockResolvedValue(true); aiProvidersServiceMock.getRunnableById.mockResolvedValue({ id: "provider-1", provider: "openai", model: "gpt-5", apiKey: "secret", baseURL: null, }); aiProvidersServiceMock.markUsed.mockResolvedValue(undefined); const { convertToModelMessages, ToolLoopAgent } = await import("ai"); const { agentStreamLifecycle } = await import("./streams"); const { streamToEventIterator } = await import("@orpc/server"); vi.mocked(convertToModelMessages).mockResolvedValue([{ role: "user", content: [{ type: "text", text: "Retry" }] }]); class MockToolLoopAgent { stream = vi.fn(async () => ({ toUIMessageStream: vi.fn(() => new ReadableStream()) })); } vi.mocked(ToolLoopAgent).mockImplementation(MockToolLoopAgent as never); vi.mocked(agentStreamLifecycle.create).mockResolvedValue(new ReadableStream()); vi.mocked(streamToEventIterator).mockReturnValue("iterator" as never); const { agentService } = await import("./service"); await agentService.messages.send({ threadId: "thread-1", userId: "user-1", // biome-ignore lint/suspicious/noExplicitAny: minimal fixture for unit test message: retryMessage.uiMessage as any, }); expect(updateSets).toContainEqual( expect.objectContaining({ uiMessage: expect.objectContaining({ parts: [ expect.objectContaining({ type: "tool-ask_user_question", toolCallId: "call-legacy", state: "output-available", output: "Only change the main resume header name", }), ], }), }), ); expect(convertToModelMessages).toHaveBeenCalledWith([ firstUserMessage.uiMessage, expect.objectContaining({ parts: [ expect.objectContaining({ toolCallId: "call-legacy", state: "output-available", output: "Only change the main resume header name", }), ], }), legacyAnswerMessage.uiMessage, retryMessage.uiMessage, ]); }); it("rejects malformed attachment IDs before persisting a message", async () => { dbMock.select.mockImplementationOnce(() => selectLimitResult([buildActiveThread()])); aiProvidersServiceMock.getRunnableById.mockResolvedValue({ id: "provider-1", provider: "openai", model: "gpt-5", apiKey: "secret", baseURL: null, }); const { agentService } = await import("./service"); const sending = agentService.messages.send({ threadId: "thread-1", userId: "user-1", // biome-ignore lint/suspicious/noExplicitAny: minimal fixture for unit test message: { id: "ui-message-1", role: "user", parts: [{ type: "text", text: "Use this file" }] } as any, attachmentIds: [123], }); await expect(sending).rejects.toBeInstanceOf(ORPCError); await expect(sending).rejects.toMatchObject({ code: "BAD_REQUEST", message: "Attachment IDs must be non-empty strings.", }); expect(dbMock.insert).not.toHaveBeenCalled(); }); it("rejects attachments that are missing, foreign, or already linked before persisting a message", async () => { dbMock.select .mockImplementationOnce(() => selectLimitResult([buildActiveThread()])) .mockImplementationOnce(() => selectWhereResult([])); aiProvidersServiceMock.getRunnableById.mockResolvedValue({ id: "provider-1", provider: "openai", model: "gpt-5", apiKey: "secret", baseURL: null, }); const { agentService } = await import("./service"); const sending = agentService.messages.send({ threadId: "thread-1", userId: "user-1", // biome-ignore lint/suspicious/noExplicitAny: minimal fixture for unit test message: { id: "ui-message-1", role: "user", parts: [{ type: "text", text: "Use this file" }] } as any, attachmentIds: ["foreign-or-linked-attachment"], }); await expect(sending).rejects.toBeInstanceOf(ORPCError); await expect(sending).rejects.toMatchObject({ code: "BAD_REQUEST", message: "One or more attachments are unavailable or already linked to a message.", }); expect(dbMock.insert).not.toHaveBeenCalled(); }); it("throws CONFLICT when the underlying thread is archived", async () => { const archivedThread = buildArchivedThread(); dbMock.select.mockImplementation(() => { const limit = vi.fn(async () => [archivedThread]); const where = vi.fn(() => ({ limit })); const from = vi.fn(() => ({ where })); return { from }; }); const { agentService } = await import("./service"); const sending = agentService.messages.send({ threadId: "thread-1", userId: "user-1", // biome-ignore lint/suspicious/noExplicitAny: minimal fixture for unit test message: { id: "msg-1", role: "user", parts: [{ type: "text", text: "hi" }] } as any, }); await expect(sending).rejects.toBeInstanceOf(ORPCError); await expect(sending).rejects.toMatchObject({ code: "CONFLICT", message: "This thread is archived." }); // Ensure we never tried to claim a run or persist anything for an archived thread. expect(aiProvidersServiceMock.getRunnableById).not.toHaveBeenCalled(); }); }); describe("agentService.threads.archive", () => { beforeEach(() => { vi.clearAllMocks(); }); it("skips active-run cleanup when there is no active run", async () => { const idleThread = buildArchivedThread({ status: "active", activeRunId: null, activeStreamId: null, archivedAt: null, }); dbMock.select.mockImplementation(() => { const limit = vi.fn(async () => [idleThread]); const where = vi.fn(() => ({ limit })); const from = vi.fn(() => ({ where })); return { from }; }); const updateWhere = vi.fn(async () => undefined); const updateSet = vi.fn(() => ({ where: updateWhere })); dbMock.update.mockReturnValue({ set: updateSet }); const { agentService } = await import("./service"); await agentService.threads.archive({ id: "thread-1", userId: "user-1" }); expect(clearActiveAgentRunIfCurrentMock).not.toHaveBeenCalled(); expect(updateSet).toHaveBeenCalledWith(expect.objectContaining({ status: "archived" })); expect(updateWhere).toHaveBeenCalled(); }); it("clears active-run state when an active run is present, then flips status", async () => { const activeThread = buildArchivedThread({ status: "active", activeRunId: "run-1", activeStreamId: "stream-1", archivedAt: null, }); dbMock.select.mockImplementation(() => { const limit = vi.fn(async () => [activeThread]); const where = vi.fn(() => ({ limit })); const from = vi.fn(() => ({ where })); return { from }; }); const updateWhere = vi.fn(async () => undefined); const updateSet = vi.fn(() => ({ where: updateWhere })); dbMock.update.mockReturnValue({ set: updateSet }); clearActiveAgentRunIfCurrentMock.mockResolvedValue(undefined); const { agentService } = await import("./service"); await agentService.threads.archive({ id: "thread-1", userId: "user-1" }); expect(clearActiveAgentRunIfCurrentMock).toHaveBeenCalledWith({ threadId: "thread-1", userId: "user-1", runId: "run-1", streamId: "stream-1", }); expect(updateSet).toHaveBeenCalledWith(expect.objectContaining({ status: "archived" })); expect(updateWhere).toHaveBeenCalled(); }); it("still flips status when clearActiveAgentRunIfCurrent throws", async () => { const activeThread = buildArchivedThread({ status: "active", activeRunId: "run-2", activeStreamId: "stream-2", archivedAt: null, }); dbMock.select.mockImplementation(() => { const limit = vi.fn(async () => [activeThread]); const where = vi.fn(() => ({ limit })); const from = vi.fn(() => ({ where })); return { from }; }); const updateWhere = vi.fn(async () => undefined); const updateSet = vi.fn(() => ({ where: updateWhere })); dbMock.update.mockReturnValue({ set: updateSet }); clearActiveAgentRunIfCurrentMock.mockRejectedValue(new Error("boom")); const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); const { agentService } = await import("./service"); await agentService.threads.archive({ id: "thread-1", userId: "user-1" }); expect(clearActiveAgentRunIfCurrentMock).toHaveBeenCalled(); expect(updateSet).toHaveBeenCalledWith(expect.objectContaining({ status: "archived" })); expect(consoleSpy).toHaveBeenCalled(); consoleSpy.mockRestore(); }); }); describe("agentService.threads.delete", () => { beforeEach(() => { vi.clearAllMocks(); }); it("throws NOT_FOUND when the thread does not belong to the user and skips destructive work", async () => { dbMock.select.mockImplementation(() => { const limit = vi.fn(async () => []); const where = vi.fn(() => ({ limit })); const from = vi.fn(() => ({ where })); return { from }; }); const { agentService } = await import("./service"); const deleting = agentService.threads.delete({ id: "thread-x", userId: "user-y" }); await expect(deleting).rejects.toBeInstanceOf(ORPCError); await expect(deleting).rejects.toMatchObject({ code: "NOT_FOUND" }); expect(storageServiceMock.delete).not.toHaveBeenCalled(); expect(dbMock.delete).not.toHaveBeenCalled(); expect(dbMock.update).not.toHaveBeenCalled(); }); it("proceeds with cleanup when the thread is owned by the user", async () => { const ownedThread = buildArchivedThread({ id: "thread-own", userId: "user-own", status: "active", activeRunId: null, activeStreamId: null, archivedAt: null, }); dbMock.select.mockImplementation(() => { const limit = vi.fn(async () => [ownedThread]); const where = vi.fn(() => ({ limit })); const from = vi.fn(() => ({ where })); return { from }; }); const deleteWhere = vi.fn(async () => undefined); dbMock.delete.mockReturnValue({ where: deleteWhere }); const updateWhere = vi.fn(async () => undefined); const updateSet = vi.fn(() => ({ where: updateWhere })); dbMock.update.mockReturnValue({ set: updateSet }); storageServiceMock.delete.mockResolvedValue(undefined); const { agentService } = await import("./service"); await agentService.threads.delete({ id: "thread-own", userId: "user-own" }); expect(storageServiceMock.delete).toHaveBeenCalledWith("uploads/user-own/agent/thread-own"); expect(dbMock.delete).toHaveBeenCalled(); expect(updateSet).toHaveBeenCalledWith(expect.objectContaining({ status: "deleted" })); }); }); describe("agentService.actions.revert", () => { beforeEach(() => { vi.clearAllMocks(); }); function buildAction(overrides: Record = {}) { return { id: "action-1", userId: "user-1", threadId: "thread-1", messageId: null, resumeId: "resume-1", kind: "resume_patch", status: "applied", title: "Tighten summary", summary: null, operations: [{ op: "replace", path: "/basics/name", value: "Bob" }], snapshotData: { basics: { name: "Alice" } }, baseUpdatedAt: new Date("2026-05-01T00:00:00.000Z"), appliedUpdatedAt: new Date("2026-05-02T00:00:00.000Z"), revertedAt: null, revertMessage: null, createdAt: new Date("2026-05-02T00:00:00.000Z"), updatedAt: new Date("2026-05-02T00:00:00.000Z"), ...overrides, }; } it("rolls back an applied action by restoring its snapshot and marks later applied actions rolled_back", async () => { const action = buildAction(); const laterAction = buildAction({ id: "action-2", appliedUpdatedAt: new Date("2026-05-03T00:00:00.000Z"), createdAt: new Date("2026-05-03T00:00:00.000Z"), }); const updatedAction = { ...action, status: "rolled_back", revertedAt: new Date(), revertMessage: null }; dbMock.select .mockImplementationOnce(() => selectLimitResult([action])) .mockImplementationOnce(() => selectWhereOrderByLimitResult([laterAction])); const updateReturning = vi.fn(async () => [updatedAction]); const updateWhere = vi.fn(() => ({ returning: updateReturning })); const updateSet = vi.fn(() => ({ where: updateWhere })); dbMock.update.mockReturnValue({ set: updateSet }); resumeServiceMock.patchInTransaction.mockResolvedValue({ id: "resume-1", updatedAt: new Date("2026-05-03T00:00:00.000Z"), }); const { agentService } = await import("./service"); const result = await agentService.actions.revert({ id: "action-1", userId: "user-1" }); expect(resumeServiceMock.patchInTransaction).toHaveBeenCalledWith(dbMock, { id: "resume-1", userId: "user-1", operations: [{ op: "replace", path: "", value: action.snapshotData }], expectedUpdatedAt: laterAction.appliedUpdatedAt, }); expect(updateSet).toHaveBeenCalledWith( expect.objectContaining({ status: "rolled_back", revertMessage: "This patch was rolled back when the resume was restored to an earlier state.", appliedUpdatedAt: new Date("2026-05-03T00:00:00.000Z"), }), ); expect(result.status).toBe("rolled_back"); }); it("returns a conflicted action when snapshot restore throws RESUME_VERSION_CONFLICT", async () => { const action = buildAction(); const latestAction = buildAction({ id: "action-2", appliedUpdatedAt: new Date("2026-05-03T00:00:00.000Z"), }); const conflictedAction = { ...action, status: "conflicted", revertMessage: "The resume changed after this action was applied.", }; dbMock.select .mockImplementationOnce(() => selectLimitResult([action])) .mockImplementationOnce(() => selectWhereOrderByLimitResult([latestAction])); const updateReturning = vi.fn(async () => [conflictedAction]); const updateWhere = vi.fn(() => ({ returning: updateReturning })); const updateSet = vi.fn(() => ({ where: updateWhere })); dbMock.update.mockReturnValue({ set: updateSet }); resumeServiceMock.patchInTransaction.mockRejectedValue(new ORPCError("RESUME_VERSION_CONFLICT")); const { agentService } = await import("./service"); const result = await agentService.actions.revert({ id: "action-1", userId: "user-1" }); expect(resumeServiceMock.patchInTransaction).toHaveBeenCalled(); expect(updateSet).toHaveBeenCalledWith( expect.objectContaining({ status: "conflicted", revertMessage: "The resume changed after this action was applied.", }), ); expect(updateWhere).toHaveBeenCalled(); expect(updateReturning).toHaveBeenCalled(); expect(result.status).toBe("conflicted"); expect(result.revertMessage).toBe("The resume changed after this action was applied."); }); it("returns the existing action unchanged when its status is already rolled_back", async () => { const action = buildAction({ status: "rolled_back", revertedAt: new Date("2026-05-03T00:00:00.000Z"), }); dbMock.select.mockImplementation(() => selectLimitResult([action])); const { agentService } = await import("./service"); const result = await agentService.actions.revert({ id: "action-1", userId: "user-1" }); expect(resumeServiceMock.patch).not.toHaveBeenCalled(); expect(dbMock.update).not.toHaveBeenCalled(); expect(result.status).toBe("rolled_back"); expect(result.id).toBe("action-1"); }); it("throws BAD_REQUEST when an applied legacy action has no snapshotData", async () => { const action = buildAction({ snapshotData: null }); dbMock.select.mockImplementation(() => selectLimitResult([action])); const { agentService } = await import("./service"); const reverting = agentService.actions.revert({ id: "action-1", userId: "user-1" }); await expect(reverting).rejects.toBeInstanceOf(ORPCError); await expect(reverting).rejects.toMatchObject({ code: "BAD_REQUEST" }); expect(resumeServiceMock.patch).not.toHaveBeenCalled(); }); it("throws BAD_REQUEST when the action has no resumeId", async () => { const action = buildAction({ resumeId: null }); dbMock.select.mockImplementation(() => selectLimitResult([action])); const { agentService } = await import("./service"); const reverting = agentService.actions.revert({ id: "action-1", userId: "user-1" }); await expect(reverting).rejects.toBeInstanceOf(ORPCError); await expect(reverting).rejects.toMatchObject({ code: "BAD_REQUEST" }); expect(resumeServiceMock.patch).not.toHaveBeenCalled(); }); it("throws NOT_FOUND when no matching action is found", async () => { dbMock.select.mockImplementation(() => selectLimitResult([])); const { agentService } = await import("./service"); const reverting = agentService.actions.revert({ id: "missing-id", userId: "user-1" }); await expect(reverting).rejects.toBeInstanceOf(ORPCError); await expect(reverting).rejects.toMatchObject({ code: "NOT_FOUND" }); expect(resumeServiceMock.patch).not.toHaveBeenCalled(); }); });