forked from EduCraft/curriculum-project-hub
295f07d111
Sessions resume across runs (ADR-0017). Without auto-compact the SDK jsonl grows unboundedly — a 7-day / 26-run session hit 31 MB / 1995 lines, making every API call resend the full history and inflating a trivial "change a title" task to 22 minutes. Enable autoCompactEnabled in the SDK settings so the SDK compacts automatically when the context window fills.
414 lines
14 KiB
TypeScript
414 lines
14 KiB
TypeScript
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<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(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, todoFeatureEnabled: true, autoCompactEnabled: true },
|
|
tools: expect.arrayContaining(["Read", "Write", "Edit", "Bash", "Glob", "Grep", "TodoWrite"]),
|
|
allowedTools: expect.arrayContaining(["TodoWrite", "mcp__cph_hub__todo_write"]),
|
|
disallowedTools: expect.arrayContaining(["Agent", "SendMessage", "Task", "TeamCreate", "ScheduleWakeup"]),
|
|
sandbox: expect.objectContaining({
|
|
enabled: true,
|
|
failIfUnavailable: true,
|
|
allowUnsandboxedCommands: false,
|
|
filesystem: expect.objectContaining({
|
|
allowWrite: expect.arrayContaining([workspace]),
|
|
denyRead: ["/"],
|
|
allowRead: expect.arrayContaining([workspace]),
|
|
}),
|
|
}),
|
|
}),
|
|
});
|
|
});
|
|
|
|
it("never exposes multi-agent orchestration tools on unrestricted roles", 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: null,
|
|
runId: "run-1",
|
|
sessionId: "hub-session-1",
|
|
prisma: stubPrisma,
|
|
});
|
|
|
|
const call = queryMock.mock.calls[0]?.[0] as {
|
|
options?: { tools?: unknown; disallowedTools?: string[] };
|
|
} | undefined;
|
|
expect(call?.options?.tools).toEqual([
|
|
"Read",
|
|
"Write",
|
|
"Edit",
|
|
"Bash",
|
|
"Glob",
|
|
"Grep",
|
|
"WebFetch",
|
|
"WebSearch",
|
|
"TodoWrite",
|
|
]);
|
|
for (const blocked of ["Agent", "SendMessage", "Task", "TeamCreate", "ScheduleWakeup"]) {
|
|
expect(call?.options?.disallowedTools).toContain(blocked);
|
|
}
|
|
});
|
|
|
|
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("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", "TodoWrite"],
|
|
allowedTools: [
|
|
"Read",
|
|
"Bash",
|
|
"mcp__cph_hub__send_file",
|
|
"TodoWrite",
|
|
"mcp__cph_hub__todo_write",
|
|
],
|
|
settings: expect.objectContaining({ todoFeatureEnabled: true }),
|
|
disallowedTools: expect.arrayContaining(["Agent", "SendMessage"]),
|
|
},
|
|
});
|
|
});
|
|
|
|
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: ["TodoWrite"],
|
|
allowedTools: ["TodoWrite", "mcp__cph_hub__todo_write"],
|
|
settings: expect.objectContaining({ todoFeatureEnabled: true }),
|
|
disallowedTools: expect.arrayContaining(["Agent", "SendMessage"]),
|
|
},
|
|
});
|
|
});
|
|
|
|
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: ["TodoWrite", "Skill"],
|
|
allowedTools: expect.arrayContaining(["TodoWrite", "mcp__cph_hub__todo_write", "Skill"]),
|
|
plugins: [expect.objectContaining({ type: "local", skipMcpDiscovery: true })],
|
|
skills: ["cph-runtime:typst"],
|
|
settings: expect.objectContaining({ todoFeatureEnabled: true }),
|
|
},
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|