Files
curriculum-project-hub/hub/test/unit/runner.test.ts
T

263 lines
8.2 KiB
TypeScript

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, costUsd?: number) {
return {
type: "result",
subtype: "success",
session_id: sessionId,
...(costUsd !== undefined ? { total_cost_usd: costUsd } : {}),
};
}
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<void>((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<string, unknown> } | undefined;
expect(call?.options).not.toHaveProperty("resume");
});
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", workspaceDir: "/tmp/ws" },
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 all SDK tools for an empty role tool whitelist", async () => {
queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1")));
await runAgent({
prompt: "只回答",
model: undefined,
project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" },
systemPrompt: undefined,
tools: [],
runId: "run-1",
sessionId: "hub-session-1",
prisma: stubPrisma,
});
expect(queryMock.mock.calls[0]?.[0]).toMatchObject({
options: {
tools: [],
allowedTools: [],
},
});
});
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", workspaceDir: "/tmp/ws" },
systemPrompt: undefined,
runId: "run-1",
sessionId: "hub-session-1",
prisma: stubPrisma,
});
expect(result.costUsd).toBe(0.0042);
});
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);
});
});