forked from EduCraft/curriculum-project-hub
a2c8fa8eaf
将 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 全过。
207 lines
6.8 KiB
TypeScript
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 },
|
|
});
|
|
}
|