forked from EduCraft/curriculum-project-hub
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:
@@ -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");
|
||||
|
||||
@@ -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"),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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("")).toBe("【图片】");
|
||||
});
|
||||
|
||||
it("ignores markdown image examples inside inline code", () => {
|
||||
const text = "例如 `` 这样写,不会当真实图片";
|
||||
expect(findMarkdownImagesOutsideCode(text)).toEqual([]);
|
||||
expect(maskMarkdownImagesForStreaming(text)).toBe(text);
|
||||
});
|
||||
});
|
||||
|
||||
describe("materializeAnswerSegments", () => {
|
||||
|
||||
@@ -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.
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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" },
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 }),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user