Files
curriculum-project-hub/hub/test/unit/workspace.test.ts
T
hongjr03 a2c8fa8eaf refactor(hub): 手搓 agent loop 换成 Vercel AI SDK
将 provider/runner/tools 三件手搓轮子替换为 AI SDK v7 的 generateText + tool:
- 删 openrouter-provider.ts(翻译层消失)、provider.ts 自定义消息类型
- runner.ts 循环体改为 generateText({stopWhen: stepCountIs}),保留 RunRequest/RunResult 公共签名
- tools.ts ToolRegistry 改为 ToolFactory + build(ctx, names?) 按 role 白名单构建 tools record
- workspace/cph/feishu 工具改写为 tool() 定义,execute 闭包捕获 per-run ToolContext
- transcript.ts 改为 ModelMessage[] JSONL(0.0.0 cutover,无迁移)
- server.ts/trigger.ts 接 modelFactory + toolWhitelist
- 测试:helpers 写 MockLanguageModel implements LanguageModelV4;5 个测试改写 + 新增 runner.test.ts
- 移除未使用的 openai 依赖
- ADR-0017 Consequences 同步:loop 委托给 AI SDK,provider seam 是 LanguageModel

tsc 干净,54 tests 全过。
2026-07-07 20:07:02 +08:00

89 lines
3.8 KiB
TypeScript

import { describe, it, expect, beforeEach } from "vitest";
import { mkdtemp, writeFile, mkdir } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ToolRegistry, type ToolContext } from "../../src/agent/tools.js";
import { readFileTool, writeFileTool, listFilesTool, PathEscape } from "../../src/agent/workspace.js";
describe("workspace path confinement", () => {
let ws: string;
let tools: ToolRegistry;
beforeEach(async () => {
ws = await mkdtemp(join(tmpdir(), "hub-unit-"));
tools = new ToolRegistry();
tools.register("read_file", readFileTool);
tools.register("write_file", writeFileTool);
tools.register("list_files", listFilesTool);
await mkdir(join(ws, "sub"));
await writeFile(join(ws, "a.txt"), "hello");
await writeFile(join(ws, "sub", "b.txt"), "world");
});
const ctx = () => ({ runId: "r", projectId: "p", boundChatId: "c", workspaceDir: ws });
it("reads a file inside the workspace", async () => {
expect(await executeTool(tools, "read_file", { path: "a.txt" }, ctx())).toBe("hello");
});
it("reads nested files", async () => {
expect(await executeTool(tools, "read_file", { path: "sub/b.txt" }, ctx())).toBe("world");
});
it("rejects .. escapes as error JSON, not a throw", async () => {
const out = await executeTool(tools, "read_file", { path: "../../etc/passwd" }, ctx());
expect((JSON.parse(out) as { error: string }).error).toContain("escapes workspace");
});
it("rejects absolute paths outside the workspace", async () => {
const out = await executeTool(tools, "read_file", { path: "/etc/passwd" }, ctx());
expect((JSON.parse(out) as { error: string }).error).toContain("escapes workspace");
});
it("writes a file and reads it back", async () => {
await executeTool(tools, "write_file", { path: "sub/c.txt", content: "new" }, ctx());
expect(await executeTool(tools, "read_file", { path: "sub/c.txt" }, ctx())).toBe("new");
});
it("creates parent dirs on write", async () => {
await executeTool(tools, "write_file", { path: "deep/nested/d.txt", content: "x" }, ctx());
expect(await executeTool(tools, "read_file", { path: "deep/nested/d.txt" }, ctx())).toBe("x");
});
it("lists the workspace root", async () => {
const out = await executeTool(tools, "list_files", {}, ctx());
const entries = JSON.parse(out) as Array<{ name: string; kind: string }>;
expect(entries).toContainEqual({ name: "a.txt", kind: "file" });
expect(entries).toContainEqual({ name: "sub", kind: "dir" });
});
it("lists a subdirectory", async () => {
const out = await executeTool(tools, "list_files", { path: "sub" }, ctx());
const entries = JSON.parse(out) as Array<{ name: string; kind: string }>;
expect(entries).toContainEqual({ name: "b.txt", kind: "file" });
});
it("PathEscape is an Error subclass", () => {
expect(new PathEscape("../x", "/ws")).toBeInstanceOf(Error);
expect(new PathEscape("../x", "/ws").name).toBe("PathEscape");
});
it("read of nonexistent file returns error JSON", async () => {
const out = await executeTool(tools, "read_file", { path: "nope.txt" }, ctx());
expect((JSON.parse(out) as { error: string }).error).toBeTruthy();
});
it("write with .. escape returns error JSON", async () => {
const out = await executeTool(tools, "write_file", { path: "../escape.txt", content: "x" }, ctx());
expect((JSON.parse(out) as { error: string }).error).toContain("escapes workspace");
});
});
async function executeTool(tools: ToolRegistry, name: string, args: unknown, ctx: ToolContext): Promise<string> {
const def = tools.build(ctx)[name];
if (def?.execute === undefined) {
throw new Error(`missing executable tool: ${name}`);
}
return def.execute(args, { toolCallId: "call", messages: [], context: undefined }) as Promise<string>;
}