import { describe, expect, it, vi, beforeEach } from "vitest"; import { runAgent } from "../../src/agent/runner.js"; const queryMock = vi.hoisted(() => vi.fn()); vi.mock("@anthropic-ai/claude-agent-sdk", () => ({ query: queryMock, })); const stubPrisma = { projectAgentLock: { update: async () => ({}) }, } as unknown as import("@prisma/client").PrismaClient; function assistantMessage(text: string) { return { type: "assistant", message: { content: [{ type: "text", text }], usage: { input_tokens: 3, output_tokens: 5 }, }, }; } function resultMessage(sessionId: string) { return { type: "result", subtype: "success", session_id: sessionId, }; } function messages(...items: unknown[]) { return (async function* () { for (const item of items) yield item; })(); } function abortableMessages(ac: AbortController, ...items: unknown[]) { return (async function* () { for (const item of items) yield item; // Simulate the SDK: after yielding, the query blocks until the abort // signal fires, then throws (the SDK raises AbortError). if (ac.signal.aborted) throw new Error("aborted"); await new Promise((resolve) => { ac.signal.addEventListener("abort", () => resolve(), { once: true }); }); throw new Error("aborted"); })(); } describe("runAgent", () => { beforeEach(() => { queryMock.mockReset(); }); it("passes the previous Claude SDK session id through resume", async () => { queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-new"))); const result = await runAgent({ prompt: "再发一次", model: "anthropic/claude-sonnet-5", project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" }, systemPrompt: undefined, resumeSessionId: "sdk-session-old", runId: "run-1", sessionId: "hub-session-1", prisma: stubPrisma, }); expect(queryMock).toHaveBeenCalledWith({ prompt: "再发一次", options: expect.objectContaining({ cwd: "/tmp/ws", model: "anthropic/claude-sonnet-5", resume: "sdk-session-old", // ADR-0018: agent execution surface bounded by workspace. permissionMode: "bypassPermissions", allowDangerouslySkipPermissions: true, sandbox: expect.objectContaining({ enabled: true, failIfUnavailable: true, filesystem: expect.objectContaining({ allowWrite: ["/tmp/ws"], }), }), }), }); }); it("does not send resume for a fresh Hub session", async () => { queryMock.mockReturnValue(messages(assistantMessage("fresh"), resultMessage("sdk-session-1"))); await runAgent({ prompt: "你好", model: undefined, project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" }, systemPrompt: undefined, runId: "run-1", sessionId: "hub-session-1", prisma: stubPrisma, }); const call = queryMock.mock.calls[0]?.[0] as { options?: Record } | undefined; expect(call?.options).not.toHaveProperty("resume"); }); it("passes provider env per run without mutating process env", async () => { delete process.env["CPH_TEST_PROVIDER_ENV_MUTATION"]; queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1"))); await runAgent({ prompt: "你好", model: undefined, project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" }, systemPrompt: undefined, providerEnv: { ANTHROPIC_BASE_URL: "https://openrouter.ai/api", ANTHROPIC_AUTH_TOKEN: "test-token", ANTHROPIC_API_KEY: "", CPH_TEST_PROVIDER_ENV_MUTATION: "per-run", }, runId: "run-1", sessionId: "hub-session-1", prisma: stubPrisma, }); const call = queryMock.mock.calls[0]?.[0]; expect(call).toMatchObject({ options: { env: { ANTHROPIC_BASE_URL: "https://openrouter.ai/api", ANTHROPIC_AUTH_TOKEN: "test-token", ANTHROPIC_API_KEY: "", CPH_TEST_PROVIDER_ENV_MUTATION: "per-run", }, }, }); expect(process.env["CPH_TEST_PROVIDER_ENV_MUTATION"]).toBeUndefined(); }); it("persists structured user and assistant messages best-effort", async () => { queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1"))); const createMessage = vi.fn().mockResolvedValue({}); const prisma = { projectAgentLock: { update: async () => ({}) }, agentMessage: { create: createMessage }, } as unknown as import("@prisma/client").PrismaClient; await runAgent({ prompt: "你好", model: undefined, project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" }, systemPrompt: undefined, runId: "run-1", sessionId: "hub-session-1", prisma, }); expect(createMessage).toHaveBeenCalledTimes(2); expect(createMessage).toHaveBeenNthCalledWith(1, { data: expect.objectContaining({ sessionId: "hub-session-1", runId: "run-1", role: "user", content: "你好", }), }); expect(createMessage).toHaveBeenNthCalledWith(2, { data: expect.objectContaining({ sessionId: "hub-session-1", runId: "run-1", role: "assistant", content: "ok", }), }); }); it("returns interrupted status when the abort controller fires", async () => { const ac = new AbortController(); queryMock.mockReturnValue(abortableMessages(ac, assistantMessage("partial"), resultMessage("sdk-session-abort"))); const runPromise = runAgent({ prompt: "长任务", model: undefined, project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" }, systemPrompt: undefined, runId: "run-abort", sessionId: "hub-session-abort", prisma: stubPrisma, abortController: ac, }); // Let the query yield its messages and block on the abort wait. await Promise.resolve(); ac.abort(); const result = await runPromise; expect(queryMock.mock.calls[0]?.[0]).toMatchObject({ options: { abortController: ac } }); expect(result.error).toBeUndefined(); expect(result.sdkSessionId).toBe("sdk-session-abort"); const call = queryMock.mock.calls[0]?.[0] as { options?: { abortController?: AbortController } } | undefined; expect(call?.options?.abortController).toBe(ac); }); });