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:
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user