Merge remote-tracking branch 'educraft/main' into merge/educraft-cph

# Conflicts:
#	.gitignore
#	hub/.env.example
#	hub/deploy/deploy_fleet_release.sh
#	hub/deploy/deploy_platform.sh
#	hub/test/integration/helpers.ts
This commit is contained in:
2026-08-06 00:49:02 +08:00
262 changed files with 10630 additions and 1377 deletions
+2 -1
View File
@@ -242,7 +242,8 @@ describe("admin auth + org API guards", () => {
headers: { cookie: `${OAUTH_STATE_COOKIE_NAME}=${nonce}` },
});
expect(res.statusCode).toBe(302);
expect(res.headers.location).toBe("/admin");
// New users without membership land on login error; session is still set.
expect(res.headers.location).toBe("/admin/login?error=no_organization");
expect(JSON.stringify(res.headers["set-cookie"])).toContain("cph_session=");
const user = await prisma.user.findUnique({ where: { feishuOpenId: "ou_new" } });
@@ -43,7 +43,7 @@ describe("Organization Agent configuration management", () => {
provider: "openrouter",
roleId: "draft",
model: "anthropic/claude-sonnet-5",
metadata: {},
metadata: { claudeSessionId: "sdk-session-old", userResumable: true },
},
});
await configuration.setRoleSkills({
@@ -59,11 +59,12 @@ describe("Organization Agent configuration management", () => {
expect(role).toMatchObject({ label: "课程草稿", systemPrompt: "write carefully" });
expect(role.tools).toEqual(["read_file", "write_file", "cph_build"]);
expect(role.skillBindings.map((binding) => binding.skill.name)).toEqual(["outline", "typst"]);
await expect(prisma.agentSession.findUniqueOrThrow({ where: { id: "session-old-role-config" } }))
.resolves.toMatchObject({
archivedAt: expect.any(Date),
metadata: expect.objectContaining({ userResumable: false }),
});
// Execution-surface change invalidates the provider session cursor but
// keeps the Hub session alive so its transcript stays reachable.
const invalidated = await prisma.agentSession.findUniqueOrThrow({ where: { id: "session-old-role-config" } });
expect(invalidated.archivedAt).toBeNull();
expect(invalidated.metadata).toEqual(expect.objectContaining({ userResumable: false }));
expect(invalidated.metadata).not.toHaveProperty("claudeSessionId");
});
it("rejects unknown, disabled and cross-Organization skills", async () => {
@@ -118,6 +119,105 @@ describe("Organization Agent configuration management", () => {
})).rejects.toThrow("must have exactly one active default Agent role");
});
it("groups roles and skills in the shared folder tree (ADR-0028)", async () => {
const teaching = await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "教学" });
const lessonPrep = await configuration.createFolder({
organizationId: DEFAULT_ORG_ID,
name: "备课",
parentId: teaching.id,
});
expect(lessonPrep.parentId).toBe(teaching.id);
const listed = await configuration.listFolders({ organizationId: DEFAULT_ORG_ID });
expect(listed.map((folder) => folder.id).sort()).toEqual([teaching.id, lessonPrep.id].sort());
const typst = await makeSkill(root, "typst");
await configuration.installSkill({ organizationId: DEFAULT_ORG_ID, sourceDir: typst, version: "1" });
await configuration.setSkillFolder({ organizationId: DEFAULT_ORG_ID, name: "typst", folderId: lessonPrep.id });
await configuration.setRoleFolder({ organizationId: DEFAULT_ORG_ID, roleId: "draft", folderId: teaching.id });
const skills = await configuration.listSkills({ organizationId: DEFAULT_ORG_ID });
expect(skills.find((skill) => skill.name === "typst")?.folderId).toBe(lessonPrep.id);
const roles = await configuration.listRoles({ organizationId: DEFAULT_ORG_ID });
expect(roles.find((role) => role.roleId === "draft")?.folderId).toBe(teaching.id);
const renamed = await configuration.updateFolder({
organizationId: DEFAULT_ORG_ID,
folderId: teaching.id,
name: "教研",
});
expect(renamed.name).toBe("教研");
});
it("moves folders within the tree and rejects moves below a descendant", async () => {
const a = await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "a" });
const b = await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "b", parentId: a.id });
const c = await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "c" });
const moved = await configuration.updateFolder({ organizationId: DEFAULT_ORG_ID, folderId: c.id, parentId: b.id });
expect(moved.parentId).toBe(b.id);
await expect(configuration.updateFolder({ organizationId: DEFAULT_ORG_ID, folderId: a.id, parentId: c.id }))
.rejects.toThrow("folder cannot be moved below its descendant");
await expect(configuration.updateFolder({ organizationId: DEFAULT_ORG_ID, folderId: a.id, parentId: a.id }))
.rejects.toThrow("folder cannot be its own parent");
});
it("deletes only empty folders", async () => {
const folder = await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "非空" });
const typst = await makeSkill(root, "typst");
await configuration.installSkill({ organizationId: DEFAULT_ORG_ID, sourceDir: typst, version: "1" });
await configuration.setSkillFolder({ organizationId: DEFAULT_ORG_ID, name: "typst", folderId: folder.id });
await expect(configuration.deleteFolder({ organizationId: DEFAULT_ORG_ID, folderId: folder.id }))
.rejects.toThrow("still has");
const parent = await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "父" });
await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "子", parentId: parent.id });
await expect(configuration.deleteFolder({ organizationId: DEFAULT_ORG_ID, folderId: parent.id }))
.rejects.toThrow("child folder");
await configuration.setSkillFolder({ organizationId: DEFAULT_ORG_ID, name: "typst", folderId: null });
await configuration.deleteFolder({ organizationId: DEFAULT_ORG_ID, folderId: folder.id });
const remaining = await configuration.listFolders({ organizationId: DEFAULT_ORG_ID });
expect(remaining.map((f) => f.id)).not.toContain(folder.id);
const skill = (await configuration.listSkills({ organizationId: DEFAULT_ORG_ID })).find((s) => s.name === "typst");
expect(skill?.folderId).toBeNull();
});
it("rejects cross-Organization folder assignment", async () => {
await seedTestOrganization("org_other", "other");
const otherFolder = await configuration.createFolder({ organizationId: "org_other", name: "外部" });
const typst = await makeSkill(root, "typst");
await configuration.installSkill({ organizationId: DEFAULT_ORG_ID, sourceDir: typst, version: "1" });
await expect(configuration.setSkillFolder({
organizationId: DEFAULT_ORG_ID,
name: "typst",
folderId: otherFolder.id,
})).rejects.toThrow("folder not found in organization");
await expect(configuration.setRoleFolder({
organizationId: DEFAULT_ORG_ID,
roleId: "draft",
folderId: otherFolder.id,
})).rejects.toThrow("folder not found in organization");
});
it("treats folder assignment as a label-class change (no session archival)", async () => {
await prisma.project.create({
data: { id: "project-a", organizationId: DEFAULT_ORG_ID, name: "A", workspaceDir: "/tmp/a" },
});
await prisma.agentSession.create({
data: {
id: "session-folder-assignment",
projectId: "project-a",
provider: "openrouter",
roleId: "draft",
model: "anthropic/claude-sonnet-5",
metadata: {},
},
});
const folder = await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "分组" });
await configuration.setRoleFolder({ organizationId: DEFAULT_ORG_ID, roleId: "draft", folderId: folder.id });
await expect(prisma.agentSession.findUniqueOrThrow({ where: { id: "session-folder-assignment" } }))
.resolves.toMatchObject({ archivedAt: null });
});
async function makeSkill(parent: string, name: string): Promise<string> {
const source = join(parent, "sources", name);
await mkdir(source, { recursive: true });
@@ -157,9 +157,10 @@ describe("real Claude SDK sandbox boundary", () => {
[result.error, sdkStderr.join(""), JSON.stringify(streamEvents)].filter(Boolean).join("\n"),
).toBe("completed");
expect(stub.requestCount()).toBeGreaterThanOrEqual(3);
expect(new Set(result.initializedSkillIds)).toEqual(new Set([
"cph-runtime:outline",
]));
const skillIds = new Set(result.initializedSkillIds ?? []);
expect(skillIds.has("cph-runtime:outline")).toBe(true);
// Workspace-local untrusted skills must never load (ADR-0018).
expect([...skillIds].some((id) => id.includes("untrusted"))).toBe(false);
const toolResults = streamEvents.filter((event) => event.type === "tool-result");
expect(toolResults).toHaveLength(2);
const rejectedOptOut = toolResults[0];
@@ -0,0 +1,187 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { prisma, resetDb, seedTestOrganization, testSecretEnvelope, DEFAULT_ORG_ID } from "./helpers.js";
import { createPbankService } from "../../src/capability/pbank.js";
import type { PbankClient, PbankLoginResult } from "../../src/capability/pbankClient.js";
import type { PbankCapabilitySecretPayload } from "../../src/capability/types.js";
import { CapabilityConnectionUnavailable } from "../../src/capability/types.js";
const CAPABILITY_ID = "pbank";
const PROBLEM_ID = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee";
describe("pbank capability service (ADR-0027)", () => {
let workspaceRoot: string;
let runId: string;
beforeEach(async () => {
await resetDb();
await seedTestOrganization();
workspaceRoot = await mkdtemp(join(tmpdir(), "cph-pbank-"));
runId = "run-pbank-test";
await prisma.project.create({
data: {
id: "proj-pbank",
organizationId: DEFAULT_ORG_ID,
name: "PBank Test",
workspaceDir: workspaceRoot,
},
});
await prisma.agentRun.create({
data: {
id: runId,
projectId: "proj-pbank",
entrypoint: "FEISHU",
provider: "openrouter",
model: "mock-model",
status: "ACTIVE",
prompt: "search pbank",
metadata: {},
},
});
});
afterEach(async () => {
await rm(workspaceRoot, { recursive: true, force: true }).catch(() => {});
});
async function seedActiveConnection(): Promise<void> {
const payload: PbankCapabilitySecretPayload = {
schemaVersion: 1,
kind: "pbank",
baseUrl: "https://pbank.example/api",
username: "teacher",
password: "secret-never-log",
rightsStatus: "owned",
rightsHolder: "Paradigm Education",
};
const connection = await prisma.organizationCapabilityConnection.create({
data: {
id: "pbank-conn-1",
organizationId: DEFAULT_ORG_ID,
capabilityId: CAPABILITY_ID,
status: "ACTIVE",
activatedAt: new Date(),
},
});
const envelope = testSecretEnvelope.encryptJson(
{
purpose: "capability",
organizationId: DEFAULT_ORG_ID,
connectionId: connection.id,
secretVersionId: "pbank-sv-1",
},
payload,
);
await prisma.capabilityCredentialVersion.create({
data: {
id: "pbank-sv-1",
connectionId: connection.id,
version: 1,
envelope: envelope as object,
keyId: envelope.keyId,
},
});
await prisma.organizationCapabilityConnection.update({
where: { id: connection.id },
data: { activeSecretVersionId: "pbank-sv-1" },
});
}
function mockClient(): PbankClient {
const login: PbankLoginResult = { token: "tok-1", expiresAt: Date.now() + 60_000 };
return {
login: vi.fn(async () => login),
searchProblems: vi.fn(async () => ({
pageNum: 1,
pageSize: 10,
total: 1,
items: [{ id: PROBLEM_ID, title: "示例题" }],
})),
getProblem: vi.fn(async () => ({ id: PROBLEM_ID, title: "示例题" })),
getOccurrences: vi.fn(async () => ({ items: [] })),
downloadProject: vi.fn(async () => ({
buffer: Buffer.from("# problem\n"),
contentType: "text/plain",
})),
};
}
it("fails closed when no ACTIVE connection exists", async () => {
const service = createPbankService({
prisma,
secrets: testSecretEnvelope,
client: mockClient(),
});
await expect(
service.searchProblems(
{ organizationId: DEFAULT_ORG_ID, runId, workspaceDir: workspaceRoot },
{ q: "函数" },
),
).rejects.toBeInstanceOf(CapabilityConnectionUnavailable);
});
it("searches via org credential and writes UsageFact without leaking password", async () => {
await seedActiveConnection();
const client = mockClient();
const service = createPbankService({
prisma,
secrets: testSecretEnvelope,
client,
});
const result = await service.searchProblems(
{ organizationId: DEFAULT_ORG_ID, runId, workspaceDir: workspaceRoot },
{ q: "函数", pageNum: 1, pageSize: 10 },
);
expect(client.login).toHaveBeenCalledTimes(1);
const loginArg = (client.login as ReturnType<typeof vi.fn>).mock.calls[0]?.[0] as PbankCapabilitySecretPayload;
expect(loginArg.username).toBe("teacher");
expect(loginArg.password).toBe("secret-never-log");
expect(loginArg.kind).toBe("pbank");
expect(result.data).toMatchObject({
rights: { derivativeUseAllowed: true, status: "owned" },
items: [{ id: PROBLEM_ID }],
});
expect(JSON.stringify(result.data)).not.toContain("secret-never-log");
const facts = await prisma.usageFact.findMany({ where: { runId } });
expect(facts).toHaveLength(1);
expect(facts[0]).toMatchObject({
kind: "external_capability",
capabilityId: CAPABILITY_ID,
provider: "paradigm_pbank",
unit: "requests",
quantity: expect.anything(),
costUsd: null,
costSource: "unknown",
});
});
it("fetches one problem and attaches rights", async () => {
await seedActiveConnection();
const client = mockClient();
const service = createPbankService({
prisma,
secrets: testSecretEnvelope,
client,
});
const result = await service.getProblem(
{ organizationId: DEFAULT_ORG_ID, runId, workspaceDir: workspaceRoot },
{ urlOrId: PROBLEM_ID, includeProjects: true, materializeProjects: false },
);
expect(result.data).toMatchObject({
id: PROBLEM_ID,
rights: { derivativeUseAllowed: true },
problem: { id: PROBLEM_ID },
projects: {
problem: { status: "text" },
answer: { status: "text" },
},
});
expect(client.getProblem).toHaveBeenCalled();
expect(client.downloadProject).toHaveBeenCalled();
});
});
@@ -58,6 +58,7 @@ describe("pdf_to_md_bundle capability adapter (ADR-0027)", () => {
async function seedActiveCapabilityConnection(): Promise<void> {
const payload: CapabilitySecretPayload = {
schemaVersion: 1,
kind: "docmind",
accessKeyId: "LTAI-test-key-id",
accessKeySecret: "test-secret-never-log",
endpoint: "docmind-api.cn-hangzhou.aliyuncs.com",
@@ -16,7 +16,9 @@ import { ProviderConnectionService } from "../../src/connections/providerConnect
import { FeishuApplicationConnectionService } from "../../src/connections/feishuApplicationConnections.js";
const execFileAsync = promisify(execFile);
const TEST_DATABASE_URL = "postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test";
const TEST_DATABASE_URL =
process.env.DATABASE_URL?.trim() ||
"postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test";
describe("deployment preflight CLI", { timeout: 20_000 }, () => {
let root: string;
+80 -59
View File
@@ -5,6 +5,8 @@
* FeishuRuntime (sendText/sendCard are no-ops that record calls), and a mock
* AI SDK model factory (doGenerate() returns canned responses - no network).
*/
import { mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { PrismaClient } from "@prisma/client";
import type { FastifyBaseLogger } from "fastify";
import type {
@@ -19,7 +21,21 @@ import type { FeishuRuntime } from "../../src/feishu/client.js";
import type { ModelFactory } from "../../src/agent/runner.js";
import { LocalSecretEnvelope } from "../../src/security/secretEnvelope.js";
export const TEST_DATABASE_URL = "postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test";
// Admin routes and agent-config tests need a skill store root; seed once for
// the whole vitest process when CI/dev didn't set one.
if (
(process.env.HUB_SKILL_STORE_ROOT === undefined || process.env.HUB_SKILL_STORE_ROOT.trim() === "") &&
(process.env.XDG_STATE_HOME === undefined || process.env.XDG_STATE_HOME.trim() === "")
) {
process.env.HUB_SKILL_STORE_ROOT = `${tmpdir()}/cph-test-skills`;
}
if (process.env.HUB_SKILL_STORE_ROOT !== undefined && process.env.HUB_SKILL_STORE_ROOT.trim() !== "") {
mkdirSync(process.env.HUB_SKILL_STORE_ROOT, { recursive: true });
}
export const TEST_DATABASE_URL =
process.env.DATABASE_URL?.trim() ||
"postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test";
export const DEFAULT_ORG_ID = "org_test_default";
export const TEST_SECRET_KEY_ID = "test-active";
export const TEST_SECRET_KEY = Buffer.alloc(32, "k");
@@ -34,26 +50,29 @@ export const prisma = new PrismaClient({
/** Truncate all tables before each test for isolation. */
export async function resetDb(): Promise<void> {
// User and Organization are the aggregate roots for all domain rows; their
// declared FK cascades clear projects, search documents, permissions,
// sessions and connections without repeatedly truncating pg_trgm indexes.
// Event receipts and global audit rows are independent roots.
await prisma.$transaction([
prisma.feishuEventReceipt.deleteMany(),
prisma.auditEntry.deleteMany(),
// Permission resource ids are intentionally polymorphic strings, so these
// two tables have no FK to Project and must be cleared explicitly.
prisma.permissionGrant.deleteMany(),
prisma.permissionSettings.deleteMany(),
// MemberGroup is global (ADR-0028): no FK to the org/user roots, so the
// cascade above never reaches it. Clear explicitly — closure/membership
// first (they FK into MemberGroup), groups last.
prisma.memberGroupClosure.deleteMany(),
prisma.memberGroupMembership.deleteMany(),
prisma.memberGroup.deleteMany(),
prisma.user.deleteMany(),
prisma.organization.deleteMany(),
]);
// Hard reset via TRUNCATE CASCADE. Parent RESTRICT edges and leftover
// folder trees made deleteMany-based cleanup race Prisma upserts
// ("Unique constraint failed on id" while the where-branch saw no row).
await prisma.$executeRawUnsafe(`
DO $$
DECLARE
stmt text;
BEGIN
SELECT 'TRUNCATE TABLE ' || string_agg(format('%I.%I', schemaname, tablename), ', ')
|| ' RESTART IDENTITY CASCADE'
INTO stmt
FROM pg_tables
WHERE schemaname = 'public'
AND tablename <> '_prisma_migrations';
IF stmt IS NOT NULL THEN
EXECUTE stmt;
END IF;
END $$;
`);
const leftover = await prisma.organization.count();
if (leftover !== 0) {
throw new Error(`resetDb truncate left ${leftover} organization row(s)`);
}
await seedTestOrganization();
}
@@ -61,49 +80,51 @@ export async function seedTestOrganization(
id: string = DEFAULT_ORG_ID,
slug: string = "test-default",
): Promise<void> {
// Serialise generate+inbox against concurrent callers in the same process.
// Integration tests share one DB and some files call seed without resetDb.
await prisma.$transaction(async (tx) => {
await tx.organization.upsert({
const existing = await tx.organization.findUnique({
where: { id },
update: {},
create: { id, slug, name: "Test Default Organization" },
select: { id: true },
});
await tx.organizationProjectSettings.upsert({
where: { organizationId: id },
update: {},
create: { organizationId: id, membersCanCreateProjects: true },
});
const defaultRole = await tx.organizationAgentRole.upsert({
where: { organizationId_roleId: { organizationId: id, roleId: "draft" } },
update: { label: "草稿", isDefault: true, disabledAt: null },
create: {
id: `agent_role_draft_${id}`,
organizationId: id,
roleId: "draft",
label: "草稿",
sortOrder: 10,
isDefault: true,
},
});
await tx.organizationAgentRole.updateMany({
where: { organizationId: id, id: { not: defaultRole.id }, isDefault: true },
data: { isDefault: false },
if (existing === null) {
await tx.organization.create({
data: {
id,
slug,
name: "Test Default Organization",
projectSettings: {
create: { membersCanCreateProjects: true },
},
agentRoles: {
create: {
id: `agent_role_draft_${id}`,
roleId: "draft",
label: "草稿",
sortOrder: 10,
isDefault: true,
},
},
},
});
}
const inbox = await tx.folder.findFirst({
where: { organizationId: id, kind: "SYSTEM_INBOX", archivedAt: null },
select: { id: true },
});
if (inbox === null) {
await tx.folder.create({
data: {
id: `folder_inbox_${id}`,
organizationId: id,
name: "Inbox",
kind: "SYSTEM_INBOX",
sortKey: "000000",
},
});
}
});
const inbox = await prisma.folder.findFirst({
where: { organizationId: id, kind: "SYSTEM_INBOX", archivedAt: null },
select: { id: true },
});
if (inbox === null) {
await prisma.folder.create({
data: {
id: `folder_inbox_${id}`,
organizationId: id,
name: "Inbox",
kind: "SYSTEM_INBOX",
sortKey: "000000",
},
});
}
}
/** A logger that discards everything (tests don't need fastify's pino). */
+31 -18
View File
@@ -11,11 +11,14 @@ import {
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";
@@ -34,6 +37,7 @@ function makeTriggerHandler(deps: TestTriggerDeps): ReturnType<typeof makeProduc
publicBaseUrl: "https://educraft.example.test",
siloOrganizationId: DEFAULT_ORG_ID,
allowLegacyFeishuIdentity: true,
secretEnvelope: testSecretEnvelope,
...deps,
});
}
@@ -190,13 +194,14 @@ describe("trigger full lifecycle (integration)", () => {
where: { id: "proj-post-image" },
data: { workspaceDir },
});
const messageResourceGet = vi.fn(async () => ({
getReadableStream: () => Readable.from([Buffer.from("image bytes")]),
}));
const imV1 = (rt.client as unknown as {
im: { v1: { messageResource?: { get: typeof messageResourceGet } } };
}).im.v1;
imV1.messageResource = { get: messageResourceGet };
const 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,
@@ -220,6 +225,7 @@ describe("trigger full lifecycle (integration)", () => {
runAgent,
projectWorkspaceRoot: workspaceRoot,
messageBatcherOptions: { maxMessages: 1 },
feishuBotCli,
});
await trigger(event, rt);
@@ -228,10 +234,11 @@ describe("trigger full lifecycle (integration)", () => {
expect(runAgentCalls).toHaveLength(1);
});
expect(runAgentCalls[0]?.prompt).toContain(join(await realpath(workspaceDir), ".cph", "inbox"));
expect(messageResourceGet).toHaveBeenCalledWith({
params: { type: "image" },
path: { message_id: event.message.message_id, file_key: "img-key-1" },
});
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"));
@@ -1109,15 +1116,18 @@ describe("trigger full lifecycle (integration)", () => {
});
const resourceEntered = deferred<void>();
const releaseResource = deferred<void>();
const messageResourceGet = vi.fn(async () => {
const downloadResource = vi.fn(async (request) => {
resourceEntered.resolve();
await releaseResource.promise;
return { getReadableStream: () => Readable.from([Buffer.from("staged image bytes")]) };
return writeNewWorkspaceFileNoFollow(
request.workspaceRoot,
request.workspaceDir,
request.workspaceRelativePath,
Readable.from([Buffer.from("staged image bytes")]),
request.maxBytes,
);
});
const imV1 = (rt.client as unknown as {
im: { v1: { messageResource?: { get: typeof messageResourceGet } } };
}).im.v1;
imV1.messageResource = { get: messageResourceGet };
const feishuBotCli: FeishuBotCli = { downloadResource };
const baseEvent = makeEvent("chat-attachment-race", "@_user_1 附件竞态");
const event: MessageReceiveEvent = {
...baseEvent,
@@ -1141,6 +1151,7 @@ describe("trigger full lifecycle (integration)", () => {
runAgent,
projectWorkspaceRoot: workspaceRoot,
messageBatcherOptions: { maxMessages: 1 },
feishuBotCli,
});
const pendingTrigger = trigger(event, rt);
@@ -1600,7 +1611,9 @@ describe("trigger full lifecycle (integration)", () => {
expect(runs[0]?.status).toBe("CANCELED");
});
expect(patch).toHaveBeenCalled();
expect(rt.sentTexts).toContain("已中断当前运行。");
await vi.waitFor(() => {
expect(rt.sentTexts).toContain("已中断当前运行。");
});
});
it("denies interrupt when the operator lacks agent.cancel permission", async () => {