import { mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { Readable } from "node:stream"; import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from "vitest"; import { DEFAULT_ORG_ID, prisma, resetDb, mockFeishuRuntime, seedProject, seedTestOrganization, silentLogger, } from "./helpers.js"; import { InMemoryModelRegistry } from "../../src/agent/models.js"; import { createSlashCommandRegistry } from "../../src/feishu/slashCommands.js"; import { makeTriggerHandler as makeProductionTriggerHandler, 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" }; const itOnLinux = process.platform === "linux" ? it : it.skip; type TestRunner = (req: RunRequest) => Promise; const workspaceRoots: string[] = []; type TestTriggerDeps = Omit[0], "projectWorkspaceRoot"> & { readonly projectWorkspaceRoot?: string; }; function makeTriggerHandler(deps: TestTriggerDeps): ReturnType { return makeProductionTriggerHandler({ projectWorkspaceRoot: "/tmp", publicBaseUrl: "https://educraft.example.test", siloOrganizationId: DEFAULT_ORG_ID, allowLegacyFeishuIdentity: true, ...deps, }); } function makeTestSettings(models: InMemoryModelRegistry): RuntimeSettings { return { async provider(providerId) { return { id: providerId, async openAgentLease() { return { sdkEnv: { ANTHROPIC_BASE_URL: "http://127.0.0.1:12345", ANTHROPIC_AUTH_TOKEN: "test-run-capability", ANTHROPIC_API_KEY: "", }, sensitiveValues: ["test-run-capability"], async close() {}, }; }, }; }, async modelRegistry() { return models; }, async runPolicy() { return { maxTurns: 7, maxConcurrentRuns: 4, maxRunSeconds: 300 }; }, }; } 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(); }); afterEach(async () => { while (workspaceRoots.length > 0) { const root = workspaceRoots.pop(); if (root !== undefined) await rm(root, { recursive: true, force: true }); } }); 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]?.providerProxyEnv).toMatchObject({ ANTHROPIC_BASE_URL: "http://127.0.0.1:12345", ANTHROPIC_AUTH_TOKEN: "test-run-capability", 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"); }); itOnLinux("includes downloaded post image paths in the agent prompt", async () => { const workspaceRoot = await tempWorkspaceRoot(); const workspaceDir = join(workspaceRoot, "project"); await mkdir(workspaceDir); await seedProject("proj-post-image", "chat-post-image"); await prisma.project.update({ where: { id: "proj-post-image" }, data: { workspaceDir }, }); const messageResourceGet = vi.fn(async () => ({ getReadableStream: () => Readable.from([Buffer.from("image bytes")]), })); const imV1 = (rt.client as unknown as { im: { v1: { messageResource?: { get: typeof messageResourceGet } } }; }).im.v1; imV1.messageResource = { get: messageResourceGet }; const baseEvent = makeEvent("chat-post-image", "@_user_1 看看这张图"); const event: MessageReceiveEvent = { ...baseEvent, message: { ...baseEvent.message, message_type: "post", content: JSON.stringify({ zh_cn: { content: [[ { tag: "text", text: "@_user_1 看看这张图" }, { tag: "img", image_key: "img-key-1" }, ]], }, }), }, }; const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, projectWorkspaceRoot: workspaceRoot, messageBatcherOptions: { maxMessages: 1 }, }); await trigger(event, rt); await vi.waitFor(() => { expect(runAgentCalls).toHaveLength(1); }); expect(runAgentCalls[0]?.prompt).toContain(join(await realpath(workspaceDir), ".cph", "inbox")); expect(messageResourceGet).toHaveBeenCalledWith({ params: { type: "image" }, path: { message_id: event.message.message_id, file_key: "img-key-1" }, }); const inboxFiles = await readdir(join(workspaceDir, ".cph", "inbox")); expect(inboxFiles).toHaveLength(1); await expect(readFile(join(workspaceDir, ".cph", "inbox", inboxFiles[0]!))).resolves.toEqual(Buffer.from("image bytes")); await expect(readdir(join(workspaceRoot, ".cph-staging"))).resolves.toEqual([]); }); it("sends an onboarding card when an unbound chat mentions the bot", async () => { await seedOnboardingUser("u-onboard-card", "ou_onboard_card", "MEMBER"); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, projectWorkspaceRoot: await tempWorkspaceRoot(), }); await trigger(makeEvent("chat-unbound-card", "@_user_1 开始项目", "ou_onboard_card"), rt); expect(rt.sentCards).toHaveLength(1); expect(rt.sentTexts.at(-1)).toContain("这个飞书群还没有绑定项目"); const values = cardActionValues(rt.sentCards[0]); expect(values).toEqual(expect.arrayContaining([ expect.objectContaining({ project_onboarding: expect.objectContaining({ action: "create_project_from_chat", organization_id: DEFAULT_ORG_ID, folder_id: expect.any(String), }), }), ])); expect(runAgentCalls).toHaveLength(0); }); it("creates and binds a project from the unbound-chat onboarding card", async () => { await seedOnboardingUser("u-onboard-create", "ou_onboard_create", "MEMBER"); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, projectWorkspaceRoot: await tempWorkspaceRoot(), }); await trigger.onCardAction(makeOnboardingEvent("chat-onboard-create", { project_onboarding: { action: "create_project_from_chat", organization_id: DEFAULT_ORG_ID, }, }, "ou_onboard_create"), rt); const binding = await prisma.projectGroupBinding.findFirst({ where: { chatId: "chat-onboard-create", archivedAt: null }, select: { projectId: true }, }); expect(binding).not.toBeNull(); const grants = await prisma.permissionGrant.findMany({ where: { resourceType: "PROJECT", resourceId: binding!.projectId, revokedAt: null }, select: { principalType: true, principalId: true, role: true }, }); expect(grants).toEqual(expect.arrayContaining([ { principalType: "USER", principalId: "ou_onboard_create", role: "MANAGE" }, { principalType: "FEISHU_CHAT", principalId: "chat-onboard-create", role: "EDIT" }, ])); expect(cardHeaderTitle(rt.sentPatches.at(-1))).toBe("已创建并绑定项目"); }); it("binds an existing manageable project from the unbound-chat onboarding card", async () => { await seedOnboardingUser("u-onboard-bind", "ou_onboard_bind", "MEMBER"); await prisma.project.create({ data: { id: "p-onboard-bind", organizationId: DEFAULT_ORG_ID, name: "可绑定项目", workspaceDir: join(await tempWorkspaceRoot(), "p-onboard-bind"), }, }); await prisma.permissionGrant.create({ data: { resourceType: "PROJECT", resourceId: "p-onboard-bind", principalType: "USER", principalId: "ou_onboard_bind", role: "MANAGE", createdByUserId: "u-onboard-bind", }, }); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, projectWorkspaceRoot: await tempWorkspaceRoot(), }); await trigger(makeEvent("chat-onboard-list", "@_user_1 绑定项目", "ou_onboard_bind"), rt); expect(cardActionValues(rt.sentCards[0])).toContainEqual({ project_onboarding: { action: "bind_project", organization_id: DEFAULT_ORG_ID, project_id: "p-onboard-bind", }, }); await trigger.onCardAction(makeOnboardingEvent("chat-onboard-list", { project_onboarding: { action: "bind_project", organization_id: DEFAULT_ORG_ID, project_id: "p-onboard-bind", }, }, "ou_onboard_bind"), rt); await expect(prisma.projectGroupBinding.findFirst({ where: { projectId: "p-onboard-bind", chatId: "chat-onboard-list", archivedAt: null }, })).resolves.not.toBeNull(); const chatGrant = await prisma.permissionGrant.findFirst({ where: { resourceType: "PROJECT", resourceId: "p-onboard-bind", principalType: "FEISHU_CHAT", principalId: "chat-onboard-list", revokedAt: null, }, select: { role: true }, }); expect(chatGrant?.role).toBe("EDIT"); expect(cardHeaderTitle(rt.sentPatches.at(-1))).toBe("已绑定项目"); }); 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", }, }); await prisma.user.createMany({ data: [ { id: "u-speaker-alice", feishuOpenId: "ou_alice", displayName: "Alice" }, { id: "u-speaker-bob", feishuOpenId: "ou_bob", displayName: "Bob" }, ], }); await prisma.organizationMembership.createMany({ data: [ { organizationId: DEFAULT_ORG_ID, userId: "u-speaker-alice", role: "MEMBER" }, { organizationId: DEFAULT_ORG_ID, userId: "u-speaker-bob", role: "MEMBER" }, ], }); 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("gives an unknown user the scoped login URL in an already-bound chat", async () => { await seedProject("proj-bound-unknown", "chat-bound-unknown"); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, allowLegacyFeishuIdentity: false, messageBatcherOptions: { maxMessages: 1 }, }); await trigger(makeEvent("chat-bound-unknown", "@_user_1 写教案", "ou_bound_unknown"), rt); expect(rt.sentTexts).toContain( "请先通过飞书登录建立身份:https://educraft.example.test/auth/feishu/test-default\n" + "登录后仍需由组织管理员将你加入组织。", ); expect(rt.sentTexts).not.toContain("无权限触发。"); expect(runAgentCalls).toHaveLength(0); }); it("tells a logged-in non-member to contact the administrator in an already-bound chat", async () => { await seedProject("proj-bound-non-member", "chat-bound-non-member"); await seedScopedIdentityWithoutMembership("bound-non-member", "ou_bound_non_member"); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, allowLegacyFeishuIdentity: false, messageBatcherOptions: { maxMessages: 1 }, }); await trigger(makeEvent("chat-bound-non-member", "@_user_1 写教案", "ou_bound_non_member"), rt); expect(rt.sentTexts).toContain( "你已完成飞书登录,但尚未加入该组织。请联系组织管理员为你开通成员权限。", ); expect(rt.sentTexts).not.toContain("无权限触发。"); expect(runAgentCalls).toHaveLength(0); }); it("keeps generic project denial for an Organization member without project permission", async () => { await seedProject("proj-bound-read-only", "chat-bound-read-only", { role: "READ" }); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 }, }); await trigger(makeEvent("chat-bound-read-only", "@_user_1 写教案"), rt); expect(rt.sentTexts).toContain("无权限触发。"); expect(rt.sentTexts.join("\n")).not.toContain("/auth/feishu/"); expect(rt.sentTexts.join("\n")).not.toContain("尚未加入该组织"); expect(runAgentCalls).toHaveLength(0); }); it.each(["SUSPENDED", "ARCHIVED"] as const)( "rejects triggers and resume commands when the organization is %s", async (status) => { await seedProject(`proj-org-${status}`, `chat-org-${status}`); const session = await prisma.agentSession.create({ data: { projectId: `proj-org-${status}`, provider: "openrouter", roleId: "draft", model: "mock-model", metadata: {}, archivedAt: new Date(), }, }); await prisma.organization.update({ where: { id: DEFAULT_ORG_ID }, data: { status } }); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); await trigger(makeEvent(`chat-org-${status}`, "@_user_1 /resume"), rt); expect(rt.sentTexts).toContain("无权限触发。"); expect(runAgentCalls).toHaveLength(0); expect(await prisma.agentRun.count()).toBe(0); await expect(prisma.agentSession.findUniqueOrThrow({ where: { id: session.id } })).resolves.toMatchObject({ archivedAt: expect.any(Date), }); }, ); it.each(["SUSPENDED", "ARCHIVED"] as const)( "rejects direct session mutation when the organization is %s", async (status) => { await seedProject(`proj-direct-${status}`, `chat-direct-${status}`); const session = await prisma.agentSession.create({ data: { projectId: `proj-direct-${status}`, provider: "openrouter", roleId: "draft", model: "mock-model", metadata: {}, archivedAt: new Date(), }, }); await prisma.organization.update({ where: { id: DEFAULT_ORG_ID }, data: { status } }); const commands = createSlashCommandRegistry({ prisma, settings, logger: silentLogger, triggerQueue: new TriggerQueue(), }); const resume = commands.get("resume"); expect(resume).toBeDefined(); await expect(resume!.run({ invocation: { name: "resume", args: [] }, projectId: `proj-direct-${status}`, chatId: `chat-direct-${status}`, rt, })).rejects.toThrow(`organization ${DEFAULT_ORG_ID} is ${status}`); await expect(prisma.agentSession.findUniqueOrThrow({ where: { id: session.id } })).resolves.toMatchObject({ archivedAt: expect.any(Date), }); }, ); it("reports a lifecycle race that rejects a slash-command mutation", async () => { await seedProject("proj-slash-race", "chat-slash-race"); const session = await prisma.agentSession.create({ data: { projectId: "proj-slash-race", provider: "openrouter", roleId: "draft", model: "mock-model", metadata: {}, archivedAt: new Date(), }, }); await prisma.organization.update({ where: { id: DEFAULT_ORG_ID }, data: { status: "SUSPENDED" } }); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 }, authorizer: { async can(request) { return { allowed: true, reason: "authorized before concurrent suspension", action: request.action, resource: request.resource, actor: request.actor, organizationId: DEFAULT_ORG_ID, principals: [{ type: "USER", id: "ou_test_user" }], requiredRole: "EDIT", effectiveRole: "EDIT", }; }, }, }); await trigger(makeEvent("chat-slash-race", "@_user_1 /resume"), rt); expect(rt.sentTexts).toContain("组织当前不可用,拒绝操作。"); await expect(prisma.agentSession.findUniqueOrThrow({ where: { id: session.id } })).resolves.toMatchObject({ archivedAt: expect.any(Date), }); }); 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("reauthorizes a queued trigger and drops it after the organization is suspended", async () => { await seedProject("proj-queue-suspended", "chat-queue-suspended"); const firstRun = deferred(); const queuedRunAgent: TestRunner = async (req) => { runAgentCalls.push(req); return firstRun.promise; }; const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent: queuedRunAgent, messageBatcherOptions: { maxMessages: 1 }, }); await trigger(makeEvent("chat-queue-suspended", "@_user_1 第一个请求"), rt); await vi.waitFor(() => expect(runAgentCalls).toHaveLength(1)); await trigger(makeEvent("chat-queue-suspended", "@_user_1 第二个请求"), rt); expect(rt.sentTexts).toContain("已加入队列(第1位),当前处理完成后将自动开始"); await prisma.organization.update({ where: { id: DEFAULT_ORG_ID }, data: { status: "SUSPENDED" } }); firstRun.resolve(completedRunResult("first done", "sdk-session-first")); await vi.waitFor(() => expect(rt.sentTexts).toContain("无权限使用角色 draft。")); expect(runAgentCalls).toHaveLength(1); await expect(prisma.agentRun.count()).resolves.toBe(1); }); it("rechecks ACTIVE atomically when suspension lands after the first authorization", async () => { await seedProject("proj-admission-race", "chat-admission-race"); const providerEntered = deferred(); const releaseProvider = deferred(); const baseProvider = settings.provider.bind(settings); const pausedSettings: RuntimeSettings = { ...settings, async provider(providerId, context) { providerEntered.resolve(); await releaseProvider.promise; return baseProvider(providerId, context); }, }; const trigger = makeTriggerHandler({ prisma, settings: pausedSettings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 }, }); const pendingTrigger = trigger(makeEvent("chat-admission-race", "@_user_1 竞态请求"), rt); await providerEntered.promise; await prisma.organization.update({ where: { id: DEFAULT_ORG_ID }, data: { status: "SUSPENDED" } }); releaseProvider.resolve(); await pendingTrigger; expect(rt.sentTexts).toContain("组织当前不可用,拒绝触发。"); expect(runAgentCalls).toHaveLength(0); await expect(prisma.agentSession.count()).resolves.toBe(0); await expect(prisma.agentRun.count()).resolves.toBe(0); await expect(prisma.projectAgentLock.count()).resolves.toBe(0); await expect(prisma.auditEntry.count({ where: { action: "run.created" } })).resolves.toBe(0); }); itOnLinux("does not publish a staged attachment when suspension wins admission", async () => { const workspaceRoot = await tempWorkspaceRoot(); const workspaceDir = join(workspaceRoot, "project"); await mkdir(workspaceDir); await seedProject("proj-attachment-race", "chat-attachment-race"); await prisma.project.update({ where: { id: "proj-attachment-race" }, data: { workspaceDir }, }); const resourceEntered = deferred(); const releaseResource = deferred(); const messageResourceGet = vi.fn(async () => { resourceEntered.resolve(); await releaseResource.promise; return { getReadableStream: () => Readable.from([Buffer.from("staged image bytes")]) }; }); const imV1 = (rt.client as unknown as { im: { v1: { messageResource?: { get: typeof messageResourceGet } } }; }).im.v1; imV1.messageResource = { get: messageResourceGet }; const baseEvent = makeEvent("chat-attachment-race", "@_user_1 附件竞态"); const event: MessageReceiveEvent = { ...baseEvent, message: { ...baseEvent.message, message_type: "post", content: JSON.stringify({ zh_cn: { content: [[ { tag: "text", text: "@_user_1 附件竞态" }, { tag: "img", image_key: "img-race" }, ]], }, }), }, }; const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, projectWorkspaceRoot: workspaceRoot, messageBatcherOptions: { maxMessages: 1 }, }); const pendingTrigger = trigger(event, rt); await resourceEntered.promise; await prisma.organization.update({ where: { id: DEFAULT_ORG_ID }, data: { status: "SUSPENDED" } }); releaseResource.resolve(); await pendingTrigger; expect(rt.sentTexts).toContain("组织当前不可用,拒绝触发。"); expect(runAgentCalls).toHaveLength(0); await expect(prisma.agentRun.count()).resolves.toBe(0); await expect(readdir(join(workspaceDir, ".cph", "inbox"))).rejects.toMatchObject({ code: "ENOENT" }); await expect(readdir(join(workspaceRoot, ".cph-staging"))).resolves.toEqual([]); }); it("does not create a run for unbound chats and gives unknown users the scoped login URL", 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 写教案", "ou_unknown_user"), rt); expect(rt.sentTexts).toContain( "请先通过飞书登录建立身份:https://educraft.example.test/auth/feishu/test-default\n" + "登录后仍需由组织管理员将你加入组织。", ); expect(rt.sentCards).toHaveLength(0); const runs = await prisma.agentRun.findMany(); expect(runs).toHaveLength(0); }); it("distinguishes a scoped Feishu identity that has not joined the Silo Organization", async () => { await seedScopedIdentityWithoutMembership("unbound-non-member", "ou_logged_in_not_member"); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, allowLegacyFeishuIdentity: false, messageBatcherOptions: { maxMessages: 1 }, }); await trigger(makeEvent("chat-unbound", "@_user_1 写教案", "ou_logged_in_not_member"), rt); expect(rt.sentTexts).toContain( "你已完成飞书登录,但尚未加入该组织。请联系组织管理员为你开通成员权限。", ); expect(rt.sentTexts.join("\n")).not.toContain("/auth/feishu/"); await expect(prisma.agentRun.count()).resolves.toBe(0); }); it("encodes the configured Organization slug in the OAuth login URL", async () => { const encodedOrgId = "org_url_encoding"; await seedTestOrganization(encodedOrgId, "school east/数学?"); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, siloOrganizationId: encodedOrgId, publicBaseUrl: "https://school.example.test/", messageBatcherOptions: { maxMessages: 1 }, }); await trigger(makeEvent("chat-unbound", "@_user_1 写教案", "ou_unknown_encoded"), rt); expect(rt.sentTexts.join("\n")).toContain( "https://school.example.test/auth/feishu/school%20east%2F%E6%95%B0%E5%AD%A6%3F", ); expect(rt.sentTexts.join("\n")).not.toContain("school east/数学?"); }); 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 makeOnboardingEvent(chatId: string, value: unknown, openId: string): CardActionEvent { return { operator: { open_id: openId }, action: { value, tag: "button" }, context: { open_chat_id: chatId, open_message_id: "card-message-1" }, }; } async function seedOnboardingUser(id: string, feishuOpenId: string, role: "OWNER" | "ADMIN" | "MEMBER"): Promise { await prisma.user.create({ data: { id, feishuOpenId, displayName: feishuOpenId, organizationMemberships: { create: { organizationId: DEFAULT_ORG_ID, role } }, }, }); } async function seedScopedIdentityWithoutMembership(id: string, openId: string): Promise { const connectionId = `feishu-connection-${id}`; await prisma.organizationFeishuApplicationConnection.create({ data: { id: connectionId, organizationId: DEFAULT_ORG_ID, appIdentityFingerprint: `fingerprint-${id}`, status: "ACTIVE", }, }); await prisma.user.create({ data: { id: `user-${id}`, displayName: "Logged in user", feishuOpenId: `legacy-${openId}`, feishuIdentities: { create: { connectionId, openId }, }, }, }); } async function tempWorkspaceRoot(): Promise { const root = await mkdtemp(join(tmpdir(), "cph-trigger-onboarding-")); workspaceRoots.push(root); return root; } function cardActionValues(card: unknown): unknown[] { if (typeof card !== "object" || card === null || !("elements" in card)) return []; const elements = (card as { elements?: unknown }).elements; if (!Array.isArray(elements)) return []; return elements.flatMap((element) => { if (typeof element !== "object" || element === null || !("actions" in element)) return []; const actions = (element as { actions?: unknown }).actions; if (!Array.isArray(actions)) return []; return actions.map((action) => { if (typeof action !== "object" || action === null || !("value" in action)) return undefined; return (action as { value?: unknown }).value; }).filter((value): value is unknown => value !== undefined); }); } function cardHeaderTitle(card: unknown): string | undefined { if (typeof card !== "object" || card === null || !("header" in card)) return undefined; const header = (card as { header?: unknown }).header; if (typeof header !== "object" || header === null || !("title" in header)) return undefined; const title = (header as { title?: unknown }).title; if (typeof title !== "object" || title === null || !("content" in title)) return undefined; const content = (title as { content?: unknown }).content; return typeof content === "string" ? content : undefined; } 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(); });