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:
2026-07-07 20:07:02 +08:00
parent afaf5bee09
commit a2c8fa8eaf
17 changed files with 516 additions and 808 deletions
+23 -10
View File
@@ -1,8 +1,8 @@
import { describe, it, expect, beforeEach } from "vitest";
import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { cp, mkdtemp, rm } 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 { cphCheckTool, cphBuildTool } from "../../src/agent/cph.js";
const EXAMPLES_DIR = join(process.cwd(), "..", "examples", "TH-141");
@@ -12,16 +12,21 @@ describe("cph subprocess tools (integration, real cph binary)", () => {
let tools: ToolRegistry;
beforeEach(async () => {
// Use the real TH-141 example as the workspace.
ws = EXAMPLES_DIR;
// Use a writable copy of the real TH-141 example as the workspace.
ws = await mkdtemp(join(tmpdir(), "hub-cph-example-"));
await cp(EXAMPLES_DIR, ws, { recursive: true });
tools = new ToolRegistry();
tools.register(cphCheckTool());
tools.register(cphBuildTool());
tools.register("cph_check", cphCheckTool);
tools.register("cph_build", cphBuildTool);
});
afterEach(async () => {
await rm(ws, { recursive: true, force: true });
});
it("cph_check runs on the TH-141 example and returns diagnostics", async () => {
const ctx = { runId: "r", projectId: "p", boundChatId: "c", workspaceDir: ws };
const out = await tools.execute("cph_check", {}, ctx);
const out = await executeTool(tools, "cph_check", {}, ctx);
const result = JSON.parse(out) as { exitCode: number; stdout: string; stderr: string };
// cph check exits 0 on a legal lesson (no error diagnostics, ADR-0010).
@@ -31,7 +36,7 @@ describe("cph subprocess tools (integration, real cph binary)", () => {
it("cph_build renders the student target PDF", async () => {
const ctx = { runId: "r", projectId: "p", boundChatId: "c", workspaceDir: ws };
const out = await tools.execute("cph_build", { target: "student", output: "build/test-student.pdf" }, ctx);
const out = await executeTool(tools, "cph_build", { target: "student", output: "build/test-student.pdf" }, ctx);
const result = JSON.parse(out) as { exitCode: number; stdout: string; stderr: string; output: string };
expect(result.exitCode).toBe(0);
@@ -41,7 +46,7 @@ describe("cph subprocess tools (integration, real cph binary)", () => {
const empty = await mkdtemp(join(tmpdir(), "hub-cph-empty-"));
try {
const ctx = { runId: "r", projectId: "p", boundChatId: "c", workspaceDir: empty };
const out = await tools.execute("cph_check", {}, ctx);
const out = await executeTool(tools, "cph_check", {}, ctx);
const result = JSON.parse(out) as { exitCode: number };
expect(result.exitCode).not.toBe(0);
} finally {
@@ -49,3 +54,11 @@ describe("cph subprocess tools (integration, real cph binary)", () => {
}
}, 15000);
});
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>;
}
+83 -13
View File
@@ -3,12 +3,19 @@
*
* Each test gets a clean DB (tables truncated before the test), a mock
* FeishuRuntime (sendText/sendCard are no-ops that record calls), and a mock
* AgentProvider (chat() returns a fixed "stop" response — no real network).
* AI SDK model factory (doGenerate() returns canned responses - no network).
*/
import { PrismaClient } from "@prisma/client";
import type { FastifyBaseLogger } from "fastify";
import type { FeishuRuntime } from "../src/feishu/client.js";
import type { AgentProvider, ChatRequest, ChatResponse, Message } from "../src/agent/provider.js";
import type {
LanguageModelV4,
LanguageModelV4CallOptions,
LanguageModelV4Content,
LanguageModelV4GenerateResult,
LanguageModelV4StreamResult,
} from "@ai-sdk/provider";
import type { FeishuRuntime } from "../../src/feishu/client.js";
import type { ModelFactory } from "../../src/agent/runner.js";
export const TEST_DATABASE_URL = "postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test";
@@ -84,19 +91,82 @@ export function mockFeishuRuntime(): MockFeishuRuntime {
return rt;
}
/** A provider that always returns a fixed "stop" response — no network. */
export class MockProvider implements AgentProvider {
readonly id = "mock";
readonly calls: ChatRequest[] = [];
export interface MockToolCall {
readonly toolCallId: string;
readonly toolName: string;
readonly input: unknown;
}
async chat(req: ChatRequest): Promise<ChatResponse> {
this.calls.push(req);
const message: Message = {
role: "assistant",
parts: [{ type: "text", text: "mock response" }],
export interface MockModelResponse {
readonly text?: string;
readonly toolCalls?: readonly MockToolCall[];
readonly finishReason?: "stop" | "length" | "content-filter" | "tool-calls" | "error" | "other";
readonly inputTokens?: number;
readonly outputTokens?: number;
}
export class MockLanguageModel implements LanguageModelV4 {
readonly specificationVersion = "v4";
readonly provider = "mock";
readonly supportedUrls: Record<string, RegExp[]> = {};
readonly calls: LanguageModelV4CallOptions[] = [];
constructor(
readonly modelId: string,
private readonly responses: readonly MockModelResponse[] = [{ text: "mock response" }],
) {}
async doGenerate(options: LanguageModelV4CallOptions): Promise<LanguageModelV4GenerateResult> {
this.calls.push(options);
const configured = this.responses[this.calls.length - 1];
const last = this.responses[this.responses.length - 1];
const response =
configured ??
((last?.toolCalls?.length ?? 0) > 0
? { text: "mock response" }
: last ?? { text: "mock response" });
const content: LanguageModelV4Content[] = [];
if (response.text !== undefined) {
content.push({ type: "text", text: response.text });
}
for (const call of response.toolCalls ?? []) {
content.push({
type: "tool-call",
toolCallId: call.toolCallId,
toolName: call.toolName,
input: JSON.stringify(call.input),
});
}
const finishReason = response.finishReason ?? ((response.toolCalls?.length ?? 0) > 0 ? "tool-calls" : "stop");
return {
content,
finishReason: { unified: finishReason, raw: finishReason },
usage: {
inputTokens: { total: response.inputTokens ?? 10, noCache: undefined, cacheRead: undefined, cacheWrite: undefined },
outputTokens: { total: response.outputTokens ?? 5, text: undefined, reasoning: undefined },
},
response: { id: `mock-${this.calls.length}`, timestamp: new Date(), modelId: this.modelId },
warnings: [],
};
return { finishReason: "stop", message, usage: { inputTokens: 10, outputTokens: 5 } };
}
async doStream(_options: LanguageModelV4CallOptions): Promise<LanguageModelV4StreamResult> {
throw new Error("MockLanguageModel does not implement streaming");
}
}
export function createMockModelFactory(
responses: readonly MockModelResponse[] = [{ text: "mock response" }],
): { readonly modelFactory: ModelFactory; readonly models: ReadonlyMap<string, MockLanguageModel> } {
const models = new Map<string, MockLanguageModel>();
return {
models,
modelFactory: (modelId) => {
const model = new MockLanguageModel(modelId, responses);
models.set(modelId, model);
return model;
},
};
}
/** Create a project + binding + user + grant for a test. */
+17 -16
View File
@@ -1,9 +1,10 @@
import { describe, it, expect, beforeEach, afterAll, vi } from "vitest";
import { prisma, resetDb, mockFeishuRuntime, MockProvider, seedProject, silentLogger } from "./helpers.js";
import { prisma, resetDb, mockFeishuRuntime, createMockModelFactory, seedProject, silentLogger } from "./helpers.js";
import { InMemoryModelRegistry } from "../../src/agent/models.js";
import { ToolRegistry, feishuContextTool } from "../../src/agent/tools.js";
import { ToolRegistry } from "../../src/agent/tools.js";
import { makeTriggerHandler, extractPrompt } from "../../src/feishu/trigger.js";
import type { MessageReceiveEvent } from "../../src/feishu/client.js";
import type { ModelFactory } from "../../src/agent/runner.js";
const bot = { key: "@_user_1", id: { open_id: "ou_bot" }, name: "Bot" };
@@ -22,14 +23,14 @@ function makeEvent(chatId: string, text: string, senderOpenId = "ou_test_user"):
}
describe("trigger full lifecycle (integration)", () => {
let provider: MockProvider;
let modelFactory: ModelFactory;
let tools: ToolRegistry;
let models: InMemoryModelRegistry;
let rt: ReturnType<typeof mockFeishuRuntime>;
beforeEach(async () => {
await resetDb();
provider = new MockProvider();
modelFactory = createMockModelFactory().modelFactory;
tools = new ToolRegistry();
models = new InMemoryModelRegistry(
[{ id: "mock-model", label: "Mock", toolCapable: true }],
@@ -43,7 +44,7 @@ describe("trigger full lifecycle (integration)", () => {
it("creates a run, acquires + releases the lock, sends status card", async () => {
await seedProject("proj-1", "chat-1");
const trigger = makeTriggerHandler({ prisma, provider, tools, models, logger: silentLogger });
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
await trigger(makeEvent("chat-1", "@_user_1 写教案"), rt);
@@ -65,7 +66,7 @@ describe("trigger full lifecycle (integration)", () => {
it("rejects a sender without edit grant (ADR-0004)", async () => {
await seedProject("proj-2", "chat-2", { role: "READ" });
const trigger = makeTriggerHandler({ prisma, provider, tools, models, logger: silentLogger });
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
await trigger(makeEvent("chat-2", "@_user_1 写教案"), rt);
@@ -84,7 +85,7 @@ describe("trigger full lifecycle (integration)", () => {
data: { projectId: "proj-3", runId: existingRun.id },
});
const trigger = makeTriggerHandler({ prisma, provider, tools, models, logger: silentLogger });
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
await trigger(makeEvent("chat-3", "@_user_1 写教案"), rt);
expect(rt.sentTexts).toContain("项目正在处理中,请稍候。");
@@ -95,7 +96,7 @@ describe("trigger full lifecycle (integration)", () => {
it("ignores messages from unbound chats (ADR-0001)", async () => {
await seedProject("proj-4", "chat-4");
const trigger = makeTriggerHandler({ prisma, provider, tools, models, logger: silentLogger });
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
await trigger(makeEvent("chat-UNKNOWN", "@_user_1 写教案"), rt);
@@ -107,7 +108,7 @@ describe("trigger full lifecycle (integration)", () => {
it("ignores messages without @bot mention", async () => {
await seedProject("proj-5", "chat-5");
const trigger = makeTriggerHandler({ prisma, provider, tools, models, logger: silentLogger });
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
const event: MessageReceiveEvent = {
message: {
@@ -129,7 +130,7 @@ describe("trigger full lifecycle (integration)", () => {
it("/new archives current session (no run created)", async () => {
await seedProject("proj-6", "chat-6");
const trigger = makeTriggerHandler({ prisma, provider, tools, models, logger: silentLogger });
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
// First @bot creates a session + run.
await trigger(makeEvent("chat-6", "@_user_1 写教案"), rt);
@@ -152,7 +153,7 @@ describe("trigger full lifecycle (integration)", () => {
it("/resume un-archives the most recent session", async () => {
await seedProject("proj-7", "chat-7");
const trigger = makeTriggerHandler({ prisma, provider, tools, models, logger: silentLogger });
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
// Create + archive a session via /new.
await trigger(makeEvent("chat-7", "@_user_1 写教案"), rt);
@@ -172,7 +173,7 @@ describe("trigger full lifecycle (integration)", () => {
it("/reset archives current session", async () => {
await seedProject("proj-8", "chat-8");
const trigger = makeTriggerHandler({ prisma, provider, tools, models, logger: silentLogger });
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
await trigger(makeEvent("chat-8", "@_user_1 写教案"), rt);
await vi.waitFor(async () => {
@@ -191,7 +192,7 @@ describe("trigger full lifecycle (integration)", () => {
it("unknown slash command falls through to agent", async () => {
await seedProject("proj-9", "chat-9");
const trigger = makeTriggerHandler({ prisma, provider, tools, models, logger: silentLogger });
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
await trigger(makeEvent("chat-9", "@_user_1 /unknown"), rt);
// Should create a run (falls through as a normal prompt).
@@ -208,7 +209,7 @@ describe("trigger full lifecycle (integration)", () => {
await prisma.roleTriggerGrant.create({
data: { projectId: "proj-10", roleId: "review", principal: "ou_other" },
});
const trigger = makeTriggerHandler({ prisma, provider, tools, models, logger: silentLogger });
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
await trigger(makeEvent("chat-10", "@_user_1 /review 看看这节"), rt);
@@ -222,7 +223,7 @@ describe("trigger full lifecycle (integration)", () => {
await prisma.roleTriggerGrant.create({
data: { projectId: "proj-11", roleId: "review", principal: "ou_test_user" },
});
const trigger = makeTriggerHandler({ prisma, provider, tools, models, logger: silentLogger });
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
await trigger(makeEvent("chat-11", "@_user_1 /review 看看这节"), rt);
@@ -236,7 +237,7 @@ describe("trigger full lifecycle (integration)", () => {
it("extractRole: /draft sets roleId=draft, strips command from prompt", async () => {
await seedProject("proj-12", "chat-12");
const trigger = makeTriggerHandler({ prisma, provider, tools, models, logger: silentLogger });
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
await trigger(makeEvent("chat-12", "@_user_1 /draft 写第三单元"), rt);
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { runAgent } from "../../src/agent/runner.js";
import { ToolRegistry } from "../../src/agent/tools.js";
import { readFileTool } from "../../src/agent/workspace.js";
import { createMockModelFactory } from "../integration/helpers.js";
describe("runAgent with AI SDK tool loop", () => {
it("executes SDK tools and returns the final assistant response", async () => {
const ws = await mkdtemp(join(tmpdir(), "hub-runner-"));
try {
await writeFile(join(ws, "lesson.txt"), "hello lesson", "utf8");
const tools = new ToolRegistry();
tools.register("read_file", readFileTool);
const { modelFactory, models } = createMockModelFactory([
{
toolCalls: [{ toolCallId: "call-1", toolName: "read_file", input: { path: "lesson.txt" } }],
},
{ text: "done", finishReason: "stop" },
]);
const result = await runAgent(modelFactory, tools, {
prompt: "read lesson.txt",
model: "mock-model",
project: { projectId: "p", boundChatId: "c", workspaceDir: ws },
systemPrompt: "system",
maxIterations: 5,
});
expect(result.status).toBe("completed");
expect(result.messages.at(-1)).toMatchObject({ role: "assistant" });
expect(models.get("mock-model")?.calls).toHaveLength(2);
} finally {
await rm(ws, { recursive: true, force: true });
}
});
});
+3 -3
View File
@@ -3,10 +3,10 @@ import { mkdtemp, rm, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { readTranscript, appendTranscript, transcriptPath, TRANSCRIPT_DIR } from "../../src/agent/transcript.js";
import type { Message } from "../../src/agent/provider.js";
import type { ModelMessage } from "ai";
const sample: Message = { role: "user", parts: [{ type: "text", text: "hello" }] };
const reply: Message = { role: "assistant", parts: [{ type: "text", text: "hi" }] };
const sample: ModelMessage = { role: "user", content: "hello" };
const reply: ModelMessage = { role: "assistant", content: "hi" };
describe("transcript JSONL", () => {
let dir: string;
+24 -16
View File
@@ -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>;
}