import { describe, it, expect, beforeEach, afterAll, vi } from "vitest"; import { prisma, resetDb, mockFeishuRuntime, createMockModelFactory, seedProject, silentLogger } from "./helpers.js"; import { InMemoryModelRegistry } from "../../src/agent/models.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" }; function makeEvent(chatId: string, text: string, senderOpenId = "ou_test_user", eventId?: string): MessageReceiveEvent { const header = eventId !== undefined ? { event_id: eventId, event_type: "im.message.receive_v1" } : undefined; return { header, 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 modelFactory: ModelFactory; let tools: ToolRegistry; let models: InMemoryModelRegistry; let rt: ReturnType; beforeEach(async () => { await resetDb(); modelFactory = createMockModelFactory().modelFactory; 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, modelFactory, 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, modelFactory, 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, modelFactory, 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, modelFactory, 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, modelFactory, 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, modelFactory, 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, modelFactory, 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, modelFactory, 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, modelFactory, 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, modelFactory, 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, modelFactory, 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, modelFactory, 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" }); }); }); it("dedups a redelivered event by event_id (no second run)", async () => { await seedProject("proj-13", "chat-13"); const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger }); // First delivery: processes normally. await trigger(makeEvent("chat-13", "@_user_1 写教案", "ou_test_user", "evt-dedup-1"), rt); await vi.waitFor(async () => { const runs = await prisma.agentRun.findMany(); expect(runs).toHaveLength(1); expect(runs[0]?.status).toBe("COMPLETED"); }); // Redelivery of the same event_id: must not create a second run. await trigger(makeEvent("chat-13", "@_user_1 写教案", "ou_test_user", "evt-dedup-1"), rt); const runs2 = await prisma.agentRun.findMany(); expect(runs2).toHaveLength(1); // Only one event receipt row. const receipts = await prisma.feishuEventReceipt.findMany(); expect(receipts).toHaveLength(1); expect(receipts[0]?.eventId).toBe("evt-dedup-1"); }); it("writes audit entries across the run lifecycle", async () => { await seedProject("proj-14", "chat-14"); const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger }); await trigger(makeEvent("chat-14", "@_user_1 写教案"), rt); await vi.waitFor(async () => { const audit = await prisma.auditEntry.findMany(); expect(audit.length).toBeGreaterThanOrEqual(2); }); const audit = await prisma.auditEntry.findMany({ orderBy: { createdAt: "asc" } }); expect(audit.some((a) => a.action === "run.created")).toBe(true); expect(audit.some((a) => a.action === "run.finished")).toBe(true); }); it("persists AgentMessage rows for the run (A-3 structured history)", async () => { await seedProject("proj-15", "chat-15"); const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger }); await trigger(makeEvent("chat-15", "@_user_1 写教案"), rt); await vi.waitFor(async () => { const msgs = await prisma.agentMessage.findMany(); expect(msgs.length).toBeGreaterThanOrEqual(1); }); const msgs = await prisma.agentMessage.findMany({ orderBy: { createdAt: "asc" } }); expect(msgs.length).toBeGreaterThanOrEqual(1); const run = await prisma.agentRun.findFirst(); expect(msgs.every((m) => m.runId === run?.id)).toBe(true); expect(msgs.some((m) => m.role === "assistant")).toBe(true); }); }); afterAll(async () => { await prisma.$disconnect(); });