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
+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);