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
+110 -2
View File
@@ -1,8 +1,9 @@
import { mkdir, mkdtemp, realpath, rm, symlink } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { createAgentSecurityPolicy } from "../../src/agent/security.js";
import { importSkillDirectory } from "../../src/agent/skillStore.js";
describe("agent subprocess security policy", () => {
const roots: string[] = [];
@@ -26,6 +27,13 @@ describe("agent subprocess security policy", () => {
PATH: "/usr/local/bin:/usr/bin:/bin",
LANG: "C.UTF-8",
CPH_BIN: "/usr/local/bin/cph",
HTTP_PROXY: "http://127.0.0.1:7890",
HTTPS_PROXY: "http://127.0.0.1:7890",
ALL_PROXY: "socks5h://127.0.0.1:7890",
NO_PROXY: "127.0.0.1,localhost,::1",
NODE_USE_ENV_PROXY: "1",
TYPST_PACKAGE_PATH: "/srv/curriculum-project-hub/typst-packages/para-26071100",
TYPST_PACKAGE_CACHE_PATH: "/srv/curriculum-project-hub/typst-packages/para-26071100",
DATABASE_URL: "postgresql://platform-secret",
FEISHU_APP_SECRET: "feishu-secret",
HUB_SESSION_SECRET: "session-secret",
@@ -38,9 +46,16 @@ describe("agent subprocess security policy", () => {
PATH: "/usr/local/bin:/usr/bin:/bin",
LANG: "C.UTF-8",
CPH_BIN: "/usr/local/bin/cph",
TYPST_PACKAGE_PATH: "/srv/curriculum-project-hub/typst-packages/para-26071100",
TYPST_PACKAGE_CACHE_PATH: "/srv/curriculum-project-hub/typst-packages/para-26071100",
ANTHROPIC_BASE_URL: "http://127.0.0.1:43123",
ANTHROPIC_AUTH_TOKEN: "run-proxy-capability",
ANTHROPIC_API_KEY: "",
HTTP_PROXY: "http://127.0.0.1:7890",
HTTPS_PROXY: "http://127.0.0.1:7890",
ALL_PROXY: "socks5h://127.0.0.1:7890",
NO_PROXY: "127.0.0.1,localhost,::1",
NODE_USE_ENV_PROXY: "1",
});
expect(policy.env).not.toHaveProperty("DATABASE_URL");
expect(policy.env).not.toHaveProperty("FEISHU_APP_SECRET");
@@ -61,7 +76,10 @@ describe("agent subprocess security policy", () => {
autoAllowBashIfSandboxed: true,
allowUnsandboxedCommands: false,
filesystem: {
allowWrite: [canonicalWorkspace],
allowWrite: expect.arrayContaining([
canonicalWorkspace,
"/srv/curriculum-project-hub/typst-packages/para-26071100",
]),
denyRead: ["/"],
allowRead: expect.arrayContaining([canonicalWorkspace, "/usr/bin"]),
},
@@ -74,6 +92,59 @@ describe("agent subprocess security policy", () => {
});
});
it("passes configured Typst package roots and exposes them read-only to the sandbox", async () => {
const { workspaceRoot, workspace } = await makeWorkspace();
const packageRoot = "/srv/curriculum-project-hub/typst-packages/para-26071100";
const cacheRoot = "/var/cache/cph-hub/para-26071100/typst";
const policy = await createAgentSecurityPolicy({
runId: "run-test",
workspaceRoot,
workspaceDir: workspace,
hostEnv: {
PATH: "/usr/bin:/bin",
TYPST_PACKAGE_PATH: packageRoot,
TYPST_PACKAGE_CACHE_PATH: cacheRoot,
},
});
const canonicalWorkspace = await realpath(workspace);
expect(policy.env).toMatchObject({
TYPST_PACKAGE_PATH: packageRoot,
TYPST_PACKAGE_CACHE_PATH: cacheRoot,
});
expect(policy.sandbox.filesystem.allowRead).toEqual(expect.arrayContaining([packageRoot, cacheRoot]));
expect(policy.sandbox.filesystem.allowWrite).toEqual(expect.arrayContaining([canonicalWorkspace, cacheRoot]));
expect(policy.sandbox.filesystem.allowWrite).not.toContain(packageRoot);
});
it("rejects a relative Typst package root instead of silently losing package access", async () => {
const { workspaceRoot, workspace } = await makeWorkspace();
await expect(createAgentSecurityPolicy({
runId: "run-test",
workspaceRoot,
workspaceDir: workspace,
hostEnv: {
PATH: "/usr/bin:/bin",
TYPST_PACKAGE_PATH: "typst-packages",
},
})).rejects.toThrow("TYPST_PACKAGE_PATH must be absolute");
});
it("rejects a Typst cache rooted at the filesystem root instead of widening writes", async () => {
const { workspaceRoot, workspace } = await makeWorkspace();
await expect(createAgentSecurityPolicy({
runId: "run-test",
workspaceRoot,
workspaceDir: workspace,
hostEnv: {
PATH: "/usr/bin:/bin",
TYPST_PACKAGE_CACHE_PATH: "/",
},
})).rejects.toThrow("TYPST_PACKAGE_CACHE_PATH must not be the filesystem root");
});
it("rejects provider environment keys outside the explicit protocol", async () => {
const { workspaceRoot, workspace } = await makeWorkspace();
@@ -125,6 +196,43 @@ describe("agent subprocess security policy", () => {
})).rejects.toThrow("Agent temp path is too long for sandbox bridge sockets");
});
it("mirrors selected skills under .cph/runtime-skills and exposes CPH_RUNTIME_SKILLS_DIR", async () => {
const { root, workspaceRoot, workspace } = await makeWorkspace();
const storeRoot = join(root, "skills-store");
const skillSource = join(root, "skill-src", "pdf-to-md");
await mkdir(skillSource, { recursive: true });
await writeFile(
join(skillSource, "SKILL.md"),
"---\nname: pdf-to-md\ndescription: convert\n---\n# pdf-to-md\nbatch items\n",
);
const installed = await importSkillDirectory({ sourceDir: skillSource, storeRoot });
const policy = await createAgentSecurityPolicy({
runId: "run-skills",
workspaceRoot,
workspaceDir: workspace,
skills: [{
name: "pdf-to-md",
version: "1",
contentDigest: installed.contentDigest,
}],
hostEnv: {
PATH: "/usr/bin:/bin",
HUB_SKILL_STORE_ROOT: storeRoot,
},
});
const canonicalWorkspace = await realpath(workspace);
const mirrored = join(canonicalWorkspace, ".cph", "runtime-skills", "pdf-to-md", "SKILL.md");
await expect(readFile(mirrored, "utf8")).resolves.toContain("batch items");
expect(policy.env.CPH_RUNTIME_SKILLS_DIR).toBe(join(canonicalWorkspace, ".cph", "runtime-skills"));
expect(policy.env.CPH_RUNTIME_SKILLS_REL).toBe(join(".cph", "runtime-skills"));
expect(policy.skillIds).toEqual(["cph-runtime:pdf-to-md"]);
expect(policy.sandbox.filesystem.allowRead).toEqual(
expect.arrayContaining([canonicalWorkspace, policy.skillPluginRoot]),
);
await policy.cleanup();
});
it("rejects a project workspace whose real path escapes the configured workspace root", async () => {
const { root, workspaceRoot } = await makeWorkspace();
const outside = join(root, "outside");
+50
View File
@@ -0,0 +1,50 @@
import { join } from "node:path";
import { tmpdir } from "node:os";
import { describe, expect, it } from "vitest";
import {
AliyunDocmindClient,
DocmindClientError,
DOCMIND_CONNECT_TIMEOUT_MS,
DOCMIND_READ_TIMEOUT_MS,
createDocmindRuntimeOptions,
} from "../../src/capability/docmindClient.js";
describe("createDocmindRuntimeOptions", () => {
it("overrides httpx's 3s default so PDF OSS uploads can complete", () => {
const runtime = createDocmindRuntimeOptions();
// Production failure: ReadTimeout(3000) on docmind OSS upload.
expect(DOCMIND_CONNECT_TIMEOUT_MS).toBeGreaterThan(3_000);
expect(DOCMIND_READ_TIMEOUT_MS).toBeGreaterThan(3_000);
expect(runtime.connectTimeout).toBe(DOCMIND_CONNECT_TIMEOUT_MS);
expect(runtime.readTimeout).toBe(DOCMIND_READ_TIMEOUT_MS);
});
});
describe("AliyunDocmindClient local file open", () => {
it("rejects a missing input file without crashing the process", async () => {
const client = new AliyunDocmindClient();
const missing = join(tmpdir(), `docmind-missing-${Date.now()}.pdf`);
// If createReadStream errors are left unhandled, Vitest aborts the suite
// with an unhandled 'error' event instead of reaching this assertion.
await expect(client.parse(
{
accessKeyId: "ak",
accessKeySecret: "sk",
endpoint: "docmind-api.cn-hangzhou.aliyuncs.com",
},
{ inputFilePath: missing },
)).rejects.toBeInstanceOf(DocmindClientError);
await expect(client.parse(
{
accessKeyId: "ak",
accessKeySecret: "sk",
endpoint: "docmind-api.cn-hangzhou.aliyuncs.com",
},
{ inputFilePath: missing },
)).rejects.toMatchObject({
code: "docmind_rejected",
message: expect.stringContaining("input file not found"),
});
});
});
+101
View File
@@ -0,0 +1,101 @@
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { describe, expect, it } from "vitest";
import { createFeishuBotCli } from "../../src/feishu/botCli.js";
import type { PrismaClient } from "@prisma/client";
import type { LocalSecretEnvelope } from "../../src/security/secretEnvelope.js";
const itOnLinux = process.platform === "linux" ? it : it.skip;
const fakeCredential = {
connectionId: "connection-1",
organizationId: "org-1",
appId: "cli-test-app",
appSecret: "cli-test-secret",
botOpenId: "ou-test-bot",
verificationToken: "verification-token",
encryptKey: "encrypt-key",
};
describe("Feishu bot CLI adapter", () => {
itOnLinux("uses bot identity and writes the CLI result into the workspace", async () => {
const root = await mkdtemp(join(tmpdir(), "hub-feishu-bot-cli-test-"));
const workspaceDir = join(root, "workspace");
const binary = join(root, "fake-lark-cli");
await mkdir(workspaceDir);
await writeFakeCli(binary);
try {
const cli = createFeishuBotCli({
organizationId: "org-1",
prisma: {} as PrismaClient,
secretEnvelope: {} as LocalSecretEnvelope,
binary,
resolveCredential: async () => fakeCredential,
});
const result = await cli.downloadResource({
messageId: "message-1",
fileKey: "file-1",
resourceType: "file",
workspaceRoot: root,
workspaceDir,
workspaceRelativePath: "inbox/resource.bin",
maxBytes: 1024,
});
await expect(readFile(result, "utf8")).resolves.toBe("resource bytes");
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("rejects a resource above the configured limit", async () => {
const root = await mkdtemp(join(tmpdir(), "hub-feishu-bot-cli-limit-"));
const workspaceDir = join(root, "workspace");
const binary = join(root, "fake-lark-cli");
await mkdir(workspaceDir);
await writeFakeCli(binary);
try {
const cli = createFeishuBotCli({
organizationId: "org-1",
prisma: {} as PrismaClient,
secretEnvelope: {} as LocalSecretEnvelope,
binary,
resolveCredential: async () => fakeCredential,
});
await expect(cli.downloadResource({
messageId: "message-1",
fileKey: "file-1",
resourceType: "file",
workspaceRoot: root,
workspaceDir,
workspaceRelativePath: "inbox/resource.bin",
maxBytes: 4,
})).rejects.toMatchObject({ reason: "limit" });
} finally {
await rm(root, { recursive: true, force: true });
}
});
});
async function writeFakeCli(path: string): Promise<void> {
await writeFile(path, `#!/usr/bin/env node
import { writeFileSync } from "node:fs";
import { join } from "node:path";
const args = process.argv.slice(2);
if (args[0] === "config" && args[1] === "init") {
process.stdin.resume();
process.stdin.on("end", () => process.exit(0));
} else if (args.includes("+messages-resources-download")) {
const asIndex = args.indexOf("--as");
const outputIndex = args.indexOf("--output");
if (asIndex < 0 || args[asIndex + 1] !== "bot" || outputIndex < 0) process.exit(2);
writeFileSync(join(process.cwd(), args[outputIndex + 1]), "resource bytes");
process.exit(0);
} else {
process.exit(3);
}
`);
await chmod(path, 0o755);
}
+28 -12
View File
@@ -5,6 +5,8 @@ import { Readable } from "node:stream";
import { describe, expect, it, vi } from "vitest";
import { claudeSdkToolConfigForRole, cphHubMcpToolsForRole } from "../../src/agent/roleTools.js";
import type { FeishuRuntime } from "../../src/feishu/client.js";
import type { FeishuBotCli } from "../../src/feishu/botCli.js";
import { writeNewWorkspaceFileNoFollow } from "../../src/security/workspaceFiles.js";
import { downloadFeishuMessageResource } from "../../src/feishu/download.js";
const itOnLinux = process.platform === "linux" ? it : it.skip;
@@ -12,7 +14,10 @@ const itOnLinux = process.platform === "linux" ? it : it.skip;
describe("Feishu message resource download", () => {
it("exposes the download tool to default and explicitly configured roles", () => {
expect(cphHubMcpToolsForRole(undefined)).toContain("feishu_download_resource");
expect(cphHubMcpToolsForRole(["feishu_download_resource"])).toEqual(["feishu_download_resource"]);
expect(cphHubMcpToolsForRole(["feishu_download_resource"])).toEqual([
"todo_write",
"feishu_download_resource",
]);
expect(claudeSdkToolConfigForRole(["feishu_download_resource"]).allowedTools).toEqual([
"mcp__cph_hub__feishu_download_resource",
]);
@@ -24,14 +29,12 @@ describe("Feishu message resource download", () => {
await mkdir(workspaceDir);
try {
const messageGet = vi.fn(async () => ({ data: { items: [{ chat_id: "chat-1" }] } }));
const messageResourceGet = vi.fn(async () => ({
getReadableStream: () => Readable.from([Buffer.from("image bytes")]),
}));
const messageResourceGet = vi.fn();
const rt = mockRuntime(messageGet, messageResourceGet);
const botCli = fakeBotCli();
const result = await downloadFeishuMessageResource(
{ messageId: "message-1", fileKey: "img-key-1", resourceType: "image" },
{ boundChatId: "chat-1", workspaceRoot, workspaceDir },
{ boundChatId: "chat-1", workspaceRoot, workspaceDir, botCli },
rt,
);
@@ -41,10 +44,7 @@ describe("Feishu message resource download", () => {
);
expect(result.path).toMatch(/\.png$/);
await expect(readFile(result.path, "utf8")).resolves.toBe("image bytes");
expect(messageResourceGet).toHaveBeenCalledWith({
params: { type: "image" },
path: { message_id: "message-1", file_key: "img-key-1" },
});
expect(messageResourceGet).not.toHaveBeenCalled();
} finally {
await rm(workspaceRoot, { recursive: true, force: true });
}
@@ -57,10 +57,14 @@ describe("Feishu message resource download", () => {
await expect(downloadFeishuMessageResource(
{ messageId: "message-other", fileKey: "img-key-other", resourceType: "image" },
{ boundChatId: "chat-1", workspaceRoot: "/tmp", workspaceDir: "/tmp/project-1" },
{
boundChatId: "chat-1",
workspaceRoot: "/tmp",
workspaceDir: "/tmp/project-1",
botCli: fakeBotCli(),
},
rt,
)).rejects.toThrow("current project's bound chat");
expect(messageResourceGet).not.toHaveBeenCalled();
});
});
@@ -89,6 +93,18 @@ function mockRuntime(
};
}
function fakeBotCli(): FeishuBotCli {
return {
downloadResource: (request) => writeNewWorkspaceFileNoFollow(
request.workspaceRoot,
request.workspaceDir,
request.workspaceRelativePath,
Readable.from([Buffer.from("image bytes")]),
request.maxBytes,
),
};
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
@@ -42,6 +42,12 @@ describe("outbound markdown image parsing", () => {
);
expect(maskMarkdownImagesForStreaming("![](https://x/y.png)")).toBe("【图片】");
});
it("ignores markdown image examples inside inline code", () => {
const text = "例如 `![](images/img_5.png)` 这样写,不会当真实图片";
expect(findMarkdownImagesOutsideCode(text)).toEqual([]);
expect(maskMarkdownImagesForStreaming(text)).toBe(text);
});
});
describe("materializeAnswerSegments", () => {
+2 -1
View File
@@ -52,7 +52,7 @@ describe("Feishu reactions", () => {
await expect(removeReaction(rt, "message-1", "reaction-1")).resolves.toBe(false);
});
it("adds Typing on start and removes it on success", async () => {
it("adds Typing on start and replaces it with CheckMark on success", async () => {
const run = deferred<RunResult>();
const rt = mockRuntime();
const runAgent = vi.fn((req: RunRequest) => {
@@ -71,6 +71,7 @@ describe("Feishu reactions", () => {
expect(rt.reactionRequests).toEqual([
{ kind: "add", messageId: "message-1", emoji: "Typing", reactionId: "reaction-1" },
{ kind: "remove", messageId: "message-1", reactionId: "reaction-1" },
{ kind: "add", messageId: "message-1", emoji: "CheckMark", reactionId: "reaction-2" },
]);
});
});
Binary file not shown.
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import {
extractProblemId,
normalizePbankBaseUrl,
pbankRightsFromCredential,
} from "../../src/capability/pbankClient.js";
import type { PbankCapabilitySecretPayload } from "../../src/capability/types.js";
import { claudeSdkToolConfigForRole, cphHubMcpToolsForRole } from "../../src/agent/roleTools.js";
describe("pbank client helpers", () => {
it("extracts problem UUID from bare id and URL", () => {
const id = "01234567-89ab-4def-8abc-0123456789ab";
expect(extractProblemId(id)).toBe(id);
expect(extractProblemId(`https://pbank.paradigm-edu.net/problem/${id}`)).toBe(id);
expect(extractProblemId(`https://pbank.example/x?id=${id}`)).toBe(id);
});
it("normalizes base URL trailing slashes", () => {
expect(normalizePbankBaseUrl("https://pbank.paradigm-edu.net/api/")).toBe(
"https://pbank.paradigm-edu.net/api",
);
expect(normalizePbankBaseUrl("")).toBe("https://pbank.paradigm-edu.net/api");
});
it("marks derivative use from operator rights status", () => {
const owned: PbankCapabilitySecretPayload = {
schemaVersion: 1,
kind: "pbank",
baseUrl: "https://pbank.paradigm-edu.net/api",
username: "u",
password: "p",
rightsStatus: "owned",
};
expect(pbankRightsFromCredential(owned).derivativeUseAllowed).toBe(true);
const unknown: PbankCapabilitySecretPayload = {
...owned,
rightsStatus: "unknown",
};
expect(pbankRightsFromCredential(unknown).derivativeUseAllowed).toBe(false);
});
});
describe("role tool mapping for pbank", () => {
it("maps umbrella pbank role tool to three MCP tools", () => {
expect(cphHubMcpToolsForRole(["pbank"])).toEqual([
"todo_write",
"pbank_search_problems",
"pbank_get_problem",
"pbank_get_many_problems",
]);
const cfg = claudeSdkToolConfigForRole(["pbank", "Read"]);
expect(cfg.tools).toContain("Read");
expect(cfg.allowedTools).toContain("mcp__cph_hub__pbank_search_problems");
expect(cfg.allowedTools).toContain("mcp__cph_hub__pbank_get_problem");
expect(cfg.allowedTools).toContain("mcp__cph_hub__pbank_get_many_problems");
});
});
+56
View File
@@ -0,0 +1,56 @@
import { readFile } from "node:fs/promises";
import { inflateRawSync } from "node:zlib";
import { describe, expect, it } from "vitest";
/** Mirrors hub/src/capability/pbank.ts zip reader contracts. */
function listZipEntries(buffer: Buffer) {
let eocd = -1;
const minEocd = Math.max(0, buffer.length - (22 + 0xffff));
for (let i = buffer.length - 22; i >= minEocd; i -= 1) {
if (buffer.readUInt32LE(i) === 0x06054b50) {
eocd = i;
break;
}
}
if (eocd < 0) throw new Error("missing eocd");
const totalEntries = buffer.readUInt16LE(eocd + 10);
const centralOffset = buffer.readUInt32LE(eocd + 16);
const entries: Array<{ name: string; method: number; compressedSize: number; uncompressedSize: number; localHeaderOffset: number }> = [];
let offset = centralOffset;
for (let i = 0; i < totalEntries; i += 1) {
const method = buffer.readUInt16LE(offset + 10);
const compressedSize = buffer.readUInt32LE(offset + 20);
const uncompressedSize = buffer.readUInt32LE(offset + 24);
const nameLen = buffer.readUInt16LE(offset + 28);
const extraLen = buffer.readUInt16LE(offset + 30);
const commentLen = buffer.readUInt16LE(offset + 32);
const localHeaderOffset = buffer.readUInt32LE(offset + 42);
const nameStart = offset + 46;
const name = buffer.subarray(nameStart, nameStart + nameLen).toString("utf8");
entries.push({ name, method, compressedSize, uncompressedSize, localHeaderOffset });
offset = nameStart + nameLen + extraLen + commentLen;
}
return entries;
}
function inflateZipEntry(buffer: Buffer, entry: { method: number; compressedSize: number; uncompressedSize: number; localHeaderOffset: number }) {
const local = entry.localHeaderOffset;
const nameLen = buffer.readUInt16LE(local + 26);
const extraLen = buffer.readUInt16LE(local + 28);
const dataStart = local + 30 + nameLen + extraLen;
const compressed = buffer.subarray(dataStart, dataStart + entry.compressedSize);
if (entry.method === 0) return Buffer.from(compressed);
if (entry.method === 8) return Buffer.from(inflateRawSync(compressed));
throw new Error(`method ${entry.method}`);
}
describe("pbank zip reader contract", () => {
it("lists and inflates deflated entries without host unzip", async () => {
const zip = await readFile(new URL("./fixtures/pbank-mini.zip", import.meta.url));
const entries = listZipEntries(zip);
expect(entries.map((e) => e.name).sort()).toEqual(["fig/a.png", "hello.txt"]);
const hello = entries.find((e) => e.name === "hello.txt")!;
const text = inflateZipEntry(zip, hello).toString("utf8");
expect(text).toBe("hello pbank\n");
});
});
+148
View File
@@ -0,0 +1,148 @@
import { describe, expect, it, vi } from "vitest";
import {
clampPdfToMdConcurrency,
invokePdfToMdBatch,
mapPool,
readPdfToMdConcurrency,
} from "../../src/capability/pdfToMdBundle.js";
import type { CapabilityAdapter, CapabilityInvocationResult } from "../../src/capability/types.js";
function okResult(label: string, pages: number): CapabilityInvocationResult {
return {
artifacts: [{ path: `out/${label}/document.md`, kind: "markdown" }],
consumption: {
provider: "aliyun_docmind",
model: null,
inputTokens: null,
outputTokens: null,
quantity: pages,
unit: "pages",
costUsd: pages * 0.0056,
correlationId: `job-${label}`,
},
};
}
describe("pdf_to_md batch concurrency helpers", () => {
it("mapPool caps in-flight workers", async () => {
let inFlight = 0;
let maxInFlight = 0;
const values = await mapPool([1, 2, 3, 4, 5], 2, async (item) => {
inFlight += 1;
maxInFlight = Math.max(maxInFlight, inFlight);
await new Promise((resolve) => setTimeout(resolve, 40));
inFlight -= 1;
return item * 10;
});
expect(values).toEqual([10, 20, 30, 40, 50]);
expect(maxInFlight).toBe(2);
});
it("clamps concurrency and reads env", () => {
expect(clampPdfToMdConcurrency(99)).toBe(8);
expect(clampPdfToMdConcurrency(0)).toBe(1);
expect(readPdfToMdConcurrency({})).toBe(3);
expect(readPdfToMdConcurrency({ HUB_PDF_TO_MD_MAX_CONCURRENT: "5" })).toBe(5);
expect(() => readPdfToMdConcurrency({ HUB_PDF_TO_MD_MAX_CONCURRENT: "nope" })).toThrow(/positive integer/);
});
it("runs batch items concurrently and preserves order", async () => {
let inFlight = 0;
let maxInFlight = 0;
const adapter: CapabilityAdapter = {
capabilityId: "pdf_to_md_bundle",
invoke: vi.fn(async (input) => {
inFlight += 1;
maxInFlight = Math.max(maxInFlight, inFlight);
await new Promise((resolve) => setTimeout(resolve, 60));
inFlight -= 1;
const label = input.inputPath.includes("b") ? "b" : input.inputPath.includes("c") ? "c" : "a";
return okResult(label, label === "a" ? 1 : label === "b" ? 2 : 3);
}),
};
const batch = await invokePdfToMdBatch(
adapter,
{
runId: "run-1",
organizationId: "org-1",
projectId: "proj-1",
workspaceDir: "/tmp/ws",
prisma: {} as never,
},
[
{ inputPath: "a.pdf", outputDir: "out/a" },
{ inputPath: "b.pdf", outputDir: "out/b" },
{ inputPath: "c.pdf", outputDir: "out/c" },
],
3,
);
expect(batch.map((item) => item.ok)).toEqual([true, true, true]);
expect(maxInFlight).toBe(3);
expect(adapter.invoke).toHaveBeenCalledTimes(3);
if (batch[0]?.ok && batch[1]?.ok && batch[2]?.ok) {
expect(batch[0].result.consumption.correlationId).toBe("job-a");
expect(batch[1].result.consumption.correlationId).toBe("job-b");
expect(batch[2].result.consumption.correlationId).toBe("job-c");
}
});
it("isolates per-item failures without canceling siblings", async () => {
const adapter: CapabilityAdapter = {
capabilityId: "pdf_to_md_bundle",
invoke: vi.fn(async (input) => {
if (input.inputPath.includes("bad")) {
throw new Error("boom");
}
return okResult("ok", 1);
}),
};
const batch = await invokePdfToMdBatch(
adapter,
{
runId: "run-1",
organizationId: "org-1",
projectId: "proj-1",
workspaceDir: "/tmp/ws",
prisma: {} as never,
},
[
{ inputPath: "ok.pdf", outputDir: "out/ok" },
{ inputPath: "bad.pdf", outputDir: "out/bad" },
],
2,
);
expect(batch[0]?.ok).toBe(true);
expect(batch[1]?.ok).toBe(false);
if (batch[1]?.ok === false) {
expect(batch[1].error).toMatch(/boom/);
}
});
it("rejects duplicate output dirs before starting work", async () => {
const invoke = vi.fn();
const adapter: CapabilityAdapter = {
capabilityId: "pdf_to_md_bundle",
invoke,
};
await expect(invokePdfToMdBatch(
adapter,
{
runId: "run-1",
organizationId: "org-1",
projectId: "proj-1",
workspaceDir: "/tmp/ws",
prisma: {} as never,
},
[
{ inputPath: "a.pdf", outputDir: "out/same" },
{ inputPath: "b.pdf", outputDir: "out/same/" },
],
2,
)).rejects.toThrow(/distinct output_dir/);
expect(invoke).not.toHaveBeenCalled();
});
});
+7 -2
View File
@@ -3,7 +3,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { removeAbandonedMessageResourceStages, stageMessageResources } from "../../src/feishu/resourceStaging.js";
import type { FeishuRuntime } from "../../src/feishu/client.js";
import type { FeishuBotCli } from "../../src/feishu/botCli.js";
const roots: string[] = [];
@@ -34,8 +34,13 @@ describe("Feishu resource staging recovery", () => {
it("rejects too many resources before contacting Feishu", async () => {
const root = await tempRoot();
const botCli: FeishuBotCli = {
downloadResource: async () => {
throw new Error("should not contact Feishu when over limit");
},
};
await expect(stageMessageResources(
{} as FeishuRuntime,
botCli,
"message-1",
[
{ fileKey: "a", resourceType: "file", workspaceRelativePath: "a" },
+64
View File
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import {
appendTeacherNotice,
isMaxTurnsError,
teacherFacingRunOutcome,
} from "../../src/feishu/runOutcomeNotice.js";
describe("teacherFacingRunOutcome", () => {
it("explains max-turn stops with the configured ceiling", () => {
const outcome = teacherFacingRunOutcome({
wallTimeExceeded: false,
interrupted: false,
resultStatus: "failed",
resultError: "Claude Code returned an error result: Reached maximum number of turns (25)",
maxTurns: 150,
maxRunSeconds: 1800,
hasPartialText: true,
});
expect(outcome.isError).toBe(true);
expect(outcome.notice).toContain("150");
expect(outcome.notice).toContain("最大步骤数");
expect(outcome.notice).toContain("部分结果");
});
it("explains wall-clock timeouts", () => {
const outcome = teacherFacingRunOutcome({
wallTimeExceeded: true,
interrupted: false,
resultStatus: "interrupted",
resultError: undefined,
maxTurns: 150,
maxRunSeconds: 1800,
hasPartialText: false,
});
expect(outcome.isError).toBe(true);
expect(outcome.notice).toContain("1800");
expect(outcome.notice).toContain("超时");
});
it("is quiet on successful completion", () => {
expect(
teacherFacingRunOutcome({
wallTimeExceeded: false,
interrupted: false,
resultStatus: "completed",
resultError: undefined,
maxTurns: 150,
maxRunSeconds: 1800,
hasPartialText: true,
}),
).toEqual({ isError: false, notice: undefined });
});
it("appendTeacherNotice joins body and notice", () => {
expect(appendTeacherNotice("hello", "bye")).toBe("hello\n\nbye");
expect(appendTeacherNotice("", "only")).toBe("only");
});
it("detects max-turn SDK wording", () => {
expect(isMaxTurnsError("Reached maximum number of turns (25)")).toBe(true);
expect(isMaxTurnsError("result_error_max_turns")).toBe(true);
expect(isMaxTurnsError("network glitch")).toBe(false);
});
});
+54 -8
View File
@@ -113,9 +113,10 @@ describe("runAgent", () => {
permissionMode: "bypassPermissions",
allowDangerouslySkipPermissions: true,
settingSources: [],
settings: { disableBundledSkills: true },
skills: [],
strictMcpConfig: true,
settings: { disableBundledSkills: true, todoFeatureEnabled: true, autoCompactEnabled: true },
tools: expect.arrayContaining(["Read", "Write", "Edit", "Bash", "Glob", "Grep", "TodoWrite"]),
allowedTools: expect.arrayContaining(["TodoWrite", "mcp__cph_hub__todo_write"]),
disallowedTools: expect.arrayContaining(["Agent", "SendMessage", "Task", "TeamCreate", "ScheduleWakeup"]),
sandbox: expect.objectContaining({
enabled: true,
failIfUnavailable: true,
@@ -130,6 +131,39 @@ describe("runAgent", () => {
});
});
it("never exposes multi-agent orchestration tools on unrestricted roles", async () => {
queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1")));
await runAgent({
prompt: "继续",
model: undefined,
project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace },
systemPrompt: undefined,
tools: null,
runId: "run-1",
sessionId: "hub-session-1",
prisma: stubPrisma,
});
const call = queryMock.mock.calls[0]?.[0] as {
options?: { tools?: unknown; disallowedTools?: string[] };
} | undefined;
expect(call?.options?.tools).toEqual([
"Read",
"Write",
"Edit",
"Bash",
"Glob",
"Grep",
"WebFetch",
"WebSearch",
"TodoWrite",
]);
for (const blocked of ["Agent", "SendMessage", "Task", "TeamCreate", "ScheduleWakeup"]) {
expect(call?.options?.disallowedTools).toContain(blocked);
}
});
it("does not send resume for a fresh Hub session", async () => {
queryMock.mockReturnValue(messages(assistantMessage("fresh"), resultMessage("sdk-session-1")));
@@ -183,8 +217,16 @@ describe("runAgent", () => {
expect(queryMock.mock.calls[0]?.[0]).toMatchObject({
options: {
tools: ["Read", "Bash"],
allowedTools: ["Read", "Bash", "mcp__cph_hub__send_file"],
tools: ["Read", "Bash", "TodoWrite"],
allowedTools: [
"Read",
"Bash",
"mcp__cph_hub__send_file",
"TodoWrite",
"mcp__cph_hub__todo_write",
],
settings: expect.objectContaining({ todoFeatureEnabled: true }),
disallowedTools: expect.arrayContaining(["Agent", "SendMessage"]),
},
});
});
@@ -205,8 +247,10 @@ describe("runAgent", () => {
expect(queryMock.mock.calls[0]?.[0]).toMatchObject({
options: {
tools: [],
allowedTools: [],
tools: ["TodoWrite"],
allowedTools: ["TodoWrite", "mcp__cph_hub__todo_write"],
settings: expect.objectContaining({ todoFeatureEnabled: true }),
disallowedTools: expect.arrayContaining(["Agent", "SendMessage"]),
},
});
});
@@ -234,9 +278,11 @@ describe("runAgent", () => {
expect(queryMock.mock.calls[0]?.[0]).toMatchObject({
options: {
tools: ["Skill"],
tools: ["TodoWrite", "Skill"],
allowedTools: expect.arrayContaining(["TodoWrite", "mcp__cph_hub__todo_write", "Skill"]),
plugins: [expect.objectContaining({ type: "local", skipMcpDiscovery: true })],
skills: ["cph-runtime:typst"],
settings: expect.objectContaining({ todoFeatureEnabled: true }),
},
});
});
+111
View File
@@ -0,0 +1,111 @@
import { describe, expect, it } from "vitest";
import {
applyChecklistToolEvent,
isTodoWriteTool,
parseTodoWriteInput,
todoProgressSummary,
} from "../../src/agent/todoList.js";
import { buildAgentCard } from "../../src/feishu/card/builder.js";
describe("todo list parse", () => {
it("accepts TodoWrite payloads", () => {
const todos = parseTodoWriteInput({
todos: [
{ content: "搜题", status: "completed", activeForm: "正在搜题" },
{ content: "写报告", status: "in_progress", activeForm: "正在写报告" },
{ content: "发卡片", status: "pending" },
],
});
expect(todos).toEqual([
{ id: undefined, content: "搜题", status: "completed", activeForm: "正在搜题" },
{ id: undefined, content: "写报告", status: "in_progress", activeForm: "正在写报告" },
{ id: undefined, content: "发卡片", status: "pending", activeForm: undefined },
]);
expect(todoProgressSummary(todos!)).toEqual({ completed: 1, total: 3, inProgress: 1 });
});
it("folds TaskCreate + TaskUpdate into a checklist", () => {
let todos = applyChecklistToolEvent([], {
toolName: "TaskCreate",
input: { subject: "说你好", description: "greet" },
result: "Task #1 created successfully: 说你好",
});
expect(todos).toEqual([
{ id: "1", content: "说你好", status: "pending", activeForm: undefined },
]);
todos = applyChecklistToolEvent(todos!, {
toolName: "TaskCreate_1",
input: { subject: "算 1+1", description: "math" },
result: "Task #2 created successfully: 算 1+1",
});
todos = applyChecklistToolEvent(todos!, {
toolName: "TaskUpdate",
input: { taskId: "1", status: "in_progress", activeForm: "正在问好" },
result: "Updated task #1 status",
});
todos = applyChecklistToolEvent(todos!, {
toolName: "TaskUpdate_4",
input: { taskId: "1", status: "completed" },
result: "Updated task #1 status",
});
todos = applyChecklistToolEvent(todos!, {
toolName: "TaskUpdate",
input: { taskId: "2", status: "completed" },
result: "Updated task #2 status",
});
expect(todos).toEqual([
{ id: "1", content: "说你好", status: "completed", activeForm: "正在问好" },
{ id: "2", content: "算 1+1", status: "completed", activeForm: undefined },
]);
expect(todoProgressSummary(todos!)).toEqual({ completed: 2, total: 2, inProgress: 0 });
});
it("recognizes TodoWrite tool names", () => {
expect(isTodoWriteTool("TodoWrite")).toBe(true);
expect(isTodoWriteTool("mcp__cph_hub__todo_write")).toBe(true);
expect(isTodoWriteTool("Bash")).toBe(false);
});
});
describe("agent card todo panel", () => {
it("renders a progress checklist when todos are present", () => {
const card = buildAgentCard({
phase: "streaming",
text: "",
reasoningText: undefined,
todos: [
{ id: "1", content: "A", status: "completed", activeForm: undefined },
{ id: "2", content: "B", status: "in_progress", activeForm: "Doing B" },
{ id: "3", content: "C", status: "pending", activeForm: undefined },
],
toolUseSteps: [
{
id: "1",
seq: 1,
toolName: "TaskCreate",
toolUseId: "t1",
input: {},
result: undefined,
error: undefined,
status: "success",
startedAt: 0,
finishedAt: 1,
durationMs: 1,
},
],
toolUseElapsedMs: 10,
isError: undefined,
interrupted: undefined,
runId: "run-1",
});
const json = JSON.stringify(card);
expect(json).toContain("任务进度 1/3");
expect(json).toContain("Doing B");
expect(json).toContain("collapsible_panel");
// TaskCreate noise filtered when checklist present (no separate tool panel if only Task*)
expect(json).not.toContain("TaskCreate");
});
});