Files
curriculum-project-hub/hub/test/integration/helpers.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

207 lines
6.8 KiB
TypeScript

/**
* Test helpers for integration tests.
*
* 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
* AI SDK model factory (doGenerate() returns canned responses - no network).
*/
import { PrismaClient } from "@prisma/client";
import type { FastifyBaseLogger } from "fastify";
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";
export const prisma = new PrismaClient({
datasources: { db: { url: TEST_DATABASE_URL } },
});
/** Truncate all tables before each test for isolation. */
export async function resetDb(): Promise<void> {
const tables = [
"AuditEntry",
"PermissionSettings",
"PermissionGrant",
"RoleTriggerGrant",
"ProjectAgentLock",
"AgentRun",
"AgentSession",
"ProjectGroupBinding",
"PlatformRoleAssignment",
"User",
"Project",
];
// Truncate with CASCADE to wipe dependent rows in one shot.
await prisma.$executeRawUnsafe(`TRUNCATE TABLE ${tables.map((t) => `"${t}"`).join(", ")} RESTART IDENTITY CASCADE`);
}
/** A logger that discards everything (tests don't need fastify's pino). */
export const silentLogger: FastifyBaseLogger = {
info() {}, warn() {}, error() {}, debug() {}, fatal() {},
child() { return this; },
level: "silent",
} as unknown as FastifyBaseLogger;
/** Records sendText/sendCard calls so tests can assert on them. */
export interface MockFeishuRuntime extends FeishuRuntime {
readonly sentTexts: string[];
readonly sentCards: unknown[];
}
export function mockFeishuRuntime(): MockFeishuRuntime {
const sentTexts: string[] = [];
const sentCards: unknown[] = [];
// Mock the client — sendText/sendCard are in client.ts, not on the runtime
// object directly. We patch by providing a runtime whose client is a stub;
// the actual send functions cast through shape, so a minimal stub works.
const rt: MockFeishuRuntime = {
client: {
im: {
v1: {
message: {
create: async (p: unknown) => {
const payload = p as { data?: { msg_type?: string; content?: string } };
if (payload.data?.msg_type === "interactive") {
sentCards.push(payload.data.content ? JSON.parse(payload.data.content) : null);
} else {
try {
const c = JSON.parse(payload.data?.content ?? "{}") as { text?: string };
sentTexts.push(c.text ?? payload.data?.content ?? "");
} catch {
sentTexts.push(payload.data?.content ?? "");
}
}
return { data: { message_id: "mock-msg-id" } };
},
},
},
},
} as unknown as FeishuRuntime["client"],
logger: silentLogger,
sentTexts,
sentCards,
};
return rt;
}
export interface MockToolCall {
readonly toolCallId: string;
readonly toolName: string;
readonly input: unknown;
}
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: [],
};
}
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. */
export async function seedProject(
projectId: string,
chatId: string,
options: { principal?: string; role?: "READ" | "EDIT" | "MANAGE" } = {},
): Promise<void> {
const principal = options.principal ?? "ou_test_user";
const role = options.role ?? "EDIT";
await prisma.project.create({
data: {
id: projectId,
name: `Test ${projectId}`,
workspaceDir: `/tmp/test-${projectId}`,
},
});
await prisma.user.create({
data: {
id: "u_" + projectId,
feishuOpenId: principal,
displayName: "Test User",
platformRoles: { create: { role: "TEACHER" } },
permissionGrants: {
create: {
resourceType: "PROJECT",
resourceId: projectId,
principal,
role,
},
},
},
});
await prisma.projectGroupBinding.create({
data: { projectId, chatId, createdByUserId: "u_" + projectId },
});
}