forked from bai/curriculum-project-hub
869 lines
35 KiB
TypeScript
869 lines
35 KiB
TypeScript
import { describe, it, expect, beforeEach, afterAll, vi } from "vitest";
|
|
import { prisma, resetDb, mockFeishuRuntime, seedProject, silentLogger } from "./helpers.js";
|
|
import { InMemoryModelRegistry } from "../../src/agent/models.js";
|
|
import { makeTriggerHandler, 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" };
|
|
type TestRunner = (req: RunRequest) => Promise<RunResult>;
|
|
|
|
function makeTestSettings(models: InMemoryModelRegistry): RuntimeSettings {
|
|
return {
|
|
async provider(providerId) {
|
|
return {
|
|
id: providerId,
|
|
baseUrl: "https://openrouter.ai/api",
|
|
authToken: "test-token",
|
|
anthropicApiKey: "",
|
|
sdkEnv: {
|
|
ANTHROPIC_BASE_URL: "https://openrouter.ai/api",
|
|
ANTHROPIC_AUTH_TOKEN: "test-token",
|
|
ANTHROPIC_API_KEY: "",
|
|
},
|
|
};
|
|
},
|
|
async modelRegistry() {
|
|
return models;
|
|
},
|
|
async runPolicy() {
|
|
return { maxTurns: 7 };
|
|
},
|
|
};
|
|
}
|
|
|
|
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 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 },
|
|
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();
|
|
});
|
|
|
|
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 } });
|
|
|
|
await trigger(makeEvent("chat-1", "@_user_1 写教案"), 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.sentTexts).toContain("mock response");
|
|
expect(runAgentCalls).toHaveLength(1);
|
|
expect(runAgentCalls[0]?.providerEnv).toMatchObject({
|
|
ANTHROPIC_BASE_URL: "https://openrouter.ai/api",
|
|
ANTHROPIC_AUTH_TOKEN: "test-token",
|
|
ANTHROPIC_API_KEY: "",
|
|
});
|
|
expect(runAgentCalls[0]?.maxTurns).toBe(7);
|
|
});
|
|
|
|
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");
|
|
expect(runs[0]?.prompt).toBe("第一段\n第二段");
|
|
});
|
|
expect(runAgentCalls).toHaveLength(1);
|
|
expect(runAgentCalls[0]?.prompt).toBe("第一段\n第二段");
|
|
});
|
|
|
|
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("/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("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("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);
|
|
});
|
|
expect(runAgentCalls[1]?.prompt).toBe("第二个请求");
|
|
|
|
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("ignores messages from unbound chats (ADR-0001)", 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 写教案"), rt);
|
|
|
|
expect(rt.sentTexts).toHaveLength(0);
|
|
expect(rt.sentCards).toHaveLength(0);
|
|
const runs = await prisma.agentRun.findMany();
|
|
expect(runs).toHaveLength(0);
|
|
});
|
|
|
|
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);
|
|
expect(runs[0]?.prompt).toBe("/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);
|
|
expect(runs[0]?.prompt).toBe("写第三单元");
|
|
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("上一条需求");
|
|
expect(prompt).toContain("User request:\n继续这个改法");
|
|
|
|
const run = await prisma.agentRun.findFirst();
|
|
expect(run?.prompt).toBe(prompt);
|
|
expect(run?.metadata).toMatchObject({
|
|
roleId: "draft",
|
|
rawPrompt: "继续这个改法",
|
|
feishuTriggerContext: {
|
|
trigger_message_id: "m-child",
|
|
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 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();
|
|
});
|