forked from bai/curriculum-project-hub
305 lines
10 KiB
TypeScript
305 lines
10 KiB
TypeScript
import { mkdir, mkdtemp, realpath, rm } 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";
|
|
|
|
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", () => {
|
|
let root: string;
|
|
let workspaceRoot: string;
|
|
let workspace: string;
|
|
let previousSecrets: Record<string, string | undefined>;
|
|
|
|
beforeEach(async () => {
|
|
queryMock.mockReset();
|
|
root = await mkdtemp(join(tmpdir(), "hub-runner-"));
|
|
workspaceRoot = join(root, "workspaces");
|
|
workspace = join(workspaceRoot, "org", "project");
|
|
await mkdir(workspace, { recursive: true });
|
|
workspaceRoot = await realpath(workspaceRoot);
|
|
workspace = await realpath(workspace);
|
|
previousSecrets = Object.fromEntries(
|
|
["DATABASE_URL", "FEISHU_APP_SECRET", "HUB_SESSION_SECRET"].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: [],
|
|
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<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", 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 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", 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("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<string, string | undefined> } }).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);
|
|
});
|
|
});
|