import { describe, it, expect, beforeEach, afterAll, vi } from "vitest"; import { prisma, resetDb, mockFeishuRuntime, seedProject, silentLogger } from "./helpers.js"; import { InMemoryModelRegistry } from "../../src/agent/models.js"; import { makeTriggerHandler, extractPrompt } from "../../src/feishu/trigger.js"; import { TriggerQueue } from "../../src/feishu/triggerQueue.js"; import type { MessageReceiveEvent } from "../../src/feishu/client.js"; import type { RunRequest, RunResult } from "../../src/agent/runner.js"; import type { RuntimeSettings } from "../../src/settings/runtime.js"; const bot = { key: "@_user_1", id: { open_id: "ou_bot" }, name: "Bot" }; type TestRunner = (req: RunRequest) => Promise; function makeTestSettings(models: InMemoryModelRegistry): RuntimeSettings { return { async provider(providerId) { return { id: providerId, baseUrl: "https://openrouter.ai/api", authToken: "test-token", anthropicApiKey: "", sdkEnv: { ANTHROPIC_BASE_URL: "https://openrouter.ai/api", ANTHROPIC_AUTH_TOKEN: "test-token", ANTHROPIC_API_KEY: "", }, }; }, async modelRegistry() { return models; }, async runPolicy() { return { maxTurns: 7 }; }, }; } 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" }, }; } function createMockRunAgent(calls: RunRequest[] = []): TestRunner { return async (req) => { calls.push(req); req.onStream?.({ type: "text-delta", text: "mock response" }); await req.prisma.agentMessage.create({ data: { sessionId: req.sessionId, runId: req.runId, role: "assistant", content: "mock response", attachments: [], }, }); return { status: "completed", text: "mock response", usage: { inputTokens: 10, outputTokens: 5 }, numTurns: 1, sdkSessionId: "sdk-session-1", }; }; } describe("trigger full lifecycle (integration)", () => { let models: InMemoryModelRegistry; let settings: RuntimeSettings; let rt: ReturnType; let runAgentCalls: RunRequest[]; let runAgent: TestRunner; beforeEach(async () => { await resetDb(); runAgentCalls = []; runAgent = createMockRunAgent(runAgentCalls); 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"] }, ], ); settings = makeTestSettings(models); rt = mockFeishuRuntime(); }); it("creates a run, acquires + releases the lock, sends status card", async () => { await seedProject("proj-1", "chat-1"); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); 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). await vi.waitFor(async () => { 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("mock response"); expect(runAgentCalls).toHaveLength(1); expect(runAgentCalls[0]?.providerEnv).toMatchObject({ ANTHROPIC_BASE_URL: "https://openrouter.ai/api", ANTHROPIC_AUTH_TOKEN: "test-token", ANTHROPIC_API_KEY: "", }); expect(runAgentCalls[0]?.maxTurns).toBe(7); }); it("batches quick text messages from the same chat and sender into one run", async () => { await seedProject("proj-1b", "chat-1b"); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { debounceMs: 10_000, maxMessages: 2 }, }); await trigger(makeEvent("chat-1b", "@_user_1 第一段"), rt); expect(await prisma.agentRun.findMany()).toHaveLength(0); await trigger(makeEvent("chat-1b", "@_user_1 第二段"), rt); await vi.waitFor(async () => { const runs = await prisma.agentRun.findMany(); expect(runs).toHaveLength(1); expect(runs[0]?.status).toBe("COMPLETED"); expect(runs[0]?.prompt).toBe("第一段\n第二段"); }); expect(runAgentCalls).toHaveLength(1); expect(runAgentCalls[0]?.prompt).toBe("第一段\n第二段"); }); it("/new bypasses message batching", async () => { await seedProject("proj-1c", "chat-1c"); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { debounceMs: 10_000 }, }); await trigger(makeEvent("chat-1c", "@_user_1 /new"), rt); expect(rt.sentTexts).toContain("已开新会话,下次 @bot 将从头开始。"); expect(runAgentCalls).toHaveLength(0); expect(await prisma.agentRun.findMany()).toHaveLength(0); }); it("rejects a sender without edit grant (ADR-0004)", async () => { await seedProject("proj-2", "chat-2", { role: "READ" }); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); await trigger(makeEvent("chat-2", "@_user_1 写教案"), rt); expect(rt.sentTexts).toContain("无权限触发。"); expect(runAgentCalls).toHaveLength(0); const runs = await prisma.agentRun.findMany(); expect(runs).toHaveLength(0); }); it("queues a text trigger 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, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); await trigger(makeEvent("chat-3", "@_user_1 写教案"), rt); expect(rt.sentTexts).toContain("已加入队列(第1位),当前处理完成后将自动开始"); expect(rt.reactions.some((reaction) => reaction.emoji === "OnIt")).toBe(false); expect(runAgentCalls).toHaveLength(0); // No new run created. const runs = await prisma.agentRun.findMany(); expect(runs).toHaveLength(1); }); it("starts the next queued text trigger when the current run finishes", async () => { await seedProject("proj-3b", "chat-3b"); const firstRun = deferred(); const secondRun = deferred(); const pendingRuns = [firstRun, secondRun]; const queuedRunAgent: TestRunner = async (req) => { runAgentCalls.push(req); req.onStream?.({ type: "text-delta", text: `mock response ${runAgentCalls.length}` }); const pendingRun = pendingRuns.shift(); if (pendingRun === undefined) { throw new Error("unexpected extra run"); } return pendingRun.promise; }; const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent: queuedRunAgent, messageBatcherOptions: { maxMessages: 1 }, }); await trigger(makeEvent("chat-3b", "@_user_1 第一个请求"), rt); await vi.waitFor(() => { expect(runAgentCalls).toHaveLength(1); }); await trigger(makeEvent("chat-3b", "@_user_1 第二个请求"), rt); expect(rt.sentTexts).toContain("已加入队列(第1位),当前处理完成后将自动开始"); expect(runAgentCalls).toHaveLength(1); firstRun.resolve(completedRunResult("first done", "sdk-session-first")); await vi.waitFor(() => { expect(runAgentCalls).toHaveLength(2); }); expect(runAgentCalls[1]?.prompt).toBe("第二个请求"); secondRun.resolve(completedRunResult("second done", "sdk-session-second")); await vi.waitFor(async () => { const runs = await prisma.agentRun.findMany({ orderBy: { startedAt: "asc" } }); expect(runs).toHaveLength(2); expect(runs.every((run) => run.status === "COMPLETED")).toBe(true); }); }); it("ignores messages from unbound chats (ADR-0001)", async () => { await seedProject("proj-4", "chat-4"); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); 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, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); 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, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); // 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, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); // 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 queue = new TriggerQueue(); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 }, triggerQueue: queue, }); 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"); }); const queuedEvent = makeEvent("chat-8", "@_user_1 后续需求"); queue.enqueue("proj-8", { chatId: "chat-8", prompt: extractPrompt(queuedEvent.message) ?? "后续需求", msg: queuedEvent.message, senderOpenId: "ou_test_user", actor: { feishuOpenId: "ou_test_user", chatId: "chat-8" }, }); expect(queue.length("proj-8")).toBe(1); await trigger(makeEvent("chat-8", "@_user_1 /reset"), rt); expect(rt.sentTexts).toContain("已重置,下次 @bot 将从头开始。"); expect(queue.length("proj-8")).toBe(0); 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, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); 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", principalType: "USER", principalId: "ou_other" }, }); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); await trigger(makeEvent("chat-10", "@_user_1 /review 看看这节"), rt); expect(rt.sentTexts).toContain("无权限使用角色 review。"); expect(runAgentCalls).toHaveLength(0); 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", principalType: "USER", principalId: "ou_test_user" }, }); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); 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, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); 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, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); // 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("dedups flattened websocket events by top-level event_id", async () => { await seedProject("proj-13b", "chat-13b"); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); const event: MessageReceiveEvent = { ...makeEvent("chat-13b", "@_user_1 写教案"), event_id: "evt-flat-1", event_type: "im.message.receive_v1", }; await trigger(event, rt); await vi.waitFor(async () => { expect(await prisma.agentRun.findMany()).toHaveLength(1); }); await trigger( { ...event, message: { ...event.message, message_id: "m_flat_redelivery" }, }, rt, ); const runs = await prisma.agentRun.findMany(); expect(runs).toHaveLength(1); const receipts = await prisma.feishuEventReceipt.findMany(); expect(receipts).toHaveLength(1); expect(receipts[0]?.eventId).toBe("evt-flat-1"); }); it("seeds replied-to message context when @bot is used in a Feishu reply", async () => { await seedProject("proj-13c", "chat-13c"); rt.readableMessages.set("m-parent", { message_id: "m-parent", root_id: "m-root", thread_id: "thread-1", msg_type: "text", chat_id: "chat-13c", sender: { id: "ou_teacher_2", sender_type: "user" }, body: { content: JSON.stringify({ text: "上一条需求: 把第二题改成探究题。" }) }, }); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); const baseEvent = makeEvent("chat-13c", "@_user_1 继续这个改法"); const event: MessageReceiveEvent = { ...baseEvent, message: { ...baseEvent.message, message_id: "m-child", root_id: "m-root", parent_id: "m-parent", thread_id: "thread-1", }, }; await trigger(event, rt); await vi.waitFor(() => { expect(runAgentCalls).toHaveLength(1); }); const prompt = runAgentCalls[0]?.prompt ?? ""; expect(prompt).toContain("Feishu trigger context"); expect(prompt).toContain("reply_to_message_id"); expect(prompt).toContain("m-parent"); expect(prompt).toContain("thread-1"); expect(prompt).toContain("上一条需求"); expect(prompt).toContain("User request:\n继续这个改法"); const run = await prisma.agentRun.findFirst(); expect(run?.prompt).toBe(prompt); expect(run?.metadata).toMatchObject({ roleId: "draft", rawPrompt: "继续这个改法", feishuTriggerContext: { trigger_message_id: "m-child", reply_to_message_id: "m-parent", root_id: "m-root", thread_id: "thread-1", }, }); }); it("writes audit entries across the run lifecycle", async () => { await seedProject("proj-14", "chat-14"); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); 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, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); 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); }); }); interface Deferred { readonly promise: Promise; readonly resolve: (value: T) => void; readonly reject: (reason?: unknown) => void; } function deferred(): Deferred { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); return { promise, resolve, reject }; } function completedRunResult(text: string, sdkSessionId: string): RunResult { return { status: "completed", text, usage: { inputTokens: 10, outputTokens: 5 }, numTurns: 1, sdkSessionId, }; } afterAll(async () => { await prisma.$disconnect(); });