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 { 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<RunResult>;
const workspaceRoots: string[] = [];

type TestTriggerDeps = Omit<Parameters<typeof makeProductionTriggerHandler>[0], "projectWorkspaceRoot"> & {
  readonly projectWorkspaceRoot?: string;
};

function makeTriggerHandler(deps: TestTriggerDeps): ReturnType<typeof makeProductionTriggerHandler> {
  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<typeof mockFeishuRuntime>;
  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"))).toBe(true);
    expect(rt.sentTexts.some((text) => text.includes("本次成本"))).toBe(false);
    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([
      {
        project_onboarding: {
        action: "create_project_from_chat",
        organization_id: DEFAULT_ORG_ID,
        },
      },
    ]));
    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("已创建并绑定项目");
    expect(JSON.stringify(rt.sentPatches.at(-1))).toContain("project_rename_form");
  });

  it("opens project management at any time and renames through a validated card form", async () => {
    await seedProject("project-management", "chat-management", { role: "MANAGE" });
    const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent });

    await trigger(makeEvent("chat-management", "@_user_1 /project"), rt);

    expect(cardHeaderTitle(rt.sentCards.at(-1))).toBe("项目管理");
    expect(JSON.stringify(rt.sentCards.at(-1))).toContain("project_rename_form");

    await trigger.onCardAction(makeOnboardingEvent("chat-management", {
      project_onboarding: {
        action: "rename_project",
        organization_id: DEFAULT_ORG_ID,
        project_id: "project-management",
      },
    }, "ou_test_user", { project_name: "表面张力课程" }), rt);

    await expect(prisma.project.findUniqueOrThrow({ where: { id: "project-management" } }))
      .resolves.toMatchObject({ name: "表面张力课程" });
    await expect(prisma.auditEntry.findFirstOrThrow({
      where: { projectId: "project-management", action: "project.renamed" },
    })).resolves.toMatchObject({
      metadata: { oldName: "Test project-management", newName: "表面张力课程" },
    });
    expect(cardHeaderTitle(rt.sentPatches.at(-1))).toBe("项目名称已更新");
  });

  it("offers folder creation only inside move flow and atomically moves into the new folder", async () => {
    await seedProject("project-folder-flow", "chat-folder-flow", { role: "MANAGE" });
    await prisma.organizationMembership.updateMany({
      where: { organizationId: DEFAULT_ORG_ID, userId: "u_project-folder-flow", revokedAt: null },
      data: { role: "ADMIN" },
    });
    const parent = await prisma.folder.create({
      data: { organizationId: DEFAULT_ORG_ID, name: "课程目录" },
    });
    const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent });

    await trigger(makeEvent("chat-folder-flow", "@_user_1 /project"), rt);
    expect(JSON.stringify(rt.sentCards.at(-1))).not.toContain("folder_create_form");

    await trigger.onCardAction(makeOnboardingEvent("chat-folder-flow", {
      project_onboarding: {
        action: "browse_move_destination",
        organization_id: DEFAULT_ORG_ID,
        project_id: "project-folder-flow",
        folder_id: parent.id,
      },
    }, "ou_test_user"), rt);
    const moveCard = JSON.stringify(rt.sentPatches.at(-1));
    expect(moveCard).toContain("folder_create_form");
    expect(moveCard).toContain("新建并移动");

    await trigger.onCardAction(makeOnboardingEvent("chat-folder-flow", {
      project_onboarding: {
        action: "create_folder",
        organization_id: DEFAULT_ORG_ID,
        project_id: "project-folder-flow",
        folder_id: parent.id,
      },
    }, "ou_test_user", { folder_name: "力学单元" }), rt);

    const folder = await prisma.folder.findFirstOrThrow({
      where: { organizationId: DEFAULT_ORG_ID, parentId: parent.id, name: "力学单元" },
    });
    await expect(prisma.project.findUniqueOrThrow({ where: { id: "project-folder-flow" } }))
      .resolves.toMatchObject({ folderId: folder.id });
    await expect(prisma.auditEntry.findFirstOrThrow({
      where: { projectId: "project-folder-flow", action: "folder.created_and_project_moved_from_feishu" },
    })).resolves.toMatchObject({ metadata: expect.objectContaining({ folderId: folder.id, parentFolderId: parent.id }) });
    expect(cardHeaderTitle(rt.sentPatches.at(-1))).toContain("已新建目录");
  });

  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("rejects a forged bind card targeting a project in another organization", async () => {
    await seedOnboardingUser("u-onboard-cross-org", "ou_onboard_cross_org", "MEMBER");
    await seedTestOrganization("org-other-card", "other-card");
    await prisma.project.create({
      data: {
        id: "project-other-card",
        organizationId: "org-other-card",
        name: "Other project",
        workspaceDir: join(await tempWorkspaceRoot(), "project-other-card"),
      },
    });
    await prisma.permissionGrant.create({
      data: {
        resourceType: "PROJECT",
        resourceId: "project-other-card",
        principalType: "USER",
        principalId: "ou_onboard_cross_org",
        role: "MANAGE",
      },
    });
    const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent });

    await trigger.onCardAction(makeOnboardingEvent("chat-cross-org", {
      project_onboarding: {
        action: "bind_project",
        organization_id: DEFAULT_ORG_ID,
        project_id: "project-other-card",
      },
    }, "ou_onboard_cross_org"), rt);

    await expect(prisma.projectGroupBinding.count({ where: { chatId: "chat-cross-org" } })).resolves.toBe(0);
    expect(cardHeaderTitle(rt.sentPatches.at(-1))).toBe("绑定失败");
  });

  it("uses unbound-chat mention text to search bindable projects", async () => {
    await seedOnboardingUser("u-onboard-search", "ou_onboard_search", "OWNER");
    const folder = await prisma.folder.create({
      data: { organizationId: DEFAULT_ORG_ID, name: "物理竞赛" },
    });
    await prisma.project.createMany({
      data: [
        {
          id: "p-search-newton",
          organizationId: DEFAULT_ORG_ID,
          folderId: folder.id,
          name: "牛顿力学专题",
          workspaceDir: join(await tempWorkspaceRoot(), "p-search-newton"),
        },
        {
          id: "p-search-optics",
          organizationId: DEFAULT_ORG_ID,
          folderId: folder.id,
          name: "几何光学专题",
          workspaceDir: join(await tempWorkspaceRoot(), "p-search-optics"),
        },
      ],
    });
    const trigger = makeTriggerHandler({
      prisma,
      settings,
      logger: silentLogger,
      runAgent,
      projectWorkspaceRoot: await tempWorkspaceRoot(),
    });

    await trigger(makeEvent("chat-onboard-search", "@_user_1 牛顿", "ou_onboard_search"), rt);

    expect(cardActionValues(rt.sentCards[0])).toContainEqual({
      project_onboarding: {
        action: "bind_project",
        organization_id: DEFAULT_ORG_ID,
        project_id: "p-search-newton",
      },
    });
    expect(cardActionValues(rt.sentCards[0])).not.toContainEqual(expect.objectContaining({
      project_onboarding: expect.objectContaining({ project_id: "p-search-optics" }),
    }));
    expect(JSON.stringify(rt.sentCards[0])).toContain("牛顿");
    expect(runAgentCalls).toHaveLength(0);
  });

  it("paginates every manageable search result through card actions", async () => {
    await seedOnboardingUser("u-onboard-pages", "ou_onboard_pages", "OWNER");
    const workspaceRoot = await tempWorkspaceRoot();
    await prisma.project.createMany({
      data: Array.from({ length: 9 }, (_, index) => ({
        id: `project-page-${index}`,
        organizationId: DEFAULT_ORG_ID,
        name: `分页项目-${index}`,
        workspaceDir: join(workspaceRoot, `project-page-${index}`),
      })),
    });
    const trigger = makeTriggerHandler({
      prisma,
      settings,
      logger: silentLogger,
      runAgent,
      projectWorkspaceRoot: workspaceRoot,
    });

    await trigger(makeEvent("chat-onboard-pages", "@_user_1 分页项目", "ou_onboard_pages"), rt);

    const firstPageValues = cardActionValues(rt.sentCards[0]);
    expect(firstPageValues.filter((value) => JSON.stringify(value).includes('"bind_project"'))).toHaveLength(8);
    expect(firstPageValues).toContainEqual({
      project_onboarding: {
        action: "search_page",
        organization_id: DEFAULT_ORG_ID,
        search_query: "分页项目",
        page: 2,
      },
    });

    await trigger.onCardAction(makeOnboardingEvent("chat-onboard-pages", {
      project_onboarding: {
        action: "search_page",
        organization_id: DEFAULT_ORG_ID,
        search_query: "分页项目",
        page: 2,
      },
    }, "ou_onboard_pages"), rt);

    const secondPageValues = cardActionValues(rt.sentPatches.at(-1));
    expect(secondPageValues.filter((value) => JSON.stringify(value).includes('"bind_project"'))).toHaveLength(1);
    expect(JSON.stringify(rt.sentPatches.at(-1))).toContain("第 **2/2** 页");
  });

  it("navigates the preserved folder tree from the onboarding card", async () => {
    await seedOnboardingUser("u-onboard-folders", "ou_onboard_folders", "OWNER");
    const root = await prisma.folder.create({
      data: { id: "folder-legacy-root", organizationId: DEFAULT_ORG_ID, name: "旧教学资产" },
    });
    await prisma.folder.create({
      data: { id: "folder-physics-child", organizationId: DEFAULT_ORG_ID, parentId: root.id, name: "物理竞赛教研" },
    });
    const trigger = makeTriggerHandler({
      prisma,
      settings,
      logger: silentLogger,
      runAgent,
      projectWorkspaceRoot: await tempWorkspaceRoot(),
    });

    await trigger(makeEvent("chat-onboard-folders", "@_user_1", "ou_onboard_folders"), rt);
    expect(cardActionValues(rt.sentCards[0])).toContainEqual({
      project_onboarding: {
        action: "browse_folder",
        organization_id: DEFAULT_ORG_ID,
        folder_id: root.id,
        page: 1,
      },
    });

    await trigger.onCardAction(makeOnboardingEvent("chat-onboard-folders", {
      project_onboarding: {
        action: "browse_folder",
        organization_id: DEFAULT_ORG_ID,
        folder_id: root.id,
        page: 1,
      },
    }, "ou_onboard_folders"), rt);

    expect(JSON.stringify(rt.sentPatches.at(-1))).toContain("旧教学资产");
    expect(cardActionValues(rt.sentPatches.at(-1))).toContainEqual({
      project_onboarding: {
        action: "browse_folder",
        organization_id: DEFAULT_ORG_ID,
        folder_id: "folder-physics-child",
        page: 1,
      },
    });
  });

  it("continues searching past unauthorized matches for a manageable project", async () => {
    await seedOnboardingUser("u-onboard-page", "ou_onboard_page", "MEMBER");
    const workspaceRoot = await tempWorkspaceRoot();
    await prisma.project.createMany({
      data: [
        ...Array.from({ length: 20 }, (_, index) => ({
          id: `z-search-denied-${String(index).padStart(2, "0")}`,
          organizationId: DEFAULT_ORG_ID,
          name: `迁移项目 ${index}`,
          workspaceDir: join(workspaceRoot, `denied-${index}`),
        })),
        {
          id: "a-search-allowed",
          organizationId: DEFAULT_ORG_ID,
          name: "迁移项目 可管理",
          workspaceDir: join(workspaceRoot, "allowed"),
        },
      ],
    });
    await prisma.permissionGrant.create({
      data: {
        resourceType: "PROJECT",
        resourceId: "a-search-allowed",
        principalType: "USER",
        principalId: "ou_onboard_page",
        role: "MANAGE",
      },
    });
    const trigger = makeTriggerHandler({
      prisma,
      settings,
      logger: silentLogger,
      runAgent,
      projectWorkspaceRoot: workspaceRoot,
    });

    await trigger(makeEvent("chat-onboard-page", "@_user_1 迁移", "ou_onboard_page"), rt);

    expect(cardActionValues(rt.sentCards[0])).toContainEqual({
      project_onboarding: {
        action: "bind_project",
        organization_id: DEFAULT_ORG_ID,
        project_id: "a-search-allowed",
      },
    });
  });

  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("partitions batched messages by the role selected when each message arrives", async () => {
    await seedProject("proj-batch-role", "chat-batch-role");
    const reviewRole = await prisma.organizationAgentRole.create({
      data: {
        organizationId: DEFAULT_ORG_ID,
        roleId: "review",
        label: "审校",
        defaultModel: "mock-model",
        tools: ["read_file"],
      },
    });
    const trigger = makeTriggerHandler({
      prisma,
      settings,
      logger: silentLogger,
      runAgent,
      messageBatcherOptions: { debounceMs: 10 },
    });

    await trigger(makeEvent("chat-batch-role", "@_user_1 草稿消息"), rt);
    await prisma.projectGroupBinding.updateMany({
      where: { projectId: "proj-batch-role", chatId: "chat-batch-role", archivedAt: null },
      data: { selectedAgentRoleId: reviewRole.id },
    });
    await trigger(makeEvent("chat-batch-role", "@_user_1 审校消息"), rt);

    await vi.waitFor(async () => {
      expect(await prisma.agentRun.count({ where: { projectId: "proj-batch-role", status: "COMPLETED" } })).toBe(2);
    }, { timeout: 5_000 });
    const runs = await prisma.agentRun.findMany({ where: { projectId: "proj-batch-role" } });
    expect(runs.find((run) => run.prompt.includes("草稿消息"))?.metadata).toMatchObject({ roleId: "draft" });
    expect(runs.find((run) => run.prompt.includes("审校消息"))?.metadata).toMatchObject({ roleId: "review" });
  }, 10_000);

  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("/help lists only the closed Hub slash protocol", 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("/project");
    expect(helpText).toContain("/usage");
    expect(helpText).not.toMatch(/\/(?:new|reset|resume|draft|review|compact)\b/);
    expect(runAgentCalls).toHaveLength(0);
    expect(await prisma.agentRun.findMany()).toHaveLength(0);
  });

  it("/usage reports the selected role's active-session cost", 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 /usage"), rt);

    const costText = rt.sentTexts.at(-1) ?? "";
    expect(costText).toContain("当前角色会话用量");
    expect(costText).toContain("$0.0023");
    expect(costText).toContain("openrouter / mock-model");
    expect(runAgentCalls).toHaveLength(1);
    expect(await prisma.agentRun.findMany()).toHaveLength(1);
  });

  it("/help <command> returns command-specific help", async () => {
    await seedProject("proj-help-usage", "chat-help-usage");
    const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });

    await trigger(makeEvent("chat-help-usage", "@_user_1 /help usage"), rt);

    const helpText = rt.sentTexts.at(-1) ?? "";
    expect(helpText).toContain("/usage");
    expect(helpText).toContain("真实 Agent 用量与成本");
    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 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 写教案"), 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("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<RunResult>();
    const secondRun = deferred<RunResult>();
    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("freezes the selected role when a trigger enters the queue", async () => {
    await seedProject("proj-queue-role", "chat-queue-role");
    const reviewRole = await prisma.organizationAgentRole.create({
      data: {
        organizationId: DEFAULT_ORG_ID,
        roleId: "review",
        label: "审校",
        defaultModel: "mock-model",
        tools: ["read_file"],
      },
    });
    const firstRun = deferred<RunResult>();
    const secondRun = deferred<RunResult>();
    const pendingRuns = [firstRun, secondRun];
    const queuedRunAgent: TestRunner = async (req) => {
      runAgentCalls.push(req);
      const pending = pendingRuns.shift();
      if (pending === undefined) throw new Error("unexpected extra run");
      return pending.promise;
    };
    const trigger = makeTriggerHandler({
      prisma,
      settings,
      logger: silentLogger,
      runAgent: queuedRunAgent,
      messageBatcherOptions: { maxMessages: 1 },
    });

    await trigger(makeEvent("chat-queue-role", "@_user_1 第一个请求"), rt);
    await vi.waitFor(() => expect(runAgentCalls).toHaveLength(1));
    await trigger(makeEvent("chat-queue-role", "@_user_1 排队的草稿请求"), rt);
    await prisma.projectGroupBinding.updateMany({
      where: { projectId: "proj-queue-role", chatId: "chat-queue-role", archivedAt: null },
      data: { selectedAgentRoleId: reviewRole.id },
    });

    firstRun.resolve(completedRunResult("first done", "sdk-session-first"));
    await vi.waitFor(() => expect(runAgentCalls).toHaveLength(2));
    const queuedRun = await prisma.agentRun.findFirstOrThrow({
      where: { projectId: "proj-queue-role", prompt: { contains: "排队的草稿请求" } },
    });
    expect(queuedRun.metadata).toMatchObject({ roleId: "draft" });
    expect(runAgentCalls[1]?.tools).toBeUndefined();

    secondRun.resolve(completedRunResult("second done", "sdk-session-second"));
    await vi.waitFor(async () => {
      expect(await prisma.agentRun.count({ where: { projectId: "proj-queue-role", status: "COMPLETED" } })).toBe(2);
    });
  });

  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<RunResult>();
    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<void>();
    const releaseProvider = deferred<void>();
    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<void>();
    const releaseResource = deferred<void>();
    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("/project opens the role and session console without creating a run", async () => {
    await seedProject("proj-6", "chat-6");
    const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });

    await trigger(makeEvent("chat-6", "@_user_1 /project"), rt);

    expect(rt.sentCards).toHaveLength(1);
    const card = JSON.stringify(rt.sentCards[0]);
    expect(card).toContain("当前角色");
    expect(card).toContain("草稿");
    expect(card).toContain("新开会话");
    expect(card).toContain("历史会话");
    expect(runAgentCalls).toHaveLength(0);
    expect(await prisma.agentRun.count()).toBe(0);
  });

  it("creates, lists, and resumes the selected role's user-managed session history", async () => {
    await seedProject("proj-session-console", "chat-session-console");
    const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
    await trigger(makeEvent("chat-session-console", "@_user_1 建立历史会话"), rt);
    await vi.waitFor(async () => {
      await expect(prisma.agentRun.findFirstOrThrow({ where: { projectId: "proj-session-console" } }))
        .resolves.toMatchObject({ status: "COMPLETED" });
    });
    const session = await prisma.agentSession.findFirstOrThrow({ where: { projectId: "proj-session-console" } });

    await trigger.onCardAction(makeOnboardingEvent("chat-session-console", {
      project_onboarding: {
        action: "new_agent_session",
        organization_id: DEFAULT_ORG_ID,
        project_id: "proj-session-console",
      },
    }, "ou_test_user"), rt);
    await expect(prisma.agentSession.findUniqueOrThrow({ where: { id: session.id } }))
      .resolves.toMatchObject({ archivedAt: expect.any(Date), metadata: expect.objectContaining({ userResumable: true }) });

    await trigger.onCardAction(makeOnboardingEvent("chat-session-console", {
      project_onboarding: {
        action: "show_session_history",
        organization_id: DEFAULT_ORG_ID,
        project_id: "proj-session-console",
      },
    }, "ou_test_user"), rt);
    expect(JSON.stringify(rt.sentPatches.at(-1))).toContain(session.id);

    await trigger.onCardAction(makeOnboardingEvent("chat-session-console", {
      project_onboarding: {
        action: "resume_agent_session",
        organization_id: DEFAULT_ORG_ID,
        project_id: "proj-session-console",
        session_id: session.id,
      },
    }, "ou_test_user"), rt);
    await expect(prisma.agentSession.findUniqueOrThrow({ where: { id: session.id } }))
      .resolves.toMatchObject({ archivedAt: null, metadata: expect.objectContaining({ userResumable: false }) });
  });

  it("switches the bound role from the project card and uses it for later messages", async () => {
    await seedProject("proj-role-switch", "chat-role-switch");
    const reviewRole = await prisma.organizationAgentRole.create({
      data: {
        organizationId: DEFAULT_ORG_ID,
        roleId: "review",
        label: "审校",
        defaultModel: "mock-model",
        tools: ["read_file"],
        sortOrder: 10,
      },
    });
    const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });

    await trigger.onCardAction(makeOnboardingEvent("chat-role-switch", {
      project_onboarding: {
        action: "select_agent_role",
        organization_id: DEFAULT_ORG_ID,
        project_id: "proj-role-switch",
        agent_role_id: reviewRole.id,
      },
    }, "ou_test_user"), rt);
    await trigger(makeEvent("chat-role-switch", "@_user_1 检查讲义"), rt);

    await vi.waitFor(async () => {
      expect(await prisma.agentRun.count()).toBe(1);
    });
    expect(runAgentCalls[0]?.tools).toEqual(["read_file"]);
    await expect(prisma.agentRun.findFirstOrThrow({ where: { projectId: "proj-role-switch" } })).resolves.toMatchObject({
      metadata: expect.objectContaining({ roleId: "review" }),
    });
    await expect(prisma.projectGroupBinding.findFirstOrThrow({
      where: { projectId: "proj-role-switch", chatId: "chat-role-switch", archivedAt: null },
      select: { selectedAgentRoleId: true },
    })).resolves.toEqual({ selectedAgentRoleId: reviewRole.id });
  });

  it("unknown slash commands fail visibly instead of reaching the 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);
    expect(rt.sentTexts.at(-1)).toContain("未知 slash 命令 /unknown");
    expect(runAgentCalls).toHaveLength(0);
    expect(await prisma.agentRun.count()).toBe(0);
  });

  it("sends native /compact as an exact SDK prompt for the current role session", async () => {
    await seedProject("proj-compact", "chat-compact");
    const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });

    await trigger(makeEvent("chat-compact", "@_user_1 写第三单元"), rt);
    await vi.waitFor(async () => {
      await expect(prisma.agentRun.findFirstOrThrow({ where: { projectId: "proj-compact" } }))
        .resolves.toMatchObject({ status: "COMPLETED" });
    });
    const session = await prisma.agentSession.findFirstOrThrow({ where: { projectId: "proj-compact", archivedAt: null } });

    await trigger.onCardAction(makeOnboardingEvent("chat-compact", {
      project_onboarding: {
        action: "compact_agent_session",
        organization_id: DEFAULT_ORG_ID,
        project_id: "proj-compact",
      },
    }, "ou_test_user"), rt);
    await vi.waitFor(async () => {
      expect(await prisma.agentRun.count()).toBe(2);
    });

    expect(runAgentCalls[1]?.prompt).toBe("/compact");
    expect(runAgentCalls[1]?.resumeSessionId).toBe("sdk-session-1");
    expect(runAgentCalls[1]?.sessionId).toBe(session.id);
    await expect(prisma.agentRun.findFirstOrThrow({
      where: { projectId: "proj-compact", prompt: "/compact" },
    })).resolves.toMatchObject({ metadata: expect.objectContaining({ roleId: "draft" }) });
  });

  it("preserves native /compact semantics while the action waits in the project queue", async () => {
    await seedProject("proj-compact-queued", "chat-compact-queued");
    const activeRun = deferred<RunResult>();
    const queuedRunner: TestRunner = async (req) => {
      runAgentCalls.push(req);
      if (runAgentCalls.length === 2) return activeRun.promise;
      return completedRunResult(`done ${runAgentCalls.length}`, "sdk-session-queued");
    };
    const trigger = makeTriggerHandler({
      prisma,
      settings,
      logger: silentLogger,
      runAgent: queuedRunner,
      messageBatcherOptions: { maxMessages: 1 },
    });

    await trigger(makeEvent("chat-compact-queued", "@_user_1 建立会话"), rt);
    await vi.waitFor(async () => {
      expect(await prisma.agentRun.count({ where: { projectId: "proj-compact-queued", status: "COMPLETED" } })).toBe(1);
    });
    await trigger(makeEvent("chat-compact-queued", "@_user_1 正在处理"), rt);
    await vi.waitFor(() => expect(runAgentCalls).toHaveLength(2));
    await trigger.onCardAction(makeOnboardingEvent("chat-compact-queued", {
      project_onboarding: {
        action: "compact_agent_session",
        organization_id: DEFAULT_ORG_ID,
        project_id: "proj-compact-queued",
      },
    }, "ou_test_user"), rt);
    expect(rt.sentTexts).toContain("已加入队列（第1位），当前处理完成后将自动开始");

    activeRun.resolve(completedRunResult("active done", "sdk-session-active"));
    await vi.waitFor(() => expect(runAgentCalls).toHaveLength(3));
    expect(runAgentCalls[2]?.prompt).toBe("/compact");
  });

  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<T> {
  readonly promise: Promise<T>;
  readonly resolve: (value: T) => void;
  readonly reject: (reason?: unknown) => void;
}

function deferred<T>(): Deferred<T> {
  let resolve!: (value: T) => void;
  let reject!: (reason?: unknown) => void;
  const promise = new Promise<T>((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<RunResult>((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,
  formValue?: Readonly<Record<string, unknown>>,
): CardActionEvent {
  return {
    operator: { open_id: openId },
    action: { value, tag: "button", ...(formValue !== undefined ? { form_value: formValue } : {}) },
    context: { open_chat_id: chatId, open_message_id: "card-message-1" },
  };
}

async function seedOnboardingUser(id: string, feishuOpenId: string, role: "OWNER" | "ADMIN" | "MEMBER"): Promise<void> {
  await prisma.user.create({
    data: {
      id,
      feishuOpenId,
      displayName: feishuOpenId,
      organizationMemberships: { create: { organizationId: DEFAULT_ORG_ID, role } },
    },
  });
}

async function seedScopedIdentityWithoutMembership(id: string, openId: string): Promise<void> {
  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<string> {
  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();
});
