forked from EduCraft/curriculum-project-hub
afaf5bee09
RoleEntry 扩成 (model, systemPrompt, tools) bundle,id 兼任 slash command 名。 ToolRegistry.subset() 支持按白名单构造 per-run 视图,模型永远看不到 role 外的工具。 trigger 解析 /<role> 命令,查 RoleEntry 组装 systemPrompt + 子集 registry 传给 runner。 新增 RoleTriggerGrant 表 + canTriggerRole gate,与 ADR-0004 canTriggerAgent 串联: 先问'能不能触发 agent',再问'能触发哪个 role'。未配置 role 放行(back-compat)。 - models.ts: RoleEntry 加 systemPrompt + tools 字段 - tools.ts: ToolRegistry.subset(names) 返回共享 handler 的子集视图 - trigger.ts: extractRole 解析 slash; 传 systemPrompt + runTools; catch 链容错 P2025 - runner.ts: RunRequest.systemPrompt 改 string | undefined (exactOptionalPropertyTypes) - schema.prisma + migration: RoleTriggerGrant(projectId, roleId, principal, revokedAt) - permission.ts: canTriggerRole gate (有 grant 记录即白名单模式,含 revoked) - server.ts: draft/review 两个 role 加 systemPrompt + tools 白名单 - 测试: role-permission.test.ts (5) + trigger.test.ts (+3), 53 全绿
255 lines
9.8 KiB
TypeScript
255 lines
9.8 KiB
TypeScript
import { describe, it, expect, beforeEach, afterAll, vi } from "vitest";
|
|
import { prisma, resetDb, mockFeishuRuntime, MockProvider, seedProject, silentLogger } from "./helpers.js";
|
|
import { InMemoryModelRegistry } from "../../src/agent/models.js";
|
|
import { ToolRegistry, feishuContextTool } from "../../src/agent/tools.js";
|
|
import { makeTriggerHandler, extractPrompt } from "../../src/feishu/trigger.js";
|
|
import type { MessageReceiveEvent } from "../../src/feishu/client.js";
|
|
|
|
const bot = { key: "@_user_1", id: { open_id: "ou_bot" }, name: "Bot" };
|
|
|
|
function makeEvent(chatId: string, text: string, senderOpenId = "ou_test_user"): MessageReceiveEvent {
|
|
return {
|
|
message: {
|
|
message_id: "m_" + Math.random().toString(36).slice(2),
|
|
chat_id: chatId,
|
|
chat_type: "group",
|
|
message_type: "text",
|
|
content: JSON.stringify({ text }),
|
|
mentions: [bot],
|
|
},
|
|
sender: { sender_id: { open_id: senderOpenId }, sender_type: "user" },
|
|
};
|
|
}
|
|
|
|
describe("trigger full lifecycle (integration)", () => {
|
|
let provider: MockProvider;
|
|
let tools: ToolRegistry;
|
|
let models: InMemoryModelRegistry;
|
|
let rt: ReturnType<typeof mockFeishuRuntime>;
|
|
|
|
beforeEach(async () => {
|
|
await resetDb();
|
|
provider = new MockProvider();
|
|
tools = new ToolRegistry();
|
|
models = new InMemoryModelRegistry(
|
|
[{ id: "mock-model", label: "Mock", toolCapable: true }],
|
|
[
|
|
{ id: "draft", label: "草稿", defaultModel: "mock-model", systemPrompt: undefined, tools: undefined },
|
|
{ id: "review", label: "审校", defaultModel: "mock-model", systemPrompt: undefined, tools: ["read_file"] },
|
|
],
|
|
);
|
|
rt = mockFeishuRuntime();
|
|
});
|
|
|
|
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 });
|
|
|
|
await trigger(makeEvent("chat-1", "@_user_1 写教案"), rt);
|
|
|
|
// Wait for the async run to complete (fire-and-forget in trigger).
|
|
await vi.waitFor(async () => {
|
|
const runs = await prisma.agentRun.findMany();
|
|
expect(runs).toHaveLength(1);
|
|
expect(runs[0]?.status).toBe("COMPLETED");
|
|
});
|
|
|
|
// Lock released (no lock row remains).
|
|
const locks = await prisma.projectAgentLock.findMany();
|
|
expect(locks).toHaveLength(0);
|
|
|
|
// A status card was sent.
|
|
expect(rt.sentCards.length).toBeGreaterThanOrEqual(1);
|
|
expect(rt.sentTexts).toContain("已开始处理(role: draft, model: mock-model)。");
|
|
});
|
|
|
|
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 });
|
|
|
|
await trigger(makeEvent("chat-2", "@_user_1 写教案"), rt);
|
|
|
|
expect(rt.sentTexts).toContain("无权限触发。");
|
|
const runs = await prisma.agentRun.findMany();
|
|
expect(runs).toHaveLength(0);
|
|
});
|
|
|
|
it("replies busy when project is already locked (ADR-0002)", async () => {
|
|
await seedProject("proj-3", "chat-3");
|
|
// Manually create a lock by inserting a run + lock.
|
|
const existingRun = await prisma.agentRun.create({
|
|
data: { projectId: "proj-3", entrypoint: "FEISHU", status: "ACTIVE", prompt: "x", model: "m", provider: "mock", metadata: {} },
|
|
});
|
|
await prisma.projectAgentLock.create({
|
|
data: { projectId: "proj-3", runId: existingRun.id },
|
|
});
|
|
|
|
const trigger = makeTriggerHandler({ prisma, provider, tools, models, logger: silentLogger });
|
|
await trigger(makeEvent("chat-3", "@_user_1 写教案"), rt);
|
|
|
|
expect(rt.sentTexts).toContain("项目正在处理中,请稍候。");
|
|
// No new run created.
|
|
const runs = await prisma.agentRun.findMany();
|
|
expect(runs).toHaveLength(1);
|
|
});
|
|
|
|
it("ignores messages from unbound chats (ADR-0001)", async () => {
|
|
await seedProject("proj-4", "chat-4");
|
|
const trigger = makeTriggerHandler({ prisma, provider, tools, models, logger: silentLogger });
|
|
|
|
await trigger(makeEvent("chat-UNKNOWN", "@_user_1 写教案"), rt);
|
|
|
|
expect(rt.sentTexts).toHaveLength(0);
|
|
expect(rt.sentCards).toHaveLength(0);
|
|
const runs = await prisma.agentRun.findMany();
|
|
expect(runs).toHaveLength(0);
|
|
});
|
|
|
|
it("ignores messages without @bot mention", async () => {
|
|
await seedProject("proj-5", "chat-5");
|
|
const trigger = makeTriggerHandler({ prisma, provider, tools, models, logger: silentLogger });
|
|
|
|
const event: MessageReceiveEvent = {
|
|
message: {
|
|
message_id: "m_nobot",
|
|
chat_id: "chat-5",
|
|
chat_type: "group",
|
|
message_type: "text",
|
|
content: JSON.stringify({ text: "hello no bot" }),
|
|
mentions: undefined,
|
|
},
|
|
sender: { sender_id: { open_id: "ou_test_user" }, sender_type: "user" },
|
|
};
|
|
await trigger(event, rt);
|
|
|
|
expect(rt.sentTexts).toHaveLength(0);
|
|
const runs = await prisma.agentRun.findMany();
|
|
expect(runs).toHaveLength(0);
|
|
});
|
|
|
|
it("/new archives current session (no run created)", async () => {
|
|
await seedProject("proj-6", "chat-6");
|
|
const trigger = makeTriggerHandler({ prisma, provider, tools, models, logger: silentLogger });
|
|
|
|
// First @bot creates a session + run.
|
|
await trigger(makeEvent("chat-6", "@_user_1 写教案"), rt);
|
|
await vi.waitFor(async () => {
|
|
const runs = await prisma.agentRun.findMany();
|
|
expect(runs).toHaveLength(1);
|
|
expect(runs[0]?.status).toBe("COMPLETED");
|
|
});
|
|
|
|
// /new archives the session.
|
|
await trigger(makeEvent("chat-6", "@_user_1 /new"), rt);
|
|
expect(rt.sentTexts).toContain("已开新会话,下次 @bot 将从头开始。");
|
|
|
|
const sessions = await prisma.agentSession.findMany();
|
|
expect(sessions).toHaveLength(1);
|
|
expect(sessions[0]?.archivedAt).not.toBeNull();
|
|
// No new run created for /new.
|
|
expect(await prisma.agentRun.findMany()).toHaveLength(1);
|
|
});
|
|
|
|
it("/resume un-archives the most recent session", async () => {
|
|
await seedProject("proj-7", "chat-7");
|
|
const trigger = makeTriggerHandler({ prisma, provider, tools, models, logger: silentLogger });
|
|
|
|
// Create + archive a session via /new.
|
|
await trigger(makeEvent("chat-7", "@_user_1 写教案"), rt);
|
|
await vi.waitFor(async () => {
|
|
expect(await prisma.agentRun.findMany()).toHaveLength(1);
|
|
});
|
|
await trigger(makeEvent("chat-7", "@_user_1 /new"), rt);
|
|
|
|
// /resume un-archives.
|
|
await trigger(makeEvent("chat-7", "@_user_1 /resume"), rt);
|
|
expect(rt.sentTexts).toContain("已恢复上一个会话。");
|
|
|
|
const sessions = await prisma.agentSession.findMany();
|
|
expect(sessions).toHaveLength(1);
|
|
expect(sessions[0]?.archivedAt).toBeNull();
|
|
});
|
|
|
|
it("/reset archives current session", async () => {
|
|
await seedProject("proj-8", "chat-8");
|
|
const trigger = makeTriggerHandler({ prisma, provider, tools, models, logger: silentLogger });
|
|
|
|
await trigger(makeEvent("chat-8", "@_user_1 写教案"), rt);
|
|
await vi.waitFor(async () => {
|
|
const runs = await prisma.agentRun.findMany();
|
|
expect(runs).toHaveLength(1);
|
|
expect(runs[0]?.status).toBe("COMPLETED");
|
|
});
|
|
|
|
await trigger(makeEvent("chat-8", "@_user_1 /reset"), rt);
|
|
expect(rt.sentTexts).toContain("已重置,下次 @bot 将从头开始。");
|
|
|
|
const sessions = await prisma.agentSession.findMany();
|
|
expect(sessions).toHaveLength(1);
|
|
expect(sessions[0]?.archivedAt).not.toBeNull();
|
|
});
|
|
|
|
it("unknown slash command falls through to agent", async () => {
|
|
await seedProject("proj-9", "chat-9");
|
|
const trigger = makeTriggerHandler({ prisma, provider, tools, models, logger: silentLogger });
|
|
|
|
await trigger(makeEvent("chat-9", "@_user_1 /unknown"), rt);
|
|
// Should create a run (falls through as a normal prompt).
|
|
await vi.waitFor(async () => {
|
|
const runs = await prisma.agentRun.findMany();
|
|
expect(runs).toHaveLength(1);
|
|
expect(runs[0]?.prompt).toBe("/unknown");
|
|
});
|
|
});
|
|
|
|
it("denies /review when sender has no role grant (per-role gate)", async () => {
|
|
await seedProject("proj-10", "chat-10");
|
|
// Someone else holds review; ou_test_user does not.
|
|
await prisma.roleTriggerGrant.create({
|
|
data: { projectId: "proj-10", roleId: "review", principal: "ou_other" },
|
|
});
|
|
const trigger = makeTriggerHandler({ prisma, provider, tools, models, logger: silentLogger });
|
|
|
|
await trigger(makeEvent("chat-10", "@_user_1 /review 看看这节"), rt);
|
|
|
|
expect(rt.sentTexts).toContain("无权限使用角色 review。");
|
|
const runs = await prisma.agentRun.findMany();
|
|
expect(runs).toHaveLength(0);
|
|
});
|
|
|
|
it("allows /review when sender holds the role grant", async () => {
|
|
await seedProject("proj-11", "chat-11");
|
|
await prisma.roleTriggerGrant.create({
|
|
data: { projectId: "proj-11", roleId: "review", principal: "ou_test_user" },
|
|
});
|
|
const trigger = makeTriggerHandler({ prisma, provider, tools, models, logger: silentLogger });
|
|
|
|
await trigger(makeEvent("chat-11", "@_user_1 /review 看看这节"), rt);
|
|
|
|
await vi.waitFor(async () => {
|
|
const runs = await prisma.agentRun.findMany();
|
|
expect(runs).toHaveLength(1);
|
|
expect(runs[0]?.status).toBe("COMPLETED");
|
|
expect(runs[0]?.metadata).toMatchObject({ roleId: "review" });
|
|
});
|
|
});
|
|
|
|
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 });
|
|
|
|
await trigger(makeEvent("chat-12", "@_user_1 /draft 写第三单元"), rt);
|
|
|
|
await vi.waitFor(async () => {
|
|
const runs = await prisma.agentRun.findMany();
|
|
expect(runs).toHaveLength(1);
|
|
expect(runs[0]?.prompt).toBe("写第三单元");
|
|
expect(runs[0]?.metadata).toMatchObject({ roleId: "draft" });
|
|
});
|
|
});
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await prisma.$disconnect();
|
|
});
|