feat: redesign Feishu project console

This commit is contained in:
2026-07-13 16:52:45 +08:00
parent 82f57317df
commit 69837bd50c
34 changed files with 1738 additions and 978 deletions
+236 -378
View File
@@ -13,7 +13,6 @@ import {
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";
@@ -633,6 +632,40 @@ describe("trigger full lifecycle (integration)", () => {
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);
});
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" });
});
it("keeps the project session shared while labeling each sender in the prompt", async () => {
await seedProject("proj-speaker", "chat-speaker");
await prisma.permissionGrant.create({
@@ -677,24 +710,7 @@ describe("trigger full lifecycle (integration)", () => {
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 () => {
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 } });
@@ -702,16 +718,14 @@ describe("trigger full lifecycle (integration)", () => {
const helpText = rt.sentTexts.at(-1) ?? "";
expect(helpText).toContain("可用 slash 命令");
expect(helpText).toContain("/new");
expect(helpText).toContain("/reset");
expect(helpText).toContain("/project");
expect(helpText).toContain("/draft <需求>");
expect(helpText).toContain("/review <需求>");
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("/cost reports recorded current-session cost without creating a run", async () => {
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 } });
@@ -722,151 +736,25 @@ describe("trigger full lifecycle (integration)", () => {
expect(runs[0]?.status).toBe("COMPLETED");
});
await trigger(makeEvent("chat-cost", "@_user_1 /cost"), rt);
await trigger(makeEvent("chat-cost", "@_user_1 /usage"), rt);
const costText = rt.sentTexts.at(-1) ?? "";
expect(costText).toContain("当前会话已记录 agent 成本");
expect(costText).toContain("总计: $0.0023");
expect(costText).toContain("Runs: 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("/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");
await seedProject("proj-help-usage", "chat-help-usage");
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
await trigger(makeEvent("chat-help-reset", "@_user_1 /help reset"), rt);
await trigger(makeEvent("chat-help-usage", "@_user_1 /help usage"), 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(helpText).toContain("/usage");
expect(helpText).toContain("真实 Agent 用量与成本");
expect(runAgentCalls).toHaveLength(0);
expect(await prisma.agentRun.findMany()).toHaveLength(0);
});
@@ -958,7 +846,7 @@ describe("trigger full lifecycle (integration)", () => {
});
it.each(["SUSPENDED", "ARCHIVED"] as const)(
"rejects triggers and resume commands when the organization is %s",
"rejects triggers when the organization is %s",
async (status) => {
await seedProject(`proj-org-${status}`, `chat-org-${status}`);
const session = await prisma.agentSession.create({
@@ -974,7 +862,7 @@ describe("trigger full lifecycle (integration)", () => {
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);
await trigger(makeEvent(`chat-org-${status}`, "@_user_1 写教案"), rt);
expect(rt.sentTexts).toContain("无权限触发。");
expect(runAgentCalls).toHaveLength(0);
@@ -985,87 +873,6 @@ describe("trigger full lifecycle (integration)", () => {
},
);
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.
@@ -1132,6 +939,56 @@ describe("trigger full lifecycle (integration)", () => {
});
});
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>();
@@ -1331,173 +1188,174 @@ describe("trigger full lifecycle (integration)", () => {
expect(runs).toHaveLength(0);
});
it("/new archives current session (no run created)", async () => {
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 } });
// 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");
});
await trigger(makeEvent("chat-6", "@_user_1 /project"), rt);
// /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);
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("/resume un-archives the most recent session", async () => {
await seedProject("proj-7", "chat-7");
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 } });
// Create + archive a session via /new.
await trigger(makeEvent("chat-7", "@_user_1 写教案"), rt);
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.findMany()).toHaveLength(1);
expect(await prisma.agentRun.count()).toBe(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();
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("/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 () => {
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);
// 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(rt.sentTexts.at(-1)).toContain("未知 slash 命令 /unknown");
expect(runAgentCalls).toHaveLength(0);
const runs = await prisma.agentRun.findMany();
expect(runs).toHaveLength(0);
expect(await prisma.agentRun.count()).toBe(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" },
});
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-11", "@_user_1 /review 看看这节"), rt);
await trigger(makeEvent("chat-compact", "@_user_1 写第三单元"), rt);
await vi.waitFor(async () => {
const runs = await prisma.agentRun.findMany();
expect(runs).toHaveLength(1);
expect(runs[0]?.status).toBe("COMPLETED");
expect(runs[0]?.metadata).toMatchObject({ roleId: "review" });
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("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);
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 () => {
const runs = await prisma.agentRun.findMany();
expect(runs).toHaveLength(1);
expectPromptFromSender(runs[0]?.prompt, "ou_test_user", "写第三单元");
expect(runs[0]?.metadata).toMatchObject({ roleId: "draft" });
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位),当前处理完成后将自动开始");
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();
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 () => {