forked from EduCraft/curriculum-project-hub
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 全过。
This commit is contained in:
@@ -2,7 +2,7 @@ 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 } from "../../src/agent/tools.js";
|
||||
import { ToolRegistry, type ToolContext } from "../../src/agent/tools.js";
|
||||
import { readFileTool, writeFileTool, listFilesTool, PathEscape } from "../../src/agent/workspace.js";
|
||||
|
||||
describe("workspace path confinement", () => {
|
||||
@@ -12,9 +12,9 @@ describe("workspace path confinement", () => {
|
||||
beforeEach(async () => {
|
||||
ws = await mkdtemp(join(tmpdir(), "hub-unit-"));
|
||||
tools = new ToolRegistry();
|
||||
tools.register(readFileTool());
|
||||
tools.register(writeFileTool());
|
||||
tools.register(listFilesTool());
|
||||
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");
|
||||
@@ -23,42 +23,42 @@ describe("workspace path confinement", () => {
|
||||
const ctx = () => ({ runId: "r", projectId: "p", boundChatId: "c", workspaceDir: ws });
|
||||
|
||||
it("reads a file inside the workspace", async () => {
|
||||
expect(await tools.execute("read_file", { path: "a.txt" }, ctx())).toBe("hello");
|
||||
expect(await executeTool(tools, "read_file", { path: "a.txt" }, ctx())).toBe("hello");
|
||||
});
|
||||
|
||||
it("reads nested files", async () => {
|
||||
expect(await tools.execute("read_file", { path: "sub/b.txt" }, ctx())).toBe("world");
|
||||
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 tools.execute("read_file", { path: "../../etc/passwd" }, ctx());
|
||||
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 tools.execute("read_file", { path: "/etc/passwd" }, ctx());
|
||||
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 tools.execute("write_file", { path: "sub/c.txt", content: "new" }, ctx());
|
||||
expect(await tools.execute("read_file", { path: "sub/c.txt" }, ctx())).toBe("new");
|
||||
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 tools.execute("write_file", { path: "deep/nested/d.txt", content: "x" }, ctx());
|
||||
expect(await tools.execute("read_file", { path: "deep/nested/d.txt" }, ctx())).toBe("x");
|
||||
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 tools.execute("list_files", {}, ctx());
|
||||
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 tools.execute("list_files", { path: "sub" }, ctx());
|
||||
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" });
|
||||
});
|
||||
@@ -69,12 +69,20 @@ describe("workspace path confinement", () => {
|
||||
});
|
||||
|
||||
it("read of nonexistent file returns error JSON", async () => {
|
||||
const out = await tools.execute("read_file", { path: "nope.txt" }, ctx());
|
||||
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 tools.execute("write_file", { path: "../escape.txt", content: "x" }, ctx());
|
||||
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>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user