Files
curriculum-project-hub/hub/test/integration/trigger.test.ts
T

1725 lines
66 KiB
TypeScript

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<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([
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("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("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("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 <command> 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<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("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("/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<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): 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<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();
});