forked from EduCraft/curriculum-project-hub
ace724c609
If StreamingAgentCard.finish cannot patch the live card, plain-text fallback can still succeed with partial answer text. Interrupt is terminal — always emit the explicit 已中断 notice when the card path failed so teachers see the abort. Harden the integration assertion with waitFor.
1801 lines
70 KiB
TypeScript
1801 lines
70 KiB
TypeScript
import { mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { dirname, join } from "node:path";
|
|
import { Readable } from "node:stream";
|
|
import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from "vitest";
|
|
import {
|
|
DEFAULT_ORG_ID,
|
|
prisma,
|
|
resetDb,
|
|
mockFeishuRuntime,
|
|
seedProject,
|
|
seedTestOrganization,
|
|
silentLogger,
|
|
testSecretEnvelope,
|
|
} from "./helpers.js";
|
|
import { InMemoryModelRegistry } from "../../src/agent/models.js";
|
|
import { makeTriggerHandler as makeProductionTriggerHandler, extractPrompt } from "../../src/feishu/trigger.js";
|
|
import { TriggerQueue } from "../../src/feishu/triggerQueue.js";
|
|
import type { MessageReceiveEvent, CardActionEvent } from "../../src/feishu/client.js";
|
|
import type { FeishuBotCli } from "../../src/feishu/botCli.js";
|
|
import { writeNewWorkspaceFileNoFollow } from "../../src/security/workspaceFiles.js";
|
|
import type { RunRequest, RunResult } from "../../src/agent/runner.js";
|
|
import type { RuntimeSettings } from "../../src/settings/runtime.js";
|
|
|
|
const bot = { key: "@_user_1", id: { open_id: "ou_bot" }, name: "Bot" };
|
|
const itOnLinux = process.platform === "linux" ? it : it.skip;
|
|
type TestRunner = (req: RunRequest) => Promise<RunResult>;
|
|
const workspaceRoots: string[] = [];
|
|
|
|
type TestTriggerDeps = Omit<Parameters<typeof makeProductionTriggerHandler>[0], "projectWorkspaceRoot"> & {
|
|
readonly projectWorkspaceRoot?: string;
|
|
};
|
|
|
|
function makeTriggerHandler(deps: TestTriggerDeps): ReturnType<typeof makeProductionTriggerHandler> {
|
|
return makeProductionTriggerHandler({
|
|
projectWorkspaceRoot: "/tmp",
|
|
publicBaseUrl: "https://educraft.example.test",
|
|
siloOrganizationId: DEFAULT_ORG_ID,
|
|
allowLegacyFeishuIdentity: true,
|
|
secretEnvelope: testSecretEnvelope,
|
|
...deps,
|
|
});
|
|
}
|
|
|
|
function makeTestSettings(models: InMemoryModelRegistry): RuntimeSettings {
|
|
return {
|
|
async provider(providerId) {
|
|
return {
|
|
id: providerId,
|
|
async openAgentLease() {
|
|
return {
|
|
sdkEnv: {
|
|
ANTHROPIC_BASE_URL: "http://127.0.0.1:12345",
|
|
ANTHROPIC_AUTH_TOKEN: "test-run-capability",
|
|
ANTHROPIC_API_KEY: "",
|
|
},
|
|
sensitiveValues: ["test-run-capability"],
|
|
async close() {},
|
|
};
|
|
},
|
|
};
|
|
},
|
|
async modelRegistry() {
|
|
return models;
|
|
},
|
|
async runPolicy() {
|
|
return { maxTurns: 7, maxConcurrentRuns: 4, maxRunSeconds: 300 };
|
|
},
|
|
};
|
|
}
|
|
|
|
function makeEvent(chatId: string, text: string, senderOpenId = "ou_test_user", eventId?: string): MessageReceiveEvent {
|
|
const header = eventId !== undefined ? { event_id: eventId, event_type: "im.message.receive_v1" } : undefined;
|
|
return {
|
|
header,
|
|
message: {
|
|
message_id: "m_" + Math.random().toString(36).slice(2),
|
|
chat_id: chatId,
|
|
chat_type: "group",
|
|
message_type: "text",
|
|
content: JSON.stringify({ text }),
|
|
mentions: [bot],
|
|
},
|
|
sender: { sender_id: { open_id: senderOpenId }, sender_type: "user" },
|
|
};
|
|
}
|
|
|
|
function expectPromptFromSender(prompt: string | undefined, senderOpenId: string, rawPrompt: string): void {
|
|
expect(prompt).toContain("Feishu trigger context");
|
|
expect(prompt).toContain(`"open_id": "${senderOpenId}"`);
|
|
expect(prompt).toContain(`User request from ${senderOpenId}:\n${rawPrompt}`);
|
|
}
|
|
|
|
function createMockRunAgent(calls: RunRequest[] = []): TestRunner {
|
|
return async (req) => {
|
|
calls.push(req);
|
|
req.onStream?.({ type: "text-delta", text: "mock response" });
|
|
await req.prisma.agentMessage.create({
|
|
data: {
|
|
sessionId: req.sessionId,
|
|
runId: req.runId,
|
|
role: "assistant",
|
|
content: "mock response",
|
|
attachments: [],
|
|
},
|
|
});
|
|
return {
|
|
status: "completed",
|
|
text: "mock response",
|
|
usage: { inputTokens: 10, outputTokens: 5 },
|
|
costUsd: 0.0023,
|
|
numTurns: 1,
|
|
sdkSessionId: "sdk-session-1",
|
|
};
|
|
};
|
|
}
|
|
|
|
describe("trigger full lifecycle (integration)", () => {
|
|
let models: InMemoryModelRegistry;
|
|
let settings: RuntimeSettings;
|
|
let rt: ReturnType<typeof mockFeishuRuntime>;
|
|
let runAgentCalls: RunRequest[];
|
|
let runAgent: TestRunner;
|
|
|
|
beforeEach(async () => {
|
|
await resetDb();
|
|
runAgentCalls = [];
|
|
runAgent = createMockRunAgent(runAgentCalls);
|
|
models = new InMemoryModelRegistry(
|
|
[{ id: "mock-model", label: "Mock", toolCapable: true }],
|
|
[
|
|
{ id: "draft", label: "草稿", defaultModel: "mock-model", systemPrompt: undefined, tools: undefined },
|
|
{ id: "review", label: "审校", defaultModel: "mock-model", systemPrompt: undefined, tools: ["read_file"] },
|
|
],
|
|
);
|
|
settings = makeTestSettings(models);
|
|
rt = mockFeishuRuntime();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
while (workspaceRoots.length > 0) {
|
|
const root = workspaceRoots.pop();
|
|
if (root !== undefined) await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("creates a run, acquires + releases the lock, sends status card", async () => {
|
|
await seedProject("proj-1", "chat-1");
|
|
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
|
const event = makeEvent("chat-1", "@_user_1 写教案");
|
|
|
|
await trigger(event, rt);
|
|
|
|
// Wait for the async run to complete (fire-and-forget in trigger).
|
|
await vi.waitFor(async () => {
|
|
const runs = await prisma.agentRun.findMany();
|
|
expect(runs).toHaveLength(1);
|
|
expect(runs[0]?.status).toBe("COMPLETED");
|
|
});
|
|
|
|
// Lock released (no lock row remains).
|
|
await vi.waitFor(async () => {
|
|
const locks = await prisma.projectAgentLock.findMany();
|
|
expect(locks).toHaveLength(0);
|
|
});
|
|
|
|
// A status card was sent.
|
|
expect(rt.sentCards.length).toBeGreaterThanOrEqual(1);
|
|
expect(rt.sentReplies[0]).toMatchObject({
|
|
messageId: event.message.message_id,
|
|
msgType: "interactive",
|
|
replyInThread: undefined,
|
|
});
|
|
expect(rt.sentTexts.some((text) => text.includes("mock response"))).toBe(true);
|
|
expect(rt.sentTexts.some((text) => text.includes("本次成本"))).toBe(false);
|
|
expect(runAgentCalls).toHaveLength(1);
|
|
expect(runAgentCalls[0]?.providerProxyEnv).toMatchObject({
|
|
ANTHROPIC_BASE_URL: "http://127.0.0.1:12345",
|
|
ANTHROPIC_AUTH_TOKEN: "test-run-capability",
|
|
ANTHROPIC_API_KEY: "",
|
|
});
|
|
expect(runAgentCalls[0]?.maxTurns).toBe(7);
|
|
const run = await prisma.agentRun.findFirstOrThrow();
|
|
expect(Number(run.costUsd)).toBeCloseTo(0.0023);
|
|
expect(run.costSource).toBe("provider_reported");
|
|
});
|
|
|
|
itOnLinux("includes downloaded post image paths in the agent prompt", async () => {
|
|
const workspaceRoot = await tempWorkspaceRoot();
|
|
const workspaceDir = join(workspaceRoot, "project");
|
|
await mkdir(workspaceDir);
|
|
await seedProject("proj-post-image", "chat-post-image");
|
|
await prisma.project.update({
|
|
where: { id: "proj-post-image" },
|
|
data: { workspaceDir },
|
|
});
|
|
const downloadResource = vi.fn(async (request) => writeNewWorkspaceFileNoFollow(
|
|
request.workspaceRoot,
|
|
request.workspaceDir,
|
|
request.workspaceRelativePath,
|
|
Readable.from([Buffer.from("image bytes")]),
|
|
request.maxBytes,
|
|
));
|
|
const feishuBotCli: FeishuBotCli = { downloadResource };
|
|
const baseEvent = makeEvent("chat-post-image", "@_user_1 看看这张图");
|
|
const event: MessageReceiveEvent = {
|
|
...baseEvent,
|
|
message: {
|
|
...baseEvent.message,
|
|
message_type: "post",
|
|
content: JSON.stringify({
|
|
zh_cn: {
|
|
content: [[
|
|
{ tag: "text", text: "@_user_1 看看这张图" },
|
|
{ tag: "img", image_key: "img-key-1" },
|
|
]],
|
|
},
|
|
}),
|
|
},
|
|
};
|
|
const trigger = makeTriggerHandler({
|
|
prisma,
|
|
settings,
|
|
logger: silentLogger,
|
|
runAgent,
|
|
projectWorkspaceRoot: workspaceRoot,
|
|
messageBatcherOptions: { maxMessages: 1 },
|
|
feishuBotCli,
|
|
});
|
|
|
|
await trigger(event, rt);
|
|
|
|
await vi.waitFor(() => {
|
|
expect(runAgentCalls).toHaveLength(1);
|
|
});
|
|
expect(runAgentCalls[0]?.prompt).toContain(join(await realpath(workspaceDir), ".cph", "inbox"));
|
|
expect(downloadResource).toHaveBeenCalledWith(expect.objectContaining({
|
|
messageId: event.message.message_id,
|
|
fileKey: "img-key-1",
|
|
resourceType: "image",
|
|
}));
|
|
const inboxFiles = await readdir(join(workspaceDir, ".cph", "inbox"));
|
|
expect(inboxFiles).toHaveLength(1);
|
|
await expect(readFile(join(workspaceDir, ".cph", "inbox", inboxFiles[0]!))).resolves.toEqual(Buffer.from("image bytes"));
|
|
await expect(readdir(join(workspaceRoot, ".cph-staging"))).resolves.toEqual([]);
|
|
});
|
|
|
|
it("sends an onboarding card when an unbound chat mentions the bot", async () => {
|
|
await seedOnboardingUser("u-onboard-card", "ou_onboard_card", "MEMBER");
|
|
const trigger = makeTriggerHandler({
|
|
prisma,
|
|
settings,
|
|
logger: silentLogger,
|
|
runAgent,
|
|
projectWorkspaceRoot: await tempWorkspaceRoot(),
|
|
});
|
|
|
|
await trigger(makeEvent("chat-unbound-card", "@_user_1", "ou_onboard_card"), rt);
|
|
|
|
expect(rt.sentCards).toHaveLength(1);
|
|
expect(rt.sentTexts.at(-1)).toContain("这个飞书群还没有绑定项目");
|
|
const values = cardActionValues(rt.sentCards[0]);
|
|
expect(values).toEqual(expect.arrayContaining([
|
|
{
|
|
project_onboarding: {
|
|
action: "create_project_from_chat",
|
|
organization_id: DEFAULT_ORG_ID,
|
|
},
|
|
},
|
|
]));
|
|
expect(runAgentCalls).toHaveLength(0);
|
|
});
|
|
|
|
it("creates and binds a project from the unbound-chat onboarding card", async () => {
|
|
await seedOnboardingUser("u-onboard-create", "ou_onboard_create", "MEMBER");
|
|
const trigger = makeTriggerHandler({
|
|
prisma,
|
|
settings,
|
|
logger: silentLogger,
|
|
runAgent,
|
|
projectWorkspaceRoot: await tempWorkspaceRoot(),
|
|
});
|
|
|
|
await trigger.onCardAction(makeOnboardingEvent("chat-onboard-create", {
|
|
project_onboarding: {
|
|
action: "create_project_from_chat",
|
|
organization_id: DEFAULT_ORG_ID,
|
|
},
|
|
}, "ou_onboard_create"), rt);
|
|
|
|
const binding = await prisma.projectGroupBinding.findFirst({
|
|
where: { chatId: "chat-onboard-create", archivedAt: null },
|
|
select: { projectId: true },
|
|
});
|
|
expect(binding).not.toBeNull();
|
|
const grants = await prisma.permissionGrant.findMany({
|
|
where: { resourceType: "PROJECT", resourceId: binding!.projectId, revokedAt: null },
|
|
select: { principalType: true, principalId: true, role: true },
|
|
});
|
|
expect(grants).toEqual(expect.arrayContaining([
|
|
{ principalType: "USER", principalId: "ou_onboard_create", role: "MANAGE" },
|
|
{ principalType: "FEISHU_CHAT", principalId: "chat-onboard-create", role: "EDIT" },
|
|
]));
|
|
expect(cardHeaderTitle(rt.sentPatches.at(-1))).toBe("已创建并绑定项目");
|
|
expect(JSON.stringify(rt.sentPatches.at(-1))).toContain("project_rename_form");
|
|
});
|
|
|
|
it("opens project management at any time and renames through a validated card form", async () => {
|
|
await seedProject("project-management", "chat-management", { role: "MANAGE" });
|
|
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent });
|
|
|
|
await trigger(makeEvent("chat-management", "@_user_1 /project"), rt);
|
|
|
|
expect(cardHeaderTitle(rt.sentCards.at(-1))).toBe("项目管理");
|
|
expect(JSON.stringify(rt.sentCards.at(-1))).toContain("project_rename_form");
|
|
|
|
await trigger.onCardAction(makeOnboardingEvent("chat-management", {
|
|
project_onboarding: {
|
|
action: "rename_project",
|
|
organization_id: DEFAULT_ORG_ID,
|
|
project_id: "project-management",
|
|
},
|
|
}, "ou_test_user", { project_name: "表面张力课程" }), rt);
|
|
|
|
await expect(prisma.project.findUniqueOrThrow({ where: { id: "project-management" } }))
|
|
.resolves.toMatchObject({ name: "表面张力课程" });
|
|
await expect(prisma.auditEntry.findFirstOrThrow({
|
|
where: { projectId: "project-management", action: "project.renamed" },
|
|
})).resolves.toMatchObject({
|
|
metadata: { oldName: "Test project-management", newName: "表面张力课程" },
|
|
});
|
|
expect(cardHeaderTitle(rt.sentPatches.at(-1))).toBe("项目名称已更新");
|
|
});
|
|
|
|
it("offers folder creation only inside move flow and atomically moves into the new folder", async () => {
|
|
await seedProject("project-folder-flow", "chat-folder-flow", { role: "MANAGE" });
|
|
await prisma.organizationMembership.updateMany({
|
|
where: { organizationId: DEFAULT_ORG_ID, userId: "u_project-folder-flow", revokedAt: null },
|
|
data: { role: "ADMIN" },
|
|
});
|
|
const parent = await prisma.folder.create({
|
|
data: { organizationId: DEFAULT_ORG_ID, name: "课程目录" },
|
|
});
|
|
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent });
|
|
|
|
await trigger(makeEvent("chat-folder-flow", "@_user_1 /project"), rt);
|
|
expect(JSON.stringify(rt.sentCards.at(-1))).not.toContain("folder_create_form");
|
|
|
|
await trigger.onCardAction(makeOnboardingEvent("chat-folder-flow", {
|
|
project_onboarding: {
|
|
action: "browse_move_destination",
|
|
organization_id: DEFAULT_ORG_ID,
|
|
project_id: "project-folder-flow",
|
|
folder_id: parent.id,
|
|
},
|
|
}, "ou_test_user"), rt);
|
|
const moveCard = JSON.stringify(rt.sentPatches.at(-1));
|
|
expect(moveCard).toContain("folder_create_form");
|
|
expect(moveCard).toContain("新建并移动");
|
|
|
|
await trigger.onCardAction(makeOnboardingEvent("chat-folder-flow", {
|
|
project_onboarding: {
|
|
action: "create_folder",
|
|
organization_id: DEFAULT_ORG_ID,
|
|
project_id: "project-folder-flow",
|
|
folder_id: parent.id,
|
|
},
|
|
}, "ou_test_user", { folder_name: "力学单元" }), rt);
|
|
|
|
const folder = await prisma.folder.findFirstOrThrow({
|
|
where: { organizationId: DEFAULT_ORG_ID, parentId: parent.id, name: "力学单元" },
|
|
});
|
|
await expect(prisma.project.findUniqueOrThrow({ where: { id: "project-folder-flow" } }))
|
|
.resolves.toMatchObject({ folderId: folder.id });
|
|
await expect(prisma.auditEntry.findFirstOrThrow({
|
|
where: { projectId: "project-folder-flow", action: "folder.created_and_project_moved_from_feishu" },
|
|
})).resolves.toMatchObject({ metadata: expect.objectContaining({ folderId: folder.id, parentFolderId: parent.id }) });
|
|
expect(cardHeaderTitle(rt.sentPatches.at(-1))).toContain("已新建目录");
|
|
});
|
|
|
|
it("binds an existing manageable project from the unbound-chat onboarding card", async () => {
|
|
await seedOnboardingUser("u-onboard-bind", "ou_onboard_bind", "MEMBER");
|
|
await prisma.project.create({
|
|
data: {
|
|
id: "p-onboard-bind",
|
|
organizationId: DEFAULT_ORG_ID,
|
|
name: "可绑定项目",
|
|
workspaceDir: join(await tempWorkspaceRoot(), "p-onboard-bind"),
|
|
},
|
|
});
|
|
await prisma.permissionGrant.create({
|
|
data: {
|
|
resourceType: "PROJECT",
|
|
resourceId: "p-onboard-bind",
|
|
principalType: "USER",
|
|
principalId: "ou_onboard_bind",
|
|
role: "MANAGE",
|
|
createdByUserId: "u-onboard-bind",
|
|
},
|
|
});
|
|
const trigger = makeTriggerHandler({
|
|
prisma,
|
|
settings,
|
|
logger: silentLogger,
|
|
runAgent,
|
|
projectWorkspaceRoot: await tempWorkspaceRoot(),
|
|
});
|
|
|
|
await trigger(makeEvent("chat-onboard-list", "@_user_1 绑定项目", "ou_onboard_bind"), rt);
|
|
expect(cardActionValues(rt.sentCards[0])).toContainEqual({
|
|
project_onboarding: {
|
|
action: "bind_project",
|
|
organization_id: DEFAULT_ORG_ID,
|
|
project_id: "p-onboard-bind",
|
|
},
|
|
});
|
|
|
|
await trigger.onCardAction(makeOnboardingEvent("chat-onboard-list", {
|
|
project_onboarding: {
|
|
action: "bind_project",
|
|
organization_id: DEFAULT_ORG_ID,
|
|
project_id: "p-onboard-bind",
|
|
},
|
|
}, "ou_onboard_bind"), rt);
|
|
|
|
await expect(prisma.projectGroupBinding.findFirst({
|
|
where: { projectId: "p-onboard-bind", chatId: "chat-onboard-list", archivedAt: null },
|
|
})).resolves.not.toBeNull();
|
|
const chatGrant = await prisma.permissionGrant.findFirst({
|
|
where: {
|
|
resourceType: "PROJECT",
|
|
resourceId: "p-onboard-bind",
|
|
principalType: "FEISHU_CHAT",
|
|
principalId: "chat-onboard-list",
|
|
revokedAt: null,
|
|
},
|
|
select: { role: true },
|
|
});
|
|
expect(chatGrant?.role).toBe("EDIT");
|
|
expect(cardHeaderTitle(rt.sentPatches.at(-1))).toBe("已绑定项目");
|
|
});
|
|
|
|
it("rejects a forged bind card targeting a project in another organization", async () => {
|
|
await seedOnboardingUser("u-onboard-cross-org", "ou_onboard_cross_org", "MEMBER");
|
|
await seedTestOrganization("org-other-card", "other-card");
|
|
await prisma.project.create({
|
|
data: {
|
|
id: "project-other-card",
|
|
organizationId: "org-other-card",
|
|
name: "Other project",
|
|
workspaceDir: join(await tempWorkspaceRoot(), "project-other-card"),
|
|
},
|
|
});
|
|
await prisma.permissionGrant.create({
|
|
data: {
|
|
resourceType: "PROJECT",
|
|
resourceId: "project-other-card",
|
|
principalType: "USER",
|
|
principalId: "ou_onboard_cross_org",
|
|
role: "MANAGE",
|
|
},
|
|
});
|
|
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent });
|
|
|
|
await trigger.onCardAction(makeOnboardingEvent("chat-cross-org", {
|
|
project_onboarding: {
|
|
action: "bind_project",
|
|
organization_id: DEFAULT_ORG_ID,
|
|
project_id: "project-other-card",
|
|
},
|
|
}, "ou_onboard_cross_org"), rt);
|
|
|
|
await expect(prisma.projectGroupBinding.count({ where: { chatId: "chat-cross-org" } })).resolves.toBe(0);
|
|
expect(cardHeaderTitle(rt.sentPatches.at(-1))).toBe("绑定失败");
|
|
});
|
|
|
|
it("uses unbound-chat mention text to search bindable projects", async () => {
|
|
await seedOnboardingUser("u-onboard-search", "ou_onboard_search", "OWNER");
|
|
const folder = await prisma.folder.create({
|
|
data: { organizationId: DEFAULT_ORG_ID, name: "物理竞赛" },
|
|
});
|
|
await prisma.project.createMany({
|
|
data: [
|
|
{
|
|
id: "p-search-newton",
|
|
organizationId: DEFAULT_ORG_ID,
|
|
folderId: folder.id,
|
|
name: "牛顿力学专题",
|
|
workspaceDir: join(await tempWorkspaceRoot(), "p-search-newton"),
|
|
},
|
|
{
|
|
id: "p-search-optics",
|
|
organizationId: DEFAULT_ORG_ID,
|
|
folderId: folder.id,
|
|
name: "几何光学专题",
|
|
workspaceDir: join(await tempWorkspaceRoot(), "p-search-optics"),
|
|
},
|
|
],
|
|
});
|
|
const trigger = makeTriggerHandler({
|
|
prisma,
|
|
settings,
|
|
logger: silentLogger,
|
|
runAgent,
|
|
projectWorkspaceRoot: await tempWorkspaceRoot(),
|
|
});
|
|
|
|
await trigger(makeEvent("chat-onboard-search", "@_user_1 牛顿", "ou_onboard_search"), rt);
|
|
|
|
expect(cardActionValues(rt.sentCards[0])).toContainEqual({
|
|
project_onboarding: {
|
|
action: "bind_project",
|
|
organization_id: DEFAULT_ORG_ID,
|
|
project_id: "p-search-newton",
|
|
},
|
|
});
|
|
expect(cardActionValues(rt.sentCards[0])).not.toContainEqual(expect.objectContaining({
|
|
project_onboarding: expect.objectContaining({ project_id: "p-search-optics" }),
|
|
}));
|
|
expect(JSON.stringify(rt.sentCards[0])).toContain("牛顿");
|
|
expect(runAgentCalls).toHaveLength(0);
|
|
});
|
|
|
|
it("paginates every manageable search result through card actions", async () => {
|
|
await seedOnboardingUser("u-onboard-pages", "ou_onboard_pages", "OWNER");
|
|
const workspaceRoot = await tempWorkspaceRoot();
|
|
await prisma.project.createMany({
|
|
data: Array.from({ length: 9 }, (_, index) => ({
|
|
id: `project-page-${index}`,
|
|
organizationId: DEFAULT_ORG_ID,
|
|
name: `分页项目-${index}`,
|
|
workspaceDir: join(workspaceRoot, `project-page-${index}`),
|
|
})),
|
|
});
|
|
const trigger = makeTriggerHandler({
|
|
prisma,
|
|
settings,
|
|
logger: silentLogger,
|
|
runAgent,
|
|
projectWorkspaceRoot: workspaceRoot,
|
|
});
|
|
|
|
await trigger(makeEvent("chat-onboard-pages", "@_user_1 分页项目", "ou_onboard_pages"), rt);
|
|
|
|
const firstPageValues = cardActionValues(rt.sentCards[0]);
|
|
expect(firstPageValues.filter((value) => JSON.stringify(value).includes('"bind_project"'))).toHaveLength(8);
|
|
expect(firstPageValues).toContainEqual({
|
|
project_onboarding: {
|
|
action: "search_page",
|
|
organization_id: DEFAULT_ORG_ID,
|
|
search_query: "分页项目",
|
|
page: 2,
|
|
},
|
|
});
|
|
|
|
await trigger.onCardAction(makeOnboardingEvent("chat-onboard-pages", {
|
|
project_onboarding: {
|
|
action: "search_page",
|
|
organization_id: DEFAULT_ORG_ID,
|
|
search_query: "分页项目",
|
|
page: 2,
|
|
},
|
|
}, "ou_onboard_pages"), rt);
|
|
|
|
const secondPageValues = cardActionValues(rt.sentPatches.at(-1));
|
|
expect(secondPageValues.filter((value) => JSON.stringify(value).includes('"bind_project"'))).toHaveLength(1);
|
|
expect(JSON.stringify(rt.sentPatches.at(-1))).toContain("第 **2/2** 页");
|
|
});
|
|
|
|
it("navigates the preserved folder tree from the onboarding card", async () => {
|
|
await seedOnboardingUser("u-onboard-folders", "ou_onboard_folders", "OWNER");
|
|
const root = await prisma.folder.create({
|
|
data: { id: "folder-legacy-root", organizationId: DEFAULT_ORG_ID, name: "旧教学资产" },
|
|
});
|
|
await prisma.folder.create({
|
|
data: { id: "folder-physics-child", organizationId: DEFAULT_ORG_ID, parentId: root.id, name: "物理竞赛教研" },
|
|
});
|
|
const trigger = makeTriggerHandler({
|
|
prisma,
|
|
settings,
|
|
logger: silentLogger,
|
|
runAgent,
|
|
projectWorkspaceRoot: await tempWorkspaceRoot(),
|
|
});
|
|
|
|
await trigger(makeEvent("chat-onboard-folders", "@_user_1", "ou_onboard_folders"), rt);
|
|
expect(cardActionValues(rt.sentCards[0])).toContainEqual({
|
|
project_onboarding: {
|
|
action: "browse_folder",
|
|
organization_id: DEFAULT_ORG_ID,
|
|
folder_id: root.id,
|
|
page: 1,
|
|
},
|
|
});
|
|
|
|
await trigger.onCardAction(makeOnboardingEvent("chat-onboard-folders", {
|
|
project_onboarding: {
|
|
action: "browse_folder",
|
|
organization_id: DEFAULT_ORG_ID,
|
|
folder_id: root.id,
|
|
page: 1,
|
|
},
|
|
}, "ou_onboard_folders"), rt);
|
|
|
|
expect(JSON.stringify(rt.sentPatches.at(-1))).toContain("旧教学资产");
|
|
expect(cardActionValues(rt.sentPatches.at(-1))).toContainEqual({
|
|
project_onboarding: {
|
|
action: "browse_folder",
|
|
organization_id: DEFAULT_ORG_ID,
|
|
folder_id: "folder-physics-child",
|
|
page: 1,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("continues searching past unauthorized matches for a manageable project", async () => {
|
|
await seedOnboardingUser("u-onboard-page", "ou_onboard_page", "MEMBER");
|
|
const workspaceRoot = await tempWorkspaceRoot();
|
|
await prisma.project.createMany({
|
|
data: [
|
|
...Array.from({ length: 20 }, (_, index) => ({
|
|
id: `z-search-denied-${String(index).padStart(2, "0")}`,
|
|
organizationId: DEFAULT_ORG_ID,
|
|
name: `迁移项目 ${index}`,
|
|
workspaceDir: join(workspaceRoot, `denied-${index}`),
|
|
})),
|
|
{
|
|
id: "a-search-allowed",
|
|
organizationId: DEFAULT_ORG_ID,
|
|
name: "迁移项目 可管理",
|
|
workspaceDir: join(workspaceRoot, "allowed"),
|
|
},
|
|
],
|
|
});
|
|
await prisma.permissionGrant.create({
|
|
data: {
|
|
resourceType: "PROJECT",
|
|
resourceId: "a-search-allowed",
|
|
principalType: "USER",
|
|
principalId: "ou_onboard_page",
|
|
role: "MANAGE",
|
|
},
|
|
});
|
|
const trigger = makeTriggerHandler({
|
|
prisma,
|
|
settings,
|
|
logger: silentLogger,
|
|
runAgent,
|
|
projectWorkspaceRoot: workspaceRoot,
|
|
});
|
|
|
|
await trigger(makeEvent("chat-onboard-page", "@_user_1 迁移", "ou_onboard_page"), rt);
|
|
|
|
expect(cardActionValues(rt.sentCards[0])).toContainEqual({
|
|
project_onboarding: {
|
|
action: "bind_project",
|
|
organization_id: DEFAULT_ORG_ID,
|
|
project_id: "a-search-allowed",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("batches quick text messages from the same chat and sender into one run", async () => {
|
|
await seedProject("proj-1b", "chat-1b");
|
|
const trigger = makeTriggerHandler({
|
|
prisma,
|
|
settings,
|
|
logger: silentLogger,
|
|
runAgent,
|
|
messageBatcherOptions: { debounceMs: 10_000, maxMessages: 2 },
|
|
});
|
|
|
|
await trigger(makeEvent("chat-1b", "@_user_1 第一段"), rt);
|
|
expect(await prisma.agentRun.findMany()).toHaveLength(0);
|
|
|
|
await trigger(makeEvent("chat-1b", "@_user_1 第二段"), rt);
|
|
|
|
await vi.waitFor(async () => {
|
|
const runs = await prisma.agentRun.findMany();
|
|
expect(runs).toHaveLength(1);
|
|
expect(runs[0]?.status).toBe("COMPLETED");
|
|
expectPromptFromSender(runs[0]?.prompt, "ou_test_user", "第一段\n第二段");
|
|
});
|
|
expect(runAgentCalls).toHaveLength(1);
|
|
expectPromptFromSender(runAgentCalls[0]?.prompt, "ou_test_user", "第一段\n第二段");
|
|
});
|
|
|
|
it("partitions batched messages by the role selected when each message arrives", async () => {
|
|
await seedProject("proj-batch-role", "chat-batch-role");
|
|
const reviewRole = await prisma.organizationAgentRole.create({
|
|
data: {
|
|
organizationId: DEFAULT_ORG_ID,
|
|
roleId: "review",
|
|
label: "审校",
|
|
defaultModel: "mock-model",
|
|
tools: ["read_file"],
|
|
},
|
|
});
|
|
const trigger = makeTriggerHandler({
|
|
prisma,
|
|
settings,
|
|
logger: silentLogger,
|
|
runAgent,
|
|
messageBatcherOptions: { debounceMs: 10 },
|
|
});
|
|
|
|
await trigger(makeEvent("chat-batch-role", "@_user_1 草稿消息"), rt);
|
|
await prisma.projectGroupBinding.updateMany({
|
|
where: { projectId: "proj-batch-role", chatId: "chat-batch-role", archivedAt: null },
|
|
data: { selectedAgentRoleId: reviewRole.id },
|
|
});
|
|
await trigger(makeEvent("chat-batch-role", "@_user_1 审校消息"), rt);
|
|
|
|
await vi.waitFor(async () => {
|
|
expect(await prisma.agentRun.count({ where: { projectId: "proj-batch-role", status: "COMPLETED" } })).toBe(2);
|
|
}, { timeout: 5_000 });
|
|
const runs = await prisma.agentRun.findMany({ where: { projectId: "proj-batch-role" } });
|
|
expect(runs.find((run) => run.prompt.includes("草稿消息"))?.metadata).toMatchObject({ roleId: "draft" });
|
|
expect(runs.find((run) => run.prompt.includes("审校消息"))?.metadata).toMatchObject({ roleId: "review" });
|
|
}, 10_000);
|
|
|
|
it("keeps the project session shared while labeling each sender in the prompt", async () => {
|
|
await seedProject("proj-speaker", "chat-speaker");
|
|
await prisma.permissionGrant.create({
|
|
data: {
|
|
resourceType: "PROJECT",
|
|
resourceId: "proj-speaker",
|
|
principalType: "FEISHU_CHAT",
|
|
principalId: "chat-speaker",
|
|
role: "EDIT",
|
|
},
|
|
});
|
|
await prisma.user.createMany({
|
|
data: [
|
|
{ id: "u-speaker-alice", feishuOpenId: "ou_alice", displayName: "Alice" },
|
|
{ id: "u-speaker-bob", feishuOpenId: "ou_bob", displayName: "Bob" },
|
|
],
|
|
});
|
|
await prisma.organizationMembership.createMany({
|
|
data: [
|
|
{ organizationId: DEFAULT_ORG_ID, userId: "u-speaker-alice", role: "MEMBER" },
|
|
{ organizationId: DEFAULT_ORG_ID, userId: "u-speaker-bob", role: "MEMBER" },
|
|
],
|
|
});
|
|
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
|
|
|
await trigger(makeEvent("chat-speaker", "@_user_1 Alice 的需求", "ou_alice"), rt);
|
|
await vi.waitFor(() => {
|
|
expect(runAgentCalls).toHaveLength(1);
|
|
});
|
|
await vi.waitFor(async () => {
|
|
expect(await prisma.projectAgentLock.findMany()).toHaveLength(0);
|
|
});
|
|
|
|
await trigger(makeEvent("chat-speaker", "@_user_1 Bob 的需求", "ou_bob"), rt);
|
|
await vi.waitFor(() => {
|
|
expect(runAgentCalls).toHaveLength(2);
|
|
});
|
|
|
|
expectPromptFromSender(runAgentCalls[0]?.prompt, "ou_alice", "Alice 的需求");
|
|
expectPromptFromSender(runAgentCalls[1]?.prompt, "ou_bob", "Bob 的需求");
|
|
const sessions = await prisma.agentSession.findMany();
|
|
expect(sessions).toHaveLength(1);
|
|
});
|
|
|
|
it("/help lists only the closed Hub slash protocol", async () => {
|
|
await seedProject("proj-help", "chat-help");
|
|
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
|
|
|
await trigger(makeEvent("chat-help", "@_user_1 /help"), rt);
|
|
|
|
const helpText = rt.sentTexts.at(-1) ?? "";
|
|
expect(helpText).toContain("可用 slash 命令");
|
|
expect(helpText).toContain("/project");
|
|
expect(helpText).toContain("/usage");
|
|
expect(helpText).not.toMatch(/\/(?:new|reset|resume|draft|review|compact)\b/);
|
|
expect(runAgentCalls).toHaveLength(0);
|
|
expect(await prisma.agentRun.findMany()).toHaveLength(0);
|
|
});
|
|
|
|
it("/usage reports the selected role's active-session cost", async () => {
|
|
await seedProject("proj-cost", "chat-cost");
|
|
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
|
|
|
await trigger(makeEvent("chat-cost", "@_user_1 写教案"), rt);
|
|
await vi.waitFor(async () => {
|
|
const runs = await prisma.agentRun.findMany();
|
|
expect(runs).toHaveLength(1);
|
|
expect(runs[0]?.status).toBe("COMPLETED");
|
|
});
|
|
|
|
await trigger(makeEvent("chat-cost", "@_user_1 /usage"), rt);
|
|
|
|
const costText = rt.sentTexts.at(-1) ?? "";
|
|
expect(costText).toContain("当前角色会话用量");
|
|
expect(costText).toContain("$0.0023");
|
|
expect(costText).toContain("openrouter / mock-model");
|
|
expect(runAgentCalls).toHaveLength(1);
|
|
expect(await prisma.agentRun.findMany()).toHaveLength(1);
|
|
});
|
|
|
|
it("/help <command> returns command-specific help", async () => {
|
|
await seedProject("proj-help-usage", "chat-help-usage");
|
|
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
|
|
|
await trigger(makeEvent("chat-help-usage", "@_user_1 /help usage"), rt);
|
|
|
|
const helpText = rt.sentTexts.at(-1) ?? "";
|
|
expect(helpText).toContain("/usage");
|
|
expect(helpText).toContain("真实 Agent 用量与成本");
|
|
expect(runAgentCalls).toHaveLength(0);
|
|
expect(await prisma.agentRun.findMany()).toHaveLength(0);
|
|
});
|
|
|
|
it("/help unknown and /unknown help report the unknown command", async () => {
|
|
await seedProject("proj-help-unknown", "chat-help-unknown");
|
|
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
|
|
|
await trigger(makeEvent("chat-help-unknown", "@_user_1 /help unknown"), rt);
|
|
expect(rt.sentTexts.at(-1) ?? "").toContain("未知 slash 命令 /unknown");
|
|
expect(runAgentCalls).toHaveLength(0);
|
|
|
|
await trigger(makeEvent("chat-help-unknown", "@_user_1 /unknown help"), rt);
|
|
expect(rt.sentTexts.at(-1) ?? "").toContain("未知 slash 命令 /unknown");
|
|
expect(runAgentCalls).toHaveLength(0);
|
|
expect(await prisma.agentRun.findMany()).toHaveLength(0);
|
|
});
|
|
|
|
it("rejects a sender without edit grant (ADR-0004)", async () => {
|
|
await seedProject("proj-2", "chat-2", { role: "READ" });
|
|
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
|
|
|
await trigger(makeEvent("chat-2", "@_user_1 写教案"), rt);
|
|
|
|
expect(rt.sentTexts).toContain("无权限触发。");
|
|
expect(runAgentCalls).toHaveLength(0);
|
|
const runs = await prisma.agentRun.findMany();
|
|
expect(runs).toHaveLength(0);
|
|
});
|
|
|
|
it("gives an unknown user the scoped login URL in an already-bound chat", async () => {
|
|
await seedProject("proj-bound-unknown", "chat-bound-unknown");
|
|
const trigger = makeTriggerHandler({
|
|
prisma,
|
|
settings,
|
|
logger: silentLogger,
|
|
runAgent,
|
|
allowLegacyFeishuIdentity: false,
|
|
messageBatcherOptions: { maxMessages: 1 },
|
|
});
|
|
|
|
await trigger(makeEvent("chat-bound-unknown", "@_user_1 写教案", "ou_bound_unknown"), rt);
|
|
|
|
expect(rt.sentTexts).toContain(
|
|
"请先通过飞书登录并加入组织:https://educraft.example.test/auth/feishu/test-default\n" +
|
|
"完成后返回群聊重试。",
|
|
);
|
|
expect(rt.sentTexts).not.toContain("无权限触发。");
|
|
expect(runAgentCalls).toHaveLength(0);
|
|
});
|
|
|
|
it("tells a logged-in non-member to contact the administrator in an already-bound chat", async () => {
|
|
await seedProject("proj-bound-non-member", "chat-bound-non-member");
|
|
await seedScopedIdentityWithoutMembership("bound-non-member", "ou_bound_non_member");
|
|
const trigger = makeTriggerHandler({
|
|
prisma,
|
|
settings,
|
|
logger: silentLogger,
|
|
runAgent,
|
|
allowLegacyFeishuIdentity: false,
|
|
messageBatcherOptions: { maxMessages: 1 },
|
|
});
|
|
|
|
await trigger(makeEvent("chat-bound-non-member", "@_user_1 写教案", "ou_bound_non_member"), rt);
|
|
|
|
expect(rt.sentTexts).toContain(
|
|
"你尚未加入该组织,或成员资格已被移除。请联系组织管理员。",
|
|
);
|
|
expect(rt.sentTexts).not.toContain("无权限触发。");
|
|
expect(runAgentCalls).toHaveLength(0);
|
|
});
|
|
|
|
it("keeps generic project denial for an Organization member without project permission", async () => {
|
|
await seedProject("proj-bound-read-only", "chat-bound-read-only", { role: "READ" });
|
|
const trigger = makeTriggerHandler({
|
|
prisma,
|
|
settings,
|
|
logger: silentLogger,
|
|
runAgent,
|
|
messageBatcherOptions: { maxMessages: 1 },
|
|
});
|
|
|
|
await trigger(makeEvent("chat-bound-read-only", "@_user_1 写教案"), rt);
|
|
|
|
expect(rt.sentTexts).toContain("无权限触发。");
|
|
expect(rt.sentTexts.join("\n")).not.toContain("/auth/feishu/");
|
|
expect(rt.sentTexts.join("\n")).not.toContain("尚未加入该组织");
|
|
expect(runAgentCalls).toHaveLength(0);
|
|
});
|
|
|
|
it.each(["SUSPENDED", "ARCHIVED"] as const)(
|
|
"rejects triggers when the organization is %s",
|
|
async (status) => {
|
|
await seedProject(`proj-org-${status}`, `chat-org-${status}`);
|
|
const session = await prisma.agentSession.create({
|
|
data: {
|
|
projectId: `proj-org-${status}`,
|
|
provider: "openrouter",
|
|
roleId: "draft",
|
|
model: "mock-model",
|
|
metadata: {},
|
|
archivedAt: new Date(),
|
|
},
|
|
});
|
|
await prisma.organization.update({ where: { id: DEFAULT_ORG_ID }, data: { status } });
|
|
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
|
|
|
await trigger(makeEvent(`chat-org-${status}`, "@_user_1 写教案"), rt);
|
|
|
|
expect(rt.sentTexts).toContain("无权限触发。");
|
|
expect(runAgentCalls).toHaveLength(0);
|
|
expect(await prisma.agentRun.count()).toBe(0);
|
|
await expect(prisma.agentSession.findUniqueOrThrow({ where: { id: session.id } })).resolves.toMatchObject({
|
|
archivedAt: expect.any(Date),
|
|
});
|
|
},
|
|
);
|
|
|
|
it("queues a text trigger when project is already locked (ADR-0002)", async () => {
|
|
await seedProject("proj-3", "chat-3");
|
|
// Manually create a lock by inserting a run + lock.
|
|
const existingRun = await prisma.agentRun.create({
|
|
data: { projectId: "proj-3", entrypoint: "FEISHU", status: "ACTIVE", prompt: "x", model: "m", provider: "mock", metadata: {} },
|
|
});
|
|
await prisma.projectAgentLock.create({
|
|
data: { projectId: "proj-3", runId: existingRun.id },
|
|
});
|
|
|
|
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
|
await trigger(makeEvent("chat-3", "@_user_1 写教案"), rt);
|
|
|
|
expect(rt.sentTexts).toContain("已加入队列(第1位),当前处理完成后将自动开始");
|
|
expect(rt.reactions.some((reaction) => reaction.emoji === "OnIt")).toBe(false);
|
|
expect(runAgentCalls).toHaveLength(0);
|
|
// No new run created.
|
|
const runs = await prisma.agentRun.findMany();
|
|
expect(runs).toHaveLength(1);
|
|
});
|
|
|
|
it("starts the next queued text trigger when the current run finishes", async () => {
|
|
await seedProject("proj-3b", "chat-3b");
|
|
const firstRun = deferred<RunResult>();
|
|
const secondRun = deferred<RunResult>();
|
|
const pendingRuns = [firstRun, secondRun];
|
|
const queuedRunAgent: TestRunner = async (req) => {
|
|
runAgentCalls.push(req);
|
|
req.onStream?.({ type: "text-delta", text: `mock response ${runAgentCalls.length}` });
|
|
const pendingRun = pendingRuns.shift();
|
|
if (pendingRun === undefined) {
|
|
throw new Error("unexpected extra run");
|
|
}
|
|
return pendingRun.promise;
|
|
};
|
|
const trigger = makeTriggerHandler({
|
|
prisma,
|
|
settings,
|
|
logger: silentLogger,
|
|
runAgent: queuedRunAgent,
|
|
messageBatcherOptions: { maxMessages: 1 },
|
|
});
|
|
|
|
await trigger(makeEvent("chat-3b", "@_user_1 第一个请求"), rt);
|
|
await vi.waitFor(() => {
|
|
expect(runAgentCalls).toHaveLength(1);
|
|
});
|
|
|
|
await trigger(makeEvent("chat-3b", "@_user_1 第二个请求"), rt);
|
|
expect(rt.sentTexts).toContain("已加入队列(第1位),当前处理完成后将自动开始");
|
|
expect(runAgentCalls).toHaveLength(1);
|
|
|
|
firstRun.resolve(completedRunResult("first done", "sdk-session-first"));
|
|
await vi.waitFor(() => {
|
|
expect(runAgentCalls).toHaveLength(2);
|
|
});
|
|
expectPromptFromSender(runAgentCalls[1]?.prompt, "ou_test_user", "第二个请求");
|
|
|
|
secondRun.resolve(completedRunResult("second done", "sdk-session-second"));
|
|
await vi.waitFor(async () => {
|
|
const runs = await prisma.agentRun.findMany({ orderBy: { startedAt: "asc" } });
|
|
expect(runs).toHaveLength(2);
|
|
expect(runs.every((run) => run.status === "COMPLETED")).toBe(true);
|
|
});
|
|
});
|
|
|
|
it("freezes the selected role when a trigger enters the queue", async () => {
|
|
await seedProject("proj-queue-role", "chat-queue-role");
|
|
const reviewRole = await prisma.organizationAgentRole.create({
|
|
data: {
|
|
organizationId: DEFAULT_ORG_ID,
|
|
roleId: "review",
|
|
label: "审校",
|
|
defaultModel: "mock-model",
|
|
tools: ["read_file"],
|
|
},
|
|
});
|
|
const firstRun = deferred<RunResult>();
|
|
const secondRun = deferred<RunResult>();
|
|
const pendingRuns = [firstRun, secondRun];
|
|
const queuedRunAgent: TestRunner = async (req) => {
|
|
runAgentCalls.push(req);
|
|
const pending = pendingRuns.shift();
|
|
if (pending === undefined) throw new Error("unexpected extra run");
|
|
return pending.promise;
|
|
};
|
|
const trigger = makeTriggerHandler({
|
|
prisma,
|
|
settings,
|
|
logger: silentLogger,
|
|
runAgent: queuedRunAgent,
|
|
messageBatcherOptions: { maxMessages: 1 },
|
|
});
|
|
|
|
await trigger(makeEvent("chat-queue-role", "@_user_1 第一个请求"), rt);
|
|
await vi.waitFor(() => expect(runAgentCalls).toHaveLength(1));
|
|
await trigger(makeEvent("chat-queue-role", "@_user_1 排队的草稿请求"), rt);
|
|
await prisma.projectGroupBinding.updateMany({
|
|
where: { projectId: "proj-queue-role", chatId: "chat-queue-role", archivedAt: null },
|
|
data: { selectedAgentRoleId: reviewRole.id },
|
|
});
|
|
|
|
firstRun.resolve(completedRunResult("first done", "sdk-session-first"));
|
|
await vi.waitFor(() => expect(runAgentCalls).toHaveLength(2));
|
|
const queuedRun = await prisma.agentRun.findFirstOrThrow({
|
|
where: { projectId: "proj-queue-role", prompt: { contains: "排队的草稿请求" } },
|
|
});
|
|
expect(queuedRun.metadata).toMatchObject({ roleId: "draft" });
|
|
expect(runAgentCalls[1]?.tools).toBeUndefined();
|
|
|
|
secondRun.resolve(completedRunResult("second done", "sdk-session-second"));
|
|
await vi.waitFor(async () => {
|
|
expect(await prisma.agentRun.count({ where: { projectId: "proj-queue-role", status: "COMPLETED" } })).toBe(2);
|
|
});
|
|
});
|
|
|
|
it("reauthorizes a queued trigger and drops it after the organization is suspended", async () => {
|
|
await seedProject("proj-queue-suspended", "chat-queue-suspended");
|
|
const firstRun = deferred<RunResult>();
|
|
const queuedRunAgent: TestRunner = async (req) => {
|
|
runAgentCalls.push(req);
|
|
return firstRun.promise;
|
|
};
|
|
const trigger = makeTriggerHandler({
|
|
prisma,
|
|
settings,
|
|
logger: silentLogger,
|
|
runAgent: queuedRunAgent,
|
|
messageBatcherOptions: { maxMessages: 1 },
|
|
});
|
|
|
|
await trigger(makeEvent("chat-queue-suspended", "@_user_1 第一个请求"), rt);
|
|
await vi.waitFor(() => expect(runAgentCalls).toHaveLength(1));
|
|
await trigger(makeEvent("chat-queue-suspended", "@_user_1 第二个请求"), rt);
|
|
expect(rt.sentTexts).toContain("已加入队列(第1位),当前处理完成后将自动开始");
|
|
|
|
await prisma.organization.update({ where: { id: DEFAULT_ORG_ID }, data: { status: "SUSPENDED" } });
|
|
firstRun.resolve(completedRunResult("first done", "sdk-session-first"));
|
|
|
|
await vi.waitFor(() => expect(rt.sentTexts).toContain("无权限使用角色 draft。"));
|
|
expect(runAgentCalls).toHaveLength(1);
|
|
await expect(prisma.agentRun.count()).resolves.toBe(1);
|
|
});
|
|
|
|
it("rechecks ACTIVE atomically when suspension lands after the first authorization", async () => {
|
|
await seedProject("proj-admission-race", "chat-admission-race");
|
|
const providerEntered = deferred<void>();
|
|
const releaseProvider = deferred<void>();
|
|
const baseProvider = settings.provider.bind(settings);
|
|
const pausedSettings: RuntimeSettings = {
|
|
...settings,
|
|
async provider(providerId, context) {
|
|
providerEntered.resolve();
|
|
await releaseProvider.promise;
|
|
return baseProvider(providerId, context);
|
|
},
|
|
};
|
|
const trigger = makeTriggerHandler({
|
|
prisma,
|
|
settings: pausedSettings,
|
|
logger: silentLogger,
|
|
runAgent,
|
|
messageBatcherOptions: { maxMessages: 1 },
|
|
});
|
|
|
|
const pendingTrigger = trigger(makeEvent("chat-admission-race", "@_user_1 竞态请求"), rt);
|
|
await providerEntered.promise;
|
|
await prisma.organization.update({ where: { id: DEFAULT_ORG_ID }, data: { status: "SUSPENDED" } });
|
|
releaseProvider.resolve();
|
|
await pendingTrigger;
|
|
|
|
expect(rt.sentTexts).toContain("组织当前不可用,拒绝触发。");
|
|
expect(runAgentCalls).toHaveLength(0);
|
|
await expect(prisma.agentSession.count()).resolves.toBe(0);
|
|
await expect(prisma.agentRun.count()).resolves.toBe(0);
|
|
await expect(prisma.projectAgentLock.count()).resolves.toBe(0);
|
|
await expect(prisma.auditEntry.count({ where: { action: "run.created" } })).resolves.toBe(0);
|
|
});
|
|
|
|
itOnLinux("does not publish a staged attachment when suspension wins admission", async () => {
|
|
const workspaceRoot = await tempWorkspaceRoot();
|
|
const workspaceDir = join(workspaceRoot, "project");
|
|
await mkdir(workspaceDir);
|
|
await seedProject("proj-attachment-race", "chat-attachment-race");
|
|
await prisma.project.update({
|
|
where: { id: "proj-attachment-race" },
|
|
data: { workspaceDir },
|
|
});
|
|
const resourceEntered = deferred<void>();
|
|
const releaseResource = deferred<void>();
|
|
const downloadResource = vi.fn(async (request) => {
|
|
resourceEntered.resolve();
|
|
await releaseResource.promise;
|
|
return writeNewWorkspaceFileNoFollow(
|
|
request.workspaceRoot,
|
|
request.workspaceDir,
|
|
request.workspaceRelativePath,
|
|
Readable.from([Buffer.from("staged image bytes")]),
|
|
request.maxBytes,
|
|
);
|
|
});
|
|
const feishuBotCli: FeishuBotCli = { downloadResource };
|
|
const baseEvent = makeEvent("chat-attachment-race", "@_user_1 附件竞态");
|
|
const event: MessageReceiveEvent = {
|
|
...baseEvent,
|
|
message: {
|
|
...baseEvent.message,
|
|
message_type: "post",
|
|
content: JSON.stringify({
|
|
zh_cn: {
|
|
content: [[
|
|
{ tag: "text", text: "@_user_1 附件竞态" },
|
|
{ tag: "img", image_key: "img-race" },
|
|
]],
|
|
},
|
|
}),
|
|
},
|
|
};
|
|
const trigger = makeTriggerHandler({
|
|
prisma,
|
|
settings,
|
|
logger: silentLogger,
|
|
runAgent,
|
|
projectWorkspaceRoot: workspaceRoot,
|
|
messageBatcherOptions: { maxMessages: 1 },
|
|
feishuBotCli,
|
|
});
|
|
|
|
const pendingTrigger = trigger(event, rt);
|
|
await resourceEntered.promise;
|
|
await prisma.organization.update({ where: { id: DEFAULT_ORG_ID }, data: { status: "SUSPENDED" } });
|
|
releaseResource.resolve();
|
|
await pendingTrigger;
|
|
|
|
expect(rt.sentTexts).toContain("组织当前不可用,拒绝触发。");
|
|
expect(runAgentCalls).toHaveLength(0);
|
|
await expect(prisma.agentRun.count()).resolves.toBe(0);
|
|
await expect(readdir(join(workspaceDir, ".cph", "inbox"))).rejects.toMatchObject({ code: "ENOENT" });
|
|
await expect(readdir(join(workspaceRoot, ".cph-staging"))).resolves.toEqual([]);
|
|
});
|
|
|
|
it("does not create a run for unbound chats and gives unknown users the scoped login URL", async () => {
|
|
await seedProject("proj-4", "chat-4");
|
|
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
|
|
|
await trigger(makeEvent("chat-UNKNOWN", "@_user_1 写教案", "ou_unknown_user"), rt);
|
|
|
|
expect(rt.sentTexts).toContain(
|
|
"请先通过飞书登录并加入组织:https://educraft.example.test/auth/feishu/test-default\n" +
|
|
"完成后返回群聊重试。",
|
|
);
|
|
expect(rt.sentCards).toHaveLength(0);
|
|
const runs = await prisma.agentRun.findMany();
|
|
expect(runs).toHaveLength(0);
|
|
});
|
|
|
|
it("distinguishes a scoped Feishu identity that has not joined the Silo Organization", async () => {
|
|
await seedScopedIdentityWithoutMembership("unbound-non-member", "ou_logged_in_not_member");
|
|
const trigger = makeTriggerHandler({
|
|
prisma,
|
|
settings,
|
|
logger: silentLogger,
|
|
runAgent,
|
|
allowLegacyFeishuIdentity: false,
|
|
messageBatcherOptions: { maxMessages: 1 },
|
|
});
|
|
|
|
await trigger(makeEvent("chat-unbound", "@_user_1 写教案", "ou_logged_in_not_member"), rt);
|
|
|
|
expect(rt.sentTexts).toContain(
|
|
"你尚未加入该组织,或成员资格已被移除。请联系组织管理员。",
|
|
);
|
|
expect(rt.sentTexts.join("\n")).not.toContain("/auth/feishu/");
|
|
await expect(prisma.agentRun.count()).resolves.toBe(0);
|
|
});
|
|
|
|
it("encodes the configured Organization slug in the OAuth login URL", async () => {
|
|
const encodedOrgId = "org_url_encoding";
|
|
await seedTestOrganization(encodedOrgId, "school east/数学?");
|
|
const trigger = makeTriggerHandler({
|
|
prisma,
|
|
settings,
|
|
logger: silentLogger,
|
|
runAgent,
|
|
siloOrganizationId: encodedOrgId,
|
|
publicBaseUrl: "https://school.example.test/",
|
|
messageBatcherOptions: { maxMessages: 1 },
|
|
});
|
|
|
|
await trigger(makeEvent("chat-unbound", "@_user_1 写教案", "ou_unknown_encoded"), rt);
|
|
|
|
expect(rt.sentTexts.join("\n")).toContain(
|
|
"https://school.example.test/auth/feishu/school%20east%2F%E6%95%B0%E5%AD%A6%3F",
|
|
);
|
|
expect(rt.sentTexts.join("\n")).not.toContain("school east/数学?");
|
|
});
|
|
|
|
it("ignores messages without @bot mention", async () => {
|
|
await seedProject("proj-5", "chat-5");
|
|
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
|
|
|
const event: MessageReceiveEvent = {
|
|
message: {
|
|
message_id: "m_nobot",
|
|
chat_id: "chat-5",
|
|
chat_type: "group",
|
|
message_type: "text",
|
|
content: JSON.stringify({ text: "hello no bot" }),
|
|
mentions: undefined,
|
|
},
|
|
sender: { sender_id: { open_id: "ou_test_user" }, sender_type: "user" },
|
|
};
|
|
await trigger(event, rt);
|
|
|
|
expect(rt.sentTexts).toHaveLength(0);
|
|
const runs = await prisma.agentRun.findMany();
|
|
expect(runs).toHaveLength(0);
|
|
});
|
|
|
|
it("/project opens the role and session console without creating a run", async () => {
|
|
await seedProject("proj-6", "chat-6");
|
|
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
|
|
|
await trigger(makeEvent("chat-6", "@_user_1 /project"), rt);
|
|
|
|
expect(rt.sentCards).toHaveLength(1);
|
|
const card = JSON.stringify(rt.sentCards[0]);
|
|
expect(card).toContain("当前角色");
|
|
expect(card).toContain("草稿");
|
|
expect(card).toContain("新开会话");
|
|
expect(card).toContain("历史会话");
|
|
expect(runAgentCalls).toHaveLength(0);
|
|
expect(await prisma.agentRun.count()).toBe(0);
|
|
});
|
|
|
|
it("creates, lists, and resumes the selected role's user-managed session history", async () => {
|
|
await seedProject("proj-session-console", "chat-session-console");
|
|
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
|
await trigger(makeEvent("chat-session-console", "@_user_1 建立历史会话"), rt);
|
|
await vi.waitFor(async () => {
|
|
await expect(prisma.agentRun.findFirstOrThrow({ where: { projectId: "proj-session-console" } }))
|
|
.resolves.toMatchObject({ status: "COMPLETED" });
|
|
});
|
|
const session = await prisma.agentSession.findFirstOrThrow({ where: { projectId: "proj-session-console" } });
|
|
|
|
await trigger.onCardAction(makeOnboardingEvent("chat-session-console", {
|
|
project_onboarding: {
|
|
action: "new_agent_session",
|
|
organization_id: DEFAULT_ORG_ID,
|
|
project_id: "proj-session-console",
|
|
},
|
|
}, "ou_test_user"), rt);
|
|
await expect(prisma.agentSession.findUniqueOrThrow({ where: { id: session.id } }))
|
|
.resolves.toMatchObject({ archivedAt: expect.any(Date), metadata: expect.objectContaining({ userResumable: true }) });
|
|
|
|
await trigger.onCardAction(makeOnboardingEvent("chat-session-console", {
|
|
project_onboarding: {
|
|
action: "show_session_history",
|
|
organization_id: DEFAULT_ORG_ID,
|
|
project_id: "proj-session-console",
|
|
},
|
|
}, "ou_test_user"), rt);
|
|
expect(JSON.stringify(rt.sentPatches.at(-1))).toContain(session.id);
|
|
|
|
await trigger.onCardAction(makeOnboardingEvent("chat-session-console", {
|
|
project_onboarding: {
|
|
action: "resume_agent_session",
|
|
organization_id: DEFAULT_ORG_ID,
|
|
project_id: "proj-session-console",
|
|
session_id: session.id,
|
|
},
|
|
}, "ou_test_user"), rt);
|
|
await expect(prisma.agentSession.findUniqueOrThrow({ where: { id: session.id } }))
|
|
.resolves.toMatchObject({ archivedAt: null, metadata: expect.objectContaining({ userResumable: false }) });
|
|
});
|
|
|
|
it("switches the bound role from the project card and uses it for later messages", async () => {
|
|
await seedProject("proj-role-switch", "chat-role-switch");
|
|
const reviewRole = await prisma.organizationAgentRole.create({
|
|
data: {
|
|
organizationId: DEFAULT_ORG_ID,
|
|
roleId: "review",
|
|
label: "审校",
|
|
defaultModel: "mock-model",
|
|
tools: ["read_file"],
|
|
sortOrder: 10,
|
|
},
|
|
});
|
|
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
|
|
|
await trigger.onCardAction(makeOnboardingEvent("chat-role-switch", {
|
|
project_onboarding: {
|
|
action: "select_agent_role",
|
|
organization_id: DEFAULT_ORG_ID,
|
|
project_id: "proj-role-switch",
|
|
agent_role_id: reviewRole.id,
|
|
},
|
|
}, "ou_test_user"), rt);
|
|
await trigger(makeEvent("chat-role-switch", "@_user_1 检查讲义"), rt);
|
|
|
|
await vi.waitFor(async () => {
|
|
expect(await prisma.agentRun.count()).toBe(1);
|
|
});
|
|
expect(runAgentCalls[0]?.tools).toEqual(["read_file"]);
|
|
await expect(prisma.agentRun.findFirstOrThrow({ where: { projectId: "proj-role-switch" } })).resolves.toMatchObject({
|
|
metadata: expect.objectContaining({ roleId: "review" }),
|
|
});
|
|
await expect(prisma.projectGroupBinding.findFirstOrThrow({
|
|
where: { projectId: "proj-role-switch", chatId: "chat-role-switch", archivedAt: null },
|
|
select: { selectedAgentRoleId: true },
|
|
})).resolves.toEqual({ selectedAgentRoleId: reviewRole.id });
|
|
});
|
|
|
|
it("unknown slash commands fail visibly instead of reaching the Agent", async () => {
|
|
await seedProject("proj-9", "chat-9");
|
|
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
|
|
|
await trigger(makeEvent("chat-9", "@_user_1 /unknown"), rt);
|
|
expect(rt.sentTexts.at(-1)).toContain("未知 slash 命令 /unknown");
|
|
expect(runAgentCalls).toHaveLength(0);
|
|
expect(await prisma.agentRun.count()).toBe(0);
|
|
});
|
|
|
|
it("sends native /compact as an exact SDK prompt for the current role session", async () => {
|
|
await seedProject("proj-compact", "chat-compact");
|
|
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
|
|
|
await trigger(makeEvent("chat-compact", "@_user_1 写第三单元"), rt);
|
|
await vi.waitFor(async () => {
|
|
await expect(prisma.agentRun.findFirstOrThrow({ where: { projectId: "proj-compact" } }))
|
|
.resolves.toMatchObject({ status: "COMPLETED" });
|
|
});
|
|
const session = await prisma.agentSession.findFirstOrThrow({ where: { projectId: "proj-compact", archivedAt: null } });
|
|
|
|
await trigger.onCardAction(makeOnboardingEvent("chat-compact", {
|
|
project_onboarding: {
|
|
action: "compact_agent_session",
|
|
organization_id: DEFAULT_ORG_ID,
|
|
project_id: "proj-compact",
|
|
},
|
|
}, "ou_test_user"), rt);
|
|
await vi.waitFor(async () => {
|
|
expect(await prisma.agentRun.count()).toBe(2);
|
|
});
|
|
|
|
expect(runAgentCalls[1]?.prompt).toBe("/compact");
|
|
expect(runAgentCalls[1]?.resumeSessionId).toBe("sdk-session-1");
|
|
expect(runAgentCalls[1]?.sessionId).toBe(session.id);
|
|
await expect(prisma.agentRun.findFirstOrThrow({
|
|
where: { projectId: "proj-compact", prompt: "/compact" },
|
|
})).resolves.toMatchObject({ metadata: expect.objectContaining({ roleId: "draft" }) });
|
|
});
|
|
|
|
it("preserves native /compact semantics while the action waits in the project queue", async () => {
|
|
await seedProject("proj-compact-queued", "chat-compact-queued");
|
|
const activeRun = deferred<RunResult>();
|
|
const queuedRunner: TestRunner = async (req) => {
|
|
runAgentCalls.push(req);
|
|
if (runAgentCalls.length === 2) return activeRun.promise;
|
|
return completedRunResult(`done ${runAgentCalls.length}`, "sdk-session-queued");
|
|
};
|
|
const trigger = makeTriggerHandler({
|
|
prisma,
|
|
settings,
|
|
logger: silentLogger,
|
|
runAgent: queuedRunner,
|
|
messageBatcherOptions: { maxMessages: 1 },
|
|
});
|
|
|
|
await trigger(makeEvent("chat-compact-queued", "@_user_1 建立会话"), rt);
|
|
await vi.waitFor(async () => {
|
|
expect(await prisma.agentRun.count({ where: { projectId: "proj-compact-queued", status: "COMPLETED" } })).toBe(1);
|
|
});
|
|
await trigger(makeEvent("chat-compact-queued", "@_user_1 正在处理"), rt);
|
|
await vi.waitFor(() => expect(runAgentCalls).toHaveLength(2));
|
|
await trigger.onCardAction(makeOnboardingEvent("chat-compact-queued", {
|
|
project_onboarding: {
|
|
action: "compact_agent_session",
|
|
organization_id: DEFAULT_ORG_ID,
|
|
project_id: "proj-compact-queued",
|
|
},
|
|
}, "ou_test_user"), rt);
|
|
expect(rt.sentTexts).toContain("已加入队列(第1位),当前处理完成后将自动开始");
|
|
|
|
activeRun.resolve(completedRunResult("active done", "sdk-session-active"));
|
|
await vi.waitFor(() => expect(runAgentCalls).toHaveLength(3));
|
|
expect(runAgentCalls[2]?.prompt).toBe("/compact");
|
|
});
|
|
|
|
it("dedups a redelivered event by event_id (no second run)", async () => {
|
|
await seedProject("proj-13", "chat-13");
|
|
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
|
|
|
// First delivery: processes normally.
|
|
await trigger(makeEvent("chat-13", "@_user_1 写教案", "ou_test_user", "evt-dedup-1"), rt);
|
|
await vi.waitFor(async () => {
|
|
const runs = await prisma.agentRun.findMany();
|
|
expect(runs).toHaveLength(1);
|
|
expect(runs[0]?.status).toBe("COMPLETED");
|
|
});
|
|
|
|
// Redelivery of the same event_id: must not create a second run.
|
|
await trigger(makeEvent("chat-13", "@_user_1 写教案", "ou_test_user", "evt-dedup-1"), rt);
|
|
const runs2 = await prisma.agentRun.findMany();
|
|
expect(runs2).toHaveLength(1);
|
|
|
|
// Only one event receipt row.
|
|
const receipts = await prisma.feishuEventReceipt.findMany();
|
|
expect(receipts).toHaveLength(1);
|
|
expect(receipts[0]?.eventId).toBe("evt-dedup-1");
|
|
});
|
|
|
|
it("dedups flattened websocket events by top-level event_id", async () => {
|
|
await seedProject("proj-13b", "chat-13b");
|
|
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
|
const event: MessageReceiveEvent = {
|
|
...makeEvent("chat-13b", "@_user_1 写教案"),
|
|
event_id: "evt-flat-1",
|
|
event_type: "im.message.receive_v1",
|
|
};
|
|
|
|
await trigger(event, rt);
|
|
await vi.waitFor(async () => {
|
|
expect(await prisma.agentRun.findMany()).toHaveLength(1);
|
|
});
|
|
|
|
await trigger(
|
|
{
|
|
...event,
|
|
message: { ...event.message, message_id: "m_flat_redelivery" },
|
|
},
|
|
rt,
|
|
);
|
|
|
|
const runs = await prisma.agentRun.findMany();
|
|
expect(runs).toHaveLength(1);
|
|
const receipts = await prisma.feishuEventReceipt.findMany();
|
|
expect(receipts).toHaveLength(1);
|
|
expect(receipts[0]?.eventId).toBe("evt-flat-1");
|
|
});
|
|
|
|
it("seeds replied-to message context when @bot is used in a Feishu reply", async () => {
|
|
await seedProject("proj-13c", "chat-13c");
|
|
rt.readableMessages.set("m-parent", {
|
|
message_id: "m-parent",
|
|
root_id: "m-root",
|
|
thread_id: "thread-1",
|
|
msg_type: "text",
|
|
chat_id: "chat-13c",
|
|
sender: { id: "ou_teacher_2", sender_type: "user" },
|
|
body: { content: JSON.stringify({ text: "上一条需求: 把第二题改成探究题。" }) },
|
|
});
|
|
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
|
const baseEvent = makeEvent("chat-13c", "@_user_1 继续这个改法");
|
|
const event: MessageReceiveEvent = {
|
|
...baseEvent,
|
|
message: {
|
|
...baseEvent.message,
|
|
message_id: "m-child",
|
|
root_id: "m-root",
|
|
parent_id: "m-parent",
|
|
thread_id: "thread-1",
|
|
},
|
|
};
|
|
|
|
await trigger(event, rt);
|
|
|
|
await vi.waitFor(() => {
|
|
expect(runAgentCalls).toHaveLength(1);
|
|
});
|
|
const prompt = runAgentCalls[0]?.prompt ?? "";
|
|
expect(prompt).toContain("Feishu trigger context");
|
|
expect(prompt).toContain("reply_to_message_id");
|
|
expect(prompt).toContain("m-parent");
|
|
expect(prompt).toContain("thread-1");
|
|
expect(prompt).toContain("上一条需求");
|
|
expectPromptFromSender(prompt, "ou_test_user", "继续这个改法");
|
|
|
|
const run = await prisma.agentRun.findFirst();
|
|
expect(run?.prompt).toBe(prompt);
|
|
expect(run?.metadata).toMatchObject({
|
|
roleId: "draft",
|
|
rawPrompt: "继续这个改法",
|
|
feishuTriggerContext: {
|
|
trigger_message_id: "m-child",
|
|
chat_id: "chat-13c",
|
|
sender: { open_id: "ou_test_user" },
|
|
reply_to_message_id: "m-parent",
|
|
root_id: "m-root",
|
|
thread_id: "thread-1",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("writes audit entries across the run lifecycle", async () => {
|
|
await seedProject("proj-14", "chat-14");
|
|
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
|
|
|
await trigger(makeEvent("chat-14", "@_user_1 写教案"), rt);
|
|
await vi.waitFor(async () => {
|
|
const audit = await prisma.auditEntry.findMany();
|
|
expect(audit.length).toBeGreaterThanOrEqual(2);
|
|
});
|
|
|
|
const audit = await prisma.auditEntry.findMany({ orderBy: { createdAt: "asc" } });
|
|
expect(audit.some((a) => a.action === "run.created")).toBe(true);
|
|
expect(audit.some((a) => a.action === "run.finished")).toBe(true);
|
|
});
|
|
|
|
it("persists AgentMessage rows for the run (A-3 structured history)", async () => {
|
|
await seedProject("proj-15", "chat-15");
|
|
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
|
|
|
await trigger(makeEvent("chat-15", "@_user_1 写教案"), rt);
|
|
await vi.waitFor(async () => {
|
|
const msgs = await prisma.agentMessage.findMany();
|
|
expect(msgs.length).toBeGreaterThanOrEqual(1);
|
|
});
|
|
const msgs = await prisma.agentMessage.findMany({ orderBy: { createdAt: "asc" } });
|
|
expect(msgs.length).toBeGreaterThanOrEqual(1);
|
|
const run = await prisma.agentRun.findFirst();
|
|
expect(msgs.every((m) => m.runId === run?.id)).toBe(true);
|
|
expect(msgs.some((m) => m.role === "assistant")).toBe(true);
|
|
});
|
|
it("interrupts a running agent via the card interrupt button", async () => {
|
|
await seedProject("proj-int", "chat-int", { role: "MANAGE" });
|
|
runAgent = createHangingRunAgent();
|
|
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
|
|
|
await trigger(makeEvent("chat-int", "@_user_1 写教案"), rt);
|
|
|
|
// The run started: a card was sent (with an interrupt button) and the run is ACTIVE.
|
|
await vi.waitFor(async () => {
|
|
const runs = await prisma.agentRun.findMany();
|
|
expect(runs).toHaveLength(1);
|
|
expect(runs[0]?.status).toBe("ACTIVE");
|
|
});
|
|
expect(rt.sentCards.length).toBeGreaterThanOrEqual(1);
|
|
const run = await prisma.agentRun.findFirst();
|
|
expect(run).not.toBeNull();
|
|
|
|
await trigger.onCardAction(makeInterruptEvent("chat-int", run!.id, "ou_test_user"), rt);
|
|
|
|
// The run transitions to CANCELED (spec RunState.canceled is terminal).
|
|
await vi.waitFor(async () => {
|
|
const runs = await prisma.agentRun.findMany();
|
|
expect(runs[0]?.status).toBe("CANCELED");
|
|
});
|
|
// The lock is released (terminal ⇒ release, ADR-0002).
|
|
await vi.waitFor(async () => {
|
|
const locks = await prisma.projectAgentLock.findMany();
|
|
expect(locks).toHaveLength(0);
|
|
});
|
|
// Audit records the interrupt.
|
|
const audit = await prisma.auditEntry.findMany();
|
|
expect(audit.some((a) => a.action === "run.interrupted")).toBe(true);
|
|
// The card was patched to a complete state with an "已中断" footer.
|
|
expect(rt.sentPatches.some((card) => cardHasInterruptedFooter(card))).toBe(true);
|
|
});
|
|
|
|
it("sends a fallback interrupt notice when the final card patch fails", async () => {
|
|
await seedProject("proj-int-patch-fail", "chat-int-patch-fail", { role: "MANAGE" });
|
|
runAgent = createHangingRunAgent();
|
|
const patch = vi.fn(async () => {
|
|
throw new Error("patch failed");
|
|
});
|
|
(rt.client as unknown as { im: { v1: { message: { patch: typeof patch } } } }).im.v1.message.patch = patch;
|
|
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
|
|
|
await trigger(makeEvent("chat-int-patch-fail", "@_user_1 写教案"), rt);
|
|
|
|
await vi.waitFor(async () => {
|
|
const runs = await prisma.agentRun.findMany();
|
|
expect(runs[0]?.status).toBe("ACTIVE");
|
|
});
|
|
const run = await prisma.agentRun.findFirst();
|
|
expect(run).not.toBeNull();
|
|
|
|
await trigger.onCardAction(makeInterruptEvent("chat-int-patch-fail", run!.id, "ou_test_user"), rt);
|
|
|
|
await vi.waitFor(async () => {
|
|
const runs = await prisma.agentRun.findMany();
|
|
expect(runs[0]?.status).toBe("CANCELED");
|
|
});
|
|
expect(patch).toHaveBeenCalled();
|
|
await vi.waitFor(() => {
|
|
expect(rt.sentTexts).toContain("已中断当前运行。");
|
|
});
|
|
});
|
|
|
|
it("denies interrupt when the operator lacks agent.cancel permission", async () => {
|
|
// EDIT role can trigger but cannot cancel (agent.cancel requires MANAGE).
|
|
await seedProject("proj-int-deny", "chat-int-deny", { role: "EDIT" });
|
|
runAgent = createHangingRunAgent();
|
|
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
|
|
|
await trigger(makeEvent("chat-int-deny", "@_user_1 写教案"), rt);
|
|
await vi.waitFor(async () => {
|
|
const runs = await prisma.agentRun.findMany();
|
|
expect(runs[0]?.status).toBe("ACTIVE");
|
|
});
|
|
const run = await prisma.agentRun.findFirst();
|
|
expect(run).not.toBeNull();
|
|
|
|
await trigger.onCardAction(makeInterruptEvent("chat-int-deny", run!.id, "ou_test_user"), rt);
|
|
|
|
expect(rt.sentTexts).toContain("无权限中断该运行。");
|
|
const audit = await prisma.auditEntry.findMany();
|
|
expect(audit.some((a) => a.action === "run.interrupt_denied")).toBe(true);
|
|
// The run is still active — it was not aborted.
|
|
const runs = await prisma.agentRun.findMany();
|
|
expect(runs[0]?.status).toBe("ACTIVE");
|
|
// Clean up: release the hanging run so afterEach isolation isn't disturbed.
|
|
const active = await prisma.agentRun.findFirst({ where: { status: "ACTIVE" } });
|
|
if (active !== null) {
|
|
await prisma.agentRun.update({ where: { id: active.id }, data: { status: "FAILED", finishedAt: new Date() } });
|
|
await prisma.projectAgentLock.deleteMany({ where: { runId: active.id } });
|
|
}
|
|
});
|
|
});
|
|
|
|
interface Deferred<T> {
|
|
readonly promise: Promise<T>;
|
|
readonly resolve: (value: T) => void;
|
|
readonly reject: (reason?: unknown) => void;
|
|
}
|
|
|
|
function deferred<T>(): Deferred<T> {
|
|
let resolve!: (value: T) => void;
|
|
let reject!: (reason?: unknown) => void;
|
|
const promise = new Promise<T>((res, rej) => {
|
|
resolve = res;
|
|
reject = rej;
|
|
});
|
|
return { promise, resolve, reject };
|
|
}
|
|
|
|
function completedRunResult(text: string, sdkSessionId: string): RunResult {
|
|
return {
|
|
status: "completed",
|
|
text,
|
|
usage: { inputTokens: 10, outputTokens: 5 },
|
|
numTurns: 1,
|
|
sdkSessionId,
|
|
};
|
|
}
|
|
function createHangingRunAgent(): TestRunner {
|
|
// Simulates a long-running agent: never resolves on its own, but resolves
|
|
// with status "interrupted" when its abortController fires (mirrors the
|
|
// real runner's abort path).
|
|
return async (req) => {
|
|
req.onStream?.({ type: "text-delta", text: "处理中..." });
|
|
await req.prisma.agentMessage.create({
|
|
data: { sessionId: req.sessionId, runId: req.runId, role: "assistant", content: "处理中...", attachments: [] },
|
|
});
|
|
const ac = req.abortController;
|
|
return new Promise<RunResult>((resolve) => {
|
|
if (ac === undefined) return;
|
|
if (ac.signal.aborted) {
|
|
resolve({ status: "interrupted", text: "处理中...", usage: { inputTokens: 1, outputTokens: 1 }, numTurns: 1, sdkSessionId: "sdk-int" });
|
|
return;
|
|
}
|
|
ac.signal.addEventListener("abort", () => {
|
|
resolve({ status: "interrupted", text: "处理中...", usage: { inputTokens: 1, outputTokens: 1 }, numTurns: 1, sdkSessionId: "sdk-int" });
|
|
}, { once: true });
|
|
});
|
|
};
|
|
}
|
|
|
|
function makeInterruptEvent(chatId: string, runId: string, openId: string): CardActionEvent {
|
|
return {
|
|
operator: { open_id: openId },
|
|
action: { value: { interrupt_run: runId }, tag: "button" },
|
|
context: { open_chat_id: chatId },
|
|
};
|
|
}
|
|
|
|
function makeOnboardingEvent(
|
|
chatId: string,
|
|
value: unknown,
|
|
openId: string,
|
|
formValue?: Readonly<Record<string, unknown>>,
|
|
): CardActionEvent {
|
|
return {
|
|
operator: { open_id: openId },
|
|
action: { value, tag: "button", ...(formValue !== undefined ? { form_value: formValue } : {}) },
|
|
context: { open_chat_id: chatId, open_message_id: "card-message-1" },
|
|
};
|
|
}
|
|
|
|
async function seedOnboardingUser(id: string, feishuOpenId: string, role: "OWNER" | "ADMIN" | "MEMBER"): Promise<void> {
|
|
await prisma.user.create({
|
|
data: {
|
|
id,
|
|
feishuOpenId,
|
|
displayName: feishuOpenId,
|
|
organizationMemberships: { create: { organizationId: DEFAULT_ORG_ID, role } },
|
|
},
|
|
});
|
|
}
|
|
|
|
async function seedScopedIdentityWithoutMembership(id: string, openId: string): Promise<void> {
|
|
const connectionId = `feishu-connection-${id}`;
|
|
await prisma.organizationFeishuApplicationConnection.create({
|
|
data: {
|
|
id: connectionId,
|
|
organizationId: DEFAULT_ORG_ID,
|
|
appIdentityFingerprint: `fingerprint-${id}`,
|
|
status: "ACTIVE",
|
|
},
|
|
});
|
|
await prisma.user.create({
|
|
data: {
|
|
id: `user-${id}`,
|
|
displayName: "Logged in user",
|
|
feishuOpenId: `legacy-${openId}`,
|
|
feishuIdentities: {
|
|
create: { connectionId, openId },
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
async function tempWorkspaceRoot(): Promise<string> {
|
|
const root = await mkdtemp(join(tmpdir(), "cph-trigger-onboarding-"));
|
|
workspaceRoots.push(root);
|
|
return root;
|
|
}
|
|
|
|
function cardActionValues(card: unknown): unknown[] {
|
|
if (typeof card !== "object" || card === null || !("elements" in card)) return [];
|
|
const elements = (card as { elements?: unknown }).elements;
|
|
if (!Array.isArray(elements)) return [];
|
|
return elements.flatMap((element) => {
|
|
if (typeof element !== "object" || element === null || !("actions" in element)) return [];
|
|
const actions = (element as { actions?: unknown }).actions;
|
|
if (!Array.isArray(actions)) return [];
|
|
return actions.map((action) => {
|
|
if (typeof action !== "object" || action === null || !("value" in action)) return undefined;
|
|
return (action as { value?: unknown }).value;
|
|
}).filter((value): value is unknown => value !== undefined);
|
|
});
|
|
}
|
|
|
|
function cardHeaderTitle(card: unknown): string | undefined {
|
|
if (typeof card !== "object" || card === null || !("header" in card)) return undefined;
|
|
const header = (card as { header?: unknown }).header;
|
|
if (typeof header !== "object" || header === null || !("title" in header)) return undefined;
|
|
const title = (header as { title?: unknown }).title;
|
|
if (typeof title !== "object" || title === null || !("content" in title)) return undefined;
|
|
const content = (title as { content?: unknown }).content;
|
|
return typeof content === "string" ? content : undefined;
|
|
}
|
|
|
|
function cardHasInterruptedFooter(card: unknown): boolean {
|
|
if (typeof card !== "object" || card === null) return false;
|
|
if (!("elements" in card)) return false;
|
|
const elements = card.elements;
|
|
if (!Array.isArray(elements)) return false;
|
|
return elements.some((el) => {
|
|
if (typeof el !== "object" || el === null) return false;
|
|
if (!("text" in el)) return false;
|
|
const text = el.text;
|
|
if (typeof text !== "object" || text === null || !("content" in text)) return false;
|
|
const content = text.content;
|
|
return typeof content === "string" && content.includes("已中断");
|
|
});
|
|
}
|
|
|
|
afterAll(async () => {
|
|
await prisma.$disconnect();
|
|
});
|