|
|
|
@@ -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 () => {
|
|
|
|
|