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, CardActionEvent } 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 expectPromptFromSender(prompt: string | undefined, senderOpenId: string, rawPrompt: string): void { expect(prompt).toContain("Feishu trigger context"); expect(prompt).toContain(`"open_id": "${senderOpenId}"`); expect(prompt).toContain(`User request from ${senderOpenId}:\n${rawPrompt}`); } 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 }, costUsd: 0.0023, 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 } }); const event = makeEvent("chat-1", "@_user_1 写教案"); await trigger(event, 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.sentReplies[0]).toMatchObject({ messageId: event.message.message_id, msgType: "interactive", replyInThread: undefined, }); expect(rt.sentTexts.some((text) => text.includes("mock response") && text.includes("本次成本: $0.0023"))).toBe(true); 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); const run = await prisma.agentRun.findFirstOrThrow(); expect(Number(run.costUsd)).toBeCloseTo(0.0023); expect(run.costSource).toBe("provider_reported"); }); 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"); expectPromptFromSender(runs[0]?.prompt, "ou_test_user", "第一段\n第二段"); }); expect(runAgentCalls).toHaveLength(1); expectPromptFromSender(runAgentCalls[0]?.prompt, "ou_test_user", "第一段\n第二段"); }); it("keeps the project session shared while labeling each sender in the prompt", async () => { await seedProject("proj-speaker", "chat-speaker"); await prisma.permissionGrant.create({ data: { resourceType: "PROJECT", resourceId: "proj-speaker", principalType: "FEISHU_CHAT", principalId: "chat-speaker", role: "EDIT", }, }); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); await trigger(makeEvent("chat-speaker", "@_user_1 Alice 的需求", "ou_alice"), rt); await vi.waitFor(() => { expect(runAgentCalls).toHaveLength(1); }); await vi.waitFor(async () => { expect(await prisma.projectAgentLock.findMany()).toHaveLength(0); }); await trigger(makeEvent("chat-speaker", "@_user_1 Bob 的需求", "ou_bob"), rt); await vi.waitFor(() => { expect(runAgentCalls).toHaveLength(2); }); expectPromptFromSender(runAgentCalls[0]?.prompt, "ou_alice", "Alice 的需求"); expectPromptFromSender(runAgentCalls[1]?.prompt, "ou_bob", "Bob 的需求"); const sessions = await prisma.agentSession.findMany(); expect(sessions).toHaveLength(1); }); 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("/help lists control and role commands without creating a run", async () => { await seedProject("proj-help", "chat-help"); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); await trigger(makeEvent("chat-help", "@_user_1 /help"), rt); const helpText = rt.sentTexts.at(-1) ?? ""; expect(helpText).toContain("可用 slash 命令"); expect(helpText).toContain("/new"); expect(helpText).toContain("/reset"); expect(helpText).toContain("/draft <需求>"); expect(helpText).toContain("/review <需求>"); expect(runAgentCalls).toHaveLength(0); expect(await prisma.agentRun.findMany()).toHaveLength(0); }); it("/cost reports recorded current-session cost without creating a run", async () => { await seedProject("proj-cost", "chat-cost"); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); await trigger(makeEvent("chat-cost", "@_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-cost", "@_user_1 /cost"), rt); const costText = rt.sentTexts.at(-1) ?? ""; expect(costText).toContain("当前会话已记录 agent 成本"); expect(costText).toContain("总计: $0.0023"); expect(costText).toContain("Runs: 1 已记录"); expect(costText).toContain("openrouter / mock-model"); expect(runAgentCalls).toHaveLength(1); expect(await prisma.agentRun.findMany()).toHaveLength(1); }); it("/cost surfaces finished runs without recorded cost", async () => { await seedProject("proj-cost-missing", "chat-cost-missing"); const session = await prisma.agentSession.create({ data: { projectId: "proj-cost-missing", provider: "openrouter", roleId: "draft", model: "mock-model", metadata: {}, }, select: { id: true }, }); await prisma.agentRun.create({ data: { projectId: "proj-cost-missing", sessionId: session.id, entrypoint: "FEISHU", status: "COMPLETED", prompt: "old test run", model: "mock-model", provider: "openrouter", inputTokens: 10, outputTokens: 5, metadata: {}, finishedAt: new Date(), }, }); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); await trigger(makeEvent("chat-cost-missing", "@_user_1 /cost"), rt); const costText = rt.sentTexts.at(-1) ?? ""; expect(costText).toContain("还没有任何 run 记录到真实成本"); expect(costText).toContain("未记录成本: 1 runs"); expect(runAgentCalls).toHaveLength(0); }); it("/help is built from the current registry on each request", async () => { await seedProject("proj-help-live", "chat-help-live"); let currentModels = new InMemoryModelRegistry( [{ id: "mock-model", label: "Mock", toolCapable: true }], [{ id: "draft", label: "草稿", defaultModel: "mock-model", systemPrompt: undefined, tools: undefined }], ); const dynamicSettings: RuntimeSettings = { async provider(providerId, scope) { return settings.provider(providerId, scope); }, async modelRegistry() { return currentModels; }, async runPolicy(input) { return settings.runPolicy(input); }, }; const trigger = makeTriggerHandler({ prisma, settings: dynamicSettings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); await trigger(makeEvent("chat-help-live", "@_user_1 /help"), rt); expect(rt.sentTexts.at(-1) ?? "").not.toContain("/coach <需求>"); currentModels = new InMemoryModelRegistry( [{ id: "mock-model", label: "Mock", toolCapable: true }], [ { id: "draft", label: "草稿", defaultModel: "mock-model", systemPrompt: undefined, tools: undefined }, { id: "coach", label: "教练", defaultModel: "mock-model", systemPrompt: undefined, tools: [] }, ], ); await trigger(makeEvent("chat-help-live", "@_user_1 /help"), rt); const helpText = rt.sentTexts.at(-1) ?? ""; expect(helpText).toContain("/coach <需求>"); expect(runAgentCalls).toHaveLength(0); expect(await prisma.agentRun.findMany()).toHaveLength(0); }); it("/help returns command-specific help", async () => { await seedProject("proj-help-reset", "chat-help-reset"); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); await trigger(makeEvent("chat-help-reset", "@_user_1 /help reset"), rt); const helpText = rt.sentTexts.at(-1) ?? ""; expect(helpText).toContain("/reset"); expect(helpText).toContain("清空当前项目已经排队"); expect(helpText).toContain("/reset help"); expect(runAgentCalls).toHaveLength(0); expect(await prisma.agentRun.findMany()).toHaveLength(0); }); it("passes the selected role tool whitelist into the runner", async () => { await seedProject("proj-role-tools", "chat-role-tools"); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); await trigger(makeEvent("chat-role-tools", "@_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(runAgentCalls).toHaveLength(1); expect(runAgentCalls[0]?.tools).toEqual(["read_file"]); }); it("control commands support a help subcommand", async () => { await seedProject("proj-new-help", "chat-new-help"); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); await trigger(makeEvent("chat-new-help", "@_user_1 /new help"), rt); const helpText = rt.sentTexts.at(-1) ?? ""; expect(helpText).toContain("/new"); expect(helpText).toContain("归档当前未归档"); expect(helpText).toContain("/help new"); expect(rt.sentTexts).not.toContain("已开新会话,下次 @bot 将从头开始。"); expect(runAgentCalls).toHaveLength(0); expect(await prisma.agentRun.findMany()).toHaveLength(0); }); it("role commands support a help subcommand without consuming role grants", async () => { await seedProject("proj-role-help", "chat-role-help"); await prisma.roleTriggerGrant.create({ data: { projectId: "proj-role-help", roleId: "review", principalType: "USER", principalId: "ou_other" }, }); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); await trigger(makeEvent("chat-role-help", "@_user_1 /review help"), rt); const helpText = rt.sentTexts.at(-1) ?? ""; expect(helpText).toContain("/review"); expect(helpText).toContain("使用“审校”角色"); expect(helpText).toContain("工具范围: read_file"); expect(rt.sentTexts).not.toContain("无权限使用角色 review。"); expect(runAgentCalls).toHaveLength(0); expect(await prisma.agentRun.findMany()).toHaveLength(0); }); it("/help unknown and /unknown help report the unknown command", async () => { await seedProject("proj-help-unknown", "chat-help-unknown"); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); await trigger(makeEvent("chat-help-unknown", "@_user_1 /help unknown"), rt); expect(rt.sentTexts.at(-1) ?? "").toContain("未知 slash 命令 /unknown"); expect(runAgentCalls).toHaveLength(0); await trigger(makeEvent("chat-help-unknown", "@_user_1 /unknown help"), rt); expect(rt.sentTexts.at(-1) ?? "").toContain("未知 slash 命令 /unknown"); 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); }); expectPromptFromSender(runAgentCalls[1]?.prompt, "ou_test_user", "第二个请求"); 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); expectPromptFromSender(runs[0]?.prompt, "ou_test_user", "/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); expectPromptFromSender(runs[0]?.prompt, "ou_test_user", "写第三单元"); expect(runs[0]?.metadata).toMatchObject({ roleId: "draft" }); }); }); it("keeps role sessions separate even when roles share a model", async () => { await seedProject("proj-12b", "chat-12b"); await prisma.roleTriggerGrant.create({ data: { projectId: "proj-12b", roleId: "review", principalType: "USER", principalId: "ou_test_user" }, }); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); await trigger(makeEvent("chat-12b", "@_user_1 /draft 写第三单元"), 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-12b", "@_user_1 /review 看看这节"), rt); await vi.waitFor(async () => { const runs = await prisma.agentRun.findMany(); expect(runs).toHaveLength(2); expect(runs.every((run) => run.status === "COMPLETED")).toBe(true); }); const sessions = await prisma.agentSession.findMany({ orderBy: { roleId: "asc" } }); expect(sessions.map((session) => session.roleId)).toEqual(["draft", "review"]); expect(new Set(sessions.map((session) => session.model))).toEqual(new Set(["mock-model"])); expect(new Set(sessions.map((session) => session.id)).size).toBe(2); expect(runAgentCalls[1]?.resumeSessionId).toBeUndefined(); }); 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("上一条需求"); expectPromptFromSender(prompt, "ou_test_user", "继续这个改法"); const run = await prisma.agentRun.findFirst(); expect(run?.prompt).toBe(prompt); expect(run?.metadata).toMatchObject({ roleId: "draft", rawPrompt: "继续这个改法", feishuTriggerContext: { trigger_message_id: "m-child", chat_id: "chat-13c", sender: { open_id: "ou_test_user" }, 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); }); it("interrupts a running agent via the card interrupt button", async () => { await seedProject("proj-int", "chat-int", { role: "MANAGE" }); runAgent = createHangingRunAgent(); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); await trigger(makeEvent("chat-int", "@_user_1 写教案"), rt); // The run started: a card was sent (with an interrupt button) and the run is ACTIVE. await vi.waitFor(async () => { const runs = await prisma.agentRun.findMany(); expect(runs).toHaveLength(1); expect(runs[0]?.status).toBe("ACTIVE"); }); expect(rt.sentCards.length).toBeGreaterThanOrEqual(1); const run = await prisma.agentRun.findFirst(); expect(run).not.toBeNull(); await trigger.onCardAction(makeInterruptEvent("chat-int", run!.id, "ou_test_user"), rt); // The run transitions to CANCELED (spec RunState.canceled is terminal). await vi.waitFor(async () => { const runs = await prisma.agentRun.findMany(); expect(runs[0]?.status).toBe("CANCELED"); }); // The lock is released (terminal ⇒ release, ADR-0002). await vi.waitFor(async () => { const locks = await prisma.projectAgentLock.findMany(); expect(locks).toHaveLength(0); }); // Audit records the interrupt. const audit = await prisma.auditEntry.findMany(); expect(audit.some((a) => a.action === "run.interrupted")).toBe(true); // The card was patched to a complete state with an "已中断" footer. expect(rt.sentPatches.some((card) => cardHasInterruptedFooter(card))).toBe(true); }); it("sends a fallback interrupt notice when the final card patch fails", async () => { await seedProject("proj-int-patch-fail", "chat-int-patch-fail", { role: "MANAGE" }); runAgent = createHangingRunAgent(); const patch = vi.fn(async () => { throw new Error("patch failed"); }); (rt.client as unknown as { im: { v1: { message: { patch: typeof patch } } } }).im.v1.message.patch = patch; const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); await trigger(makeEvent("chat-int-patch-fail", "@_user_1 写教案"), rt); await vi.waitFor(async () => { const runs = await prisma.agentRun.findMany(); expect(runs[0]?.status).toBe("ACTIVE"); }); const run = await prisma.agentRun.findFirst(); expect(run).not.toBeNull(); await trigger.onCardAction(makeInterruptEvent("chat-int-patch-fail", run!.id, "ou_test_user"), rt); await vi.waitFor(async () => { const runs = await prisma.agentRun.findMany(); expect(runs[0]?.status).toBe("CANCELED"); }); expect(patch).toHaveBeenCalled(); expect(rt.sentTexts).toContain("已中断当前运行。"); }); it("denies interrupt when the operator lacks agent.cancel permission", async () => { // EDIT role can trigger but cannot cancel (agent.cancel requires MANAGE). await seedProject("proj-int-deny", "chat-int-deny", { role: "EDIT" }); runAgent = createHangingRunAgent(); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); await trigger(makeEvent("chat-int-deny", "@_user_1 写教案"), rt); await vi.waitFor(async () => { const runs = await prisma.agentRun.findMany(); expect(runs[0]?.status).toBe("ACTIVE"); }); const run = await prisma.agentRun.findFirst(); expect(run).not.toBeNull(); await trigger.onCardAction(makeInterruptEvent("chat-int-deny", run!.id, "ou_test_user"), rt); expect(rt.sentTexts).toContain("无权限中断该运行。"); const audit = await prisma.auditEntry.findMany(); expect(audit.some((a) => a.action === "run.interrupt_denied")).toBe(true); // The run is still active — it was not aborted. const runs = await prisma.agentRun.findMany(); expect(runs[0]?.status).toBe("ACTIVE"); // Clean up: release the hanging run so afterEach isolation isn't disturbed. const active = await prisma.agentRun.findFirst({ where: { status: "ACTIVE" } }); if (active !== null) { await prisma.agentRun.update({ where: { id: active.id }, data: { status: "FAILED", finishedAt: new Date() } }); await prisma.projectAgentLock.deleteMany({ where: { runId: active.id } }); } }); }); 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, }; } function createHangingRunAgent(): TestRunner { // Simulates a long-running agent: never resolves on its own, but resolves // with status "interrupted" when its abortController fires (mirrors the // real runner's abort path). return async (req) => { req.onStream?.({ type: "text-delta", text: "处理中..." }); await req.prisma.agentMessage.create({ data: { sessionId: req.sessionId, runId: req.runId, role: "assistant", content: "处理中...", attachments: [] }, }); const ac = req.abortController; return new Promise((resolve) => { if (ac === undefined) return; if (ac.signal.aborted) { resolve({ status: "interrupted", text: "处理中...", usage: { inputTokens: 1, outputTokens: 1 }, numTurns: 1, sdkSessionId: "sdk-int" }); return; } ac.signal.addEventListener("abort", () => { resolve({ status: "interrupted", text: "处理中...", usage: { inputTokens: 1, outputTokens: 1 }, numTurns: 1, sdkSessionId: "sdk-int" }); }, { once: true }); }); }; } function makeInterruptEvent(chatId: string, runId: string, openId: string): CardActionEvent { return { operator: { open_id: openId }, action: { value: { interrupt_run: runId }, tag: "button" }, context: { open_chat_id: chatId }, }; } function cardHasInterruptedFooter(card: unknown): boolean { if (typeof card !== "object" || card === null) return false; if (!("elements" in card)) return false; const elements = card.elements; if (!Array.isArray(elements)) return false; return elements.some((el) => { if (typeof el !== "object" || el === null) return false; if (!("text" in el)) return false; const text = el.text; if (typeof text !== "object" || text === null || !("content" in text)) return false; const content = text.content; return typeof content === "string" && content.includes("已中断"); }); } afterAll(async () => { await prisma.$disconnect(); });