import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { runAgent } from "../../src/agent/runner.js"; import { importSkillDirectory } from "../../src/agent/skillStore.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, costUsd?: number) { return { type: "result", subtype: "success", session_id: sessionId, ...(costUsd !== undefined ? { total_cost_usd: costUsd } : {}), }; } function initMessage(skills: string[]) { return { type: "system", subtype: "init", skills, tools: [], plugins: [], }; } 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", () => { let root: string; let workspaceRoot: string; let workspace: string; let previousSecrets: Record; beforeEach(async () => { queryMock.mockReset(); root = await mkdtemp(join(process.platform === "win32" ? tmpdir() : "/tmp", "r-")); workspaceRoot = join(root, "w"); workspace = join(workspaceRoot, "o", "p"); await mkdir(workspace, { recursive: true }); workspaceRoot = await realpath(workspaceRoot); workspace = await realpath(workspace); previousSecrets = Object.fromEntries( ["DATABASE_URL", "FEISHU_APP_SECRET", "HUB_SESSION_SECRET", "HUB_SKILL_STORE_ROOT"].map((name) => [name, process.env[name]]), ); }); afterEach(async () => { for (const [name, value] of Object.entries(previousSecrets)) { if (value === undefined) delete process.env[name]; else process.env[name] = value; } await rm(root, { recursive: true, force: true }); }); 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", workspaceRoot, workspaceDir: workspace }, systemPrompt: undefined, resumeSessionId: "sdk-session-old", runId: "run-1", sessionId: "hub-session-1", prisma: stubPrisma, }); expect(queryMock).toHaveBeenCalledWith({ prompt: "再发一次", options: expect.objectContaining({ cwd: workspace, model: "anthropic/claude-sonnet-5", resume: "sdk-session-old", // ADR-0018: agent execution surface bounded by workspace. permissionMode: "bypassPermissions", allowDangerouslySkipPermissions: true, settingSources: [], settings: { disableBundledSkills: true }, skills: [], strictMcpConfig: true, sandbox: expect.objectContaining({ enabled: true, failIfUnavailable: true, allowUnsandboxedCommands: false, filesystem: expect.objectContaining({ allowWrite: expect.arrayContaining([workspace]), denyRead: ["/"], allowRead: expect.arrayContaining([workspace]), }), }), }), }); }); 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", workspaceRoot, workspaceDir: workspace }, 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("returns the skills actually reported by SDK initialization", async () => { queryMock.mockReturnValue(messages( initMessage(["cph-runtime:outline"]), assistantMessage("fresh"), resultMessage("sdk-session-1"), )); const result = await runAgent({ prompt: "写一个大纲", model: undefined, project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace }, systemPrompt: undefined, runId: "run-1", sessionId: "hub-session-1", prisma: stubPrisma, }); expect(result.initializedSkillIds).toEqual(["cph-runtime:outline"]); }); it("maps role tool ids to the Claude SDK tool whitelist", async () => { queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1"))); await runAgent({ prompt: "审校一下", model: undefined, project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace }, systemPrompt: undefined, tools: ["read_file", "cph_check", "send_file"], runId: "run-1", sessionId: "hub-session-1", prisma: stubPrisma, }); expect(queryMock.mock.calls[0]?.[0]).toMatchObject({ options: { tools: ["Read", "Bash"], allowedTools: ["Read", "Bash", "mcp__cph_hub__send_file"], }, }); }); it("disables SDK tools for an empty role tool and skill selection", async () => { queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1"))); await runAgent({ prompt: "只回答", model: undefined, project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace }, systemPrompt: undefined, tools: [], runId: "run-1", sessionId: "hub-session-1", prisma: stubPrisma, }); expect(queryMock.mock.calls[0]?.[0]).toMatchObject({ options: { tools: [], allowedTools: [], }, }); }); it("loads only the dynamic skills selected by the role", async () => { const source = join(root, "skill-source"); const storeRoot = join(root, "skill-store"); await mkdir(source); await writeFile(join(source, "SKILL.md"), "---\nname: typst\ndescription: Typst\n---\n"); const installed = await importSkillDirectory({ sourceDir: source, storeRoot }); process.env["HUB_SKILL_STORE_ROOT"] = storeRoot; queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1"))); await runAgent({ prompt: "排版", model: undefined, project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace }, systemPrompt: undefined, tools: [], skills: [{ name: "typst", version: "0.15.0", contentDigest: installed.contentDigest }], runId: "run-skill", sessionId: "hub-session-1", prisma: stubPrisma, }); expect(queryMock.mock.calls[0]?.[0]).toMatchObject({ options: { tools: ["Skill"], plugins: [expect.objectContaining({ type: "local", skipMcpDiscovery: true })], skills: ["cph-runtime:typst"], }, }); }); it("returns SDK-reported cost when present", async () => { queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1", 0.0042))); const result = await runAgent({ prompt: "算一下成本", model: undefined, project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace }, systemPrompt: undefined, runId: "run-1", sessionId: "hub-session-1", prisma: stubPrisma, }); expect(result.costUsd).toBe(0.0042); }); it("passes only the loopback provider proxy capability and safe runtime env without mutating process env", async () => { process.env["DATABASE_URL"] = "postgresql://platform-secret"; process.env["FEISHU_APP_SECRET"] = "feishu-secret"; process.env["HUB_SESSION_SECRET"] = "session-secret"; queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1"))); await runAgent({ prompt: "你好", model: undefined, project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace }, systemPrompt: undefined, providerProxyEnv: { ANTHROPIC_BASE_URL: "http://127.0.0.1:43123", ANTHROPIC_AUTH_TOKEN: "run-proxy-capability", ANTHROPIC_API_KEY: "", }, runId: "run-1", sessionId: "hub-session-1", prisma: stubPrisma, }); const call = queryMock.mock.calls[0]?.[0]; expect(call).toMatchObject({ options: { env: { ANTHROPIC_BASE_URL: "http://127.0.0.1:43123", ANTHROPIC_AUTH_TOKEN: "run-proxy-capability", ANTHROPIC_API_KEY: "", }, sandbox: { credentials: { envVars: expect.arrayContaining([ { name: "ANTHROPIC_AUTH_TOKEN", mode: "deny" }, { name: "ANTHROPIC_API_KEY", mode: "deny" }, ]), }, }, }, }); const env = (call as { options: { env: Record } }).options.env; expect(env).not.toHaveProperty("DATABASE_URL"); expect(env).not.toHaveProperty("FEISHU_APP_SECRET"); expect(env).not.toHaveProperty("HUB_SESSION_SECRET"); expect(process.env["DATABASE_URL"]).toBe("postgresql://platform-secret"); }); 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", workspaceRoot, workspaceDir: workspace }, 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", workspaceRoot, workspaceDir: workspace }, 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); }); });