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; 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" }], ); 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("已开始处理(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); }); }); afterAll(async () => { await prisma.$disconnect(); });