Make English the repository working language (#5)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yaowei ZhengandClaude Fable 5 authored and GitHub committed 2026-07-20 17:45:02 +08:00
1 parent e80cde0ea6
commit 0eaaa54e25
136 files changed
+1515 -1352

No files matched your search

+1 -1
View File
@@ -14,7 +14,7 @@
"test": "pnpm -r test", "test": "pnpm -r test",
"test:e2e": "pnpm --filter @prismshadow/penguin-core test:e2e", "test:e2e": "pnpm --filter @prismshadow/penguin-core test:e2e",
"build": "pnpm -r build && pnpm link:cli", "build": "pnpm -r build && pnpm link:cli",
"link:cli": "pnpm --dir packages/cli link --global || echo '[link:cli] 未配置 pnpm 全局目录,跳过 penguin 全局链接(先运行一次 pnpm setup)'", "link:cli": "pnpm --dir packages/cli link --global || echo '[link:cli] pnpm global directory not configured; skipping the global penguin link (run pnpm setup once first)'",
"penguin": "tsx packages/cli/src/index.ts", "penguin": "tsx packages/cli/src/index.ts",
"dev:server": "pnpm --filter @prismshadow/penguin-skills --filter @prismshadow/penguin-core build && pnpm --filter @prismshadow/penguin-server dev", "dev:server": "pnpm --filter @prismshadow/penguin-skills --filter @prismshadow/penguin-core build && pnpm --filter @prismshadow/penguin-server dev",
"dev:web": "pnpm --filter @prismshadow/penguin-skills --filter @prismshadow/penguin-core build && pnpm --filter @prismshadow/penguin-web dev", "dev:web": "pnpm --filter @prismshadow/penguin-skills --filter @prismshadow/penguin-core build && pnpm --filter @prismshadow/penguin-web dev",
+2 -2
View File
@@ -354,7 +354,7 @@ function confirmYes(prompt: string): Promise<boolean> {
function parseIntArg(value: string): number { function parseIntArg(value: string): number {
const n = Number.parseInt(value, 10); const n = Number.parseInt(value, 10);
if (Number.isNaN(n)) { if (Number.isNaN(n)) {
throw new Error(`无效的整数:${value}`); throw new Error(`Invalid integer: ${value}`);
} }
return n; return n;
} }
@@ -362,7 +362,7 @@ function parseIntArg(value: string): number {
function parseFloatArg(value: string): number { function parseFloatArg(value: string): number {
const n = Number.parseFloat(value); const n = Number.parseFloat(value);
if (Number.isNaN(n)) { if (Number.isNaN(n)) {
throw new Error(`无效的数值:${value}`); throw new Error(`Invalid number: ${value}`);
} }
return n; return n;
} }
+8 -8
View File
@@ -2,40 +2,40 @@ import { describe, expect, it } from "vitest";
import { decideSigint } from "../src/commands/chat.js"; import { decideSigint } from "../src/commands/chat.js";
import { parseApprovalAnswer } from "../src/approval.js"; import { parseApprovalAnswer } from "../src/approval.js";
describe("decideSigint (Ctrl-C 行为状态机)", () => { describe("decideSigint (Ctrl-C behavior state machine)", () => {
it("approving → deny(无论缓冲区是否有内容)", () => { it("approving -> deny (whether or not the buffer has content)", () => {
expect(decideSigint("approving", false)).toBe("deny"); expect(decideSigint("approving", false)).toBe("deny");
expect(decideSigint("approving", true)).toBe("deny"); expect(decideSigint("approving", true)).toBe("deny");
}); });
it("running → abort(中断当前 Task,不退出)", () => { it("running -> abort (interrupts the current Task, does not exit)", () => {
expect(decideSigint("running", false)).toBe("abort"); expect(decideSigint("running", false)).toBe("abort");
expect(decideSigint("running", true)).toBe("abort"); expect(decideSigint("running", true)).toBe("abort");
}); });
it("idle + 有输入 → clear(清空缓冲区)", () => { it("idle + input -> clear (empties the buffer)", () => {
expect(decideSigint("idle", true)).toBe("clear"); expect(decideSigint("idle", true)).toBe("clear");
}); });
it("idle + 无输入 → confirm-exit(弹出 y/N 退出确认)", () => { it("idle + no input -> confirm-exit (shows the y/N exit confirmation)", () => {
expect(decideSigint("idle", false)).toBe("confirm-exit"); expect(decideSigint("idle", false)).toBe("confirm-exit");
}); });
it("confirming-exit → exit(确认中再次 Ctrl-C 直接退出)", () => { it("confirming-exit -> exit (a second Ctrl-C during confirmation exits immediately)", () => {
expect(decideSigint("confirming-exit", false)).toBe("exit"); expect(decideSigint("confirming-exit", false)).toBe("exit");
expect(decideSigint("confirming-exit", true)).toBe("exit"); expect(decideSigint("confirming-exit", true)).toBe("exit");
}); });
}); });
describe("parseApprovalAnswer", () => { describe("parseApprovalAnswer", () => {
it("y / yes(trim、不区分大小写)→ allow;n / no → deny", () => { it("y / yes (trimmed, case-insensitive) -> allow; n / no -> deny", () => {
expect(parseApprovalAnswer("y")).toBe("allow"); expect(parseApprovalAnswer("y")).toBe("allow");
expect(parseApprovalAnswer(" YES \n")).toBe("allow"); expect(parseApprovalAnswer(" YES \n")).toBe("allow");
expect(parseApprovalAnswer("Y")).toBe("allow"); expect(parseApprovalAnswer("Y")).toBe("allow");
expect(parseApprovalAnswer("n")).toBe("deny"); expect(parseApprovalAnswer("n")).toBe("deny");
expect(parseApprovalAnswer("NO")).toBe("deny"); expect(parseApprovalAnswer("NO")).toBe("deny");
}); });
it("空/无关输入用 fallback(缺省 deny;工具审批传 allow)", () => { it("empty/unrelated input uses the fallback (default deny; tool approval passes allow)", () => {
expect(parseApprovalAnswer("")).toBe("deny"); // default fallback expect(parseApprovalAnswer("")).toBe("deny"); // default fallback
expect(parseApprovalAnswer("nope")).toBe("deny"); expect(parseApprovalAnswer("nope")).toBe("deny");
expect(parseApprovalAnswer("", "allow")).toBe("allow"); // tool approval defaults to allow expect(parseApprovalAnswer("", "allow")).toBe("allow"); // tool approval defaults to allow
+4 -4
View File
@@ -29,7 +29,7 @@ describe("formatModelRows", () => {
], ],
}; };
it("provider 与 model_id 双列展示;预置模型 vision 经目录成对匹配,默认模型以 * 标记", () => { it("provider and model_id shown as two columns; preset model vision resolved by catalog pair match, default model marked with *", () => {
const lines = formatModelRows(cfg); const lines = formatModelRows(cfg);
expect(lines).toHaveLength(2); expect(lines).toHaveLength(2);
expect(lines[0]).toMatch(/^\* anthropic\s+claude-sonnet-4-6\s+vision=Y/); expect(lines[0]).toMatch(/^\* anthropic\s+claude-sonnet-4-6\s+vision=Y/);
@@ -39,7 +39,7 @@ describe("formatModelRows", () => {
expect(lines[0]).not.toContain("anthropic/claude-sonnet-4-6"); expect(lines[0]).not.toContain("anthropic/claude-sonnet-4-6");
}); });
it("自定义模型 vision 按标注(显式 false 记 -);内联 api_key 掩码显示", () => { it("custom model vision follows its annotation (explicit false shown as -); inline api_key displayed masked", () => {
const lines = formatModelRows(cfg); const lines = formatModelRows(cfg);
expect(lines[1]).toMatch(/^ {2}custom\s+my-proxy-model\s+vision=-/); expect(lines[1]).toMatch(/^ {2}custom\s+my-proxy-model\s+vision=-/);
expect(lines[1]).toContain("client_type=openai"); expect(lines[1]).toContain("client_type=openai");
@@ -47,7 +47,7 @@ describe("formatModelRows", () => {
expect(lines[1]).not.toContain("sk-test-abcd-1234"); expect(lines[1]).not.toContain("sk-test-abcd-1234");
}); });
it("同名 model_id 双 provider 并存时各占一行,默认标记只落在成对命中的那行", () => { it("two providers sharing a model_id each get their own row; the default marker lands only on the exact pair match", () => {
const lines = formatModelRows({ const lines = formatModelRows({
default_model: { provider: "deepseek", model_id: "m1" }, default_model: { provider: "deepseek", model_id: "m1" },
models: [ models: [
@@ -59,7 +59,7 @@ describe("formatModelRows", () => {
expect(lines[1]).toMatch(/^ {2}siliconflow\s+m1\s+vision=Y/); expect(lines[1]).toMatch(/^ {2}siliconflow\s+m1\s+vision=Y/);
}); });
it("无标注按「缺省=支持」记 Y;全空列省略", () => { it('unannotated vision defaults to "supported" and shows Y; fully empty columns are omitted', () => {
const lines = formatModelRows({ const lines = formatModelRows({
models: [{ provider: "custom", model_id: "m1" }], models: [{ provider: "custom", model_id: "m1" }],
}); });
+10 -10
View File
@@ -78,8 +78,8 @@ async function runModel(args: string[]): Promise<{ out: string; err: string; cod
} }
} }
describe("penguin config model add/list(--root 与 provider / model_id 分列存储)", () => { describe("penguin config model add/list (--root plus provider / model_id stored as separate fields)", () => {
it("--root 优先于 PENGUIN_HOME:落盘到指定根目录的隐藏 .project_config.toml(0600)", async () => { it("--root beats PENGUIN_HOME: written to the hidden .project_config.toml under the given root (0600)", async () => {
const add = await runModel([ const add = await runModel([
"add", "add",
"--model-id", "--model-id",
@@ -119,7 +119,7 @@ describe("penguin config model add/list(--root 与 provider / model_id 分列
expect(list.out).not.toContain("sk-root-secret-1"); expect(list.out).not.toContain("sk-root-secret-1");
}); });
it("内置目录推断分组:上游 id 命中目录时条目落该 provider;--set-default 写成对引用", async () => { it("built-in catalog infers the grouping: an upstream id matching the catalog lands under that provider; --set-default writes a pair reference", async () => {
const add = await runModel([ const add = await runModel([
"add", "add",
"--model-id", "--model-id",
@@ -144,7 +144,7 @@ describe("penguin config model add/list(--root 与 provider / model_id 分列
).toBeDefined(); ).toBeDefined();
}); });
it("--provider 显式指定分组:同名上游 id 与预置条目互不冲突(各自独立条目)", async () => { it("--provider picks the grouping explicitly: a same-name upstream id does not clash with the preset entry (independent entries)", async () => {
const add = await runModel([ const add = await runModel([
"add", "add",
"--model-id", "--model-id",
@@ -167,7 +167,7 @@ describe("penguin config model add/list(--root 与 provider / model_id 分列
expect(list.out.split("\n").some((l) => /anthropic\s+claude-sonnet-4-6/.test(l))).toBe(true); expect(list.out.split("\n").some((l) => /anthropic\s+claude-sonnet-4-6/.test(l))).toBe(true);
}); });
it("client_type 缺省按分组语义(PRN-021):custom / 自建 / 网关落 openai,一方厂商不落", async () => { it("client_type defaults by grouping semantics (PRN-021): custom / self-hosted / gateway get openai, first-party providers get none", async () => {
// custom (catalog inference fails) and self-hosted groups (--provider not a catalog value): default to client_type=openai. // custom (catalog inference fails) and self-hosted groups (--provider not a catalog value): default to client_type=openai.
await runModel(["add", "--model-id", "my-openai-proxy", "--root", tmpRoot]); await runModel(["add", "--model-id", "my-openai-proxy", "--root", tmpRoot]);
await runModel(["add", "--model-id", "in-house-1", "--provider", "mylab", "--root", tmpRoot]); await runModel(["add", "--model-id", "in-house-1", "--provider", "mylab", "--root", tmpRoot]);
@@ -217,7 +217,7 @@ describe("penguin config model add/list(--root 与 provider / model_id 分列
expect(by("mylab", "special-1").client_type).toBe("verbatim-type"); expect(by("mylab", "special-1").client_type).toBe("verbatim-type");
}); });
it("model default 经 --root 指定根目录设置默认模型(--model-id 上游 id + --provider 成对)", async () => { it("model default sets the default model under the --root data root (--model-id upstream id + --provider as a pair)", async () => {
const set = await runModel([ const set = await runModel([
"default", "default",
"--model-id", "--model-id",
@@ -241,14 +241,14 @@ describe("penguin config model add/list(--root 与 provider / model_id 分列
}); });
}); });
describe("model default/vision:--provider 必填,(provider, model_id) 成对引用", () => { describe("model default/vision: --provider is required, (provider, model_id) pair reference", () => {
it("缺 --provider:commander 用法报错,非零退出码", async () => { it("missing --provider: commander usage error, nonzero exit code", async () => {
const bad = await runModel(["default", "--model-id", "deepseek-v4-flash", "--root", tmpRoot]); const bad = await runModel(["default", "--model-id", "deepseek-v4-flash", "--root", tmpRoot]);
expect(bad.code).not.toBe(0); expect(bad.code).not.toBe(0);
expect(bad.err).toContain("--provider"); expect(bad.err).toContain("--provider");
}); });
it("引用落空:成对引用不在 models 中,报错带成对引用与 model list 提示", async () => { it("dangling reference: the pair is not in models; the error carries the pair reference and a model list hint", async () => {
const bad = await runModel([ const bad = await runModel([
"default", "default",
"--model-id", "--model-id",
@@ -276,7 +276,7 @@ describe("model default/vision:--provider 必填,(provider, model_id) 成对
expect(wrongGroup.err).toContain("penguin config model list"); expect(wrongGroup.err).toContain("penguin config model list");
}); });
it("model vision 成对引用命中:设置视觉模型(落盘内联表)", async () => { it("model vision pair reference hit: sets the vision model (written as an inline table)", async () => {
const ok = await runModel([ const ok = await runModel([
"vision", "vision",
"--model-id", "--model-id",
+4 -4
View File
@@ -57,7 +57,7 @@ async function runVault(args: string[]): Promise<{ out: string; err: string; cod
} }
describe("penguin config vault", () => { describe("penguin config vault", () => {
it("set → list(掩码)→ remove 全链路;落盘为隐藏 .vault.toml 且 0600", async () => { it("set -> list (masked) -> remove full round trip; stored as hidden .vault.toml with 0600", async () => {
const set = await runVault(["set", "--key", "MY_KEY", "--value", "vault-secret-9876"]); const set = await runVault(["set", "--key", "MY_KEY", "--value", "vault-secret-9876"]);
expect(set.code).toBe(0); expect(set.code).toBe(0);
expect(set.out).toContain("Saved vault entry MY_KEY."); expect(set.out).toContain("Saved vault entry MY_KEY.");
@@ -81,7 +81,7 @@ describe("penguin config vault", () => {
expect(empty.out).toContain("The vault is empty."); expect(empty.out).toContain("The vault is empty.");
}); });
it("--agent-id 定向到目标 Agent 的 vault,不影响 default_agent", async () => { it("--agent-id targets that Agent's vault without touching default_agent", async () => {
const set = await runVault([ const set = await runVault([
"set", "set",
"--key", "--key",
@@ -99,7 +99,7 @@ describe("penguin config vault", () => {
expect(defaultList.out).toContain("The vault is empty."); expect(defaultList.out).toContain("The vault is empty.");
}); });
it("--root 指定数据根目录(优先于 PENGUIN_HOME),set/list 均定向到该根目录", async () => { it("--root picks the data root (beats PENGUIN_HOME); set/list both target that root", async () => {
const otherRoot = await fs.mkdtemp(path.join(os.tmpdir(), "penguin-cli-vault-root-")); const otherRoot = await fs.mkdtemp(path.join(os.tmpdir(), "penguin-cli-vault-root-"));
try { try {
const set = await runVault([ const set = await runVault([
@@ -125,7 +125,7 @@ describe("penguin config vault", () => {
} }
}); });
it("非法键名 / 超长值以非零码退出并打印原因;remove 不存在的键报错", async () => { it("invalid key name / oversized value exit nonzero and print the reason; removing a missing key errors", async () => {
const badKey = await runVault(["set", "--key", "1BAD", "--value", "v"]); const badKey = await runVault(["set", "--key", "1BAD", "--value", "v"]);
expect(badKey.code).toBe(1); expect(badKey.code).toBe(1);
expect(badKey.err).toContain("Invalid vault key"); expect(badKey.err).toContain("Invalid vault key");
+3 -3
View File
@@ -292,7 +292,7 @@ describe("StreamRenderer", () => {
); );
}); });
it("轮结束后的压缩:压缩完成行展示本次消耗,但不计入本轮统计增量;不更新上下文", () => { it("compaction after the turn ends: the completion line shows its own cost, excluded from the turn stats delta; context not updated", () => {
const { stream, text } = collector(); const { stream, text } = collector();
const r = new StreamRenderer(stream, t); const r = new StreamRenderer(stream, t);
// Ordinary request: context 5000. // Ordinary request: context 5000.
@@ -325,7 +325,7 @@ describe("StreamRenderer", () => {
expect(s).toContain("tokens 14k (+5k)"); expect(s).toContain("tokens 14k (+5k)");
}); });
it("轮途中的压缩(其后还有普通 request_end):用时含压缩跨度,Token 增量计入压缩", () => { it("mid-turn compaction (a normal request_end follows): elapsed time includes the compaction span, Token delta includes compaction", () => {
const { stream, text } = collector(); const { stream, text } = collector();
const r = new StreamRenderer(stream, t); const r = new StreamRenderer(stream, t);
// own1: ordinary request, request 5000, 00:00 -> 00:02. // own1: ordinary request, request 5000, 00:00 -> 00:02.
@@ -362,7 +362,7 @@ describe("StreamRenderer", () => {
expect(s).toContain("16s (+16s)"); expect(s).toContain("16s (+16s)");
}); });
it("轮结束后的压缩(带 request 事件):用时止于压缩前的最后一个 request_end", () => { it("compaction after the turn ends (with request events): elapsed time stops at the last request_end before compaction", () => {
const { stream, text } = collector(); const { stream, text } = collector();
const r = new StreamRenderer(stream, t); const r = new StreamRenderer(stream, t);
// own1: 00:00 -> 00:03. // own1: 00:00 -> 00:03.
+13 -13
View File
@@ -10,19 +10,19 @@ import {
} from "../src/commands/serve.js"; } from "../src/commands/serve.js";
import { getMessages } from "../src/i18n.js"; import { getMessages } from "../src/i18n.js";
describe("resolvePort(选项 > 环境变量 > 缺省 7364)", () => { describe("resolvePort (option > env var > default 7364)", () => {
it("都未给时用缺省 7364", () => { it("uses the default 7364 when neither is given", () => {
expect(DEFAULT_PORT).toBe(7364); expect(DEFAULT_PORT).toBe(7364);
expect(resolvePort(undefined, undefined)).toBe(7364); expect(resolvePort(undefined, undefined)).toBe(7364);
expect(resolvePort(undefined, "")).toBe(7364); // an empty string counts as unset expect(resolvePort(undefined, "")).toBe(7364); // an empty string counts as unset
}); });
it("只有环境变量时取环境变量", () => { it("takes the env var when only the env var is set", () => {
expect(resolvePort(undefined, "8080")).toBe(8080); expect(resolvePort(undefined, "8080")).toBe(8080);
}); });
it("选项优先于环境变量", () => { it("the option beats the env var", () => {
expect(resolvePort("9000", "8080")).toBe(9000); expect(resolvePort("9000", "8080")).toBe(9000);
}); });
it("非法值(非整数 / 越界)抛错", () => { it("throws on invalid values (non-integer / out of range)", () => {
expect(() => resolvePort("abc", undefined)).toThrow(/abc/); expect(() => resolvePort("abc", undefined)).toThrow(/abc/);
expect(() => resolvePort("3.14", undefined)).toThrow(); expect(() => resolvePort("3.14", undefined)).toThrow();
expect(() => resolvePort("-1", undefined)).toThrow(); expect(() => resolvePort("-1", undefined)).toThrow();
@@ -31,36 +31,36 @@ describe("resolvePort(选项 > 环境变量 > 缺省 7364)", () => {
}); });
}); });
describe("browserCommand(按平台选择打开命令)", () => { describe("browserCommand (picks the open command per platform)", () => {
const url = "http://127.0.0.1:7364/"; const url = "http://127.0.0.1:7364/";
it("darwin → open", () => { it("darwin → open", () => {
expect(browserCommand("darwin", url)).toEqual({ command: "open", args: [url] }); expect(browserCommand("darwin", url)).toEqual({ command: "open", args: [url] });
}); });
it("win32 → cmd /c start(空标题占位在 URL 前)", () => { it("win32 -> cmd /c start (empty title placeholder before the URL)", () => {
expect(browserCommand("win32", url)).toEqual({ expect(browserCommand("win32", url)).toEqual({
command: "cmd", command: "cmd",
args: ["/c", "start", "", url], args: ["/c", "start", "", url],
}); });
}); });
it("其他平台(linux 等)→ xdg-open", () => { it("other platforms (linux etc.) -> xdg-open", () => {
expect(browserCommand("linux", url)).toEqual({ command: "xdg-open", args: [url] }); expect(browserCommand("linux", url)).toEqual({ command: "xdg-open", args: [url] });
expect(browserCommand("freebsd", url)).toEqual({ command: "xdg-open", args: [url] }); expect(browserCommand("freebsd", url)).toEqual({ command: "xdg-open", args: [url] });
}); });
}); });
describe("browserUrl(通配监听地址转 127.0.0.1)", () => { describe("browserUrl (wildcard listen addresses map to 127.0.0.1)", () => {
it("常规 host 原样拼接", () => { it("a regular host is joined as-is", () => {
expect(browserUrl(DEFAULT_HOST, 7364)).toBe("http://127.0.0.1:7364/"); expect(browserUrl(DEFAULT_HOST, 7364)).toBe("http://127.0.0.1:7364/");
expect(browserUrl("192.168.1.2", 8080)).toBe("http://192.168.1.2:8080/"); expect(browserUrl("192.168.1.2", 8080)).toBe("http://192.168.1.2:8080/");
}); });
it("0.0.0.0 / :: 时浏览器 URL 用 127.0.0.1", () => { it("the browser URL uses 127.0.0.1 for 0.0.0.0 / ::", () => {
expect(browserUrl("0.0.0.0", 7364)).toBe("http://127.0.0.1:7364/"); expect(browserUrl("0.0.0.0", 7364)).toBe("http://127.0.0.1:7364/");
expect(browserUrl("::", 7364)).toBe("http://127.0.0.1:7364/"); expect(browserUrl("::", 7364)).toBe("http://127.0.0.1:7364/");
}); });
}); });
describe("registerServeCommands(命令注册)", () => { describe("registerServeCommands (command registration)", () => {
it("注册 server 与 web 两个顶层命令,web 缺省 open=true(--no-open 可关)", () => { it("registers the server and web top-level commands; web defaults to open=true (--no-open turns it off)", () => {
const program = new Command(); const program = new Command();
registerServeCommands(program, getMessages("en")); registerServeCommands(program, getMessages("en"));
const names = program.commands.map((c) => c.name()); const names = program.commands.map((c) => c.name());
+18 -12
View File
@@ -140,7 +140,7 @@ export class Agent {
// credentials, pricing, and the context window would all be unavailable. // credentials, pricing, and the context window would all be unavailable.
if (opts.modelId === undefined && opts.provider !== undefined) { if (opts.modelId === undefined && opts.provider !== undefined) {
throw new Error( throw new Error(
"指定了 provider 却未指定 modelId:模型引用须成对给出(provider 不能单独使用)。", "provider was specified without modelId: a model reference must be given as a pair (provider cannot be used alone).",
); );
} }
let ref: ModelRef; let ref: ModelRef;
@@ -151,13 +151,13 @@ export class Agent {
ref = this.projectConfig.default_model; ref = this.projectConfig.default_model;
} else { } else {
throw new Error( throw new Error(
"未指定 modelId,且 Project 配置中没有 default_model。请用 `penguin config model add/default` 设置默认模型。", "No modelId was specified and the Project config has no default_model. Use `penguin config model add/default` to set the default model.",
); );
} }
const modelEntry = getModel(this.projectConfig, ref); const modelEntry = getModel(this.projectConfig, ref);
if (!modelEntry) { if (!modelEntry) {
throw new Error( throw new Error(
`Model 不在 Project 配置中:${formatModelRef(ref)}。请用 \`penguin config model list\` 查看已配置的模型,或用 \`penguin config model add\` 添加。`, `Model is not in the Project config: ${formatModelRef(ref)}. Use \`penguin config model list\` to see the configured models, or \`penguin config model add\` to add one.`,
); );
} }
// Credentials are inlined on the model entry (single config file); an // Credentials are inlined on the model entry (single config file); an
@@ -177,11 +177,11 @@ export class Agent {
stat = await fs.stat(workspaceDir); stat = await fs.stat(workspaceDir);
} catch { } catch {
throw new Error( throw new Error(
`Workspace 不存在:${workspaceDir}。请指定一个已存在的目录,或不指定 Workspace 以使用临时目录。`, `Workspace does not exist: ${workspaceDir}. Specify an existing directory, or omit the Workspace to use a temporary directory.`,
); );
} }
if (!stat.isDirectory()) { if (!stat.isDirectory()) {
throw new Error(`Workspace 不是目录:${workspaceDir}。`); throw new Error(`Workspace is not a directory: ${workspaceDir}.`);
} }
} else { } else {
workspaceDir = await createTempWorkspace( workspaceDir = await createTempWorkspace(
@@ -284,17 +284,19 @@ export class Agent {
const dir = tracesDir(this.state.root, this.state.projectId, this.state.agentId); const dir = tracesDir(this.state.root, this.state.projectId, this.state.agentId);
const located = await findLatestTraceFile(dir, sessionId); const located = await findLatestTraceFile(dir, sessionId);
if (!located) { if (!located) {
throw new Error(`Session 不存在:${sessionId}(在 ${dir} 下未找到对应 Trace 文件)。`); throw new Error(
`Session does not exist: ${sessionId} (no matching Trace file found under ${dir}).`,
);
} }
const resumed = resumeTrace(await readTraceTolerant(located.path)); const resumed = resumeTrace(await readTraceTolerant(located.path));
if (!resumed.meta) { if (!resumed.meta) {
throw new Error(`Trace 缺少 session_meta,无法恢复:${located.path}`); throw new Error(`Trace is missing session_meta and cannot be resumed: ${located.path}`);
} }
const meta = resumed.meta.payload; const meta = resumed.meta.payload;
// Model reference is stored as a pair in session_meta; a missing provider means legacy data (no migration since the product hasn't shipped yet). // Model reference is stored as a pair in session_meta; a missing provider means legacy data (no migration since the product hasn't shipped yet).
if (typeof meta.provider !== "string") { if (typeof meta.provider !== "string") {
throw new Error( throw new Error(
`Trace 来自旧版本数据(session_meta 缺少 provider,模型引用未分列):${located.path}。请删除数据目录后重建会话。`, `Trace is from legacy data (session_meta is missing provider; the model reference is not split into separate fields): ${located.path}. Delete the data directory and recreate the Session.`,
); );
} }
@@ -304,10 +306,14 @@ export class Agent {
try { try {
stat = await fs.stat(workspaceDir); stat = await fs.stat(workspaceDir);
} catch { } catch {
throw new Error(`原 Session 的 Workspace 已不存在:${workspaceDir},无法恢复。`); throw new Error(
`The original Session's Workspace no longer exists: ${workspaceDir}; cannot resume.`,
);
} }
if (!stat.isDirectory()) { if (!stat.isDirectory()) {
throw new Error(`原 Session 的 Workspace 不是目录:${workspaceDir},无法恢复。`); throw new Error(
`The original Session's Workspace is not a directory: ${workspaceDir}; cannot resume.`,
);
} }
// The Model carries over from the original Session (paired reference) and must still be present in the Project config. // The Model carries over from the original Session (paired reference) and must still be present in the Project config.
@@ -315,7 +321,7 @@ export class Agent {
const modelEntry = getModel(this.projectConfig, ref); const modelEntry = getModel(this.projectConfig, ref);
if (!modelEntry) { if (!modelEntry) {
throw new Error( throw new Error(
`原 Session 的 Model 不在 Project 配置中:${formatModelRef(ref)}。请用 \`penguin config model add\` 重新配置后再恢复。`, `The original Session's Model is not in the Project config: ${formatModelRef(ref)}. Use \`penguin config model add\` to configure it again before resuming.`,
); );
} }
const apiKey = opts.apiKey ?? modelEntry.api_key; const apiKey = opts.apiKey ?? modelEntry.api_key;
@@ -349,7 +355,7 @@ export class Agent {
} catch (err) { } catch (err) {
const detail = err instanceof Error ? err.message : String(err); const detail = err instanceof Error ? err.message : String(err);
throw new Error( throw new Error(
`恢复失败:Trace 历史无法注入(记录可能损坏,如非法的工具参数 JSON):${detail}`, `Resume failed: the Trace history could not be injected (records may be corrupted, e.g. invalid tool-argument JSON): ${detail}`,
); );
} }
} }
+3 -1
View File
@@ -130,7 +130,9 @@ export async function loadOrInitAgentState(opts?: {
typeof parsed !== "object" || typeof parsed !== "object" ||
typeof (parsed as SystemConfig).system_prompt !== "string" typeof (parsed as SystemConfig).system_prompt !== "string"
) { ) {
throw new Error(`Agent State 配置非法:${configPath} 为空、损坏或缺少 system_prompt 字段。`); throw new Error(
`Invalid Agent State config: ${configPath} is empty, corrupted, or missing the system_prompt field.`,
);
} }
systemConfig = parsed as SystemConfig; systemConfig = parsed as SystemConfig;
agentsMd = (await fileExists(mdPath)) ? await fs.readFile(mdPath, "utf8") : defaultAgentsMd(); agentsMd = (await fileExists(mdPath)) ? await fs.readFile(mdPath, "utf8") : defaultAgentsMd();
+12 -10
View File
@@ -126,7 +126,7 @@ export function defaultProjectConfig(): ProjectConfig {
/** The old format (concatenated storage id / string reference) is never migrated: reading it reports a clear error immediately (the product hasn't shipped yet). */ /** The old format (concatenated storage id / string reference) is never migrated: reading it reports a clear error immediately (the product hasn't shipped yet). */
const OLD_FORMAT_HINT = const OLD_FORMAT_HINT =
"产品未发布不做迁移:请删除该配置文件后用 `penguin config model add/default` 重建。"; "No migration since the product hasn't shipped yet: delete this config file and rebuild it with `penguin config model add/default`.";
/** Validates the default_model / vision_model fields: must be a { provider, model_id } paired reference. */ /** Validates the default_model / vision_model fields: must be a { provider, model_id } paired reference. */
function parseRefField(file: string, name: string, value: unknown): ModelRef | undefined { function parseRefField(file: string, name: string, value: unknown): ModelRef | undefined {
@@ -139,7 +139,7 @@ function parseRefField(file: string, name: string, value: unknown): ModelRef | u
typeof ref.model_id !== "string" typeof ref.model_id !== "string"
) { ) {
throw new Error( throw new Error(
`.project_config.toml 的 ${name} 是旧版本/非法格式(须为 { provider = "...", model_id = "..." } 成对引用):${file}。${OLD_FORMAT_HINT}`, `${name} in .project_config.toml is in a legacy/invalid format (must be a { provider = "...", model_id = "..." } paired reference): ${file}. ${OLD_FORMAT_HINT}`,
); );
} }
return { provider: ref.provider, model_id: ref.model_id }; return { provider: ref.provider, model_id: ref.model_id };
@@ -155,7 +155,7 @@ function assertModelEntry(file: string, entry: unknown): ModelEntry {
typeof m.model_id !== "string" typeof m.model_id !== "string"
) { ) {
throw new Error( throw new Error(
`.project_config.toml 的 models 条目是旧版本/非法格式(provider 与 model_id 须为两个独立字段):${file}。${OLD_FORMAT_HINT}`, `A models entry in .project_config.toml is in a legacy/invalid format (provider and model_id must be two separate fields): ${file}. ${OLD_FORMAT_HINT}`,
); );
} }
return entry as ModelEntry; return entry as ModelEntry;
@@ -359,7 +359,7 @@ export async function setDefaultModel(
const cfg = await loadProjectConfig(root, projectId); const cfg = await loadProjectConfig(root, projectId);
if (!getModel(cfg, ref)) { if (!getModel(cfg, ref)) {
throw new Error( throw new Error(
`default_model 必须指向已配置的模型:${formatModelRef(ref)} 不在 models 中。用 \`penguin config model list\` 查看已配置的模型。`, `default_model must point to a configured model: ${formatModelRef(ref)} is not in models. Use \`penguin config model list\` to see the configured models.`,
); );
} }
cfg.default_model = { provider: ref.provider, model_id: ref.model_id }; cfg.default_model = { provider: ref.provider, model_id: ref.model_id };
@@ -381,11 +381,13 @@ export async function setVisionModel(
const entry = getModel(cfg, ref); const entry = getModel(cfg, ref);
if (!entry) { if (!entry) {
throw new Error( throw new Error(
`vision_model 必须指向已配置的模型:${formatModelRef(ref)} 不在 models 中。用 \`penguin config model list\` 查看已配置的模型。`, `vision_model must point to a configured model: ${formatModelRef(ref)} is not in models. Use \`penguin config model list\` to see the configured models.`,
); );
} }
if (entry.vision === false) { if (entry.vision === false) {
throw new Error(`vision_model 不能指向标注为不支持图片的模型:${formatModelRef(ref)}。`); throw new Error(
`vision_model cannot point to a model tagged as not supporting images: ${formatModelRef(ref)}.`,
);
} }
cfg.vision_model = { provider: ref.provider, model_id: ref.model_id }; cfg.vision_model = { provider: ref.provider, model_id: ref.model_id };
await saveProjectConfig(root, projectId, cfg); await saveProjectConfig(root, projectId, cfg);
@@ -410,7 +412,7 @@ export function resolveModelRef(cfg: ProjectConfig, modelId: string, provider?:
const ref: ModelRef = { provider, model_id: modelId }; const ref: ModelRef = { provider, model_id: modelId };
if (!getModel(cfg, ref)) { if (!getModel(cfg, ref)) {
throw new Error( throw new Error(
`Model 不在 Project 配置中:${formatModelRef(ref)}。请用 \`penguin config model list\` 查看已配置的模型,或用 \`penguin config model add\` 添加。`, `Model is not in the Project config: ${formatModelRef(ref)}. Use \`penguin config model list\` to see the configured models, or \`penguin config model add\` to add one.`,
); );
} }
return ref; return ref;
@@ -421,12 +423,12 @@ export function resolveModelRef(cfg: ProjectConfig, modelId: string, provider?:
} }
if (candidates.length === 0) { if (candidates.length === 0) {
throw new Error( throw new Error(
`Model 不在 Project 配置中:没有 model_id 为 ${modelId} 的条目。请用 \`penguin config model list\` 查看已配置的模型,或用 \`penguin config model add\` 添加。`, `Model is not in the Project config: no entry has model_id ${modelId}. Use \`penguin config model list\` to see the configured models, or \`penguin config model add\` to add one.`,
); );
} }
throw new Error( throw new Error(
`模型引用有歧义:model_id ${modelId} 命中多个条目——${candidates `Ambiguous model reference: model_id ${modelId} matches multiple entries: ${candidates
.map((m) => formatModelRef({ provider: m.provider, model_id: m.model_id })) .map((m) => formatModelRef({ provider: m.provider, model_id: m.model_id }))
.join("、")}。请补充 provider 以给出成对引用。`, .join(", ")}. Specify provider to give a paired reference.`,
); );
} }
+1 -1
View File
@@ -165,7 +165,7 @@ describe("listInstalledSkills", () => {
}); });
}); });
describe("skillMetadataSection / assembleSystemPrompt 注入", () => { describe("skillMetadataSection / assembleSystemPrompt injection", () => {
it("renders one `- \\`name\\` — description` line per skill; empty input renders empty", () => { it("renders one `- \\`name\\` — description` line per skill; empty input renders empty", () => {
expect(skillMetadataSection([])).toBe(""); expect(skillMetadataSection([])).toBe("");
expect( expect(
+9 -7
View File
@@ -43,7 +43,7 @@ afterEach(async () => {
await fs.rm(tmpRoot, { recursive: true, force: true }); await fs.rm(tmpRoot, { recursive: true, force: true });
}); });
describe("effectiveMaxContextLength (压缩阈值按模型窗口钳制)", () => { describe("effectiveMaxContextLength (compaction threshold clamped to the model window)", () => {
it("clamps to 75% of a small model window; leaves big/unknown windows and off untouched", () => { it("clamps to 75% of a small model window; leaves big/unknown windows and off untouched", () => {
expect(effectiveMaxContextLength(128000, 32768)).toBe(24576); // small window: clamp to 75% expect(effectiveMaxContextLength(128000, 32768)).toBe(24576); // small window: clamp to 75%
expect(effectiveMaxContextLength(128000, 200000)).toBe(128000); // ample window: unchanged expect(effectiveMaxContextLength(128000, 200000)).toBe(128000); // ample window: unchanged
@@ -57,7 +57,7 @@ describe("Agent.createSession workspace handling", () => {
it("throws a clear error when the given workspace does not exist (no auto-create)", async () => { it("throws a clear error when the given workspace does not exist (no auto-create)", async () => {
const agent = await createAgent(); const agent = await createAgent();
const ws = path.join(tmpRoot, "nested", "does-not-exist"); const ws = path.join(tmpRoot, "nested", "does-not-exist");
await expect(agent.createSession({ workspaceDir: ws })).rejects.toThrow(/不存在/); await expect(agent.createSession({ workspaceDir: ws })).rejects.toThrow(/does not exist/);
// Must not be auto-created. // Must not be auto-created.
await expect(fs.stat(ws)).rejects.toThrow(); await expect(fs.stat(ws)).rejects.toThrow();
}); });
@@ -66,7 +66,9 @@ describe("Agent.createSession workspace handling", () => {
const agent = await createAgent(); const agent = await createAgent();
const filePath = path.join(tmpRoot, "a-file"); const filePath = path.join(tmpRoot, "a-file");
await fs.writeFile(filePath, "x", "utf8"); await fs.writeFile(filePath, "x", "utf8");
await expect(agent.createSession({ workspaceDir: filePath })).rejects.toThrow(/不是目录/); await expect(agent.createSession({ workspaceDir: filePath })).rejects.toThrow(
/is not a directory/,
);
}); });
it("accepts an existing directory and resolves it to an absolute path", async () => { it("accepts an existing directory and resolves it to an absolute path", async () => {
@@ -86,7 +88,7 @@ describe("Agent.createSession workspace handling", () => {
// model_id); the error is thrown before creating the temp Workspace. // model_id); the error is thrown before creating the temp Workspace.
await expect( await expect(
agent.createSession({ workspaceDir: ws, modelId: "not-configured-model" }), agent.createSession({ workspaceDir: ws, modelId: "not-configured-model" }),
).rejects.toThrow(/不在 Project 配置中/); ).rejects.toThrow(/is not in the Project config/);
await expect( await expect(
agent.createSession({ workspaceDir: ws, modelId: "deepseek-v4-pro", provider: "openai" }), agent.createSession({ workspaceDir: ws, modelId: "deepseek-v4-pro", provider: "openai" }),
).rejects.toThrow(/\(provider=openai, model_id=deepseek-v4-pro\)/); ).rejects.toThrow(/\(provider=openai, model_id=deepseek-v4-pro\)/);
@@ -108,7 +110,7 @@ describe("Agent.createSession workspace handling", () => {
}); });
}); });
describe("Agent.createSession model reference((provider, model_id) 成对)", () => { describe("Agent.createSession model reference ((provider, model_id) pair)", () => {
it("records the pair reference in session_meta (default_model when unspecified)", async () => { it("records the pair reference in session_meta (default_model when unspecified)", async () => {
const agent = await createAgent(); const agent = await createAgent();
const ws = path.join(tmpRoot, "ws-ref-default"); const ws = path.join(tmpRoot, "ws-ref-default");
@@ -164,7 +166,7 @@ describe("Agent.createSession model reference((provider, model_id) 成对)",
await fs.mkdir(ws, { recursive: true }); await fs.mkdir(ws, { recursive: true });
await expect( await expect(
agent.createSession({ workspaceDir: ws, modelId: "claude-sonnet-4-6" }), agent.createSession({ workspaceDir: ws, modelId: "claude-sonnet-4-6" }),
).rejects.toThrow(/歧义.*\(provider=anthropic, model_id=claude-sonnet-4-6\)/); ).rejects.toThrow(/Ambiguous.*\(provider=anthropic, model_id=claude-sonnet-4-6\)/);
// Adding provider resolves it. // Adding provider resolves it.
const session = await agent.createSession({ const session = await agent.createSession({
workspaceDir: ws, workspaceDir: ws,
@@ -178,7 +180,7 @@ describe("Agent.createSession model reference((provider, model_id) 成对)",
} }
// provider cannot be used alone (the reference must be a pair). // provider cannot be used alone (the reference must be a pair).
await expect(agent.createSession({ workspaceDir: ws, provider: "anthropic" })).rejects.toThrow( await expect(agent.createSession({ workspaceDir: ws, provider: "anthropic" })).rejects.toThrow(
/provider 不能单独使用/, /provider cannot be used alone/,
); );
}); });
}); });
+9 -9
View File
@@ -42,8 +42,8 @@ afterEach(async () => {
const skillMdPath = (agentId: string, skillName: string): string => const skillMdPath = (agentId: string, skillName: string): string =>
path.join(skillsDir(tmpRoot, DEFAULT_PROJECT_ID, agentId), skillName, "SKILL.md"); path.join(skillsDir(tmpRoot, DEFAULT_PROJECT_ID, agentId), skillName, "SKILL.md");
describe("Skill 安装策略", () => { describe("Skill installation policy", () => {
it("普通新建 Agent 不预装任何 Skill,AGENTS.md 为空文件(指导在模板 Suggested workflows)", async () => { it("a plain new Agent preinstalls no Skills and AGENTS.md is an empty file (guidance lives in the template's Suggested workflows)", async () => {
const state = await loadOrInitAgentState({ agentId: "some_agent" }); const state = await loadOrInitAgentState({ agentId: "some_agent" });
expect(await listInstalledSkills(tmpRoot, DEFAULT_PROJECT_ID, "some_agent")).toEqual([]); expect(await listInstalledSkills(tmpRoot, DEFAULT_PROJECT_ID, "some_agent")).toEqual([]);
@@ -58,7 +58,7 @@ describe("Skill 安装策略", () => {
expect(onDisk).toBe(""); expect(onDisk).toBe("");
}); });
it("无 preset 直建的 default_agent(如 CLI 首次运行)同样预装库内全部 Skill", async () => { it("a default_agent created directly without a preset (e.g. first CLI run) likewise preinstalls every Skill in the library", async () => {
await loadOrInitAgentState({ agentId: DEFAULT_AGENT_ID }); await loadOrInitAgentState({ agentId: DEFAULT_AGENT_ID });
const names = (await listInstalledSkills(tmpRoot, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID)).map( const names = (await listInstalledSkills(tmpRoot, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID)).map(
(s) => s.name, (s) => s.name,
@@ -68,7 +68,7 @@ describe("Skill 安装策略", () => {
}); });
describe("provisionProjectAgents", () => { describe("provisionProjectAgents", () => {
it("唯一内置 Agent default_agent:装库内全部 Skill、AGENTS.md 为空", async () => { it("the only built-in Agent default_agent: installs every Skill in the library, AGENTS.md is empty", async () => {
const ids = await provisionProjectAgents(); const ids = await provisionProjectAgents();
expect(ids).toEqual([DEFAULT_AGENT_ID]); expect(ids).toEqual([DEFAULT_AGENT_ID]);
expect(BUILTIN_AGENT_IDS).toEqual([DEFAULT_AGENT_ID]); expect(BUILTIN_AGENT_IDS).toEqual([DEFAULT_AGENT_ID]);
@@ -86,7 +86,7 @@ describe("provisionProjectAgents", () => {
expect(sdkMd).toBe(librarySkill("penguin-sdk")!.content); expect(sdkMd).toBe(librarySkill("penguin-sdk")!.content);
}); });
it("provision 幂等:重复执行不改变结果", async () => { it("provision is idempotent: running it again does not change the result", async () => {
await provisionProjectAgents(); await provisionProjectAgents();
const ids = await provisionProjectAgents(); const ids = await provisionProjectAgents();
expect(ids).toEqual([DEFAULT_AGENT_ID]); expect(ids).toEqual([DEFAULT_AGENT_ID]);
@@ -99,8 +99,8 @@ describe("provisionProjectAgents", () => {
expect(installed.map((s) => s.name).sort()).toEqual(loadLibrarySkills().map((s) => s.name)); expect(installed.map((s) => s.name).sort()).toEqual(loadLibrarySkills().map((s) => s.name));
}); });
it("已存在的 Agent 不被覆盖(preset 仅初始化生效)", async () => { it("an existing Agent is not overwritten (the preset only applies at initialization)", async () => {
const custom = "# AGENTS.md\n\n用户自己改过的内容\n"; const custom = "# AGENTS.md\n\nContent the user edited themselves\n";
await loadOrInitAgentState({ agentId: DEFAULT_AGENT_ID }); await loadOrInitAgentState({ agentId: DEFAULT_AGENT_ID });
await fs.writeFile(agentsMdPath(tmpRoot, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID), custom, "utf8"); await fs.writeFile(agentsMdPath(tmpRoot, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID), custom, "utf8");
@@ -114,8 +114,8 @@ describe("provisionProjectAgents", () => {
}); });
}); });
describe("Project Dir / Agent ID 占位符", () => { describe("Project Dir / Agent ID placeholders", () => {
it("assembleSystemPrompt 注入 Project Dir 与 Agent ID(Skill 定位改走项目相对路径,不依赖 .penguin)", async () => { it("assembleSystemPrompt injects Project Dir and Agent ID (Skill lookup uses project-relative paths, no .penguin dependency)", async () => {
const state = await loadOrInitAgentState({ agentId: "env_agent" }); const state = await loadOrInitAgentState({ agentId: "env_agent" });
const prompt = assembleSystemPrompt(state, { const prompt = assembleSystemPrompt(state, {
sessionId: "session-x", sessionId: "session-x",
+1 -1
View File
@@ -592,7 +592,7 @@ describe("context compaction", () => {
expect(llm1.calls).toHaveLength(1); expect(llm1.calls).toHaveLength(1);
}); });
it("compactability(): 逐一给出「不能压缩」的原因,而不是笼统的 false", async () => { it('compactability(): reports each specific "cannot compact" reason instead of a blanket false', async () => {
// compact() emits **zero messages** when there is nothing compactable. The caller (Web / CLI) // compact() emits **zero messages** when there is nothing compactable. The caller (Web / CLI)
// must be able to ask ahead of time, otherwise it can only wait forever for a compaction // must be able to ask ahead of time, otherwise it can only wait forever for a compaction
// banner that never comes -- exactly how "no response from /compact after interrupting on the // banner that never comes -- exactly how "no response from /compact after interrupting on the
+9 -9
View File
@@ -101,14 +101,14 @@ afterEach(async () => {
await rm(tmp, { recursive: true, force: true }); await rm(tmp, { recursive: true, force: true });
}); });
describe("describe_image(read_image 的纯文本模型版)", () => { describe("describe_image (the text-only-model variant of read_image)", () => {
it("定义原样取自配置条目(不做运行期改写)", () => { it("the definition is taken verbatim from the config entry (no runtime rewriting)", () => {
const tool = createDescribeImageTool(definition, { modelId: "vis-1" }); const tool = createDescribeImageTool(definition, { modelId: "vis-1" });
expect(tool.name).toBe(DESCRIBE_IMAGE_NAME); expect(tool.name).toBe(DESCRIBE_IMAGE_NAME);
expect(tool.definition).toBe(definition); expect(tool.definition).toBe(definition);
}); });
it("registry 按工具名装配;未注入 visionDescriber 时以 failed 说明收尾(而非回图片)", async () => { it("the registry assembles by tool name; without an injected visionDescriber it finishes as failed with a note (instead of returning the image)", async () => {
const factory = BUILTIN_TOOL_FACTORIES[DESCRIBE_IMAGE_NAME]!; const factory = BUILTIN_TOOL_FACTORIES[DESCRIBE_IMAGE_NAME]!;
await writeFile(path.join(tmp, "a.png"), PNG_1X1); await writeFile(path.join(tmp, "a.png"), PNG_1X1);
const describeTool = factory(definition, undefined); const describeTool = factory(definition, undefined);
@@ -127,7 +127,7 @@ describe("describe_image(read_image 的纯文本模型版)", () => {
expect(text).toContain("No vision model"); expect(text).toContain("No vision model");
}); });
it("图片 + 自定义 prompt 单发视觉模型,回其文本,结果不携带 images", async () => { it("image + custom prompt is sent to the vision model in a single shot, its text comes back, the result carries no images", async () => {
await writeFile(path.join(tmp, "a.png"), PNG_1X1); await writeFile(path.join(tmp, "a.png"), PNG_1X1);
const { llm, calls } = fakeLLM("图里是一只企鹅。"); const { llm, calls } = fakeLLM("图里是一只企鹅。");
const describer: VisionDescriberService = { modelId: "vis-1", createLLM: () => llm }; const describer: VisionDescriberService = { modelId: "vis-1", createLLM: () => llm };
@@ -159,7 +159,7 @@ describe("describe_image(read_image 的纯文本模型版)", () => {
expect(result?.stopReason).toBeUndefined(); // defaults to completed expect(result?.stopReason).toBeUndefined(); // defaults to completed
}); });
it("未给 prompt 时用默认问题", async () => { it("uses the default question when no prompt is given", async () => {
await writeFile(path.join(tmp, "a.png"), PNG_1X1); await writeFile(path.join(tmp, "a.png"), PNG_1X1);
const { llm, calls } = fakeLLM("desc"); const { llm, calls } = fakeLLM("desc");
await run({ source: "a.png" }, tmp, { modelId: "vis-1", createLLM: () => llm }); await run({ source: "a.png" }, tmp, { modelId: "vis-1", createLLM: () => llm });
@@ -167,7 +167,7 @@ describe("describe_image(read_image 的纯文本模型版)", () => {
expect(first.text).toContain("Describe this image"); expect(first.text).toContain("Describe this image");
}); });
it("未配置视觉模型:failed 并解释如何配置", async () => { it("no vision model configured: failed plus an explanation of how to configure one", async () => {
await writeFile(path.join(tmp, "a.png"), PNG_1X1); await writeFile(path.join(tmp, "a.png"), PNG_1X1);
const { result, text } = await run({ source: "a.png" }, tmp, { modelId: null }); const { result, text } = await run({ source: "a.png" }, tmp, { modelId: null });
expect(result?.stopReason).toBe("failed"); expect(result?.stopReason).toBe("failed");
@@ -175,7 +175,7 @@ describe("describe_image(read_image 的纯文本模型版)", () => {
expect(text).toContain("vision_model"); expect(text).toContain("vision_model");
}); });
it("视觉模型请求失败:failed 并带状态与消息", async () => { it("vision model request failure: failed with the status and message", async () => {
await writeFile(path.join(tmp, "a.png"), PNG_1X1); await writeFile(path.join(tmp, "a.png"), PNG_1X1);
const { llm } = fakeLLM("", { status: "failed", message: "401 unauthorized" }); const { llm } = fakeLLM("", { status: "failed", message: "401 unauthorized" });
const { result, text } = await run({ source: "a.png" }, tmp, { const { result, text } = await run({ source: "a.png" }, tmp, {
@@ -187,7 +187,7 @@ describe("describe_image(read_image 的纯文本模型版)", () => {
expect(text).toContain("401 unauthorized"); expect(text).toContain("401 unauthorized");
}); });
it("图片校验复用 read_image:不支持的类型直接 failed,不请求视觉模型", async () => { it("image validation reuses read_image: an unsupported type fails immediately without calling the vision model", async () => {
await writeFile(path.join(tmp, "a.txt"), "not an image"); await writeFile(path.join(tmp, "a.txt"), "not an image");
const { llm, calls } = fakeLLM("desc"); const { llm, calls } = fakeLLM("desc");
const { result } = await run({ source: "a.txt" }, tmp, { const { result } = await run({ source: "a.txt" }, tmp, {
@@ -198,7 +198,7 @@ describe("describe_image(read_image 的纯文本模型版)", () => {
expect(calls).toHaveLength(0); expect(calls).toHaveLength(0);
}); });
it("Environment 装配 describe_image 条目走代读实现,定义与配置一致", async () => { it("Environment assembles the describe_image entry with the delegated-description implementation; definition matches the config", async () => {
await writeFile(path.join(tmp, "a.png"), PNG_1X1); await writeFile(path.join(tmp, "a.png"), PNG_1X1);
const { llm } = fakeLLM("代读结果"); const { llm } = fakeLLM("代读结果");
const env = new Environment({ const env = new Environment({
+1 -1
View File
@@ -817,7 +817,7 @@ describe("Environment structure invariant on tool throw (PRN-012)", () => {
}); });
}); });
describe("Environment abort handling (打断 → aborted, PRN-012)", () => { describe("Environment abort handling (interrupt -> aborted, PRN-012)", () => {
it("labels a thrown error as aborted (not failed) when the signal is aborted", async () => { it("labels a thrown error as aborted (not failed) when the signal is aborted", async () => {
const NAME = "__abort_throw_tool__"; const NAME = "__abort_throw_tool__";
BUILTIN_TOOL_FACTORIES[NAME] = (definition) => ({ BUILTIN_TOOL_FACTORIES[NAME] = (definition) => ({
+7 -7
View File
@@ -29,10 +29,10 @@ afterEach(async () => {
}); });
describe("imagesToScratchpadPaths", () => { describe("imagesToScratchpadPaths", () => {
it("data URL 图片落盘、路径拼接进用户文本,图片消息移除", async () => { it("data URL images are saved to disk, paths appended to the user text, image messages removed", async () => {
const dir = path.join(tmp, "scratch", "session-1"); // auto-created if the directory doesn't exist const dir = path.join(tmp, "scratch", "session-1"); // auto-created if the directory doesn't exist
const out = await imagesToScratchpadPaths( const out = await imagesToScratchpadPaths(
[userText("看看这两张图"), imageUrlMessage(DATA_URL), imageUrlMessage(DATA_URL)], [userText("Look at these two images"), imageUrlMessage(DATA_URL), imageUrlMessage(DATA_URL)],
dir, dir,
); );
@@ -40,7 +40,7 @@ describe("imagesToScratchpadPaths", () => {
const p = out[0]!.payload as TextPayload; const p = out[0]!.payload as TextPayload;
expect(p.type).toBe("text"); expect(p.type).toBe("text");
expect(p.role).toBe("user"); expect(p.role).toBe("user");
expect(p.text.startsWith("看看这两张图\n\n")).toBe(true); expect(p.text.startsWith("Look at these two images\n\n")).toBe(true);
const paths = [...p.text.matchAll(/\[attached image: ([^\]]+)\]/g)].map((m) => m[1]!); const paths = [...p.text.matchAll(/\[attached image: ([^\]]+)\]/g)].map((m) => m[1]!);
expect(paths).toHaveLength(2); expect(paths).toHaveLength(2);
@@ -56,7 +56,7 @@ describe("imagesToScratchpadPaths", () => {
expect(new Set(paths).size).toBe(2); expect(new Set(paths).size).toBe(2);
}); });
it("http(s) URL 不落盘,原样引用;仅图片输入时补一条纯路径文本", async () => { it("http(s) URLs are not saved but referenced as-is; image-only input gets a paths-only text message", async () => {
const out = await imagesToScratchpadPaths([imageUrlMessage("https://example.com/a.png")], tmp); const out = await imagesToScratchpadPaths([imageUrlMessage("https://example.com/a.png")], tmp);
expect(out).toHaveLength(1); expect(out).toHaveLength(1);
const p = out[0]!.payload as TextPayload; const p = out[0]!.payload as TextPayload;
@@ -65,13 +65,13 @@ describe("imagesToScratchpadPaths", () => {
expect(await readdir(tmp)).toHaveLength(0); expect(await readdir(tmp)).toHaveLength(0);
}); });
it("无图片输入原样返回(不触碰文件系统)", async () => { it("input without images is returned as-is (never touches the filesystem)", async () => {
const input = [userText("纯文本")]; const input = [userText("plain text")];
const out = await imagesToScratchpadPaths(input, path.join(tmp, "untouched")); const out = await imagesToScratchpadPaths(input, path.join(tmp, "untouched"));
expect(out).toBe(input); expect(out).toBe(input);
}); });
it("无法解析的图片以说明行代替,不静默丢失", async () => { it("an unparsable image is replaced with an explanatory line, never silently dropped", async () => {
const out = await imagesToScratchpadPaths( const out = await imagesToScratchpadPaths(
[userText("hi"), imageUrlMessage("data:text/plain,oops")], [userText("hi"), imageUrlMessage("data:text/plain,oops")],
tmp, tmp,
+2 -2
View File
@@ -77,9 +77,9 @@ describe(`GenerativeModel live e2e (${provider?.modelId ?? "skipped"})`, () => {
const runGemini = process.env.PENGUIN_E2E === "1" && process.env.GEMINI_API_KEY !== undefined; const runGemini = process.env.PENGUIN_E2E === "1" && process.env.GEMINI_API_KEY !== undefined;
const maybeGemini = runGemini ? it : it.skip; const maybeGemini = runGemini ? it : it.skip;
describe(`GenerativeModel live e2e (gemini-3.5-flash: tool_call_id 唯一化${runGemini ? "" : ",skipped"})`, () => { describe(`GenerativeModel live e2e (gemini-3.5-flash: tool_call_id uniquification${runGemini ? "" : ", skipped"})`, () => {
maybeGemini( maybeGemini(
"连续两轮同名工具调用拿到不同 tool_call_id;带后缀 id 的 tool_result 回传被正确配对", "same-name tool calls in two consecutive rounds get distinct tool_call_ids; a tool_result sent back with a suffixed id is paired correctly",
async () => { async () => {
const model = new GenerativeModel({ const model = new GenerativeModel({
modelId: "gemini-3.5-flash", modelId: "gemini-3.5-flash",
+11 -11
View File
@@ -873,7 +873,7 @@ describe("translateEvents", () => {
}); });
}); });
describe("EventTranslator.finishInterrupted (PRN-012 结构闭合)", () => { describe("EventTranslator.finishInterrupted (PRN-012 structural closure)", () => {
it("closes an open text segment with a stop + complete text marked with the interruption reason, and emits no token_usage", () => { it("closes an open text segment with a stop + complete text marked with the interruption reason, and emits no token_usage", () => {
const tr = new EventTranslator(); const tr = new EventTranslator();
const out: OmniMessage[] = []; const out: OmniMessage[] = [];
@@ -1196,7 +1196,7 @@ describe("GenerativeModel.streamGenerate outcome classification (PRN-013)", () =
expect(messages).toHaveLength(0); expect(messages).toHaveLength(0);
}); });
it("中断落在消费者挂起于 yield 期间:立即以 aborted 收尾,绝不再去拉已被 abort 的上游", async () => { it("interrupt lands while the consumer is suspended at yield: finish immediately as aborted, never pull the already-aborted upstream again", async () => {
// This is exactly the cause of "the session hangs forever after interrupting it in the // This is exactly the cause of "the session hangs forever after interrupting it in the
// browser": when the user interrupts, this generator is usually suspended at `yield` // browser": when the user interrupts, this generator is usually suspended at `yield`
// (the engine is blocked on `await approve(tc)` waiting for manual approval). onUserAbort // (the engine is blocked on `await approve(tc)` waiting for manual approval). onUserAbort
@@ -1462,13 +1462,13 @@ describe("flushText signature parity (PR #39 review)", () => {
}); });
}); });
describe("tool_call_id 唯一化(name-as-id provider,如 Gemini 以函数名当 id)", () => { describe("tool_call_id uniquification (name-as-id providers, e.g. Gemini uses the function name as the id)", () => {
const callIdsOf = (messages: OmniMessage[]): string[] => const callIdsOf = (messages: OmniMessage[]): string[] =>
messages messages
.filter((m) => (m.payload as { type: string }).type === "tool_call") .filter((m) => (m.payload as { type: string }).type === "tool_call")
.map((m) => (m.payload as ToolCallPayload).tool_call_id); .map((m) => (m.payload as ToolCallPayload).tool_call_id);
it("同一 Request 内重复 id 的第二个完整 tool_call 不被丢弃,且分到 #2 后缀", () => { it("the second complete tool_call with a duplicate id within one Request is not dropped and gets the #2 suffix", () => {
const { messages } = translateEvents([ const { messages } = translateEvents([
ev({ ev({
event_type: "stop", event_type: "stop",
@@ -1498,7 +1498,7 @@ describe("tool_call_id 唯一化(name-as-id provider,如 Gemini 以函数名
expect(calls.every((c) => c.stop_reason === "completed")).toBe(true); expect(calls.every((c) => c.stop_reason === "completed")).toBe(true);
}); });
it("不同 id 的并行调用不受影响(原样透传,无后缀)", () => { it("parallel calls with distinct ids are unaffected (passed through as-is, no suffix)", () => {
const { messages } = translateEvents([ const { messages } = translateEvents([
ev({ ev({
event_type: "stop", event_type: "stop",
@@ -1513,7 +1513,7 @@ describe("tool_call_id 唯一化(name-as-id provider,如 Gemini 以函数名
expect(callIdsOf(messages)).toEqual(["get_time", "get_weather", "get_time#2"]); expect(callIdsOf(messages)).toEqual(["get_time", "get_weather", "get_time#2"]);
}); });
it("跨 Request 共享登记表:下一轮同名调用分到新后缀(前端工具卡不再互相覆盖)", () => { it("the registry is shared across Requests: a same-name call in the next round gets a new suffix (frontend tool cards no longer overwrite each other)", () => {
const ids = new ToolCallIdAllocator(); const ids = new ToolCallIdAllocator();
const round = (city: string): string[] => { const round = (city: string): string[] => {
const translator = new EventTranslator(ids); const translator = new EventTranslator(ids);
@@ -1534,7 +1534,7 @@ describe("tool_call_id 唯一化(name-as-id provider,如 Gemini 以函数名
expect(round("NYC")).toEqual(["get_time#3"]); expect(round("NYC")).toEqual(["get_time#3"]);
}); });
it("跨 Request 撞车时,partial 片段与完整消息用同一个带后缀 id", () => { it("on a cross-Request collision, partial fragments and the complete message use the same suffixed id", () => {
const ids = new ToolCallIdAllocator(); const ids = new ToolCallIdAllocator();
ids.markUsed("exec"); // this provider id was already taken in the previous turn ids.markUsed("exec"); // this provider id was already taken in the previous turn
const translator = new EventTranslator(ids); const translator = new EventTranslator(ids);
@@ -1576,7 +1576,7 @@ describe("tool_call_id 唯一化(name-as-id provider,如 Gemini 以函数名
expect(callIdsOf(out)).toEqual(["exec#2"]); expect(callIdsOf(out)).toEqual(["exec#2"]);
}); });
it("出站还原:tool_call / tool_call_output 的 #n 后缀发往 provider 前剥掉,无后缀原样", () => { it("outbound restoration: the #n suffix on tool_call / tool_call_output is stripped before sending to the provider; unsuffixed ids pass as-is", () => {
const result = mergeOmniToUniMessage([ const result = mergeOmniToUniMessage([
toolCallOutput({ output: "10:00", toolCallId: "get_time#2" }), toolCallOutput({ output: "10:00", toolCallId: "get_time#2" }),
]); ]);
@@ -1595,7 +1595,7 @@ describe("tool_call_id 唯一化(name-as-id provider,如 Gemini 以函数名
); );
}); });
it("stripToolCallIdSuffix 只剥结尾的 #数字(幂等,不误伤中缀)", () => { it("stripToolCallIdSuffix only strips a trailing #digits (idempotent, never touches an infix)", () => {
expect(stripToolCallIdSuffix("get_time#2")).toBe("get_time"); expect(stripToolCallIdSuffix("get_time#2")).toBe("get_time");
expect(stripToolCallIdSuffix("get_time#12")).toBe("get_time"); expect(stripToolCallIdSuffix("get_time#12")).toBe("get_time");
expect(stripToolCallIdSuffix("get_time")).toBe("get_time"); expect(stripToolCallIdSuffix("get_time")).toBe("get_time");
@@ -1603,7 +1603,7 @@ describe("tool_call_id 唯一化(name-as-id provider,如 Gemini 以函数名
expect(stripToolCallIdSuffix("a#x")).toBe("a#x"); expect(stripToolCallIdSuffix("a#x")).toBe("a#x");
}); });
it("ToolCallIdAllocator:探测跳过已占用后缀;markUsed 播种生效", () => { it("ToolCallIdAllocator: probing skips occupied suffixes; markUsed seeding takes effect", () => {
const ids = new ToolCallIdAllocator(); const ids = new ToolCallIdAllocator();
ids.markUsed("t"); ids.markUsed("t");
ids.markUsed("t#2"); ids.markUsed("t#2");
@@ -1612,7 +1612,7 @@ describe("tool_call_id 唯一化(name-as-id provider,如 Gemini 以函数名
expect(ids.allocate("u")).toBe("u#2"); expect(ids.allocate("u")).toBe("u#2");
}); });
it("恢复播种:setHistory 后新的同名调用不与历史撞 id", async () => { it("resume seeding: after setHistory, new same-name calls do not collide with historical ids", async () => {
class SeedModel extends GenerativeModel { class SeedModel extends GenerativeModel {
constructor() { constructor() {
super({ modelId: "claude-sonnet-4-6", tools: [] }); super({ modelId: "claude-sonnet-4-6", tools: [] });
+13 -13
View File
@@ -14,7 +14,7 @@ import {
} from "../src/state/index.js"; } from "../src/state/index.js";
describe("model-catalog", () => { describe("model-catalog", () => {
it("model id 全局唯一;DeepSeek 排在最前(默认模型所属厂商)", () => { it("model ids are globally unique; DeepSeek comes first (the default model's provider)", () => {
const ids = MODEL_CATALOG.map((m) => m.modelId); const ids = MODEL_CATALOG.map((m) => m.modelId);
expect(new Set(ids).size).toBe(ids.length); expect(new Set(ids).size).toBe(ids.length);
expect(MODEL_CATALOG[0]!.provider).toBe("deepseek"); expect(MODEL_CATALOG[0]!.provider).toBe("deepseek");
@@ -36,7 +36,7 @@ describe("model-catalog", () => {
expect(ids).not.toContain("glm-5-turbo"); expect(ids).not.toContain("glm-5-turbo");
}); });
it("provider 均在 MODEL_PROVIDERS 内(custom 仅收拢自定义模型,目录不使用)", () => { it("every provider is in MODEL_PROVIDERS (custom only groups user-defined models; the catalog never uses it)", () => {
const providerIds = new Set(MODEL_PROVIDERS.map((p) => p.id)); const providerIds = new Set(MODEL_PROVIDERS.map((p) => p.id));
for (const m of MODEL_CATALOG) { for (const m of MODEL_CATALOG) {
expect(providerIds.has(m.provider)).toBe(true); expect(providerIds.has(m.provider)).toBe(true);
@@ -61,7 +61,7 @@ describe("model-catalog", () => {
} }
}); });
it("三桶价格均为正数,context_window 为正整数", () => { it("all three price buckets are positive; context_window is a positive integer", () => {
for (const m of MODEL_CATALOG) { for (const m of MODEL_CATALOG) {
expect(m.pricing, m.modelId).toBeDefined(); expect(m.pricing, m.modelId).toBeDefined();
expect(m.pricing!.unit).toBe("usd_per_mtok"); expect(m.pricing!.unit).toBe("usd_per_mtok");
@@ -73,12 +73,12 @@ describe("model-catalog", () => {
} }
}); });
it("providerInfo 按 id 命中,未知返回 undefined", () => { it("providerInfo matches by id; unknown ids return undefined", () => {
expect(providerInfo("moonshot")?.envKey).toBe("MOONSHOT_API_KEY"); expect(providerInfo("moonshot")?.envKey).toBe("MOONSHOT_API_KEY");
expect(providerInfo("nonexistent")).toBeUndefined(); expect(providerInfo("nonexistent")).toBeUndefined();
}); });
it("成对匹配与分组推断:catalogEntryFor / inferProviderForUpstream", () => { it("pair matching and provider-grouping inference: catalogEntryFor / inferProviderForUpstream", () => {
// catalogEntryFor is the sole catalog lookup entry point: it matches on (group, upstream id) // catalogEntryFor is the sole catalog lookup entry point: it matches on (group, upstream id)
// pairs, so an identically named upstream id never matches across the wrong group. // pairs, so an identically named upstream id never matches across the wrong group.
expect(catalogEntryFor("anthropic", "claude-sonnet-4-6")?.displayName).toBe( expect(catalogEntryFor("anthropic", "claude-sonnet-4-6")?.displayName).toBe(
@@ -95,7 +95,7 @@ describe("model-catalog", () => {
expect(inferProviderForUpstream("my-own-model")).toBe("custom"); expect(inferProviderForUpstream("my-own-model")).toBe("custom");
}); });
it("presetModelEntries:provider 与纯上游 model_id 分列;网关模型内联 base_url", () => { it("presetModelEntries: provider and bare upstream model_id are separate fields; gateway models inline base_url", () => {
const entries = presetModelEntries(); const entries = presetModelEntries();
expect(entries).toHaveLength(MODEL_CATALOG.length); expect(entries).toHaveLength(MODEL_CATALOG.length);
for (const [i, entry] of entries.entries()) { for (const [i, entry] of entries.entries()) {
@@ -115,7 +115,7 @@ describe("model-catalog", () => {
} }
}); });
it("网关模型(OpenRouter / SiliconFlow):openai 协议 + 预置 base URL;env 兜底为 OPENAI_API_KEY", () => { it("gateway models (OpenRouter / SiliconFlow): openai protocol + preset base URL; env fallback is OPENAI_API_KEY", () => {
const or = MODEL_CATALOG.filter((m) => m.provider === "openrouter"); const or = MODEL_CATALOG.filter((m) => m.provider === "openrouter");
expect(or.map((m) => m.modelId)).toEqual([ expect(or.map((m) => m.modelId)).toEqual([
"xiaomi/mimo-v2.5", "xiaomi/mimo-v2.5",
@@ -164,7 +164,7 @@ describe("model-catalog", () => {
); );
}); });
it("DeepSeek 与 Kimi 按官方人民币价初始化(存储美元,×7 还原官方原价)", () => { it("DeepSeek and Kimi are initialized from official CNY prices (stored in USD; x7 recovers the official price)", () => {
const cnyOf = (usdV: number) => Math.round(usdV * 7 * 1000) / 1000; const cnyOf = (usdV: number) => Math.round(usdV * 7 * 1000) / 1000;
const pro = MODEL_CATALOG.find((m) => m.modelId === "deepseek-v4-pro")!.pricing!; const pro = MODEL_CATALOG.find((m) => m.modelId === "deepseek-v4-pro")!.pricing!;
expect([cnyOf(pro.cache_read), cnyOf(pro.cache_write), cnyOf(pro.output)]).toEqual([ expect([cnyOf(pro.cache_read), cnyOf(pro.cache_write), cnyOf(pro.output)]).toEqual([
@@ -177,8 +177,8 @@ describe("model-catalog", () => {
}); });
}); });
describe("resolveModelEnv(PRN-021:env 兜底按 AgentHub 路由规则解析)", () => { describe("resolveModelEnv (PRN-021: env fallback resolved by AgentHub routing rules)", () => {
it("一方厂商 id 按子串路由到厂商客户端的环境变量", () => { it("first-party provider ids route by substring to the provider client's env var", () => {
expect(resolveModelEnv("deepseek-v4-pro")?.envKey).toBe("DEEPSEEK_API_KEY"); expect(resolveModelEnv("deepseek-v4-pro")?.envKey).toBe("DEEPSEEK_API_KEY");
expect(resolveModelEnv("claude-opus-4-8")?.envKey).toBe("ANTHROPIC_API_KEY"); expect(resolveModelEnv("claude-opus-4-8")?.envKey).toBe("ANTHROPIC_API_KEY");
expect(resolveModelEnv("claude-sonnet-4-6")?.envKey).toBe("ANTHROPIC_API_KEY"); expect(resolveModelEnv("claude-sonnet-4-6")?.envKey).toBe("ANTHROPIC_API_KEY");
@@ -188,17 +188,17 @@ describe("resolveModelEnv(PRN-021:env 兜底按 AgentHub 路由规则解析
expect(resolveModelEnv("kimi-k2.6")?.envBaseUrlKey).toBe("MOONSHOT_BASE_URL"); expect(resolveModelEnv("kimi-k2.6")?.envBaseUrlKey).toBe("MOONSHOT_BASE_URL");
}); });
it("显式 client_type 优先于 id:openai 协议一律 OPENAI_*(与分组无关)", () => { it("explicit client_type beats id: the openai protocol always uses OPENAI_* (independent of grouping)", () => {
expect(resolveModelEnv("deepseek-v4-pro", "openai")?.envKey).toBe("OPENAI_API_KEY"); expect(resolveModelEnv("deepseek-v4-pro", "openai")?.envKey).toBe("OPENAI_API_KEY");
expect(resolveModelEnv("zai-org/GLM-5.2", "openai")?.envKey).toBe("OPENAI_API_KEY"); expect(resolveModelEnv("zai-org/GLM-5.2", "openai")?.envKey).toBe("OPENAI_API_KEY");
}); });
it("无法路由的 id 返回 undefined(AgentHub 会拒绝,需显式 client_type 或 OpenAI 协议分组)", () => { it("unroutable ids return undefined (AgentHub would reject; needs explicit client_type or an OpenAI-protocol grouping)", () => {
expect(resolveModelEnv("totally-unknown-model")).toBeUndefined(); expect(resolveModelEnv("totally-unknown-model")).toBeUndefined();
expect(resolveModelEnv("xiaomi/mimo-v2.5")).toBeUndefined(); expect(resolveModelEnv("xiaomi/mimo-v2.5")).toBeUndefined();
}); });
it("目录不变式:无 client_type 的条目按 id 可路由且 envKey 与厂商一致;网关条目经 client_type 解析为 OPENAI_*", () => { it("catalog invariant: entries without client_type route by id with envKey matching the provider; gateway entries resolve to OPENAI_* via client_type", () => {
for (const m of MODEL_CATALOG) { for (const m of MODEL_CATALOG) {
const env = resolveModelEnv(m.modelId, m.clientType); const env = resolveModelEnv(m.modelId, m.clientType);
expect(env, `${m.provider}/${m.modelId}`).toBeDefined(); expect(env, `${m.provider}/${m.modelId}`).toBeDefined();
+1 -1
View File
@@ -161,7 +161,7 @@ describe("PartialAggregator", () => {
expect(p.arguments).toBe('{"cmd":"ls -la"}'); expect(p.arguments).toBe('{"cmd":"ls -la"}');
}); });
it("folds tool output image deltas into the complete tool_call_output (拼接 == 完整)", () => { it("folds tool output image deltas into the complete tool_call_output (concatenated == complete)", () => {
const dataUrl = "data:image/png;base64,AAAA"; const dataUrl = "data:image/png;base64,AAAA";
const out = aggregateAll([ const out = aggregateAll([
partialToolCallOutput({ eventType: "start", toolCallId: "c9" }), partialToolCallOutput({ eventType: "start", toolCallId: "c9" }),
+11 -11
View File
@@ -61,8 +61,8 @@ afterEach(async () => {
await rm(tmp, { recursive: true, force: true }); await rm(tmp, { recursive: true, force: true });
}); });
describe("read_image — 本地文件", () => { describe("read_image — local files", () => {
it("按相对路径读取 png,输出 data URL 与一行 mime/大小说明", async () => { it("reads a png by relative path, outputs a data URL plus a one-line mime/size note", async () => {
await writeFile(path.join(tmp, "img.png"), PNG_1X1); await writeFile(path.join(tmp, "img.png"), PNG_1X1);
const { result, text } = await run({ source: "img.png" }, tmp); const { result, text } = await run({ source: "img.png" }, tmp);
expect(result?.stopReason).toBeUndefined(); // Defaults to completed expect(result?.stopReason).toBeUndefined(); // Defaults to completed
@@ -70,20 +70,20 @@ describe("read_image — 本地文件", () => {
expect(text).toBe(`image/png, ${PNG_1X1.length} B`); expect(text).toBe(`image/png, ${PNG_1X1.length} B`);
}); });
it("扩展名与内容不符时以魔数嗅探为准", async () => { it("magic-number sniffing wins when the extension disagrees with the content", async () => {
await writeFile(path.join(tmp, "photo.jpg"), PNG_1X1); // Content is actually PNG await writeFile(path.join(tmp, "photo.jpg"), PNG_1X1); // Content is actually PNG
const { result } = await run({ source: "photo.jpg" }, tmp); const { result } = await run({ source: "photo.jpg" }, tmp);
expect(result?.images?.[0]).toMatch(/^data:image\/png;base64,/); expect(result?.images?.[0]).toMatch(/^data:image\/png;base64,/);
}); });
it("文件不存在时以 failed 收尾并输出解释", async () => { it("finishes as failed with an explanation when the file does not exist", async () => {
const { result, text } = await run({ source: "missing.png" }, tmp); const { result, text } = await run({ source: "missing.png" }, tmp);
expect(result?.stopReason).toBe("failed"); expect(result?.stopReason).toBe("failed");
expect(result?.images).toBeUndefined(); expect(result?.images).toBeUndefined();
expect(text).toContain("missing.png"); expect(text).toContain("missing.png");
}); });
it("超过大小上限时以 failed 收尾", async () => { it("finishes as failed when the size limit is exceeded", async () => {
const big = Buffer.alloc(MAX_IMAGE_BYTES + 1); const big = Buffer.alloc(MAX_IMAGE_BYTES + 1);
PNG_1X1.copy(big); // Header carries the PNG magic number, ensuring the failure is due to size, not type PNG_1X1.copy(big); // Header carries the PNG magic number, ensuring the failure is due to size, not type
await writeFile(path.join(tmp, "big.png"), big); await writeFile(path.join(tmp, "big.png"), big);
@@ -92,14 +92,14 @@ describe("read_image — 本地文件", () => {
expect(text).toContain("too large"); expect(text).toContain("too large");
}); });
it("不支持的图片类型以 failed 收尾", async () => { it("finishes as failed for an unsupported image type", async () => {
await writeFile(path.join(tmp, "img.bmp"), Buffer.from("BM not really an image")); await writeFile(path.join(tmp, "img.bmp"), Buffer.from("BM not really an image"));
const { result, text } = await run({ source: "img.bmp" }, tmp); const { result, text } = await run({ source: "img.bmp" }, tmp);
expect(result?.stopReason).toBe("failed"); expect(result?.stopReason).toBe("failed");
expect(text).toContain("Unsupported image type"); expect(text).toContain("Unsupported image type");
}); });
it("source 指向目录时以 failed 收尾并明确说明(不透传 EISDIR)", async () => { it("finishes as failed with a clear note when source points to a directory (no raw EISDIR passthrough)", async () => {
await mkdir(path.join(tmp, "subdir")); await mkdir(path.join(tmp, "subdir"));
const { result, text } = await run({ source: "subdir" }, tmp); const { result, text } = await run({ source: "subdir" }, tmp);
expect(result?.stopReason).toBe("failed"); expect(result?.stopReason).toBe("failed");
@@ -107,14 +107,14 @@ describe("read_image — 本地文件", () => {
expect(text).not.toContain("EISDIR"); expect(text).not.toContain("EISDIR");
}); });
it("空文件以 failed 收尾(扩展名兜底不得放行空 base64)", async () => { it("finishes as failed for an empty file (the extension fallback must not let empty base64 through)", async () => {
await writeFile(path.join(tmp, "empty.png"), Buffer.alloc(0)); await writeFile(path.join(tmp, "empty.png"), Buffer.alloc(0));
const { result, text } = await run({ source: "empty.png" }, tmp); const { result, text } = await run({ source: "empty.png" }, tmp);
expect(result?.stopReason).toBe("failed"); expect(result?.stopReason).toBe("failed");
expect(text).toContain("empty"); expect(text).toContain("empty");
}); });
it("缺少 source 参数以 failed 收尾", async () => { it("finishes as failed when the source argument is missing", async () => {
const { result, text } = await run({}, tmp); const { result, text } = await run({}, tmp);
expect(result?.stopReason).toBe("failed"); expect(result?.stopReason).toBe("failed");
expect(text).toContain('"source"'); expect(text).toContain('"source"');
@@ -122,7 +122,7 @@ describe("read_image — 本地文件", () => {
}); });
describe("read_image — http(s) URL", () => { describe("read_image — http(s) URL", () => {
it("经全局 fetch 下载并取响应头 content-type 作为 mime", async () => { it("downloads via global fetch and takes the content-type response header as the mime", async () => {
const fetchMock = vi.fn( const fetchMock = vi.fn(
async (_input: unknown) => async (_input: unknown) =>
new Response(PNG_1X1, { status: 200, headers: { "content-type": "image/png" } }), new Response(PNG_1X1, { status: 200, headers: { "content-type": "image/png" } }),
@@ -135,7 +135,7 @@ describe("read_image — http(s) URL", () => {
expect(text).toContain("image/png"); expect(text).toContain("image/png");
}); });
it("非 2xx 响应以 failed 收尾并带状态码", async () => { it("finishes as failed with the status code on a non-2xx response", async () => {
vi.stubGlobal( vi.stubGlobal(
"fetch", "fetch",
vi.fn(async () => new Response("nope", { status: 404 })), vi.fn(async () => new Response("nope", { status: 404 })),
+8 -4
View File
@@ -188,7 +188,7 @@ describe("agent.resumeSession", () => {
it("errors when the session does not exist", async () => { it("errors when the session does not exist", async () => {
const agent = await createAgent({}); const agent = await createAgent({});
await expect(agent.resumeSession({ sessionId: "session-none" })).rejects.toThrow( await expect(agent.resumeSession({ sessionId: "session-none" })).rejects.toThrow(
/Session 不存在/, /Session does not exist/,
); );
}); });
@@ -196,7 +196,9 @@ describe("agent.resumeSession", () => {
const agent = await createAgent({}); const agent = await createAgent({});
const gone = path.join(workspace, "gone"); const gone = path.join(workspace, "gone");
await writeTraceFile(tmpRoot, SID, [metaFor(SID, gone), userText("x")]); await writeTraceFile(tmpRoot, SID, [metaFor(SID, gone), userText("x")]);
await expect(agent.resumeSession({ sessionId: SID })).rejects.toThrow(/Workspace 已不存在/); await expect(agent.resumeSession({ sessionId: SID })).rejects.toThrow(
/Workspace no longer exists/,
);
}); });
it("errors when the recorded model is no longer in the project config", async () => { it("errors when the recorded model is no longer in the project config", async () => {
@@ -205,7 +207,9 @@ describe("agent.resumeSession", () => {
metaFor(SID, workspace, { provider: "custom", model_id: "vanished-model" }), metaFor(SID, workspace, { provider: "custom", model_id: "vanished-model" }),
userText("x"), userText("x"),
]); ]);
await expect(agent.resumeSession({ sessionId: SID })).rejects.toThrow(/不在 Project 配置/); await expect(agent.resumeSession({ sessionId: SID })).rejects.toThrow(
/is not in the Project config/,
);
}); });
it("errors clearly when session_meta lacks provider (old-format trace, no migration)", async () => { it("errors clearly when session_meta lacks provider (old-format trace, no migration)", async () => {
@@ -214,7 +218,7 @@ describe("agent.resumeSession", () => {
const legacy = metaFor(SID, workspace); const legacy = metaFor(SID, workspace);
delete (legacy.payload as { provider?: string }).provider; delete (legacy.payload as { provider?: string }).provider;
await writeTraceFile(tmpRoot, SID, [legacy, userText("x")]); await writeTraceFile(tmpRoot, SID, [legacy, userText("x")]);
await expect(agent.resumeSession({ sessionId: SID })).rejects.toThrow(/旧版本/); await expect(agent.resumeSession({ sessionId: SID })).rejects.toThrow(/legacy data/);
}); });
it("latestSessionId returns the newest session by embedded timestamp", async () => { it("latestSessionId returns the newest session by embedded timestamp", async () => {
+15 -15
View File
@@ -60,7 +60,7 @@ const META: SessionMetaPayload = {
}; };
describe("session-title", () => { describe("session-title", () => {
it("generateTitleWithLLM:收集模型 text 与用量,清洗后返回", async () => { it("generateTitleWithLLM: collects model text and usage, returns the sanitized result", async () => {
const seen: string[] = []; const seen: string[] = [];
const result = await generateTitleWithLLM( const result = await generateTitleWithLLM(
fakeLLM( fakeLLM(
@@ -80,7 +80,7 @@ describe("session-title", () => {
expect(seen[0]).toContain("SAME language"); expect(seen[0]).toContain("SAME language");
}); });
it("素材为空不发请求;outcome 非 completed 时 title 为 null(usage 保留)", async () => { it("sends no request when material is empty; title is null when the outcome is not completed (usage kept)", async () => {
const seen: string[] = []; const seen: string[] = [];
const empty = await generateTitleWithLLM(fakeLLM([], { status: "completed" }, seen), { const empty = await generateTitleWithLLM(fakeLLM([], { status: "completed" }, seen), {
userText: " ", userText: " ",
@@ -92,7 +92,7 @@ describe("session-title", () => {
const failed = await generateTitleWithLLM( const failed = await generateTitleWithLLM(
fakeLLM( fakeLLM(
[ [
assistantText("半截"), assistantText("partial"),
tokenUsage(emptyTokenCounts(), { cache_read: 0, cache_write: 0, output: 1, total: 1 }), tokenUsage(emptyTokenCounts(), { cache_read: 0, cache_write: 0, output: 1, total: 1 }),
], ],
{ status: "failed", message: "401" }, { status: "failed", message: "401" },
@@ -103,7 +103,7 @@ describe("session-title", () => {
expect(failed.usage?.total).toBe(1); expect(failed.usage?.total).toBe(1);
}); });
it("助手素材为空也生成(纯工具轮次):只据用户请求,prompt 省去助手段", async () => { it("still generates with empty assistant material (tool-only turn): uses only the user request, prompt omits the assistant section", async () => {
const seen: string[] = []; const seen: string[] = [];
const result = await generateTitleWithLLM( const result = await generateTitleWithLLM(
fakeLLM([assistantText("配置 Tailwind 主题")], { status: "completed" }, seen), fakeLLM([assistantText("配置 Tailwind 主题")], { status: "completed" }, seen),
@@ -114,24 +114,24 @@ describe("session-title", () => {
expect(seen[0]).not.toContain("[Assistant]"); expect(seen[0]).not.toContain("[Assistant]");
}); });
it("sanitizeTitle:剥引号与句读到稳定、折叠空白、超长截断、空返回 null", () => { it("sanitizeTitle: strips quotes/punctuation to a fixed point, collapses whitespace, truncates overlong input, returns null for empty", () => {
expect(sanitizeTitle("“ 构建配置 说明 。”")).toBe("构建配置 说明"); expect(sanitizeTitle("“ 构建配置 说明 。”")).toBe("构建配置 说明");
expect(sanitizeTitle("『标题』!")).toBe("标题"); expect(sanitizeTitle("『标题』!")).toBe("标题");
expect(sanitizeTitle(" \n ")).toBeNull(); expect(sanitizeTitle(" \n ")).toBeNull();
expect(sanitizeTitle("x".repeat(50))).toHaveLength(30); expect(sanitizeTitle("x".repeat(50))).toHaveLength(30);
}); });
it("Session.generateTitle:经 createBareLLM 发起;未提供工厂时返回 null", async () => { it("Session.generateTitle: sends via createBareLLM; returns null when no factory is provided", async () => {
const withFactory = new Session({ const withFactory = new Session({
meta: META, meta: META,
llm: fakeLLM([]), llm: fakeLLM([]),
environment: fakeEnvironment, environment: fakeEnvironment,
createBareLLM: () => fakeLLM([assistantText("标题 A")]), createBareLLM: () => fakeLLM([assistantText("Title A")]),
}); });
expect( expect(
await withFactory.generateTitle({ material: { userText: "u", assistantText: "a" } }), await withFactory.generateTitle({ material: { userText: "u", assistantText: "a" } }),
).toEqual({ ).toEqual({
title: "标题 A", title: "Title A",
usage: null, usage: null,
}); });
@@ -146,29 +146,29 @@ describe("session-title", () => {
}); });
}); });
it("Session.generateTitle:素材自采(run 收集用户输入与模型正文),无需调用方提供", async () => { it("Session.generateTitle: self-collects material (run gathers the user input and model text), none needed from the caller", async () => {
const seen: string[] = []; const seen: string[] = [];
const session = new Session({ const session = new Session({
meta: META, meta: META,
llm: fakeLLM([thinkingMessage("想想"), assistantText("答案正文")]), llm: fakeLLM([thinkingMessage("thinking"), assistantText("answer body")]),
environment: fakeEnvironment, environment: fakeEnvironment,
createBareLLM: () => fakeLLM([assistantText("标题 B")], { status: "completed" }, seen), createBareLLM: () => fakeLLM([assistantText("Title B")], { status: "completed" }, seen),
}); });
for await (const _ of session.run([userText("用户问题")])) { for await (const _ of session.run([userText("user question")])) {
void _; // Drains the output stream; once run finishes, the material is settled void _; // Drains the output stream; once run finishes, the material is settled
} }
const res = await session.generateTitle(); const res = await session.generateTitle();
expect(res.title).toBe("标题 B"); expect(res.title).toBe("Title B");
// Material = the first Task's user text + model text (thinking does not count), matching // Material = the first Task's user text + model text (thinking does not count), matching
// buildTitlePrompt's shape. // buildTitlePrompt's shape.
expect(seen[0]).toBe(buildTitlePrompt("用户问题", "答案正文")); expect(seen[0]).toBe(buildTitlePrompt("user question", "answer body"));
// No request is sent when no material has been collected (run was never called). // No request is sent when no material has been collected (run was never called).
const idle = new Session({ const idle = new Session({
meta: META, meta: META,
llm: fakeLLM([]), llm: fakeLLM([]),
environment: fakeEnvironment, environment: fakeEnvironment,
createBareLLM: () => fakeLLM([assistantText("不应产生")]), createBareLLM: () => fakeLLM([assistantText("must not be produced")]),
}); });
expect(await idle.generateTitle()).toEqual({ title: null, usage: null }); expect(await idle.generateTitle()).toEqual({ title: null, usage: null });
}); });
+15 -11
View File
@@ -621,7 +621,7 @@ describe("project-config round trip", () => {
).rejects.toThrow(/model_id=nope/); ).rejects.toThrow(/model_id=nope/);
await expect( await expect(
setVisionModel(tmpRoot, DEFAULT_PROJECT_ID, { provider: "custom", model_id: "blind" }), setVisionModel(tmpRoot, DEFAULT_PROJECT_ID, { provider: "custom", model_id: "blind" }),
).rejects.toThrow(/不支持图片/); ).rejects.toThrow(/not supporting images/);
}); });
it("upsert preserves existing context_window and inline credential when not re-specified", async () => { it("upsert preserves existing context_window and inline credential when not re-specified", async () => {
@@ -763,7 +763,7 @@ describe("project-config round trip", () => {
).rejects.toThrow(/\(provider=custom, model_id=nope\).*model list/); ).rejects.toThrow(/\(provider=custom, model_id=nope\).*model list/);
await expect( await expect(
setDefaultModel(tmpRoot, DEFAULT_PROJECT_ID, { provider: "openai", model_id: "m2" }), setDefaultModel(tmpRoot, DEFAULT_PROJECT_ID, { provider: "openai", model_id: "m2" }),
).rejects.toThrow(/不在 models 中/); ).rejects.toThrow(/is not in models/);
expect((await loadProjectConfig(tmpRoot, DEFAULT_PROJECT_ID)).default_model).toEqual(m2Ref); expect((await loadProjectConfig(tmpRoot, DEFAULT_PROJECT_ID)).default_model).toEqual(m2Ref);
}); });
@@ -780,7 +780,9 @@ describe("project-config round trip", () => {
await fs.mkdir(path.dirname(file), { recursive: true }); await fs.mkdir(path.dirname(file), { recursive: true });
// Old format 1: default_model is a concatenated storage id string. // Old format 1: default_model is a concatenated storage id string.
await fs.writeFile(file, 'default_model = "deepseek/deepseek-v4-pro"\n', "utf8"); await fs.writeFile(file, 'default_model = "deepseek/deepseek-v4-pro"\n', "utf8");
await expect(loadProjectConfig(tmpRoot, DEFAULT_PROJECT_ID)).rejects.toThrow(/旧版本|成对引用/); await expect(loadProjectConfig(tmpRoot, DEFAULT_PROJECT_ID)).rejects.toThrow(
/legacy|paired reference/,
);
// Old format 2: a model entry missing provider (from the era of composite model_id + // Old format 2: a model entry missing provider (from the era of composite model_id +
// request_model_id). // request_model_id).
await fs.writeFile( await fs.writeFile(
@@ -792,11 +794,13 @@ describe("project-config round trip", () => {
].join("\n"), ].join("\n"),
"utf8", "utf8",
); );
await expect(loadProjectConfig(tmpRoot, DEFAULT_PROJECT_ID)).rejects.toThrow(/旧版本|独立字段/); await expect(loadProjectConfig(tmpRoot, DEFAULT_PROJECT_ID)).rejects.toThrow(
/legacy|separate fields/,
);
}); });
}); });
describe("resolveModelRef(唯一的「省略 provider」解析入口)", () => { describe('resolveModelRef (the single "provider omitted" resolution entry point)', () => {
const cfg: ProjectConfig = { const cfg: ProjectConfig = {
models: [ models: [
{ provider: "deepseek", model_id: "deepseek-v4-pro" }, { provider: "deepseek", model_id: "deepseek-v4-pro" },
@@ -811,7 +815,7 @@ describe("resolveModelRef(唯一的「省略 provider」解析入口)", () =
model_id: "deepseek-v4-pro", model_id: "deepseek-v4-pro",
}); });
// Exact-match lookup, no fuzzy/prefix matching. // Exact-match lookup, no fuzzy/prefix matching.
expect(() => resolveModelRef(cfg, "deepseek-v4")).toThrow(/不在 Project 配置中/); expect(() => resolveModelRef(cfg, "deepseek-v4")).toThrow(/is not in the Project config/);
}); });
it("validates the exact pair when provider is given", () => { it("validates the exact pair when provider is given", () => {
@@ -827,19 +831,19 @@ describe("resolveModelRef(唯一的「省略 provider」解析入口)", () =
it("errors clearly on zero matches", () => { it("errors clearly on zero matches", () => {
expect(() => resolveModelRef(cfg, "no-such-model")).toThrow( expect(() => resolveModelRef(cfg, "no-such-model")).toThrow(
/不在 Project 配置中.*no-such-model/, /is not in the Project config.*no-such-model/,
); );
}); });
it("errors on ambiguity, listing the candidate pair references", () => { it("errors on ambiguity, listing the candidate pair references", () => {
expect(() => resolveModelRef(cfg, "shared-id")).toThrow(/歧义/); expect(() => resolveModelRef(cfg, "shared-id")).toThrow(/Ambiguous/);
expect(() => resolveModelRef(cfg, "shared-id")).toThrow( expect(() => resolveModelRef(cfg, "shared-id")).toThrow(
/\(provider=siliconflow, model_id=shared-id\).*\(provider=openrouter, model_id=shared-id\)/, /\(provider=siliconflow, model_id=shared-id\).*\(provider=openrouter, model_id=shared-id\)/,
); );
}); });
}); });
describe("单一隐藏配置文件(.project_config.toml,credential 内联)", () => { describe("single hidden config file (.project_config.toml, credentials inlined)", () => {
it("addModel writes one hidden file with 0600 permission; api_key lives inline", async () => { it("addModel writes one hidden file with 0600 permission; api_key lives inline", async () => {
await addModel(tmpRoot, DEFAULT_PROJECT_ID, { await addModel(tmpRoot, DEFAULT_PROJECT_ID, {
provider: "custom", provider: "custom",
@@ -980,9 +984,9 @@ describe("defensive config parsing", () => {
await loadOrInitAgentState(); await loadOrInitAgentState();
const cfgPath = systemConfigPath(tmpRoot, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID); const cfgPath = systemConfigPath(tmpRoot, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID);
await fs.writeFile(cfgPath, "", "utf8"); await fs.writeFile(cfgPath, "", "utf8");
await expect(loadOrInitAgentState()).rejects.toThrow(/system_prompt|非法|损坏/); await expect(loadOrInitAgentState()).rejects.toThrow(/system_prompt|Invalid|corrupted/);
await fs.writeFile(cfgPath, "just a string, not a mapping", "utf8"); await fs.writeFile(cfgPath, "just a string, not a mapping", "utf8");
await expect(loadOrInitAgentState()).rejects.toThrow(/system_prompt|非法|损坏/); await expect(loadOrInitAgentState()).rejects.toThrow(/system_prompt|Invalid|corrupted/);
}); });
}); });
+8 -8
View File
@@ -27,25 +27,25 @@ describe("isValidId / assertValidId", () => {
"emoji😀", "emoji😀",
]; ];
it.each(valid)("接受合法单段目录名:%j", (id) => { it.each(valid)("accepts a valid single-segment directory name: %j", (id) => {
expect(isValidId(id)).toBe(true); expect(isValidId(id)).toBe(true);
expect(() => assertValidId("project_id", id)).not.toThrow(); expect(() => assertValidId("project_id", id)).not.toThrow();
expect(() => assertValidId("agent_id", id)).not.toThrow(); expect(() => assertValidId("agent_id", id)).not.toThrow();
}); });
it.each(invalid)("拒绝非法 id:%j", (id) => { it.each(invalid)("rejects an invalid id: %j", (id) => {
expect(isValidId(id)).toBe(false); expect(isValidId(id)).toBe(false);
expect(() => assertValidId("agent_id", id)).toThrow(); expect(() => assertValidId("agent_id", id)).toThrow();
}); });
it("错误信息点明 kind 与非法 id", () => { it("the error message names the kind and the invalid id", () => {
expect(() => assertValidId("agent_id", "a/b")).toThrow(/agent_id/); expect(() => assertValidId("agent_id", "a/b")).toThrow(/agent_id/);
expect(() => assertValidId("agent_id", "a/b")).toThrow(/a\/b/); expect(() => assertValidId("agent_id", "a/b")).toThrow(/a\/b/);
expect(() => assertValidId("project_id", "..")).toThrow(/project_id/); expect(() => assertValidId("project_id", "..")).toThrow(/project_id/);
}); });
}); });
describe("loadOrInitAgentState id 校验", () => { describe("loadOrInitAgentState id validation", () => {
let tmpRoot: string; let tmpRoot: string;
let prevHome: string | undefined; let prevHome: string | undefined;
@@ -64,19 +64,19 @@ describe("loadOrInitAgentState id 校验", () => {
await fs.rm(tmpRoot, { recursive: true, force: true }); await fs.rm(tmpRoot, { recursive: true, force: true });
}); });
it("agentId 含路径分隔符时抛错", async () => { it("throws when agentId contains a path separator", async () => {
await expect(loadOrInitAgentState({ agentId: "a/b" })).rejects.toThrow(/agent_id/); await expect(loadOrInitAgentState({ agentId: "a/b" })).rejects.toThrow(/agent_id/);
}); });
it("projectId 为 .. 时抛错", async () => { it("throws when projectId is ..", async () => {
await expect(loadOrInitAgentState({ projectId: ".." })).rejects.toThrow(/project_id/); await expect(loadOrInitAgentState({ projectId: ".." })).rejects.toThrow(/project_id/);
}); });
it("agentId 为 Windows 结尾空格变体 '.. ' 时抛错(防路径穿越绕过)", async () => { it("throws on the Windows trailing-space variant '.. ' as agentId (blocks a path-traversal bypass)", async () => {
await expect(loadOrInitAgentState({ agentId: ".. " })).rejects.toThrow(/agent_id/); await expect(loadOrInitAgentState({ agentId: ".. " })).rejects.toThrow(/agent_id/);
}); });
it("默认 id 合法,可正常初始化", async () => { it("the default ids are valid and initialization succeeds", async () => {
const state = await loadOrInitAgentState(); const state = await loadOrInitAgentState();
expect(state.projectId).toBe("default_project"); expect(state.projectId).toBe("default_project");
expect(state.agentId).toBe("default_agent"); expect(state.agentId).toBe("default_agent");
+3 -3
View File
@@ -38,14 +38,14 @@ afterEach(async () => {
}); });
describe("createTempWorkspace", () => { describe("createTempWorkspace", () => {
it("在 workspaces/ 下创建 tmp-<8hex> 目录并返回路径", async () => { it("creates a tmp-<8hex> directory under workspaces/ and returns its path", async () => {
const dir = await createTempWorkspace(tmpRoot, "proj", "agent"); const dir = await createTempWorkspace(tmpRoot, "proj", "agent");
expect(path.dirname(dir)).toBe(workspacesDir(tmpRoot, "proj", "agent")); expect(path.dirname(dir)).toBe(workspacesDir(tmpRoot, "proj", "agent"));
expect(path.basename(dir)).toMatch(/^tmp-[0-9a-f]{8}$/); expect(path.basename(dir)).toMatch(/^tmp-[0-9a-f]{8}$/);
expect((await fs.stat(dir)).isDirectory()).toBe(true); expect((await fs.stat(dir)).isDirectory()).toBe(true);
}); });
it("id 与已有目录冲突时重新生成,不复用已有目录", async () => { it("regenerates the id when it collides with an existing directory instead of reusing it", async () => {
const base = workspacesDir(tmpRoot, "proj", "agent"); const base = workspacesDir(tmpRoot, "proj", "agent");
await fs.mkdir(path.join(base, "tmp-aaaaaaaa"), { recursive: true }); await fs.mkdir(path.join(base, "tmp-aaaaaaaa"), { recursive: true });
await fs.writeFile(path.join(base, "tmp-aaaaaaaa", "keep.txt"), "old"); await fs.writeFile(path.join(base, "tmp-aaaaaaaa", "keep.txt"), "old");
@@ -64,7 +64,7 @@ describe("createTempWorkspace", () => {
); );
}); });
it("重试耗尽时报错,而非复用已有目录", async () => { it("errors when retries are exhausted instead of reusing an existing directory", async () => {
const base = workspacesDir(tmpRoot, "proj", "agent"); const base = workspacesDir(tmpRoot, "proj", "agent");
await fs.mkdir(path.join(base, "tmp-cccccccc"), { recursive: true }); await fs.mkdir(path.join(base, "tmp-cccccccc"), { recursive: true });
mocked.uuids.push(...Array.from({ length: 16 }, () => "cccccccc-3333-4333-8333-333333333333")); mocked.uuids.push(...Array.from({ length: 16 }, () => "cccccccc-3333-4333-8333-333333333333"));
+1 -1
View File
@@ -3,7 +3,7 @@
"version": "0.0.1", "version": "0.0.1",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "PenguinHarness Landing Page:产品落地页(React + Vite + Tailwind CSS),多语言、亮暗主题、Benchmark 展示与本地 Markdown 博客,经 GitHub Actions 部署到 GitHub Pages。", "description": "PenguinHarness landing page (React + Vite + Tailwind CSS): multilingual, light/dark themes, benchmark showcase, and a local Markdown blog, deployed to GitHub Pages via GitHub Actions.",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build && node scripts/postbuild.mjs", "build": "vite build && node scripts/postbuild.mjs",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@prismshadow/penguin-server", "name": "@prismshadow/penguin-server",
"version": "0.0.1", "version": "0.0.1",
"type": "module", "type": "module",
"description": "PenguinHarness Web 服务端:多用户认证与授权、Session 运行与 SSE 流式通道、用量统计,引用 @prismshadow/penguin-core。", "description": "PenguinHarness web server: multi-user authentication and authorization, Session execution with SSE streaming, and usage accounting, built on @prismshadow/penguin-core.",
"license": "Apache-2.0", "license": "Apache-2.0",
"repository": { "repository": {
"type": "git", "type": "git",
+4 -4
View File
@@ -254,7 +254,7 @@ export function createApp(deps: AppDeps): Hono<AppEnv> {
}); });
return handleError(err, c); return handleError(err, c);
}); });
app.notFound((c) => c.json(errorBody("not_found", "接口不存在。"), 404)); app.notFound((c) => c.json(errorBody("not_found", "Endpoint does not exist."), 404));
// Request logging: a minimal one-liner (method path status ms). // Request logging: a minimal one-liner (method path status ms).
app.use("*", async (c, next) => { app.use("*", async (c, next) => {
@@ -268,7 +268,7 @@ export function createApp(deps: AppDeps): Hono<AppEnv> {
app.use("/api/*", async (c, next) => { app.use("/api/*", async (c, next) => {
const contentLength = Number(c.req.header("content-length") ?? 0); const contentLength = Number(c.req.header("content-length") ?? 0);
if (contentLength > MAX_BODY_BYTES) { if (contentLength > MAX_BODY_BYTES) {
throw new HttpError(413, "payload_too_large", "请求体超过 20MB 上限。"); throw new HttpError(413, "payload_too_large", "Request body exceeds the 20MB limit.");
} }
await next(); await next();
}); });
@@ -362,7 +362,7 @@ function registerStaticRoutes(app: Hono<AppEnv>, webDist: string): void {
app.get("*", async (c) => { app.get("*", async (c) => {
const reqPath = decodeURIComponent(c.req.path); const reqPath = decodeURIComponent(c.req.path);
if (reqPath.startsWith("/api/")) { if (reqPath.startsWith("/api/")) {
return c.json(errorBody("not_found", "接口不存在。"), 404); return c.json(errorBody("not_found", "Endpoint does not exist."), 404);
} }
const rel = reqPath.replace(/^\/+/, ""); const rel = reqPath.replace(/^\/+/, "");
const resolved = path.resolve(webDist, rel === "" ? "index.html" : rel); const resolved = path.resolve(webDist, rel === "" ? "index.html" : rel);
@@ -384,7 +384,7 @@ function registerStaticRoutes(app: Hono<AppEnv>, webDist: string): void {
try { try {
content = await fsp.readFile(file); content = await fsp.readFile(file);
} catch { } catch {
return c.json(errorBody("not_found", "资源不存在。"), 404); return c.json(errorBody("not_found", "Resource does not exist."), 404);
} }
const type = CONTENT_TYPES[path.extname(file).toLowerCase()] ?? "application/octet-stream"; const type = CONTENT_TYPES[path.extname(file).toLowerCase()] ?? "application/octet-stream";
return new Response(new Uint8Array(content), { return new Response(new Uint8Array(content), {
+6 -2
View File
@@ -33,7 +33,7 @@ export function authMiddleware(auth: AuthService): MiddlewareHandler<AppEnv> {
const token = getCookie(c, SESSION_COOKIE); const token = getCookie(c, SESSION_COOKIE);
const user = token ? auth.authenticate(token) : null; const user = token ? auth.authenticate(token) : null;
if (!user) { if (!user) {
throw new HttpError(401, "unauthorized", "未登录或登录已过期。"); throw new HttpError(401, "unauthorized", "Not signed in or the sign-in has expired.");
} }
c.set("user", user); c.set("user", user);
await next(); await next();
@@ -51,7 +51,11 @@ export const jsonOnlyWrites: MiddlewareHandler = async (c, next) => {
if (WRITE_METHODS.has(c.req.method)) { if (WRITE_METHODS.has(c.req.method)) {
const contentType = c.req.header("content-type"); const contentType = c.req.header("content-type");
if (contentType && !contentType.toLowerCase().startsWith("application/json")) { if (contentType && !contentType.toLowerCase().startsWith("application/json")) {
throw new HttpError(415, "unsupported_media_type", "写请求仅接受 application/json。"); throw new HttpError(
415,
"unsupported_media_type",
"Write requests only accept application/json.",
);
} }
} }
await next(); await next();
+3 -3
View File
@@ -81,7 +81,7 @@ export class AuthService {
const row = this.deps.users.findById(userId); const row = this.deps.users.findById(userId);
const ok = row !== null && (await verifyPassword(password, row.passwordHash)); const ok = row !== null && (await verifyPassword(password, row.passwordHash));
if (!row || !ok) { if (!row || !ok) {
throw new HttpError(401, "invalid_credentials", "用户名或密码错误。"); throw new HttpError(401, "invalid_credentials", "Incorrect username or password.");
} }
this.deps.authSessions.deleteExpired(this.now().toISOString()); this.deps.authSessions.deleteExpired(this.now().toISOString());
return { user: toUserInfo(row), token: this.issueSession(row.userId) }; return { user: toUserInfo(row), token: this.issueSession(row.userId) };
@@ -91,10 +91,10 @@ export class AuthService {
async changePassword(userId: string, oldPassword: string, newPassword: string): Promise<void> { async changePassword(userId: string, oldPassword: string, newPassword: string): Promise<void> {
const row = this.deps.users.findById(userId); const row = this.deps.users.findById(userId);
if (!row || !(await verifyPassword(oldPassword, row.passwordHash))) { if (!row || !(await verifyPassword(oldPassword, row.passwordHash))) {
throw new HttpError(400, "password_mismatch", "当前密码不正确。"); throw new HttpError(400, "password_mismatch", "Current password is incorrect.");
} }
if (newPassword.length < MIN_PASSWORD_LENGTH) { if (newPassword.length < MIN_PASSWORD_LENGTH) {
throw new HttpError(400, "invalid_password", "密码至少 8 个字符。"); throw new HttpError(400, "invalid_password", "Password must be at least 8 characters.");
} }
this.deps.users.updatePassword(userId, await hashPassword(newPassword), false); this.deps.users.updatePassword(userId, await hashPassword(newPassword), false);
} }
+1 -1
View File
@@ -54,7 +54,7 @@ export function resolveServerConfig(env: NodeJS.ProcessEnv = process.env): Serve
// port; this matches the CLI's resolvePort convention. // port; this matches the CLI's resolvePort convention.
const port = Number(env.PORT || 7364); const port = Number(env.PORT || 7364);
if (!Number.isInteger(port) || port < 0 || port > 65535) { if (!Number.isInteger(port) || port < 0 || port > 65535) {
throw new Error(`非法端口配置 PORT=${env.PORT}`); throw new Error(`Invalid port configuration PORT=${env.PORT}`);
} }
return { return {
root, root,
+1 -1
View File
@@ -114,7 +114,7 @@ export class SchedulesRepo {
.run(args.defHash, args.projectId, args.agentId, args.name); .run(args.defHash, args.projectId, args.agentId, args.name);
} }
const row = this.find(args.projectId, args.agentId, args.name); const row = this.find(args.projectId, args.agentId, args.name);
if (!row) throw new Error("schedule_state 登记后读取失败"); if (!row) throw new Error("Failed to read back schedule_state after registration");
return { row, fresh }; return { row, fresh };
} }
+38 -38
View File
@@ -9,30 +9,30 @@
export const SCHEMA_SQL = ` export const SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
user_id TEXT PRIMARY KEY, -- 语义 id 即登录名:^[a-z][a-z0-9_-]{1,31}$ user_id TEXT PRIMARY KEY, -- semantic id doubles as login name: ^[a-z][a-z0-9_-]{1,31}$
password_hash TEXT NOT NULL, -- scrypt$N$r$p$salt$hash(base64) password_hash TEXT NOT NULL, -- scrypt$N$r$p$salt$hash(base64)
is_admin INTEGER NOT NULL DEFAULT 0, -- 内置 admin(启动时种子)为 1 is_admin INTEGER NOT NULL DEFAULT 0, -- 1 for the built-in admin (seeded at startup)
password_is_initial INTEGER NOT NULL DEFAULT 0, -- 1=初始密码(种子/管理员设置);本人改密后清 0 password_is_initial INTEGER NOT NULL DEFAULT 0, -- 1=initial password (seeded/admin-set); cleared once the user changes it
created_at TEXT NOT NULL created_at TEXT NOT NULL
); );
CREATE TABLE IF NOT EXISTS auth_sessions ( CREATE TABLE IF NOT EXISTS auth_sessions (
token_hash TEXT PRIMARY KEY, -- sha256(token) hex;cookie 只存原始 token token_hash TEXT PRIMARY KEY, -- sha256(token) hex; the cookie stores only the raw token
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
expires_at TEXT NOT NULL -- 7 天滑动续期(剩余 <6 天则续满) expires_at TEXT NOT NULL -- 7-day sliding renewal (topped up when <6 days remain)
); );
CREATE TABLE IF NOT EXISTS projects ( CREATE TABLE IF NOT EXISTS projects (
project_id TEXT PRIMARY KEY, -- 目录名即 id;显示名在 project_config.toml project_id TEXT PRIMARY KEY, -- directory name doubles as id; display name lives in project_config.toml
owner_user_id TEXT NOT NULL REFERENCES users(user_id), owner_user_id TEXT NOT NULL REFERENCES users(user_id),
created_at TEXT NOT NULL created_at TEXT NOT NULL
); );
CREATE TABLE IF NOT EXISTS project_members ( -- 仅 member 授权关系;owner 不入此表 CREATE TABLE IF NOT EXISTS project_members ( -- member grants only; owners are not stored here
project_id TEXT NOT NULL REFERENCES projects(project_id) ON DELETE CASCADE, project_id TEXT NOT NULL REFERENCES projects(project_id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
PRIMARY KEY (project_id, user_id) PRIMARY KEY (project_id, user_id)
); );
CREATE TABLE IF NOT EXISTS agents ( -- 索引;name/description 在 system_config.yaml CREATE TABLE IF NOT EXISTS agents ( -- index only; name/description live in system_config.yaml
project_id TEXT NOT NULL, project_id TEXT NOT NULL,
agent_id TEXT NOT NULL, agent_id TEXT NOT NULL,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
@@ -42,63 +42,63 @@ CREATE TABLE IF NOT EXISTS sessions (
session_id TEXT PRIMARY KEY, session_id TEXT PRIMARY KEY,
project_id TEXT NOT NULL, project_id TEXT NOT NULL,
agent_id TEXT NOT NULL, agent_id TEXT NOT NULL,
provider TEXT NOT NULL, -- 会话模型的厂商分组(与 model_id 成对构成模型引用) provider TEXT NOT NULL, -- provider group of the session model (paired with model_id as a model reference)
model_id TEXT NOT NULL, -- 上游模型 id(原样发给 AgentHub;禁止 <provider>/<id> 拼接) model_id TEXT NOT NULL, -- upstream model id (sent to AgentHub as-is; never concatenate <provider>/<id>)
workspace TEXT NOT NULL, workspace TEXT NOT NULL,
approval_mode TEXT NOT NULL DEFAULT 'allow-all', -- allow-all|deny-all|read-only|always-ask approval_mode TEXT NOT NULL DEFAULT 'allow-all', -- allow-all|deny-all|read-only|always-ask
title TEXT, -- 首次对话后由模型自动生成;NULL=未生成(前端显示「新对话」) title TEXT, -- auto-generated by the model after the first exchange; NULL=not yet (frontend shows "New chat")
archived_at TEXT, -- 归档时刻;NULL=未归档(默认展示;归档后收进「已归档」) archived_at TEXT, -- archive time; NULL=not archived (shown by default; archived ones move under "Archived")
source TEXT, -- 会话来源:NULL=用户创建 | schedule(定时任务)| subagent(子会话) source TEXT, -- session origin: NULL=user-created | schedule (scheduled task) | subagent (child session)
created_at TEXT NOT NULL created_at TEXT NOT NULL
); );
CREATE TABLE IF NOT EXISTS usage_records ( CREATE TABLE IF NOT EXISTS usage_records (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL, ts TEXT NOT NULL,
date TEXT NOT NULL, -- 本地日期 yyyy-mm-dd(聚合键,与 Trace 日期目录同口径) date TEXT NOT NULL, -- local date yyyy-mm-dd (aggregation key; same convention as Trace date directories)
project_id TEXT NOT NULL, project_id TEXT NOT NULL,
agent_id TEXT NOT NULL, agent_id TEXT NOT NULL,
session_id TEXT NOT NULL, -- 顶层 Session(子会话消耗计入所属主 Session) session_id TEXT NOT NULL, -- top-level Session (child-session usage counts toward its main Session)
origin_session_id TEXT, -- 直接来源子 Session(origin 链末项);NULL=主会话 origin_session_id TEXT, -- immediate source child Session (last of the origin chain); NULL=main session
provider TEXT NOT NULL, -- 厂商分组:与 model_id 成对构成归因键,聚合一律 GROUP BY provider, model_id provider TEXT NOT NULL, -- provider group: paired with model_id as the attribution key; always GROUP BY provider, model_id
model_id TEXT NOT NULL, -- 上游模型 id(与 provider 成对;同名 model_id 跨厂商分开聚合) model_id TEXT NOT NULL, -- upstream model id (paired with provider; same model_id across providers aggregates separately)
cache_read INTEGER NOT NULL, cache_read INTEGER NOT NULL,
cache_write INTEGER NOT NULL, cache_write INTEGER NOT NULL,
output INTEGER NOT NULL, output INTEGER NOT NULL,
total INTEGER NOT NULL, -- 取 token_usage.request(每 Request 一条) total INTEGER NOT NULL, -- from token_usage.request (one row per Request)
status TEXT NOT NULL DEFAULT 'completed' -- 请求结局:completed=成功(含 token);其余=失败(0 token,供成功率) status TEXT NOT NULL DEFAULT 'completed' -- request outcome: completed=success (with tokens); others=failure (0 tokens, for success rate)
); -- 成本不落库:查询时按当前 pricing 实时折算 ); -- cost is not stored: computed at query time from current pricing
CREATE INDEX IF NOT EXISTS idx_usage_project_date ON usage_records(project_id, date); CREATE INDEX IF NOT EXISTS idx_usage_project_date ON usage_records(project_id, date);
CREATE INDEX IF NOT EXISTS idx_usage_session ON usage_records(session_id); CREATE INDEX IF NOT EXISTS idx_usage_session ON usage_records(session_id);
CREATE TABLE IF NOT EXISTS error_records ( -- 服务端异常捕获(统计中心「异常」) CREATE TABLE IF NOT EXISTS error_records ( -- server-side error capture (the Costs page's "Errors" tab)
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL, ts TEXT NOT NULL,
date TEXT NOT NULL, -- 本地日期 yyyy-mm-dd(与 usage_records 同口径) date TEXT NOT NULL, -- local date yyyy-mm-dd (same convention as usage_records)
project_id TEXT, -- 可空:登录/注册、进程级异常没有 Project 上下文 project_id TEXT, -- nullable: sign-in/registration and process-level errors have no Project context
agent_id TEXT, agent_id TEXT,
session_id TEXT, session_id TEXT,
source TEXT NOT NULL, -- http | session | usage | title | subagent | process | llm | environment | schedule source TEXT NOT NULL, -- http | session | usage | title | subagent | process | llm | environment | schedule
kind TEXT NOT NULL, -- expected(HttpError,业务 4xx)| unexpected(500/运行时) kind TEXT NOT NULL, -- expected (HttpError, business 4xx) | unexpected (500/runtime)
code TEXT NOT NULL, -- HttpError.code / internal / session_run_failed / ... code TEXT NOT NULL, -- HttpError.code / internal / session_run_failed / ...
status INTEGER, -- HTTP 状态码;非 HTTP 来源为 NULL status INTEGER, -- HTTP status code; NULL for non-HTTP sources
message TEXT NOT NULL -- 截断 500 字符(不存堆栈:堆栈只进日志) message TEXT NOT NULL -- truncated to 500 chars (no stack stored: stacks go to logs only)
); );
CREATE INDEX IF NOT EXISTS idx_error_project_date ON error_records(project_id, date); CREATE INDEX IF NOT EXISTS idx_error_project_date ON error_records(project_id, date);
CREATE TABLE IF NOT EXISTS schedule_state ( -- 定时任务运行状态(文件是声明式意图,系统不写回) CREATE TABLE IF NOT EXISTS schedule_state ( -- schedule runtime state (files are declarative intent; the system never writes back)
project_id TEXT NOT NULL, project_id TEXT NOT NULL,
agent_id TEXT NOT NULL, agent_id TEXT NOT NULL,
name TEXT NOT NULL, -- 文件名(去 .toml)即标识 name TEXT NOT NULL, -- file name (without .toml) doubles as the identifier
creator_user_id TEXT, -- 创建者(API 创建时记;手编文件对账登记回退 Project owner) creator_user_id TEXT, -- creator (recorded on API create; hand-edited file reconciliation falls back to the Project owner)
start_at_ms INTEGER NOT NULL, -- 定义身份:start_at 变更视为新任务实例,重置触发状态 start_at_ms INTEGER NOT NULL, -- defines identity: a start_at change counts as a new task instance, resetting fire state
def_hash TEXT NOT NULL, -- 文件内容指纹:变更即清除失效标记(文件修改后重新生效) def_hash TEXT NOT NULL, -- file content fingerprint: any change clears the invalid flag (the file takes effect again after edits)
last_slot_ms INTEGER, -- 已消化的最近应触发时刻(触发或跳过都推进;重启不重复触发) last_slot_ms INTEGER, -- latest due slot already consumed (advances on fire or skip; no re-fire across restarts)
last_fired_at TEXT, -- 最近实际发送时刻(展示用) last_fired_at TEXT, -- last actual fire time (for display)
fired_once INTEGER NOT NULL DEFAULT 0, -- 一次性任务已触发 fired_once INTEGER NOT NULL DEFAULT 0, -- one-shot task has fired
missed INTEGER NOT NULL DEFAULT 0, -- 一次性任务已错过(启动/登记对账标记,错过不补) missed INTEGER NOT NULL DEFAULT 0, -- one-shot task was missed (flagged by startup/registration reconciliation; not backfilled)
invalid_reason TEXT, -- 失效原因(如绑定 Session 已删除);NULL 即正常 invalid_reason TEXT, -- invalidation reason (e.g. the bound Session was deleted); NULL=healthy
PRIMARY KEY (project_id, agent_id, name) PRIMARY KEY (project_id, agent_id, name)
); );
CREATE TABLE IF NOT EXISTS ui_prefs ( CREATE TABLE IF NOT EXISTS ui_prefs (
user_id TEXT PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE, user_id TEXT PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE,
prefs_json TEXT NOT NULL -- {theme?, lastProjectId?, ...} 自由 JSON prefs_json TEXT NOT NULL -- {theme?, lastProjectId?, ...} free-form JSON
); );
`; `;
+4 -4
View File
@@ -1,6 +1,6 @@
/** /**
* Unified HTTP error: `{error: {code, message}}` response body, * Unified HTTP error: `{error: {code, message}}` response body,
* with message in Chinese. * with message in English.
* *
* Routes and services express business errors via throw HttpError; app-level onError * Routes and services express business errors via throw HttpError; app-level onError
* uniformly converges these into a JSON response, with unknown errors converged to 500 * uniformly converges these into a JSON response, with unknown errors converged to 500
@@ -40,7 +40,7 @@ export function modelCredentialMissing(modelId: string): HttpError {
return new HttpError( return new HttpError(
400, 400,
"model_credential_missing", "model_credential_missing",
`模型 ${modelId} 还没有可用的 API key,请先在「模型」页为它配置。`, `Model ${modelId} has no API key yet. Configure it on the Models page first.`,
); );
} }
@@ -50,6 +50,6 @@ export function handleError(err: Error, c: Context): Response {
return c.json(errorBody(err.code, err.message), err.status as 400); return c.json(errorBody(err.code, err.message), err.status as 400);
} }
// Unknown error: print the stack for diagnosis, but never expose details externally. // Unknown error: print the stack for diagnosis, but never expose details externally.
console.error(`[server] 未处理异常: ${err.stack ?? err.message}`); console.error(`[server] Unhandled exception: ${err.stack ?? err.message}`);
return c.json(errorBody("internal", "服务器内部错误。"), 500); return c.json(errorBody("internal", "Internal server error."), 500);
} }
+1 -1
View File
@@ -14,7 +14,7 @@ export function adminUsersRoutes(deps: AppDeps): Hono<AppEnv> {
app.use("*", async (c, next) => { app.use("*", async (c, next) => {
if (!c.var.user.isAdmin) { if (!c.var.user.isAdmin) {
throw new HttpError(403, "admin_required", "该操作仅管理员可执行。"); throw new HttpError(403, "admin_required", "Only an admin can perform this operation.");
} }
await next(); await next();
}); });
@@ -33,7 +33,7 @@ export function agentConfigRoutes(deps: AppDeps): Hono<AppEnv> {
if (agentsMd !== undefined) req.agentsMd = agentsMd; if (agentsMd !== undefined) req.agentsMd = agentsMd;
if (body.config !== undefined) { if (body.config !== undefined) {
if (body.config === null || typeof body.config !== "object" || Array.isArray(body.config)) { if (body.config === null || typeof body.config !== "object" || Array.isArray(body.config)) {
throw badRequest("config 必须是对象。"); throw badRequest("config must be an object.");
} }
req.config = body.config as AgentConfigUpdateRequest["config"]; req.config = body.config as AgentConfigUpdateRequest["config"];
} }
@@ -42,10 +42,11 @@ export function agentTransferRoutes(deps: AppDeps): Hono<AppEnv> {
try { try {
archive = Buffer.from(dataBase64, "base64"); archive = Buffer.from(dataBase64, "base64");
} catch { } catch {
throw badRequest("dataBase64 不是合法的 base64。"); throw badRequest("dataBase64 is not valid base64.");
} }
if (archive.byteLength === 0) throw badRequest("导入包为空。"); if (archive.byteLength === 0) throw badRequest("Import package is empty.");
if (archive.byteLength > MAX_ARCHIVE_BYTES) throw badRequest("导入包超过 14MB 上限。"); if (archive.byteLength > MAX_ARCHIVE_BYTES)
throw badRequest("Import package exceeds the 14MB limit.");
const { version } = await deps.snapshots.importArchive(projectId, agentId, archive, confirm); const { version } = await deps.snapshots.importArchive(projectId, agentId, archive, confirm);
const res: AgentImportResponse = { version }; const res: AgentImportResponse = { version };
return c.json(res); return c.json(res);
+7 -3
View File
@@ -31,18 +31,22 @@ export function dirsRoutes(deps: AppDeps): Hono<AppEnv> {
const raw = c.req.query("path"); const raw = c.req.query("path");
const target = raw && raw.trim() ? raw.trim() : os.homedir(); const target = raw && raw.trim() ? raw.trim() : os.homedir();
if (!path.isAbsolute(target)) { if (!path.isAbsolute(target)) {
throw new HttpError(400, "dir_not_absolute", "目录必须是绝对路径。"); throw new HttpError(400, "dir_not_absolute", "Directory must be an absolute path.");
} }
let real: string; let real: string;
try { try {
real = await fs.realpath(target); real = await fs.realpath(target);
} catch { } catch {
throw new HttpError(404, "dir_not_found", `目录不存在或不可访问:${target}。`); throw new HttpError(
404,
"dir_not_found",
`Directory does not exist or is inaccessible: ${target}.`,
);
} }
const stat = await fs.stat(real); const stat = await fs.stat(real);
if (!stat.isDirectory()) { if (!stat.isDirectory()) {
throw new HttpError(400, "not_a_dir", "不是目录。"); throw new HttpError(400, "not_a_dir", "Not a directory.");
} }
let dirents: import("node:fs").Dirent[] = []; let dirents: import("node:fs").Dirent[] = [];
+16 -16
View File
@@ -18,7 +18,7 @@ import type { AppDeps } from "../../app.js";
/** Validate a paired reference object ({ provider, modelId }); shape mismatch throws 400. */ /** Validate a paired reference object ({ provider, modelId }); shape mismatch throws 400. */
function parseRef(value: unknown, label: string): ModelRefDto { function parseRef(value: unknown, label: string): ModelRefDto {
if (value === null || typeof value !== "object" || Array.isArray(value)) { if (value === null || typeof value !== "object" || Array.isArray(value)) {
throw badRequest(`${label} 必须是 { provider, modelId } 对象。`); throw badRequest(`${label} must be a { provider, modelId } object.`);
} }
const r = value as Record<string, unknown>; const r = value as Record<string, unknown>;
return { return {
@@ -29,10 +29,10 @@ function parseRef(value: unknown, label: string): ModelRefDto {
/** Validate the PUT request body and shape it into a ModelsUpdateRequest (rejects any shape errors). */ /** Validate the PUT request body and shape it into a ModelsUpdateRequest (rejects any shape errors). */
function parseModelsUpdate(body: Record<string, unknown>): ModelsUpdateRequest { function parseModelsUpdate(body: Record<string, unknown>): ModelsUpdateRequest {
if (!Array.isArray(body.models)) throw badRequest("models 必须是数组。"); if (!Array.isArray(body.models)) throw badRequest("models must be an array.");
const models: ModelUpdateEntry[] = body.models.map((item, i) => { const models: ModelUpdateEntry[] = body.models.map((item, i) => {
if (item === null || typeof item !== "object" || Array.isArray(item)) { if (item === null || typeof item !== "object" || Array.isArray(item)) {
throw badRequest(`models[${i}] 必须是对象。`); throw badRequest(`models[${i}] must be an object.`);
} }
const m = item as Record<string, unknown>; const m = item as Record<string, unknown>;
const entry: ModelUpdateEntry = { const entry: ModelUpdateEntry = {
@@ -49,7 +49,7 @@ function parseModelsUpdate(body: Record<string, unknown>): ModelsUpdateRequest {
}; };
if (m.displayName !== undefined) { if (m.displayName !== undefined) {
if (typeof m.displayName !== "string" || m.displayName.length > 100) { if (typeof m.displayName !== "string" || m.displayName.length > 100) {
throw badRequest(`models[${i}].displayName 必须是长度不超过 100 的字符串。`); throw badRequest(`models[${i}].displayName must be a string of at most 100 characters.`);
} }
if (m.displayName) entry.displayName = m.displayName; if (m.displayName) entry.displayName = m.displayName;
} }
@@ -59,32 +59,32 @@ function parseModelsUpdate(body: Record<string, unknown>): ModelsUpdateRequest {
} }
if (m.contextWindow !== undefined) { if (m.contextWindow !== undefined) {
if (typeof m.contextWindow !== "number" || !(m.contextWindow > 0)) { if (typeof m.contextWindow !== "number" || !(m.contextWindow > 0)) {
throw badRequest(`models[${i}].contextWindow 必须是正数。`); throw badRequest(`models[${i}].contextWindow must be a positive number.`);
} }
entry.contextWindow = m.contextWindow; entry.contextWindow = m.contextWindow;
} }
if (m.clientType !== undefined) { if (m.clientType !== undefined) {
if (typeof m.clientType !== "string" || m.clientType.length > 64) { if (typeof m.clientType !== "string" || m.clientType.length > 64) {
throw badRequest(`models[${i}].clientType 必须是长度不超过 64 的字符串。`); throw badRequest(`models[${i}].clientType must be a string of at most 64 characters.`);
} }
// An empty string is treated as "unspecified", leaving AgentHub to infer it from modelId. // An empty string is treated as "unspecified", leaving AgentHub to infer it from modelId.
if (m.clientType) entry.clientType = m.clientType; if (m.clientType) entry.clientType = m.clientType;
} }
if (m.vision !== undefined) { if (m.vision !== undefined) {
if (typeof m.vision !== "boolean") { if (typeof m.vision !== "boolean") {
throw badRequest(`models[${i}].vision 必须是布尔值。`); throw badRequest(`models[${i}].vision must be a boolean.`);
} }
entry.vision = m.vision; entry.vision = m.vision;
} }
if (m.pricing !== undefined) { if (m.pricing !== undefined) {
const p = m.pricing as Record<string, unknown>; const p = m.pricing as Record<string, unknown>;
if (p === null || typeof p !== "object" || Array.isArray(p)) { if (p === null || typeof p !== "object" || Array.isArray(p)) {
throw badRequest(`models[${i}].pricing 必须是对象。`); throw badRequest(`models[${i}].pricing must be an object.`);
} }
for (const key of ["cacheRead", "cacheWrite", "output"] as const) { for (const key of ["cacheRead", "cacheWrite", "output"] as const) {
const v = p[key]; const v = p[key];
if (typeof v !== "number" || !Number.isFinite(v) || v < 0) { if (typeof v !== "number" || !Number.isFinite(v) || v < 0) {
throw badRequest(`models[${i}].pricing.${key} 必须是非负数字。`); throw badRequest(`models[${i}].pricing.${key} must be a non-negative number.`);
} }
} }
entry.pricing = { entry.pricing = {
@@ -95,19 +95,19 @@ function parseModelsUpdate(body: Record<string, unknown>): ModelsUpdateRequest {
} }
if (m.apiKey !== undefined) { if (m.apiKey !== undefined) {
if (typeof m.apiKey !== "string" || m.apiKey.length === 0) { if (typeof m.apiKey !== "string" || m.apiKey.length === 0) {
throw badRequest(`models[${i}].apiKey 必须是非空字符串。`); throw badRequest(`models[${i}].apiKey must be a non-empty string.`);
} }
entry.apiKey = m.apiKey; entry.apiKey = m.apiKey;
} }
if (m.clearApiKey !== undefined) { if (m.clearApiKey !== undefined) {
if (typeof m.clearApiKey !== "boolean") { if (typeof m.clearApiKey !== "boolean") {
throw badRequest(`models[${i}].clearApiKey 必须是布尔值。`); throw badRequest(`models[${i}].clearApiKey must be a boolean.`);
} }
entry.clearApiKey = m.clearApiKey; entry.clearApiKey = m.clearApiKey;
} }
if (m.baseUrl !== undefined) { if (m.baseUrl !== undefined) {
if (m.baseUrl !== null && typeof m.baseUrl !== "string") { if (m.baseUrl !== null && typeof m.baseUrl !== "string") {
throw badRequest(`models[${i}].baseUrl 必须是字符串或 null。`); throw badRequest(`models[${i}].baseUrl must be a string or null.`);
} }
entry.baseUrl = m.baseUrl as string | null; entry.baseUrl = m.baseUrl as string | null;
} }
@@ -153,22 +153,22 @@ export function modelsRoutes(deps: AppDeps): Hono<AppEnv> {
modelId: requireString(body, "modelId", { minLen: 1, maxLen: 200 }), modelId: requireString(body, "modelId", { minLen: 1, maxLen: 200 }),
}; };
if (body.apiKey !== undefined) { if (body.apiKey !== undefined) {
if (typeof body.apiKey !== "string") throw badRequest("apiKey 必须是字符串。"); if (typeof body.apiKey !== "string") throw badRequest("apiKey must be a string.");
if (body.apiKey) req.apiKey = body.apiKey; if (body.apiKey) req.apiKey = body.apiKey;
} }
if (body.clearApiKey !== undefined) { if (body.clearApiKey !== undefined) {
if (typeof body.clearApiKey !== "boolean") throw badRequest("clearApiKey 必须是布尔值。"); if (typeof body.clearApiKey !== "boolean") throw badRequest("clearApiKey must be a boolean.");
req.clearApiKey = body.clearApiKey; req.clearApiKey = body.clearApiKey;
} }
// null = explicit clear (test against the draft, don't fall back to the stored value); empty string is treated as null. // null = explicit clear (test against the draft, don't fall back to the stored value); empty string is treated as null.
if (body.baseUrl !== undefined) { if (body.baseUrl !== undefined) {
if (body.baseUrl !== null && typeof body.baseUrl !== "string") { if (body.baseUrl !== null && typeof body.baseUrl !== "string") {
throw badRequest("baseUrl 必须是字符串或 null。"); throw badRequest("baseUrl must be a string or null.");
} }
req.baseUrl = body.baseUrl ? body.baseUrl : null; req.baseUrl = body.baseUrl ? body.baseUrl : null;
} }
if (body.clientType !== undefined) { if (body.clientType !== undefined) {
if (typeof body.clientType !== "string") throw badRequest("clientType 必须是字符串。"); if (typeof body.clientType !== "string") throw badRequest("clientType must be a string.");
if (body.clientType) req.clientType = body.clientType; if (body.clientType) req.clientType = body.clientType;
} }
return c.json(await deps.projectConfigService.testModel(projectId, req)); return c.json(await deps.projectConfigService.testModel(projectId, req));
+10 -9
View File
@@ -48,7 +48,7 @@ function parseUpsertBody(body: Record<string, unknown>): {
modelId?: string; modelId?: string;
provider?: string; provider?: string;
} { } {
if (typeof body.enabled !== "boolean") throw badRequest("enabled 必须是布尔值。"); if (typeof body.enabled !== "boolean") throw badRequest("enabled must be a boolean.");
const prompt = requireString(body, "prompt", { minLen: 1, maxLen: 100_000 }); const prompt = requireString(body, "prompt", { minLen: 1, maxLen: 100_000 });
const startAt = requireString(body, "startAt", { minLen: 1, maxLen: 100 }); const startAt = requireString(body, "startAt", { minLen: 1, maxLen: 100 });
const period = optionalString(body, "period", { minLen: 1, maxLen: 20 }); const period = optionalString(body, "period", { minLen: 1, maxLen: 20 });
@@ -128,7 +128,7 @@ function toItem(
/** Schedule name in the path: same character rules as directories/files, validated before any path construction. */ /** Schedule name in the path: same character rules as directories/files, validated before any path construction. */
function requireScheduleName(raw: string | undefined): string { function requireScheduleName(raw: string | undefined): string {
if (!raw || !isValidId(raw)) throw badRequest("定时任务名非法。"); if (!raw || !isValidId(raw)) throw badRequest("Invalid schedule name.");
return raw; return raw;
} }
@@ -157,7 +157,7 @@ export function scheduleRoutes(deps: AppDeps): Hono<AppEnv> {
const body = await readJson(c); const body = await readJson(c);
const name = requireScheduleName(requireString(body, "name", { minLen: 1, maxLen: 100 })); const name = requireScheduleName(requireString(body, "name", { minLen: 1, maxLen: 100 }));
if (await readScheduleFile(deps.config.root, projectId, agentId, name)) { if (await readScheduleFile(deps.config.root, projectId, agentId, name)) {
throw new HttpError(409, "schedule_exists", `定时任务已存在:${name}`); throw new HttpError(409, "schedule_exists", `Schedule already exists: ${name}`);
} }
await upsert(deps, c.var.user.userId, projectId, agentId, name, body); await upsert(deps, c.var.user.userId, projectId, agentId, name, body);
return c.json(await readItem(deps, projectId, agentId, name), 201); return c.json(await readItem(deps, projectId, agentId, name), 201);
@@ -178,7 +178,7 @@ export function scheduleRoutes(deps: AppDeps): Hono<AppEnv> {
deps.projectService.requireProjectOwner(c.var.user.userId, projectId); deps.projectService.requireProjectOwner(c.var.user.userId, projectId);
const name = requireScheduleName(c.req.param("name")); const name = requireScheduleName(c.req.param("name"));
if (!(await readScheduleFile(deps.config.root, projectId, agentId, name))) { if (!(await readScheduleFile(deps.config.root, projectId, agentId, name))) {
throw new HttpError(404, "schedule_not_found", `定时任务不存在:${name}`); throw new HttpError(404, "schedule_not_found", `Schedule does not exist: ${name}`);
} }
const body = await readJson(c); const body = await readJson(c);
await upsert(deps, c.var.user.userId, projectId, agentId, name, body); await upsert(deps, c.var.user.userId, projectId, agentId, name, body);
@@ -191,7 +191,8 @@ export function scheduleRoutes(deps: AppDeps): Hono<AppEnv> {
deps.projectService.requireProjectOwner(c.var.user.userId, projectId); deps.projectService.requireProjectOwner(c.var.user.userId, projectId);
const name = requireScheduleName(c.req.param("name")); const name = requireScheduleName(c.req.param("name"));
const removed = await deleteScheduleFile(deps.config.root, projectId, agentId, name); const removed = await deleteScheduleFile(deps.config.root, projectId, agentId, name);
if (!removed) throw new HttpError(404, "schedule_not_found", `定时任务不存在:${name}`); if (!removed)
throw new HttpError(404, "schedule_not_found", `Schedule does not exist: ${name}`);
deps.scheduler.dropEntry(projectId, agentId, name); deps.scheduler.dropEntry(projectId, agentId, name);
return c.body(null, 204); return c.body(null, 204);
}); });
@@ -211,10 +212,10 @@ async function upsert(
const fields = parseUpsertBody(body); const fields = parseUpsertBody(body);
const raw = serializeSchedule(fields); const raw = serializeSchedule(fields);
const parsed = parseScheduleFile(name, raw); const parsed = parseScheduleFile(name, raw);
if (!parsed.ok) throw badRequest(`定时任务配置非法:${parsed.error}`); if (!parsed.ok) throw badRequest(`Invalid schedule configuration: ${parsed.error}`);
// At save time, verify the model reference resolves (resolveModelRef semantics; same rules as reconciliation) so we never persist a broken file. // At save time, verify the model reference resolves (resolveModelRef semantics; same rules as reconciliation) so we never persist a broken file.
const refError = await validateScheduleModelRef(deps.config.root, projectId, parsed.def); const refError = await validateScheduleModelRef(deps.config.root, projectId, parsed.def);
if (refError !== null) throw badRequest(`定时任务配置非法:${refError}`); if (refError !== null) throw badRequest(`Invalid schedule configuration: ${refError}`);
await writeScheduleFile(deps.config.root, projectId, agentId, name, raw); await writeScheduleFile(deps.config.root, projectId, agentId, name, raw);
// Creator attribution: the API writer is the creator (falls back to the Project owner only for hand-edited files). // Creator attribution: the API writer is the creator (falls back to the Project owner only for hand-edited files).
deps.schedulesRepo.registerOrSync({ deps.schedulesRepo.registerOrSync({
@@ -238,6 +239,6 @@ async function readItem(
const entry = entries.find((e) => e.def.name === name); const entry = entries.find((e) => e.def.name === name);
if (entry) return toItem(entry.def, entry.state, entry.queued, Date.now()); if (entry) return toItem(entry.def, entry.state, entry.queued, Date.now());
const bad = invalid.find((i) => i.name === name); const bad = invalid.find((i) => i.name === name);
if (bad) throw badRequest(`定时任务文件非法:${bad.error}`); if (bad) throw badRequest(`Invalid schedule file: ${bad.error}`);
throw new HttpError(404, "schedule_not_found", `定时任务不存在:${name}`); throw new HttpError(404, "schedule_not_found", `Schedule does not exist: ${name}`);
} }
+39 -17
View File
@@ -59,16 +59,16 @@ const APPROVAL_MODES: readonly ApprovalMode[] = [
function parseTaskInput(body: Record<string, unknown>): OmniMessage[] { function parseTaskInput(body: Record<string, unknown>): OmniMessage[] {
const input = body.input; const input = body.input;
if (!Array.isArray(input) || input.length === 0) { if (!Array.isArray(input) || input.length === 0) {
throw badRequest("input 必须是至少包含一项的数组。"); throw badRequest("input must be an array with at least one item.");
} }
return input.map((item, i) => { return input.map((item, i) => {
if (item === null || typeof item !== "object" || Array.isArray(item)) { if (item === null || typeof item !== "object" || Array.isArray(item)) {
throw badRequest(`input[${i}] 必须是对象。`); throw badRequest(`input[${i}] must be an object.`);
} }
const part = item as Record<string, unknown>; const part = item as Record<string, unknown>;
if (part.type === "text") { if (part.type === "text") {
if (typeof part.text !== "string" || part.text.length === 0) { if (typeof part.text !== "string" || part.text.length === 0) {
throw badRequest(`input[${i}].text 必须是非空字符串。`); throw badRequest(`input[${i}].text must be a non-empty string.`);
} }
return userText(part.text); return userText(part.text);
} }
@@ -78,11 +78,11 @@ function parseTaskInput(body: Record<string, unknown>): OmniMessage[] {
typeof url !== "string" || typeof url !== "string" ||
!(url.startsWith("data:") || url.startsWith("http://") || url.startsWith("https://")) !(url.startsWith("data:") || url.startsWith("http://") || url.startsWith("https://"))
) { ) {
throw badRequest(`input[${i}].imageUrl 仅支持 data: 或 http(s) URL。`); throw badRequest(`input[${i}].imageUrl only supports data: or http(s) URLs.`);
} }
return imageUrlMessage(url); return imageUrlMessage(url);
} }
throw badRequest(`input[${i}].type 必须是 text / image_url 之一。`); throw badRequest(`input[${i}].type must be one of text / image_url.`);
}); });
} }
@@ -110,7 +110,9 @@ export function agentSessionsRoutes(deps: AppDeps): Hono<AppEnv> {
const provider = optionalString(body, "provider", { minLen: 1, label: "provider" }); const provider = optionalString(body, "provider", { minLen: 1, label: "provider" });
// Model reference is submitted as a pair: provider can't appear without modelId (core does the same validation; this catches it early). // Model reference is submitted as a pair: provider can't appear without modelId (core does the same validation; this catches it early).
if (provider !== undefined && modelId === undefined) { if (provider !== undefined && modelId === undefined) {
throw badRequest("指定了 provider 却未指定 modelId:模型引用须成对给出。"); throw badRequest(
"provider is specified but modelId is not: a model reference must be given as a pair.",
);
} }
const approvalMode = optionalEnum(body, "approvalMode", APPROVAL_MODES); const approvalMode = optionalEnum(body, "approvalMode", APPROVAL_MODES);
let workspace = optionalString(body, "workspace", { minLen: 1, label: "workspace" }); let workspace = optionalString(body, "workspace", { minLen: 1, label: "workspace" });
@@ -141,12 +143,20 @@ export function sessionsRoutes(deps: AppDeps): Hono<AppEnv> {
const sessionId = c.req.param("sessionId"); const sessionId = c.req.param("sessionId");
const row = sessionId ? deps.sessionsRepo.findById(sessionId) : null; const row = sessionId ? deps.sessionsRepo.findById(sessionId) : null;
if (!row) { if (!row) {
throw new HttpError(404, "session_not_found", "Session 不存在或无权访问。"); throw new HttpError(
404,
"session_not_found",
"Session does not exist or you do not have access.",
);
} }
try { try {
deps.projectService.requireProjectAccess(c.var.user.userId, row.projectId); deps.projectService.requireProjectAccess(c.var.user.userId, row.projectId);
} catch { } catch {
throw new HttpError(404, "session_not_found", "Session 不存在或无权访问。"); throw new HttpError(
404,
"session_not_found",
"Session does not exist or you do not have access.",
);
} }
return row; return row;
}; };
@@ -167,15 +177,23 @@ export function sessionsRoutes(deps: AppDeps): Hono<AppEnv> {
let title: string | undefined; let title: string | undefined;
if (titleRaw !== undefined) { if (titleRaw !== undefined) {
if (typeof titleRaw !== "string") { if (typeof titleRaw !== "string") {
throw new HttpError(400, "invalid_title", "title 必须是字符串。"); throw new HttpError(400, "invalid_title", "title must be a string.");
} }
title = titleRaw.trim(); title = titleRaw.trim();
if (!title || title.length > SESSION_TITLE_MAX) { if (!title || title.length > SESSION_TITLE_MAX) {
throw new HttpError(400, "invalid_title", `title 需为 1–${SESSION_TITLE_MAX} 个字符。`); throw new HttpError(
400,
"invalid_title",
`title must be 1–${SESSION_TITLE_MAX} characters.`,
);
} }
} }
if (approvalMode === undefined && archived === undefined && title === undefined) { if (approvalMode === undefined && archived === undefined && title === undefined) {
throw new HttpError(400, "no_update", "缺少可更新字段(approvalMode / archived / title)。"); throw new HttpError(
400,
"no_update",
"No updatable field provided (approvalMode / archived / title).",
);
} }
let updated: SessionRow = { ...row }; let updated: SessionRow = { ...row };
if (title !== undefined) { if (title !== undefined) {
@@ -239,7 +257,7 @@ export function sessionsRoutes(deps: AppDeps): Hono<AppEnv> {
const row = resolveSession(c); const row = resolveSession(c);
const fileName = c.req.param("fileName") ?? ""; const fileName = c.req.param("fileName") ?? "";
if (!/^[A-Za-z0-9._-]+$/.test(fileName) || fileName.includes("..")) { if (!/^[A-Za-z0-9._-]+$/.test(fileName) || fileName.includes("..")) {
throw new HttpError(404, "file_not_found", "文件不存在。"); throw new HttpError(404, "file_not_found", "File does not exist.");
} }
const filePath = path.join( const filePath = path.join(
scratchpadDir(deps.config.root, row.projectId, row.agentId), scratchpadDir(deps.config.root, row.projectId, row.agentId),
@@ -250,7 +268,7 @@ export function sessionsRoutes(deps: AppDeps): Hono<AppEnv> {
try { try {
bytes = await fs.readFile(filePath); bytes = await fs.readFile(filePath);
} catch { } catch {
throw new HttpError(404, "file_not_found", "文件不存在。"); throw new HttpError(404, "file_not_found", "File does not exist.");
} }
const MIME_BY_EXT: Record<string, string> = { const MIME_BY_EXT: Record<string, string> = {
".png": "image/png", ".png": "image/png",
@@ -309,7 +327,11 @@ export function sessionsRoutes(deps: AppDeps): Hono<AppEnv> {
const decision = requireEnum(body, "decision", ["allow", "deny"] as const); const decision = requireEnum(body, "decision", ["allow", "deny"] as const);
const ok = deps.manager.decideApproval(row.sessionId, pathParam(c, "toolCallId"), decision); const ok = deps.manager.decideApproval(row.sessionId, pathParam(c, "toolCallId"), decision);
if (!ok) { if (!ok) {
throw new HttpError(404, "approval_not_found", "该审批不存在或已被决定。"); throw new HttpError(
404,
"approval_not_found",
"Approval does not exist or has already been decided.",
);
} }
return c.body(null, 204); return c.body(null, 204);
}); });
@@ -374,7 +396,7 @@ export function sessionsRoutes(deps: AppDeps): Hono<AppEnv> {
!paths.every((p) => typeof p === "string" && p.length <= STAT_MAX_PATH_LEN) !paths.every((p) => typeof p === "string" && p.length <= STAT_MAX_PATH_LEN)
) { ) {
throw badRequest( throw badRequest(
`paths 必须是字符串数组(≤${STAT_MAX_PATHS} 项,每项 ≤${STAT_MAX_PATH_LEN} 字符)。`, `paths must be an array of strings (≤${STAT_MAX_PATHS} items, each ≤${STAT_MAX_PATH_LEN} characters).`,
); );
} }
const existing = await deps.workspaceFiles.statExisting(row.workspace, paths as string[]); const existing = await deps.workspaceFiles.statExisting(row.workspace, paths as string[]);
@@ -386,11 +408,11 @@ export function sessionsRoutes(deps: AppDeps): Hono<AppEnv> {
const rel = c.req.query("path") ?? ""; const rel = c.req.query("path") ?? "";
const body = await readJson(c); const body = await readJson(c);
if (typeof body.dataBase64 !== "string") { if (typeof body.dataBase64 !== "string") {
throw badRequest("dataBase64 必须是 base64 字符串。"); throw badRequest("dataBase64 must be a base64 string.");
} }
const data = Buffer.from(body.dataBase64, "base64"); const data = Buffer.from(body.dataBase64, "base64");
if (data.length > MAX_UPLOAD_BYTES) { if (data.length > MAX_UPLOAD_BYTES) {
throw new HttpError(413, "file_too_large", "上传文件超过 14MB 上限。"); throw new HttpError(413, "file_too_large", "Uploaded file exceeds the 14MB limit.");
} }
await deps.workspaceFiles.write(row.workspace, rel, data); await deps.workspaceFiles.write(row.workspace, rel, data);
return c.body(null, 204); return c.body(null, 204);
+4 -4
View File
@@ -64,11 +64,11 @@ function libraryResponse(): SkillLibraryResponse {
/** Validate the POST request body: names must be a non-empty array of strings. */ /** Validate the POST request body: names must be a non-empty array of strings. */
function parseInstallNames(body: Record<string, unknown>): string[] { function parseInstallNames(body: Record<string, unknown>): string[] {
if (!Array.isArray(body.names) || body.names.length === 0) { if (!Array.isArray(body.names) || body.names.length === 0) {
throw badRequest("names 必须是非空数组。"); throw badRequest("names must be a non-empty array.");
} }
return body.names.map((v, i) => { return body.names.map((v, i) => {
if (typeof v !== "string" || v.length === 0) { if (typeof v !== "string" || v.length === 0) {
throw badRequest(`names[${i}] 必须是非空字符串。`); throw badRequest(`names[${i}] must be a non-empty string.`);
} }
return v; return v;
}); });
@@ -110,7 +110,7 @@ export function agentSkillsRoutes(deps: AppDeps): Hono<AppEnv> {
// Verify all names up front before writing anything: if any name isn't in the library, reject the whole request rather than leaving a half-installed state. // Verify all names up front before writing anything: if any name isn't in the library, reject the whole request rather than leaving a half-installed state.
const skills: LibrarySkill[] = names.map((name) => { const skills: LibrarySkill[] = names.map((name) => {
const skill = librarySkill(name); const skill = librarySkill(name);
if (!skill) throw new HttpError(404, "unknown_skill", `Skill 库中不存在:${name}`); if (!skill) throw new HttpError(404, "unknown_skill", `Skill is not in the library: ${name}`);
return skill; return skill;
}); });
for (const skill of skills) { for (const skill of skills) {
@@ -129,7 +129,7 @@ export function agentSkillsRoutes(deps: AppDeps): Hono<AppEnv> {
try { try {
await fs.access(file); await fs.access(file);
} catch { } catch {
throw new HttpError(404, "not_found", `Skill 未安装:${name}`); throw new HttpError(404, "not_found", `Skill is not installed: ${name}`);
} }
await removeSkill(deps.config.root, projectId, agentId, name); await removeSkill(deps.config.root, projectId, agentId, name);
return c.body(null, 204); return c.body(null, 204);
+1 -1
View File
@@ -20,7 +20,7 @@ export function usageRoutes(deps: AppDeps): Hono<AppEnv> {
deps.projectService.requireProjectAccess(c.var.user.userId, projectId); deps.projectService.requireProjectAccess(c.var.user.userId, projectId);
const groupByRaw = c.req.query("groupBy") ?? "date"; const groupByRaw = c.req.query("groupBy") ?? "date";
if (!(GROUP_BYS as readonly string[]).includes(groupByRaw)) { if (!(GROUP_BYS as readonly string[]).includes(groupByRaw)) {
throw badRequest(`groupBy 必须是 ${GROUP_BYS.join(" / ")} 之一。`); throw badRequest(`groupBy must be one of ${GROUP_BYS.join(" / ")}.`);
} }
const from = optionalDateParam(c.req.query("from"), "from"); const from = optionalDateParam(c.req.query("from"), "from");
const to = optionalDateParam(c.req.query("to"), "to"); const to = optionalDateParam(c.req.query("to"), "to");
+3 -3
View File
@@ -11,10 +11,10 @@ import type { AppDeps } from "../../app.js";
/** Validate the PUT request body and shape it into a VaultUpdateRequest (semantic checks like key-name rules live in the service layer). */ /** Validate the PUT request body and shape it into a VaultUpdateRequest (semantic checks like key-name rules live in the service layer). */
function parseVaultUpdate(body: Record<string, unknown>): VaultUpdateRequest { function parseVaultUpdate(body: Record<string, unknown>): VaultUpdateRequest {
if (!Array.isArray(body.entries)) throw badRequest("entries 必须是数组。"); if (!Array.isArray(body.entries)) throw badRequest("entries must be an array.");
const entries: VaultEntryUpdate[] = body.entries.map((item, i) => { const entries: VaultEntryUpdate[] = body.entries.map((item, i) => {
if (item === null || typeof item !== "object" || Array.isArray(item)) { if (item === null || typeof item !== "object" || Array.isArray(item)) {
throw badRequest(`entries[${i}] 必须是对象。`); throw badRequest(`entries[${i}] must be an object.`);
} }
const e = item as Record<string, unknown>; const e = item as Record<string, unknown>;
const entry: VaultEntryUpdate = { const entry: VaultEntryUpdate = {
@@ -22,7 +22,7 @@ function parseVaultUpdate(body: Record<string, unknown>): VaultUpdateRequest {
}; };
if (e.value !== undefined) { if (e.value !== undefined) {
if (typeof e.value !== "string" || e.value.length === 0) { if (typeof e.value !== "string" || e.value.length === 0) {
throw badRequest(`entries[${i}].value 必须是非空字符串。`); throw badRequest(`entries[${i}].value must be a non-empty string.`);
} }
entry.value = e.value; entry.value = e.value;
} }
+18 -17
View File
@@ -19,7 +19,7 @@ export function badRequest(message: string): HttpError {
export function pathParam(c: Context, name: string): string { export function pathParam(c: Context, name: string): string {
const v = c.req.param(name); const v = c.req.param(name);
if (v === undefined || v === "") { if (v === undefined || v === "") {
throw new HttpError(404, "not_found", "路径参数缺失。"); throw new HttpError(404, "not_found", "Missing path parameter.");
} }
return v; return v;
} }
@@ -35,7 +35,7 @@ export function pathParam(c: Context, name: string): string {
export function requireValidId(c: Context, name: string): string { export function requireValidId(c: Context, name: string): string {
const v = pathParam(c, name); const v = pathParam(c, name);
if (!isValidId(v)) { if (!isValidId(v)) {
throw new HttpError(404, "not_found", "资源不存在或无权访问。"); throw new HttpError(404, "not_found", "Resource does not exist or you do not have access.");
} }
return v; return v;
} }
@@ -43,7 +43,7 @@ export function requireValidId(c: Context, name: string): string {
/** Parse a positive-integer path parameter (e.g. Trace file index). */ /** Parse a positive-integer path parameter (e.g. Trace file index). */
export function positiveIntParam(c: Context, name: string): number { export function positiveIntParam(c: Context, name: string): number {
const v = Number.parseInt(pathParam(c, name), 10); const v = Number.parseInt(pathParam(c, name), 10);
if (!Number.isInteger(v) || v < 1) throw badRequest(`${name} 必须是正整数。`); if (!Number.isInteger(v) || v < 1) throw badRequest(`${name} must be a positive integer.`);
return v; return v;
} }
@@ -51,9 +51,10 @@ export function positiveIntParam(c: Context, name: string): number {
export function paginationQuery(c: Context): { offset: number; limit: number } { export function paginationQuery(c: Context): { offset: number; limit: number } {
const offset = Number.parseInt(c.req.query("offset") ?? "0", 10); const offset = Number.parseInt(c.req.query("offset") ?? "0", 10);
const limit = Number.parseInt(c.req.query("limit") ?? "200", 10); const limit = Number.parseInt(c.req.query("limit") ?? "200", 10);
if (!Number.isInteger(offset) || offset < 0) throw badRequest("offset 必须是非负整数。"); if (!Number.isInteger(offset) || offset < 0)
throw badRequest("offset must be a non-negative integer.");
if (!Number.isInteger(limit) || limit < 1 || limit > 1000) { if (!Number.isInteger(limit) || limit < 1 || limit > 1000) {
throw badRequest("limit 必须是 1~1000 的整数。"); throw badRequest("limit must be an integer between 1 and 1000.");
} }
return { offset, limit }; return { offset, limit };
} }
@@ -64,10 +65,10 @@ export async function readJson(c: Context): Promise<Record<string, unknown>> {
try { try {
body = await c.req.json(); body = await c.req.json();
} catch { } catch {
throw badRequest("请求体必须是合法 JSON。"); throw badRequest("Request body must be valid JSON.");
} }
if (body === null || typeof body !== "object" || Array.isArray(body)) { if (body === null || typeof body !== "object" || Array.isArray(body)) {
throw badRequest("请求体必须是 JSON 对象。"); throw badRequest("Request body must be a JSON object.");
} }
return body as Record<string, unknown>; return body as Record<string, unknown>;
} }
@@ -87,15 +88,15 @@ export function requireString(
): string { ): string {
const v = obj[key]; const v = obj[key];
const label = rule.label ?? key; const label = rule.label ?? key;
if (typeof v !== "string") throw badRequest(`${label} 必须是字符串。`); if (typeof v !== "string") throw badRequest(`${label} must be a string.`);
if (rule.minLen !== undefined && v.length < rule.minLen) { if (rule.minLen !== undefined && v.length < rule.minLen) {
throw badRequest(`${label} 长度至少 ${rule.minLen} 个字符。`); throw badRequest(`${label} must be at least ${rule.minLen} characters.`);
} }
if (rule.maxLen !== undefined && v.length > rule.maxLen) { if (rule.maxLen !== undefined && v.length > rule.maxLen) {
throw badRequest(`${label} 长度不能超过 ${rule.maxLen} 个字符。`); throw badRequest(`${label} must be at most ${rule.maxLen} characters.`);
} }
if (rule.pattern !== undefined && !rule.pattern.test(v)) { if (rule.pattern !== undefined && !rule.pattern.test(v)) {
throw badRequest(`${label} 格式不合法。`); throw badRequest(`${label} has an invalid format.`);
} }
return v; return v;
} }
@@ -117,7 +118,7 @@ export function requireEnum<T extends string>(
): T { ): T {
const v = obj[key]; const v = obj[key];
if (typeof v !== "string" || !(values as readonly string[]).includes(v)) { if (typeof v !== "string" || !(values as readonly string[]).includes(v)) {
throw badRequest(`${label} 必须是 ${values.join(" / ")} 之一。`); throw badRequest(`${label} must be one of ${values.join(" / ")}.`);
} }
return v as T; return v as T;
} }
@@ -150,12 +151,12 @@ export function optionalNumber(
const v = obj[key]; const v = obj[key];
if (v === undefined) return undefined; if (v === undefined) return undefined;
const label = rule.label ?? key; const label = rule.label ?? key;
if (typeof v !== "number" || !Number.isFinite(v)) throw badRequest(`${label} 必须是数字。`); if (typeof v !== "number" || !Number.isFinite(v)) throw badRequest(`${label} must be a number.`);
if (rule.integer && !Number.isInteger(v)) throw badRequest(`${label} 必须是整数。`); if (rule.integer && !Number.isInteger(v)) throw badRequest(`${label} must be an integer.`);
if (rule.positiveOrMinusOne && !(v > 0 || v === -1)) { if (rule.positiveOrMinusOne && !(v > 0 || v === -1)) {
throw badRequest(`${label} 必须大于 0 或为 -1。`); throw badRequest(`${label} must be greater than 0 or equal to -1.`);
} }
if (rule.nonNegative && v < 0) throw badRequest(`${label} 不能为负数。`); if (rule.nonNegative && v < 0) throw badRequest(`${label} must not be negative.`);
return v; return v;
} }
@@ -163,7 +164,7 @@ export function optionalNumber(
export function optionalDateParam(value: string | undefined, label: string): string | undefined { export function optionalDateParam(value: string | undefined, label: string): string | undefined {
if (value === undefined || value === "") return undefined; if (value === undefined || value === "") return undefined;
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) { if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
throw badRequest(`${label} 必须是 YYYY-MM-DD 格式。`); throw badRequest(`${label} must be in YYYY-MM-DD format.`);
} }
return value; return value;
} }
+5 -5
View File
@@ -27,8 +27,8 @@ await deps.authService.seedAdmin();
await deps.scheduler.start(); await deps.scheduler.start();
const server = serve({ fetch: app.fetch, hostname: config.host, port: config.port }, (info) => { const server = serve({ fetch: app.fetch, hostname: config.host, port: config.port }, (info) => {
console.log(`penguin-server 已启动: http://${config.host}:${info.port}`); console.log(`penguin-server started: http://${config.host}:${info.port}`);
console.log(`数据根目录: ${config.root}`); console.log(`Data root: ${config.root}`);
console.log(`SQLite: ${config.dbPath}`); console.log(`SQLite: ${config.dbPath}`);
}); });
@@ -36,7 +36,7 @@ let shuttingDown = false;
async function shutdown(signal: string, exitCode = 0): Promise<void> { async function shutdown(signal: string, exitCode = 0): Promise<void> {
if (shuttingDown) return; if (shuttingDown) return;
shuttingDown = true; shuttingDown = true;
console.log(`收到 ${signal},正在关停…`); console.log(`Received ${signal}, shutting down…`);
deps.scheduler.stop(); deps.scheduler.stop();
await deps.manager.shutdown(5000); await deps.manager.shutdown(5000);
deps.channels.dispose(); deps.channels.dispose();
@@ -56,7 +56,7 @@ process.on("SIGTERM", () => void shutdown("SIGTERM"));
// reaches the process without passing through any catch — persist it first for a // reaches the process without passing through any catch — persist it first for a
// record, then handle each case according to its nature. // record, then handle each case according to its nature.
process.on("uncaughtException", (err) => { process.on("uncaughtException", (err) => {
console.error(`[server] 未捕获异常: ${err.stack ?? err.message}`); console.error(`[server] Uncaught exception: ${err.stack ?? err.message}`);
deps.errors.record({ source: "process", err, code: "uncaught_exception" }); deps.errors.record({ source: "process", err, code: "uncaught_exception" });
// From this point the process state can't be trusted (the error was never converged // From this point the process state can't be trusted (the error was never converged
// by any catch): don't swallow it — wrap up per existing shutdown semantics and exit // by any catch): don't swallow it — wrap up per existing shutdown semantics and exit
@@ -68,7 +68,7 @@ process.on("uncaughtException", (err) => {
}); });
process.on("unhandledRejection", (reason) => { process.on("unhandledRejection", (reason) => {
const err = reason instanceof Error ? reason : new Error(String(reason)); const err = reason instanceof Error ? reason : new Error(String(reason));
console.error(`[server] 未处理的 Promise 拒绝: ${err.stack ?? err.message}`); console.error(`[server] Unhandled promise rejection: ${err.stack ?? err.message}`);
deps.errors.record({ source: "process", err, code: "unhandled_rejection" }); deps.errors.record({ source: "process", err, code: "unhandled_rejection" });
// Unlike uncaughtException, this **doesn't** exit: a rejected promise is a localized // Unlike uncaughtException, this **doesn't** exit: a rejected promise is a localized
// failure of some background task, and the process state isn't compromised; dragging // failure of some background task, and the process state isn't compromised; dragging
+22 -16
View File
@@ -82,30 +82,31 @@ export function parseScheduleFile(name: string, raw: string): ScheduleParseResul
} catch (err) { } catch (err) {
return { return {
ok: false, ok: false,
error: `TOML 解析失败:${err instanceof Error ? err.message : String(err)}`, error: `Failed to parse TOML: ${err instanceof Error ? err.message : String(err)}`,
}; };
} }
if (parsed === null || typeof parsed !== "object") if (parsed === null || typeof parsed !== "object")
return { ok: false, error: "内容不是 TOML 表" }; return { ok: false, error: "Content is not a TOML table" };
const t = parsed as Record<string, unknown>; const t = parsed as Record<string, unknown>;
const prompt = t["prompt"]; const prompt = t["prompt"];
if (typeof prompt !== "string" || prompt.trim() === "") { if (typeof prompt !== "string" || prompt.trim() === "") {
return { ok: false, error: "缺少必填字段 prompt" }; return { ok: false, error: "Missing required field prompt" };
} }
const enabled = t["enabled"] === undefined ? false : t["enabled"]; const enabled = t["enabled"] === undefined ? false : t["enabled"];
if (typeof enabled !== "boolean") return { ok: false, error: "enabled 必须是布尔值" }; if (typeof enabled !== "boolean") return { ok: false, error: "enabled must be a boolean" };
const startAt = parseInstant(t["start_at"]); const startAt = parseInstant(t["start_at"]);
if (startAt === null) return { ok: false, error: "start_at 缺失或不是合法的 ISO 8601 时刻" }; if (startAt === null)
return { ok: false, error: "start_at is missing or not a valid ISO 8601 instant" };
let period: string | undefined; let period: string | undefined;
let periodMs: number | undefined; let periodMs: number | undefined;
if (t["period"] !== undefined) { if (t["period"] !== undefined) {
if (typeof t["period"] !== "string") return { ok: false, error: "period 必须是字符串" }; if (typeof t["period"] !== "string") return { ok: false, error: "period must be a string" };
const ms = parsePeriod(t["period"]); const ms = parsePeriod(t["period"]);
if (ms === null) return { ok: false, error: "period 必须形如 30m / 12h / 7d" }; if (ms === null) return { ok: false, error: "period must look like 30m / 12h / 7d" };
if (ms < MIN_PERIOD_MS) return { ok: false, error: "period 低于下限 5m" }; if (ms < MIN_PERIOD_MS) return { ok: false, error: "period is below the 5m minimum" };
period = t["period"].trim(); period = t["period"].trim();
periodMs = ms; periodMs = ms;
} }
@@ -113,41 +114,46 @@ export function parseScheduleFile(name: string, raw: string): ScheduleParseResul
let endAt: { ms: number; raw: string } | undefined; let endAt: { ms: number; raw: string } | undefined;
if (t["end_at"] !== undefined) { if (t["end_at"] !== undefined) {
const parsedEnd = parseInstant(t["end_at"]); const parsedEnd = parseInstant(t["end_at"]);
if (parsedEnd === null) return { ok: false, error: "end_at 不是合法的 ISO 8601 时刻" }; if (parsedEnd === null) return { ok: false, error: "end_at is not a valid ISO 8601 instant" };
if (parsedEnd.ms <= startAt.ms) return { ok: false, error: "end_at 必须晚于 start_at" }; if (parsedEnd.ms <= startAt.ms)
return { ok: false, error: "end_at must be later than start_at" };
endAt = parsedEnd; endAt = parsedEnd;
} }
let sessionId: string | undefined; let sessionId: string | undefined;
if (t["session_id"] !== undefined) { if (t["session_id"] !== undefined) {
if (typeof t["session_id"] !== "string" || t["session_id"] === "") { if (typeof t["session_id"] !== "string" || t["session_id"] === "") {
return { ok: false, error: "session_id 必须是非空字符串" }; return { ok: false, error: "session_id must be a non-empty string" };
} }
sessionId = t["session_id"]; sessionId = t["session_id"];
} }
let workspace: string | undefined; let workspace: string | undefined;
if (t["workspace"] !== undefined) { if (t["workspace"] !== undefined) {
if (typeof t["workspace"] !== "string" || t["workspace"] === "") { if (typeof t["workspace"] !== "string" || t["workspace"] === "") {
return { ok: false, error: "workspace 必须是非空字符串" }; return { ok: false, error: "workspace must be a non-empty string" };
} }
workspace = t["workspace"]; workspace = t["workspace"];
} }
let modelId: string | undefined; let modelId: string | undefined;
if (t["model_id"] !== undefined) { if (t["model_id"] !== undefined) {
if (typeof t["model_id"] !== "string" || t["model_id"] === "") { if (typeof t["model_id"] !== "string" || t["model_id"] === "") {
return { ok: false, error: "model_id 必须是非空字符串" }; return { ok: false, error: "model_id must be a non-empty string" };
} }
modelId = t["model_id"]; modelId = t["model_id"];
} }
let provider: string | undefined; let provider: string | undefined;
if (t["provider"] !== undefined) { if (t["provider"] !== undefined) {
if (typeof t["provider"] !== "string" || t["provider"] === "") { if (typeof t["provider"] !== "string" || t["provider"] === "") {
return { ok: false, error: "provider 必须是非空字符串" }; return { ok: false, error: "provider must be a non-empty string" };
} }
provider = t["provider"]; provider = t["provider"];
} }
if (provider !== undefined && modelId === undefined) { if (provider !== undefined && modelId === undefined) {
return { ok: false, error: "provider 仅与 model_id 成对使用(模型引用须成对给出)" }; return {
ok: false,
error:
"provider is only used together with model_id (a model reference must be given as a pair)",
};
} }
if ( if (
sessionId !== undefined && sessionId !== undefined &&
@@ -155,7 +161,7 @@ export function parseScheduleFile(name: string, raw: string): ScheduleParseResul
) { ) {
return { return {
ok: false, ok: false,
error: "目标二选一:workspace 与 provider / model_id 仅用于新建 Session 模式", error: "Pick one target: workspace and provider / model_id are only for new-Session mode",
}; };
} }
+34 -13
View File
@@ -52,9 +52,10 @@ import type { UsageContext } from "./usage-recorder.js";
/** 409 for when there's nothing to compact: give the specific reason rather than a one-size-fits-none message. */ /** 409 for when there's nothing to compact: give the specific reason rather than a one-size-fits-none message. */
function compactUnavailable(why: Exclude<CompactAvailability, "ok">): HttpError { function compactUnavailable(why: Exclude<CompactAvailability, "ok">): HttpError {
const messages: Record<typeof why, string> = { const messages: Record<typeof why, string> = {
unsupported: "该 Agent 未配置上下文压缩能力。", unsupported: "This Agent does not have context compaction configured.",
empty: "当前上下文没有可压缩的内容(尚无已完成的对话轮次)。", empty: "The current context has nothing to compact (no completed conversation turns yet).",
just_compacted: "上下文刚压缩过,此后还没有新的对话,无需再次压缩。", just_compacted:
"The context was just compacted and there is no new conversation since; no need to compact again.",
}; };
return new HttpError(409, "nothing_to_compact", messages[why]); return new HttpError(409, "nothing_to_compact", messages[why]);
} }
@@ -129,7 +130,7 @@ export function createCoreSessionLoader(root: string): SessionLoader {
throw new HttpError( throw new HttpError(
409, 409,
"workspace_missing", "workspace_missing",
`该 Session 的 Workspace 已不存在:${row.workspace},无法继续。请新建 Session。`, `This Session's Workspace no longer exists: ${row.workspace}, so it cannot continue. Create a new Session.`,
); );
} }
try { try {
@@ -533,7 +534,11 @@ export class SessionManager {
private assertOpen(): void { private assertOpen(): void {
if (this.closed) { if (this.closed) {
throw new HttpError(503, "shutting_down", "服务端正在关停,暂不接受新任务。"); throw new HttpError(
503,
"shutting_down",
"Server is shutting down; not accepting new Tasks.",
);
} }
} }
@@ -541,23 +546,35 @@ export class SessionManager {
private assertAgentNotDeleting(sessionId: string): void { private assertAgentNotDeleting(sessionId: string): void {
const row = this.deps.sessions.findById(sessionId); const row = this.deps.sessions.findById(sessionId);
if (row && this.deletingAgents.has(agentKey(row.projectId, row.agentId))) { if (row && this.deletingAgents.has(agentKey(row.projectId, row.agentId))) {
throw new HttpError(409, "agent_deleting", "该 Agent 正在删除,暂不接受新任务。"); throw new HttpError(
409,
"agent_deleting",
"This Agent is being deleted; not accepting new Tasks.",
);
} }
} }
/** This Session is being deleted → 409 (guards against the entry/Trace being rebuilt and reviving it inside the deletion race window). */ /** This Session is being deleted → 409 (guards against the entry/Trace being rebuilt and reviving it inside the deletion race window). */
private assertSessionNotDeleting(sessionId: string): void { private assertSessionNotDeleting(sessionId: string): void {
if (this.deletingSessions.has(sessionId)) { if (this.deletingSessions.has(sessionId)) {
throw new HttpError(409, "session_deleting", "该 Session 正在删除,暂不接受新任务。"); throw new HttpError(
409,
"session_deleting",
"This Session is being deleted; not accepting new Tasks.",
);
} }
} }
private assertIdle(entry: RuntimeEntry): void { private assertIdle(entry: RuntimeEntry): void {
if (entry.status === "running") { if (entry.status === "running") {
throw new HttpError(409, "task_in_progress", "该 Session 已有进行中的 Task。"); throw new HttpError(409, "task_in_progress", "This Session already has a Task in progress.");
} }
if (entry.status === "compacting") { if (entry.status === "compacting") {
throw new HttpError(409, "compacting", "该 Session 正在压缩上下文,暂不接受新输入。"); throw new HttpError(
409,
"compacting",
"This Session is compacting its context; not accepting new input.",
);
} }
} }
@@ -567,7 +584,11 @@ export class SessionManager {
if (existing) return existing; if (existing) return existing;
const row = this.deps.sessions.findById(sessionId); const row = this.deps.sessions.findById(sessionId);
if (!row) { if (!row) {
throw new HttpError(404, "session_not_found", "Session 不存在或无权访问。"); throw new HttpError(
404,
"session_not_found",
"Session does not exist or you do not have access.",
);
} }
const session = await this.deps.loader.load(row); const session = await this.deps.loader.load(row);
// The Session/Agent was marked for deletion while loading: discard the load result, // The Session/Agent was marked for deletion while loading: discard the load result,
@@ -676,7 +697,7 @@ export class SessionManager {
} }
} catch (err) { } catch (err) {
this.log( this.log(
`[subagent] 子会话登记失败: ${err instanceof Error ? err.message : String(err)}`, `[subagent] Failed to register child session: ${err instanceof Error ? err.message : String(err)}`,
); );
this.deps.errors?.record({ this.deps.errors?.record({
source: "subagent", source: "subagent",
@@ -702,7 +723,7 @@ export class SessionManager {
try { try {
await this.deps.recorder.record(ctx, msg); await this.deps.recorder.record(ctx, msg);
} catch (err) { } catch (err) {
this.log(`[usage] 落库失败: ${err instanceof Error ? err.message : String(err)}`); this.log(`[usage] Insert failed: ${err instanceof Error ? err.message : String(err)}`);
this.deps.errors?.record({ source: "usage", err, ctx, code: "usage_insert_failed" }); this.deps.errors?.record({ source: "usage", err, ctx, code: "usage_insert_failed" });
} }
} }
@@ -710,7 +731,7 @@ export class SessionManager {
// The SDK doesn't normally throw (errors are converged into the message stream); // The SDK doesn't normally throw (errors are converged into the message stream);
// this is a defensive record here to avoid crashing the runtime. // this is a defensive record here to avoid crashing the runtime.
this.log( this.log(
`[session] 运行异常: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`, `[session] Run failed: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`,
); );
this.deps.errors?.record({ source: "session", err, ctx, code: "session_run_failed" }); this.deps.errors?.record({ source: "session", err, ctx, code: "session_run_failed" });
} finally { } finally {
@@ -75,12 +75,16 @@ type LlmFailure = "failed" | "timeout" | "malformed";
/** LLM failure state → error code, classification, and fallback message (used when the abort reason isn't available). */ /** LLM failure state → error code, classification, and fallback message (used when the abort reason isn't available). */
const LLM_FAILURES: Record<LlmFailure, { code: string; kind: ErrorKind; text: string }> = { const LLM_FAILURES: Record<LlmFailure, { code: string; kind: ErrorKind; text: string }> = {
failed: { code: "llm_failed", kind: "unexpected", text: "LLM 请求失败(不可重试)。" }, failed: { code: "llm_failed", kind: "unexpected", text: "LLM request failed (not retryable)." },
timeout: { code: "llm_timeout", kind: "expected", text: "LLM 请求超时(引擎重连重试)。" }, timeout: {
code: "llm_timeout",
kind: "expected",
text: "LLM request timed out (the engine reconnects and retries).",
},
malformed: { malformed: {
code: "llm_malformed", code: "llm_malformed",
kind: "expected", kind: "expected",
text: "LLM 响应无法解析(引擎重连重试)。", text: "LLM response could not be parsed (the engine reconnects and retries).",
}, },
}; };
@@ -113,7 +117,7 @@ function originKey(msg: OmniMessage): string {
* drop the reason. * drop the reason.
*/ */
function toolFailureText(output: string): string { function toolFailureText(output: string): string {
if (!output) return "工具执行失败(无输出)。"; if (!output) return "Tool execution failed (no output).";
if (output.length <= MESSAGE_MAX) return output; if (output.length <= MESSAGE_MAX) return output;
return `…${output.slice(output.length - (MESSAGE_MAX - 1))}`; return `…${output.slice(output.length - (MESSAGE_MAX - 1))}`;
} }
@@ -71,7 +71,7 @@ export class TitleGenerator implements TitleNotifier {
this.inflight.add(ctx.sessionId); this.inflight.add(ctx.sessionId);
void this.generate(ctx, session, req) void this.generate(ctx, session, req)
.catch((err: unknown) => { .catch((err: unknown) => {
this.log(`[title] 生成失败: ${err instanceof Error ? err.message : String(err)}`); this.log(`[title] Generation failed: ${err instanceof Error ? err.message : String(err)}`);
this.deps.errors?.record({ source: "title", err, ctx, code: "title_failed" }); this.deps.errors?.record({ source: "title", err, ctx, code: "title_failed" });
}) })
.finally(() => { .finally(() => {
@@ -97,7 +97,9 @@ export class TitleGenerator implements TitleNotifier {
try { try {
await this.deps.recorder.record(ctx, tokenUsage(emptyTokenCounts(), res.usage)); await this.deps.recorder.record(ctx, tokenUsage(emptyTokenCounts(), res.usage));
} catch (err) { } catch (err) {
this.log(`[title] 用量落库失败: ${err instanceof Error ? err.message : String(err)}`); this.log(
`[title] Usage insert failed: ${err instanceof Error ? err.message : String(err)}`,
);
this.deps.errors?.record({ this.deps.errors?.record({
source: "title", source: "title",
err, err,
@@ -109,7 +111,7 @@ export class TitleGenerator implements TitleNotifier {
} catch (err) { } catch (err) {
// A model request error (rate limit / timeout / network, etc.) shouldn't leave the // A model request error (rate limit / timeout / network, etc.) shouldn't leave the
// title permanently missing: log it and fall through to the fallback. // title permanently missing: log it and fall through to the fallback.
this.log(`[title] 模型请求失败: ${err instanceof Error ? err.message : String(err)}`); this.log(`[title] Model request failed: ${err instanceof Error ? err.message : String(err)}`);
this.deps.errors?.record({ source: "title", err, ctx, code: "title_llm_failed" }); this.deps.errors?.record({ source: "title", err, ctx, code: "title_llm_failed" });
} }
// When the LLM produces no usable title (failure / empty result), truncate the fallback material's first line — this guarantees a title is always generated. // When the LLM produces no usable title (failure / empty result), truncate the fallback material's first line — this guarantees a title is always generated.
+11 -7
View File
@@ -41,13 +41,17 @@ export class AdminService {
async createUser(userId: string, password: string): Promise<UserInfo> { async createUser(userId: string, password: string): Promise<UserInfo> {
if (!USERNAME_PATTERN.test(userId)) { if (!USERNAME_PATTERN.test(userId)) {
throw new HttpError(400, "invalid_user_id", `用户名须为 2~32 位:${SEMANTIC_ID_RULE}。`); throw new HttpError(
400,
"invalid_user_id",
`Username must be 2–32 characters: ${SEMANTIC_ID_RULE}.`,
);
} }
if (password.length < MIN_PASSWORD_LENGTH) { if (password.length < MIN_PASSWORD_LENGTH) {
throw new HttpError(400, "invalid_password", "密码至少 8 个字符。"); throw new HttpError(400, "invalid_password", "Password must be at least 8 characters.");
} }
if (this.deps.users.findById(userId)) { if (this.deps.users.findById(userId)) {
throw new HttpError(409, "user_exists", `用户已存在:${userId}。`); throw new HttpError(409, "user_exists", `User already exists: ${userId}.`);
} }
const user: UserRow = { const user: UserRow = {
userId, userId,
@@ -70,10 +74,10 @@ export class AdminService {
/** Reset another user's password: flags it as initial and clears all their sessions (prompts a password change on next login). */ /** Reset another user's password: flags it as initial and clears all their sessions (prompts a password change on next login). */
async resetPassword(userId: string, password: string): Promise<void> { async resetPassword(userId: string, password: string): Promise<void> {
if (!this.deps.users.findById(userId)) { if (!this.deps.users.findById(userId)) {
throw new HttpError(404, "user_not_found", `用户不存在:${userId}。`); throw new HttpError(404, "user_not_found", `User does not exist: ${userId}.`);
} }
if (password.length < MIN_PASSWORD_LENGTH) { if (password.length < MIN_PASSWORD_LENGTH) {
throw new HttpError(400, "invalid_password", "密码至少 8 个字符。"); throw new HttpError(400, "invalid_password", "Password must be at least 8 characters.");
} }
this.deps.users.updatePassword(userId, await hashPassword(password), true); this.deps.users.updatePassword(userId, await hashPassword(password), true);
this.deps.authSessions.deleteByUser(userId); this.deps.authSessions.deleteByUser(userId);
@@ -83,10 +87,10 @@ export class AdminService {
async deleteUser(userId: string): Promise<void> { async deleteUser(userId: string): Promise<void> {
const target = this.deps.users.findById(userId); const target = this.deps.users.findById(userId);
if (!target) { if (!target) {
throw new HttpError(404, "user_not_found", `用户不存在:${userId}。`); throw new HttpError(404, "user_not_found", `User does not exist: ${userId}.`);
} }
if (target.isAdmin) { if (target.isAdmin) {
throw new HttpError(409, "cannot_delete_admin", "内置管理员不能删除。"); throw new HttpError(409, "cannot_delete_admin", "The built-in admin cannot be deleted.");
} }
for (const project of this.deps.projects.listByOwner(userId)) { for (const project of this.deps.projects.listByOwner(userId)) {
await this.deps.projectService.destroyProject(project.projectId); await this.deps.projectService.destroyProject(project.projectId);
@@ -68,7 +68,7 @@ export class AgentConfigService {
async requireExists(projectId: string, agentId: string): Promise<void> { async requireExists(projectId: string, agentId: string): Promise<void> {
if (!(await this.exists(projectId, agentId))) { if (!(await this.exists(projectId, agentId))) {
throw new HttpError(404, "agent_not_found", "Agent 不存在。"); throw new HttpError(404, "agent_not_found", "Agent does not exist.");
} }
} }
@@ -269,11 +269,11 @@ export class AgentConfigService {
for (const entry of req.entries) { for (const entry of req.entries) {
if (!isValidVaultKey(entry.key)) { if (!isValidVaultKey(entry.key)) {
throw badRequest( throw badRequest(
`vault 键名不合法:${entry.key}(仅字母、数字与下划线,且不能以数字开头)。`, `Invalid vault key name: ${entry.key} (letters, digits and underscores only, and must not start with a digit).`,
); );
} }
if (seen.has(entry.key)) { if (seen.has(entry.key)) {
throw badRequest(`entries 中存在重复的键名:${entry.key}。`); throw badRequest(`entries contains a duplicate key: ${entry.key}.`);
} }
seen.add(entry.key); seen.add(entry.key);
const prevValue = prev[entry.key]; const prevValue = prev[entry.key];
@@ -281,13 +281,15 @@ export class AgentConfigService {
// Values are injected into the child process environment: an oversized value would // Values are injected into the child process environment: an oversized value would
// make exec spawn fail (E2BIG), so we reject it on write (same limit as core). // make exec spawn fail (E2BIG), so we reject it on write (same limit as core).
if (entry.value.length > VAULT_VALUE_MAX_LENGTH) { if (entry.value.length > VAULT_VALUE_MAX_LENGTH) {
throw badRequest(`vault 值过长:${entry.key}(上限 ${VAULT_VALUE_MAX_LENGTH} 字符)。`); throw badRequest(
`Vault value too long: ${entry.key} (limit ${VAULT_VALUE_MAX_LENGTH} characters).`,
);
} }
nextVault[entry.key] = entry.value; nextVault[entry.key] = entry.value;
} else if (prevValue !== undefined) { } else if (prevValue !== undefined) {
nextVault[entry.key] = prevValue; nextVault[entry.key] = prevValue;
} else { } else {
throw badRequest(`新增键 ${entry.key} 必须提供 value。`); throw badRequest(`New key ${entry.key} must provide a value.`);
} }
} }
@@ -297,20 +299,20 @@ export class AgentConfigService {
} }
function validateToolsBuiltin(value: unknown): ToolDefinitionConfig[] { function validateToolsBuiltin(value: unknown): ToolDefinitionConfig[] {
if (!Array.isArray(value)) throw badRequest("toolsBuiltin 必须是数组。"); if (!Array.isArray(value)) throw badRequest("toolsBuiltin must be an array.");
return value.map((item, i) => { return value.map((item, i) => {
const t = asRecord(item); const t = asRecord(item);
if (typeof t.name !== "string" || t.name.length === 0) { if (typeof t.name !== "string" || t.name.length === 0) {
throw badRequest(`toolsBuiltin[${i}].name 必须是非空字符串。`); throw badRequest(`toolsBuiltin[${i}].name must be a non-empty string.`);
} }
if (typeof t.description !== "string") { if (typeof t.description !== "string") {
throw badRequest(`toolsBuiltin[${i}].description 必须是字符串。`); throw badRequest(`toolsBuiltin[${i}].description must be a string.`);
} }
if (t.permission !== undefined && t.permission !== "r" && t.permission !== "rw") { if (t.permission !== undefined && t.permission !== "r" && t.permission !== "rw") {
throw badRequest(`toolsBuiltin[${i}].permission 必须是 r / rw 之一。`); throw badRequest(`toolsBuiltin[${i}].permission must be one of r / rw.`);
} }
if (t.forModel !== undefined && t.forModel !== "vision" && t.forModel !== "text-only") { if (t.forModel !== undefined && t.forModel !== "vision" && t.forModel !== "text-only") {
throw badRequest(`toolsBuiltin[${i}].forModel 必须是 vision / text-only 之一。`); throw badRequest(`toolsBuiltin[${i}].forModel must be one of vision / text-only.`);
} }
optionalNumber(t, "timeoutMs", { optionalNumber(t, "timeoutMs", {
integer: true, integer: true,
@@ -327,14 +329,14 @@ function validateToolsBuiltin(value: unknown): ToolDefinitionConfig[] {
} }
function validateMcpServers(value: unknown): MCPServerConfig[] { function validateMcpServers(value: unknown): MCPServerConfig[] {
if (!Array.isArray(value)) throw badRequest("mcpServers 必须是数组。"); if (!Array.isArray(value)) throw badRequest("mcpServers must be an array.");
return value.map((item, i) => { return value.map((item, i) => {
const s = asRecord(item); const s = asRecord(item);
if (typeof s.name !== "string" || s.name.length === 0) { if (typeof s.name !== "string" || s.name.length === 0) {
throw badRequest(`mcpServers[${i}].name 必须是非空字符串。`); throw badRequest(`mcpServers[${i}].name must be a non-empty string.`);
} }
if (s.config === null || typeof s.config !== "object" || Array.isArray(s.config)) { if (s.config === null || typeof s.config !== "object" || Array.isArray(s.config)) {
throw badRequest(`mcpServers[${i}].config 必须是对象。`); throw badRequest(`mcpServers[${i}].config must be an object.`);
} }
return s as unknown as MCPServerConfig; return s as unknown as MCPServerConfig;
}); });
@@ -155,7 +155,7 @@ export class AgentService {
throw new HttpError( throw new HttpError(
409, 409,
"cannot_delete_builtin_agent", "cannot_delete_builtin_agent",
"内置 Agent(default_agent)随 Project 供给,不能从 Web 删除。", "Built-in Agents (default_agent) are provisioned with the Project and cannot be deleted from the web.",
); );
} }
await fs.rm(agentDir(this.root, projectId, agentId), { recursive: true, force: true }); await fs.rm(agentDir(this.root, projectId, agentId), { recursive: true, force: true });
@@ -175,7 +175,11 @@ export class AgentService {
description?: string, description?: string,
): Promise<AgentListItem> { ): Promise<AgentListItem> {
if (!SEMANTIC_ID_PATTERN.test(agentId)) { if (!SEMANTIC_ID_PATTERN.test(agentId)) {
throw new HttpError(400, "invalid_agent_id", `Agent id 须为 2~64 位:${SEMANTIC_ID_RULE}。`); throw new HttpError(
400,
"invalid_agent_id",
`Agent id must be 2–64 characters: ${SEMANTIC_ID_RULE}.`,
);
} }
const taken = const taken =
this.agents.exists(projectId, agentId) || this.agents.exists(projectId, agentId) ||
@@ -184,7 +188,7 @@ export class AgentService {
() => false, () => false,
)); ));
if (taken) { if (taken) {
throw new HttpError(409, "agent_exists", `Agent id 已被占用:${agentId}。`); throw new HttpError(409, "agent_exists", `Agent id is already taken: ${agentId}.`);
} }
const displayName = name ?? agentId; const displayName = name ?? agentId;
await coreCreateAgent({ root: this.root, projectId, agentId }); await coreCreateAgent({ root: this.root, projectId, agentId });
+2 -1
View File
@@ -31,4 +31,5 @@ export const PROJECT_SUFFIX_PATTERN = /^[a-z0-9_]+$/;
export const PROJECT_ID_MAX_LENGTH = 64; export const PROJECT_ID_MAX_LENGTH = 64;
/** Human-readable description of the rule (reused in error messages). */ /** Human-readable description of the rule (reused in error messages). */
export const SEMANTIC_ID_RULE = "小写字母开头,仅小写字母、数字与下划线"; export const SEMANTIC_ID_RULE =
"starts with a lowercase letter; lowercase letters, digits and underscores only";
@@ -364,7 +364,7 @@ export class ProjectConfigService {
const key = refKey(entry.provider, entry.modelId); const key = refKey(entry.provider, entry.modelId);
if (seen.has(key)) { if (seen.has(key)) {
throw badRequest( throw badRequest(
`models 中存在重复的模型引用:${showRef(entry.provider, entry.modelId)}。`, `models contains a duplicate model reference: ${showRef(entry.provider, entry.modelId)}.`,
); );
} }
seen.add(key); seen.add(key);
@@ -436,7 +436,7 @@ export class ProjectConfigService {
if (req.defaultModel !== undefined) { if (req.defaultModel !== undefined) {
if (!seen.has(refKey(req.defaultModel.provider, req.defaultModel.modelId))) { if (!seen.has(refKey(req.defaultModel.provider, req.defaultModel.modelId))) {
throw badRequest( throw badRequest(
`defaultModel 必须包含在 models 内:${showRef(req.defaultModel.provider, req.defaultModel.modelId)}。`, `defaultModel must be included in models: ${showRef(req.defaultModel.provider, req.defaultModel.modelId)}.`,
); );
} }
defaultModel = req.defaultModel; defaultModel = req.defaultModel;
@@ -459,12 +459,12 @@ export class ProjectConfigService {
if (req.visionModel !== undefined) { if (req.visionModel !== undefined) {
if (!seen.has(refKey(req.visionModel.provider, req.visionModel.modelId))) { if (!seen.has(refKey(req.visionModel.provider, req.visionModel.modelId))) {
throw badRequest( throw badRequest(
`visionModel 必须包含在 models 内:${showRef(req.visionModel.provider, req.visionModel.modelId)}。`, `visionModel must be included in models: ${showRef(req.visionModel.provider, req.visionModel.modelId)}.`,
); );
} }
if (targetOf(req.visionModel)?.vision === false) { if (targetOf(req.visionModel)?.vision === false) {
throw badRequest( throw badRequest(
`visionModel 不能指向标注为不支持图片的模型:${showRef(req.visionModel.provider, req.visionModel.modelId)}。`, `visionModel must not point to a model annotated as not supporting images: ${showRef(req.visionModel.provider, req.visionModel.modelId)}.`,
); );
} }
visionModel = req.visionModel; visionModel = req.visionModel;
+28 -12
View File
@@ -84,7 +84,11 @@ export class ProjectService {
requireProjectAccess(userId: string, projectId: string): ProjectRow & { role: ProjectRole } { requireProjectAccess(userId: string, projectId: string): ProjectRow & { role: ProjectRole } {
const row = this.resolveAccess(userId, projectId); const row = this.resolveAccess(userId, projectId);
if (!row) { if (!row) {
throw new HttpError(404, "project_not_found", "Project 不存在或无权访问。"); throw new HttpError(
404,
"project_not_found",
"Project does not exist or you do not have access.",
);
} }
return row; return row;
} }
@@ -103,7 +107,11 @@ export class ProjectService {
requireProjectOwner(userId: string, projectId: string): ProjectRow { requireProjectOwner(userId: string, projectId: string): ProjectRow {
const row = this.requireProjectAccess(userId, projectId); const row = this.requireProjectAccess(userId, projectId);
if (row.role !== "owner") { if (row.role !== "owner") {
throw new HttpError(403, "owner_required", "该操作仅 Project owner 可执行。"); throw new HttpError(
403,
"owner_required",
"Only the Project owner can perform this operation.",
);
} }
return row; return row;
} }
@@ -149,7 +157,7 @@ export class ProjectService {
throw new HttpError( throw new HttpError(
400, 400,
"invalid_project_id", "invalid_project_id",
`Project id 须为 2~64 位:${SEMANTIC_ID_RULE}(连字符保留作用户命名空间分隔)。`, `Project id must be 2–64 characters: ${SEMANTIC_ID_RULE} (the hyphen is reserved as the user namespace separator).`,
); );
} }
} else { } else {
@@ -159,7 +167,7 @@ export class ProjectService {
throw new HttpError( throw new HttpError(
400, 400,
"project_id_prefix_required", "project_id_prefix_required",
`Project id 须以 ${prefix} 开头,后接小写字母、数字或下划线(总长不超过 ${PROJECT_ID_MAX_LENGTH})。`, `Project id must start with ${prefix}, followed by lowercase letters, digits or underscores (at most ${PROJECT_ID_MAX_LENGTH} in total).`,
); );
} }
} }
@@ -167,7 +175,7 @@ export class ProjectService {
this.deps.projects.findById(projectId) !== null || this.deps.projects.findById(projectId) !== null ||
(await dirExists(projectDir(this.deps.root, projectId))) (await dirExists(projectDir(this.deps.root, projectId)))
) { ) {
throw new HttpError(409, "project_exists", `Project id 已被占用:${projectId}。`); throw new HttpError(409, "project_exists", `Project id is already taken: ${projectId}.`);
} }
const displayName = name ?? projectId; const displayName = name ?? projectId;
const createdAt = new Date().toISOString(); const createdAt = new Date().toISOString();
@@ -179,7 +187,7 @@ export class ProjectService {
this.deps.projects.insert({ projectId, ownerUserId: owner.userId, createdAt }); this.deps.projects.insert({ projectId, ownerUserId: owner.userId, createdAt });
} catch (err) { } catch (err) {
if (err instanceof Error && err.message.includes("UNIQUE")) { if (err instanceof Error && err.message.includes("UNIQUE")) {
throw new HttpError(409, "project_exists", `Project id 已被占用:${projectId}。`); throw new HttpError(409, "project_exists", `Project id is already taken: ${projectId}.`);
} }
throw err; throw err;
} }
@@ -248,14 +256,14 @@ export class ProjectService {
throw new HttpError( throw new HttpError(
409, 409,
"cannot_delete_default_project", "cannot_delete_default_project",
"default_project 与 CLI 共用,不能从 Web 删除。", "default_project is shared with the CLI and cannot be deleted from the web.",
); );
} }
if (this.deps.projects.listAccessible(userId).length <= 1) { if (this.deps.projects.listAccessible(userId).length <= 1) {
throw new HttpError( throw new HttpError(
409, 409,
"cannot_delete_last_project", "cannot_delete_last_project",
"这是当前账号最后一个 Project,删除后将无 Project 可用;请先创建新的 Project。", "This is the account's last Project; deleting it would leave no Project available. Create a new Project first.",
); );
} }
await this.destroyProject(projectId); await this.destroyProject(projectId);
@@ -306,13 +314,21 @@ export class ProjectService {
const project = this.requireProjectOwner(userId, projectId); const project = this.requireProjectOwner(userId, projectId);
const target = this.deps.users.findById(targetUserId); const target = this.deps.users.findById(targetUserId);
if (!target) { if (!target) {
throw new HttpError(404, "user_not_found", `用户不存在:${targetUserId}。`); throw new HttpError(404, "user_not_found", `User does not exist: ${targetUserId}.`);
} }
if (target.userId === project.ownerUserId) { if (target.userId === project.ownerUserId) {
throw new HttpError(409, "already_owner", "owner 无需授权给自己。"); throw new HttpError(
409,
"already_owner",
"The owner does not need to grant access to themselves.",
);
} }
if (this.deps.members.isMember(projectId, target.userId)) { if (this.deps.members.isMember(projectId, target.userId)) {
throw new HttpError(409, "already_member", `${targetUserId} 已是该 Project 的成员。`); throw new HttpError(
409,
"already_member",
`${targetUserId} is already a member of this Project.`,
);
} }
const createdAt = new Date().toISOString(); const createdAt = new Date().toISOString();
this.deps.members.insert({ projectId, userId: target.userId, createdAt }); this.deps.members.insert({ projectId, userId: target.userId, createdAt });
@@ -323,7 +339,7 @@ export class ProjectService {
removeMember(userId: string, projectId: string, targetUserId: string): void { removeMember(userId: string, projectId: string, targetUserId: string): void {
this.requireProjectOwner(userId, projectId); this.requireProjectOwner(userId, projectId);
if (!this.deps.members.isMember(projectId, targetUserId)) { if (!this.deps.members.isMember(projectId, targetUserId)) {
throw new HttpError(404, "member_not_found", `该 Project 没有成员:${targetUserId}。`); throw new HttpError(404, "member_not_found", `This Project has no member: ${targetUserId}.`);
} }
this.deps.members.delete(projectId, targetUserId); this.deps.members.delete(projectId, targetUserId);
} }
@@ -168,7 +168,7 @@ export class SessionService {
throw new HttpError( throw new HttpError(
400, 400,
"no_default_model", "no_default_model",
"该 Project 尚未配置默认模型,请先在「模型」页添加模型并设为默认。", "This Project has no default model yet. Add a model on the Models page and set it as the default first.",
); );
} }
modelId = def.model_id; modelId = def.model_id;
@@ -42,7 +42,7 @@ export class SnapshotService {
try { try {
raw = await fs.readFile(systemConfigPath(this.root, projectId, agentId), "utf8"); raw = await fs.readFile(systemConfigPath(this.root, projectId, agentId), "utf8");
} catch { } catch {
throw new HttpError(404, "agent_not_found", "Agent 不存在。"); throw new HttpError(404, "agent_not_found", "Agent does not exist.");
} }
const parsed = parseYaml(raw) as { version?: unknown } | null; const parsed = parseYaml(raw) as { version?: unknown } | null;
return agentStateVersion({ return agentStateVersion({
@@ -117,7 +117,7 @@ export class SnapshotService {
filter: (p) => !isVaultEntry(p), filter: (p) => !isVaultEntry(p),
}); });
} catch { } catch {
throw badRequest("导入失败:不是合法的 tar.gz 快照包。"); throw badRequest("Import failed: not a valid tar.gz snapshot package.");
} }
// Validation: the package must contain agent_state/system_config.yaml, and version must be valid. // Validation: the package must contain agent_state/system_config.yaml, and version must be valid.
@@ -126,21 +126,21 @@ export class SnapshotService {
try { try {
parsed = parseYaml(await fs.readFile(configPath, "utf8")); parsed = parseYaml(await fs.readFile(configPath, "utf8"));
} catch { } catch {
throw badRequest("导入失败:包内缺少 agent_state/system_config.yaml。"); throw badRequest("Import failed: the package is missing agent_state/system_config.yaml.");
} }
if ( if (
parsed === null || parsed === null ||
typeof parsed !== "object" || typeof parsed !== "object" ||
typeof (parsed as { system_prompt?: unknown }).system_prompt !== "string" typeof (parsed as { system_prompt?: unknown }).system_prompt !== "string"
) { ) {
throw badRequest("导入失败:包内 system_config.yaml 非法。"); throw badRequest("Import failed: the package's system_config.yaml is invalid.");
} }
const incomingRaw = (parsed as { version?: unknown }).version; const incomingRaw = (parsed as { version?: unknown }).version;
if ( if (
incomingRaw !== undefined && incomingRaw !== undefined &&
(!Number.isInteger(incomingRaw) || (incomingRaw as number) < 1) (!Number.isInteger(incomingRaw) || (incomingRaw as number) < 1)
) { ) {
throw badRequest("导入失败:包内 version 非法。"); throw badRequest("Import failed: the package's version is invalid.");
} }
const incoming = agentStateVersion({ const incoming = agentStateVersion({
version: typeof incomingRaw === "number" ? incomingRaw : undefined, version: typeof incomingRaw === "number" ? incomingRaw : undefined,
@@ -149,7 +149,7 @@ export class SnapshotService {
throw new HttpError( throw new HttpError(
409, 409,
"version_conflict", "version_conflict",
`包版本 v${incoming} 不高于当前 v${current},需要确认后导入。`, `Package version v${incoming} is not higher than the current v${current}; confirmation is required to import.`,
); );
} }
@@ -675,7 +675,7 @@ export class TraceService {
throw new HttpError( throw new HttpError(
404, 404,
"trace_not_found", "trace_not_found",
`该 Session 没有 index 为 ${index} 的 Trace 文件。`, `This Session has no Trace file with index ${index}.`,
); );
} }
return readTraceTolerant(file.path); return readTraceTolerant(file.path);
@@ -56,7 +56,7 @@ export class WorkspaceFilesService {
try { try {
return await fs.realpath(path.resolve(workspace)); return await fs.realpath(path.resolve(workspace));
} catch { } catch {
throw new HttpError(404, "workspace_missing", "该 Session 的 Workspace 已不存在。"); throw new HttpError(404, "workspace_missing", "This Session's Workspace no longer exists.");
} }
} }
@@ -74,17 +74,17 @@ export class WorkspaceFilesService {
/** Lexical prefix check (a relative path, once resolved, must still be inside the Workspace); returns the absolute target path. */ /** Lexical prefix check (a relative path, once resolved, must still be inside the Workspace); returns the absolute target path. */
private lexicalTarget(base: string, rel: string): string { private lexicalTarget(base: string, rel: string): string {
if (rel.includes("\0")) throw badRequest("path 非法。"); if (rel.includes("\0")) throw badRequest("path is invalid.");
const target = path.resolve(base, rel === "" ? "." : rel); const target = path.resolve(base, rel === "" ? "." : rel);
if (!this.isInside(target, base)) { if (!this.isInside(target, base)) {
throw badRequest("path 必须位于 Workspace 内。"); throw badRequest("path must be inside the Workspace.");
} }
return target; return target;
} }
private assertInside(real: string, realBase: string): void { private assertInside(real: string, realBase: string): void {
if (!this.isInside(real, realBase)) { if (!this.isInside(real, realBase)) {
throw badRequest("path 必须位于 Workspace 内。"); throw badRequest("path must be inside the Workspace.");
} }
} }
@@ -103,7 +103,7 @@ export class WorkspaceFilesService {
canonical = await fs.realpath(target); canonical = await fs.realpath(target);
} catch (err) { } catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") { if ((err as NodeJS.ErrnoException).code === "ENOENT") {
throw new HttpError(404, "path_not_found", "文件不存在。"); throw new HttpError(404, "path_not_found", "File does not exist.");
} }
throw err; throw err;
} }
@@ -132,7 +132,7 @@ export class WorkspaceFilesService {
const realBase = await this.realBase(workspace); const realBase = await this.realBase(workspace);
const target = this.lexicalTarget(path.resolve(workspace), rel); const target = this.lexicalTarget(path.resolve(workspace), rel);
const name = path.basename(target); const name = path.basename(target);
if (name === "" || name === "." || name === "..") throw badRequest("path 必须是文件路径。"); if (name === "" || name === "." || name === "..") throw badRequest("path must be a file path.");
const parent = path.dirname(target); const parent = path.dirname(target);
let canonicalParent: string; let canonicalParent: string;
try { try {
@@ -147,7 +147,7 @@ export class WorkspaceFilesService {
} catch (probeErr) { } catch (probeErr) {
if ((probeErr as NodeJS.ErrnoException).code !== "ENOENT") throw probeErr; if ((probeErr as NodeJS.ErrnoException).code !== "ENOENT") throw probeErr;
const up = path.dirname(probe); const up = path.dirname(probe);
if (up === probe) throw badRequest("path 非法。"); if (up === probe) throw badRequest("path is invalid.");
probe = up; probe = up;
} }
} }
@@ -189,10 +189,10 @@ export class WorkspaceFilesService {
dirents = await fs.readdir(dir, { withFileTypes: true }); dirents = await fs.readdir(dir, { withFileTypes: true });
} catch (err) { } catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") { if ((err as NodeJS.ErrnoException).code === "ENOENT") {
throw new HttpError(404, "path_not_found", "目录不存在。"); throw new HttpError(404, "path_not_found", "Directory does not exist.");
} }
if ((err as NodeJS.ErrnoException).code === "ENOTDIR") { if ((err as NodeJS.ErrnoException).code === "ENOTDIR") {
throw badRequest("path 不是目录。"); throw badRequest("path is not a directory.");
} }
throw err; throw err;
} }
@@ -231,11 +231,11 @@ export class WorkspaceFilesService {
try { try {
stat = await fs.stat(file); stat = await fs.stat(file);
} catch { } catch {
throw new HttpError(404, "path_not_found", "文件不存在。"); throw new HttpError(404, "path_not_found", "File does not exist.");
} }
if (stat.isDirectory()) throw badRequest("path 是目录。"); if (stat.isDirectory()) throw badRequest("path is a directory.");
if (stat.size > MAX_READ_BYTES) { if (stat.size > MAX_READ_BYTES) {
throw new HttpError(413, "file_too_large", "文件超过 50MB 读取上限。"); throw new HttpError(413, "file_too_large", "File exceeds the 50MB read limit.");
} }
const data = await fs.readFile(file); const data = await fs.readFile(file);
const ext = path.extname(file).toLowerCase(); const ext = path.extname(file).toLowerCase();
@@ -256,9 +256,9 @@ export class WorkspaceFilesService {
* sandbox escapes). * sandbox escapes).
*/ */
async write(workspace: string, rel: string, data: Buffer): Promise<void> { async write(workspace: string, rel: string, data: Buffer): Promise<void> {
if (rel === "" || rel.endsWith("/")) throw badRequest("path 必须是文件路径。"); if (rel === "" || rel.endsWith("/")) throw badRequest("path must be a file path.");
if (data.length > MAX_UPLOAD_BYTES) { if (data.length > MAX_UPLOAD_BYTES) {
throw new HttpError(413, "file_too_large", "上传文件超过 14MB 上限。"); throw new HttpError(413, "file_too_large", "Uploaded file exceeds the 14MB limit.");
} }
const { dir, name } = await this.resolveWriteParent(workspace, rel); const { dir, name } = await this.resolveWriteParent(workspace, rel);
const file = path.join(dir, name); const file = path.join(dir, name);
@@ -269,9 +269,10 @@ export class WorkspaceFilesService {
handle = await fs.open(file, flags, 0o644); handle = await fs.open(file, flags, 0o644);
} catch (err) { } catch (err) {
const code = (err as NodeJS.ErrnoException).code; const code = (err as NodeJS.ErrnoException).code;
if (code === "ELOOP") throw badRequest("path 不能是符号链接。"); if (code === "ELOOP") throw badRequest("path must not be a symlink.");
if (code === "ENOENT") throw new HttpError(404, "path_not_found", "父目录不存在。"); if (code === "ENOENT")
if (code === "EISDIR") throw badRequest("path 是目录。"); throw new HttpError(404, "path_not_found", "Parent directory does not exist.");
if (code === "EISDIR") throw badRequest("path is a directory.");
throw err; throw err;
} }
try { try {
@@ -26,12 +26,16 @@ export async function assertWorkspaceAllowed(args: { workspace: string }): Promi
throw new HttpError( throw new HttpError(
400, 400,
"workspace_not_found", "workspace_not_found",
`Workspace 不存在或不可访问:${args.workspace}。请指定一个已存在的目录,或留空以使用临时目录。`, `Workspace does not exist or is inaccessible: ${args.workspace}. Specify an existing directory, or leave it empty to use a temporary directory.`,
); );
} }
const stat = await fs.stat(ws); const stat = await fs.stat(ws);
if (!stat.isDirectory()) { if (!stat.isDirectory()) {
throw new HttpError(400, "workspace_not_found", `Workspace 不是目录:${args.workspace}。`); throw new HttpError(
400,
"workspace_not_found",
`Workspace is not a directory: ${args.workspace}.`,
);
} }
return ws; return ws;
} }
+9 -9
View File
@@ -10,7 +10,7 @@ import type { AdminUsersResponse, MembersResponse } from "../src/api/types.js";
import { apiClient, createTestApp, loginAdmin, loginUser, provisionUser } from "./helpers.js"; import { apiClient, createTestApp, loginAdmin, loginUser, provisionUser } from "./helpers.js";
import type { TestApp } from "./helpers.js"; import type { TestApp } from "./helpers.js";
describe("admin 用户后台", () => { describe("admin users backend", () => {
let t: TestApp; let t: TestApp;
let admin: ReturnType<typeof apiClient>; let admin: ReturnType<typeof apiClient>;
@@ -22,7 +22,7 @@ describe("admin 用户后台", () => {
await t.cleanup(); await t.cleanup();
}); });
it("非管理员访问一律 403", async () => { it("non-admin access is always 403", async () => {
const { cookie } = await provisionUser(t.app, "norm"); const { cookie } = await provisionUser(t.app, "norm");
const api = apiClient(t.app, cookie); const api = apiClient(t.app, cookie);
expect((await api.get("/api/admin/users")).status).toBe(403); expect((await api.get("/api/admin/users")).status).toBe(403);
@@ -35,7 +35,7 @@ describe("admin 用户后台", () => {
expect((await api.delete("/api/admin/users/norm")).status).toBe(403); expect((await api.delete("/api/admin/users/norm")).status).toBe(403);
}); });
it("建号校验:非法用户名 / 过短密码 400;重复 409", async () => { it("account creation: invalid username / short password 400; duplicate 409", async () => {
for (const bad of ["Bob", "1abc", "a", "-abc", "a-b", "a!b", "a".repeat(33)]) { for (const bad of ["Bob", "1abc", "a", "-abc", "a-b", "a!b", "a".repeat(33)]) {
const res = await admin.post("/api/admin/users", { userId: bad, password: "password-123" }); const res = await admin.post("/api/admin/users", { userId: bad, password: "password-123" });
expect(res.status, `userId=${bad}`).toBe(400); expect(res.status, `userId=${bad}`).toBe(400);
@@ -56,7 +56,7 @@ describe("admin 用户后台", () => {
).toBe(409); ).toBe(409);
}); });
it("默认 Project id 被占用时建号失败并回滚用户", async () => { it("default Project id taken: account creation fails and rolls back the user", async () => {
// Occupy the frank-default_project directory (simulating CLI creation; no Web-side user can construct another's prefix). // Occupy the frank-default_project directory (simulating CLI creation; no Web-side user can construct another's prefix).
await fs.mkdir(path.join(t.root, "frank-default_project"), { recursive: true }); await fs.mkdir(path.join(t.root, "frank-default_project"), { recursive: true });
const res = await admin.post("/api/admin/users", { userId: "frank", password: "password-123" }); const res = await admin.post("/api/admin/users", { userId: "frank", password: "password-123" });
@@ -66,7 +66,7 @@ describe("admin 用户后台", () => {
expect(list.users.map((u) => u.userId)).not.toContain("frank"); expect(list.users.map((u) => u.userId)).not.toContain("frank");
}); });
it("用户列表:种子 admin 与新建用户,字段齐全", async () => { it("user list: seeded admin and newly created user, all fields present", async () => {
await provisionUser(t.app, "kate"); await provisionUser(t.app, "kate");
const list = (await (await admin.get("/api/admin/users")).json()) as AdminUsersResponse; const list = (await (await admin.get("/api/admin/users")).json()) as AdminUsersResponse;
expect(list.users.map((u) => u.userId)).toEqual(["admin", "kate"]); expect(list.users.map((u) => u.userId)).toEqual(["admin", "kate"]);
@@ -77,7 +77,7 @@ describe("admin 用户后台", () => {
expect(list.users[1]!.isAdmin).toBe(false); expect(list.users[1]!.isAdmin).toBe(false);
}); });
it("重置密码:清空目标用户全部会话,新密码带初始标记", async () => { it("password reset: clears the user's sessions, new password keeps initial flag", async () => {
const rex = await provisionUser(t.app, "rex"); const rex = await provisionUser(t.app, "rex");
const rexApi = apiClient(t.app, rex.cookie); const rexApi = apiClient(t.app, rex.cookie);
expect((await rexApi.get("/api/me")).status).toBe(200); expect((await rexApi.get("/api/me")).status).toBe(200);
@@ -99,11 +99,11 @@ describe("admin 用户后台", () => {
expect(again.user.passwordIsInitial).toBe(true); expect(again.user.passwordIsInitial).toBe(true);
}); });
it("删除用户:owned Project(含目录)连带删除,成员关系随级联清除", async () => { it("user deletion: owned Projects (with directories) and memberships removed", async () => {
const gone = await provisionUser(t.app, "gone"); const gone = await provisionUser(t.app, "gone");
const goneApi = apiClient(t.app, gone.cookie); const goneApi = apiClient(t.app, gone.cookie);
expect( expect(
(await goneApi.post("/api/projects", { projectId: "gone-extra", name: "多余项目" })).status, (await goneApi.post("/api/projects", { projectId: "gone-extra", name: "Extra" })).status,
).toBe(201); ).toBe(201);
// gone is also a member of admin's default_project. // gone is also a member of admin's default_project.
expect( expect(
@@ -131,7 +131,7 @@ describe("admin 用户后台", () => {
expect(members.members.map((m) => m.userId)).toEqual(["admin"]); expect(members.members.map((m) => m.userId)).toEqual(["admin"]);
}); });
it("内置 admin 不可删除;未知用户 404", async () => { it("built-in admin cannot be deleted; unknown user 404", async () => {
expect((await admin.delete("/api/admin/users/admin")).status).toBe(409); expect((await admin.delete("/api/admin/users/admin")).status).toBe(409);
expect((await admin.delete("/api/admin/users/ghost")).status).toBe(404); expect((await admin.delete("/api/admin/users/ghost")).status).toBe(404);
}); });
@@ -48,12 +48,12 @@ describe("agent-trace-detail", () => {
owner = apiClient(t.app, a.cookie); owner = apiClient(t.app, a.cookie);
outsider = apiClient(t.app, b.cookie); outsider = apiClient(t.app, b.cookie);
const created = (await ( const created = (await (
await owner.post("/api/projects", { projectId: "owner-trace", name: "项目" }) await owner.post("/api/projects", { projectId: "owner-trace", name: "Project" })
).json()) as ProjectCreateResponse; ).json()) as ProjectCreateResponse;
projectId = created.project.projectId; projectId = created.project.projectId;
await writeTraceFile(t.root, projectId, "default_agent", "2026-07-06", UNMANAGED, 1, [ await writeTraceFile(t.root, projectId, "default_agent", "2026-07-06", UNMANAGED, 1, [
sessionMeta(metaPayload()), sessionMeta(metaPayload()),
userText("子会话输入"), userText("child session input"),
requestBegin(), requestBegin(),
requestEnd("completed"), requestEnd("completed"),
]); ]);
@@ -62,7 +62,7 @@ describe("agent-trace-detail", () => {
await t.cleanup(); await t.cleanup();
}); });
it("未纳管 Session 经 Session 级端点 404,经 Agent 级明细端点可读", async () => { it("unmanaged Session: 404 via the Session-level endpoint, readable at Agent level", async () => {
// Session-level: no such row in the sessions table -> 404. // Session-level: no such row in the sessions table -> 404.
expect((await owner.get(`/api/sessions/${UNMANAGED}/traces/1`)).status).toBe(404); expect((await owner.get(`/api/sessions/${UNMANAGED}/traces/1`)).status).toBe(404);
// Agent-level detail: locates the Trace file directly -> 200. // Agent-level detail: locates the Trace file directly -> 200.
@@ -70,10 +70,10 @@ describe("agent-trace-detail", () => {
expect(res.status).toBe(200); expect(res.status).toBe(200);
const body = (await res.json()) as TraceEventsResponse; const body = (await res.json()) as TraceEventsResponse;
expect(body.total).toBe(4); expect(body.total).toBe(4);
expect((body.events[1]!.payload as { text: string }).text).toBe("子会话输入"); expect((body.events[1]!.payload as { text: string }).text).toBe("child session input");
}); });
it("Agent 级明细分页参数生效", async () => { it("Agent-level detail pagination params take effect", async () => {
const res = await owner.get(`${base()}/${UNMANAGED}/1?offset=1&limit=2`); const res = await owner.get(`${base()}/${UNMANAGED}/1?offset=1&limit=2`);
expect(res.status).toBe(200); expect(res.status).toBe(200);
const body = (await res.json()) as TraceEventsResponse; const body = (await res.json()) as TraceEventsResponse;
@@ -85,7 +85,7 @@ describe("agent-trace-detail", () => {
expect((await owner.get(`${base()}/${UNMANAGED}/1?limit=5000`)).status).toBe(400); expect((await owner.get(`${base()}/${UNMANAGED}/1?limit=5000`)).status).toBe(400);
}); });
it("Agent 级性能分析端点派生 Request 配对", async () => { it("Agent-level analysis endpoint derives Request pairings", async () => {
const res = await owner.get(`${base()}/${UNMANAGED}/1/analysis`); const res = await owner.get(`${base()}/${UNMANAGED}/1/analysis`);
expect(res.status).toBe(200); expect(res.status).toBe(200);
const body = (await res.json()) as TraceAnalysisResponse; const body = (await res.json()) as TraceAnalysisResponse;
@@ -93,11 +93,11 @@ describe("agent-trace-detail", () => {
expect(body.requests[0]!.status).toBe("completed"); expect(body.requests[0]!.status).toBe("completed");
}); });
it("不存在的 index → 404 trace_not_found", async () => { it("nonexistent index → 404 trace_not_found", async () => {
expect((await owner.get(`${base()}/${UNMANAGED}/9`)).status).toBe(404); expect((await owner.get(`${base()}/${UNMANAGED}/9`)).status).toBe(404);
}); });
it("无访问权的用户 → 404(requireProjectAccess)", async () => { it("user without access → 404 (requireProjectAccess)", async () => {
expect((await outsider.get(`${base()}/${UNMANAGED}/1`)).status).toBe(404); expect((await outsider.get(`${base()}/${UNMANAGED}/1`)).status).toBe(404);
expect((await outsider.get(`${base()}/${UNMANAGED}/1/analysis`)).status).toBe(404); expect((await outsider.get(`${base()}/${UNMANAGED}/1/analysis`)).status).toBe(404);
}); });
+4 -4
View File
@@ -31,7 +31,7 @@ describe("agent export/import", () => {
owner = apiClient(t.app, a.cookie); owner = apiClient(t.app, a.cookie);
member = apiClient(t.app, b.cookie); member = apiClient(t.app, b.cookie);
const created = (await ( const created = (await (
await owner.post("/api/projects", { projectId: "owner_a-snap", name: "快照项目" }) await owner.post("/api/projects", { projectId: "owner_a-snap", name: "Snapshot project" })
).json()) as ProjectCreateResponse; ).json()) as ProjectCreateResponse;
projectId = created.project.projectId; projectId = created.project.projectId;
base = `/api/projects/${projectId}/agents/default_agent`; base = `/api/projects/${projectId}/agents/default_agent`;
@@ -43,7 +43,7 @@ describe("agent export/import", () => {
await t.cleanup(); await t.cleanup();
}); });
it("导出→改动→导入回滚:同版本需确认、agent_state 被替换、vault 保留", async () => { it("reimport same version: confirm required, agent_state replaced, vault kept", async () => {
// First set a vault key (must be preserved after import). // First set a vault key (must be preserved after import).
expect( expect(
(await owner.put(`${base}/vault`, { entries: [{ key: "TOKEN", value: "secret-1" }] })).status, (await owner.put(`${base}/vault`, { entries: [{ key: "TOKEN", value: "secret-1" }] })).status,
@@ -79,7 +79,7 @@ describe("agent export/import", () => {
expect(vault.entries.map((e) => e.key)).toEqual(["TOKEN"]); expect(vault.entries.map((e) => e.key)).toEqual(["TOKEN"]);
}); });
it("高版本包直接导入并落 version;非法包 400", async () => { it("newer-version package imports directly, persists version; invalid package 400", async () => {
// Bump the current agent_state's version to 3, then export to get the v3 package. // Bump the current agent_state's version to 3, then export to get the v3 package.
const configPath = path.join( const configPath = path.join(
agentStateDir(t.root, projectId, "default_agent"), agentStateDir(t.root, projectId, "default_agent"),
@@ -101,7 +101,7 @@ describe("agent export/import", () => {
).toBe(400); ).toBe(400);
}); });
it("包内混入 .vault.toml 会被忽略,导入后仍是现行 vault", async () => { it("a .vault.toml in the package is ignored; the current vault survives import", async () => {
expect( expect(
(await owner.put(`${base}/vault`, { entries: [{ key: "TOKEN", value: "current" }] })).status, (await owner.put(`${base}/vault`, { entries: [{ key: "TOKEN", value: "current" }] })).status,
).toBe(200); ).toBe(200);
+9 -9
View File
@@ -19,14 +19,14 @@ describe("auth", () => {
await t.cleanup(); await t.cleanup();
}); });
it("未登录访问受保护 API 返回 401", async () => { it("accessing a protected API while not logged in returns 401", async () => {
const res = await t.app.request("/api/projects"); const res = await t.app.request("/api/projects");
expect(res.status).toBe(401); expect(res.status).toBe(401);
const body = (await res.json()) as { error: { code: string } }; const body = (await res.json()) as { error: { code: string } };
expect(body.error.code).toBe("unauthorized"); expect(body.error.code).toBe("unauthorized");
}); });
it("不开放注册:register 接口不存在", async () => { it("registration is closed: no register endpoint", async () => {
// Not logged in: no such route under /api/auth, falls into the protected-section 401. // Not logged in: no such route under /api/auth, falls into the protected-section 401.
const anon = await t.app.request("/api/auth/register", { const anon = await t.app.request("/api/auth/register", {
method: "POST", method: "POST",
@@ -43,7 +43,7 @@ describe("auth", () => {
expect(res.status).toBe(404); expect(res.status).toBe(404);
}); });
it("种子 admin 纳管 default_project;初始密码带标记", async () => { it("seeded admin manages default_project; initial password carries the flag", async () => {
const admin = await loginAdmin(t.app); const admin = await loginAdmin(t.app);
expect(admin.user.isAdmin).toBe(true); expect(admin.user.isAdmin).toBe(true);
expect(admin.user.passwordIsInitial).toBe(true); expect(admin.user.passwordIsInitial).toBe(true);
@@ -60,7 +60,7 @@ describe("auth", () => {
expect(t.deps.db.prepare("SELECT COUNT(*) AS n FROM users").get()?.n).toBe(1); expect(t.deps.db.prepare("SELECT COUNT(*) AS n FROM users").get()?.n).toBe(1);
}); });
it("管理员建号:默认 Project 为 <用户名>-default_project,显示名缺省为用户名", async () => { it("admin-created: default Project is <userId>-default_project, name defaults", async () => {
const bob = await provisionUser(t.app, "bob"); const bob = await provisionUser(t.app, "bob");
expect(bob.user.isAdmin).toBe(false); expect(bob.user.isAdmin).toBe(false);
expect(bob.user.passwordIsInitial).toBe(true); expect(bob.user.passwordIsInitial).toBe(true);
@@ -83,7 +83,7 @@ describe("auth", () => {
); );
}); });
it("登录 / me / 登出闭环;错误密码 401", async () => { it("login / me / logout round trip; wrong password 401", async () => {
await provisionUser(t.app, "carol"); await provisionUser(t.app, "carol");
const wrong = await t.app.request("/api/auth/login", { const wrong = await t.app.request("/api/auth/login", {
method: "POST", method: "POST",
@@ -105,7 +105,7 @@ describe("auth", () => {
expect(after.status).toBe(401); expect(after.status).toBe(401);
}); });
it("本人改密:旧密码校验、新密码生效、初始密码标记清除", async () => { it("self password change: old checked, new takes effect, initial flag cleared", async () => {
const { cookie } = await provisionUser(t.app, "dave"); const { cookie } = await provisionUser(t.app, "dave");
const api = apiClient(t.app, cookie); const api = apiClient(t.app, cookie);
@@ -138,7 +138,7 @@ describe("auth", () => {
await loginUser(t.app, "dave", "new-password-1"); await loginUser(t.app, "dave", "new-password-1");
}); });
it("写请求拒绝非 JSON Content-Type(CSRF 防线)", async () => { it("write requests reject non-JSON Content-Type (CSRF defense)", async () => {
const res = await t.app.request("/api/auth/login", { const res = await t.app.request("/api/auth/login", {
method: "POST", method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" }, headers: { "content-type": "application/x-www-form-urlencoded" },
@@ -147,7 +147,7 @@ describe("auth", () => {
expect(res.status).toBe(415); expect(res.status).toBe(415);
}); });
it("ui prefs 读写", async () => { it("ui prefs read/write", async () => {
const { cookie } = await provisionUser(t.app, "erin"); const { cookie } = await provisionUser(t.app, "erin");
const api = apiClient(t.app, cookie); const api = apiClient(t.app, cookie);
const empty = (await (await api.get("/api/me/prefs")).json()) as { prefs: unknown }; const empty = (await (await api.get("/api/me/prefs")).json()) as { prefs: unknown };
@@ -159,7 +159,7 @@ describe("auth", () => {
expect(got.prefs.theme).toBe("dark"); expect(got.prefs.theme).toBe("dark");
}); });
it("PUT prefs 浅合并,不覆盖其他写入方的字段", async () => { it("PUT prefs shallow-merges without clobbering other writers' fields", async () => {
const { cookie } = await provisionUser(t.app, "fred"); const { cookie } = await provisionUser(t.app, "fred");
const api = apiClient(t.app, cookie); const api = apiClient(t.app, cookie);
// Simulate two independent writers: switching Project writes lastProjectId, and onboarding writes credentialGuideSeen. // Simulate two independent writers: switching Project writes lastProjectId, and onboarding writes credentialGuideSeen.
+7 -7
View File
@@ -30,7 +30,7 @@ describe("authz", () => {
member = apiClient(t.app, b.cookie); member = apiClient(t.app, b.cookie);
outsider = apiClient(t.app, c.cookie); outsider = apiClient(t.app, c.cookie);
const created = (await ( const created = (await (
await owner.post("/api/projects", { projectId: "owner_a-shared", name: "共享项目" }) await owner.post("/api/projects", { projectId: "owner_a-shared", name: "Shared project" })
).json()) as ProjectCreateResponse; ).json()) as ProjectCreateResponse;
projectId = created.project.projectId; projectId = created.project.projectId;
const add = await owner.post(`/api/projects/${projectId}/members`, { userId: "member_b" }); const add = await owner.post(`/api/projects/${projectId}/members`, { userId: "member_b" });
@@ -40,14 +40,14 @@ describe("authz", () => {
await t.cleanup(); await t.cleanup();
}); });
it("非成员访问返回 404(不泄露存在性)", async () => { it("non-member access returns 404 (does not leak existence)", async () => {
expect((await outsider.get(`/api/projects/${projectId}/models`)).status).toBe(404); expect((await outsider.get(`/api/projects/${projectId}/models`)).status).toBe(404);
expect((await outsider.get(`/api/projects/${projectId}/agents`)).status).toBe(404); expect((await outsider.get(`/api/projects/${projectId}/agents`)).status).toBe(404);
expect((await outsider.get(`/api/projects/${projectId}/members`)).status).toBe(404); expect((await outsider.get(`/api/projects/${projectId}/members`)).status).toBe(404);
expect((await outsider.get(`/api/projects/${projectId}/usage`)).status).toBe(404); expect((await outsider.get(`/api/projects/${projectId}/usage`)).status).toBe(404);
}); });
it("member 可读模型(掩码)但不可写;owner 可写", async () => { it("member can read models (masked) but not write; owner can write", async () => {
const put = await owner.put(`/api/projects/${projectId}/models`, { const put = await owner.put(`/api/projects/${projectId}/models`, {
defaultModel: { provider: "custom", modelId: "m-1" }, defaultModel: { provider: "custom", modelId: "m-1" },
models: [{ provider: "custom", modelId: "m-1", apiKey: "sk-super-secret-key-123456" }], models: [{ provider: "custom", modelId: "m-1", apiKey: "sk-super-secret-key-123456" }],
@@ -68,7 +68,7 @@ describe("authz", () => {
expect(denied.status).toBe(403); expect(denied.status).toBe(403);
}); });
it("成员管理仅 owner:列表包含 owner 与 member;重复与自授权 409;未知用户 404", async () => { it("member management owner-only: duplicate and self-grant 409; unknown user 404", async () => {
const list = (await ( const list = (await (
await member.get(`/api/projects/${projectId}/members`) await member.get(`/api/projects/${projectId}/members`)
).json()) as MembersResponse; ).json()) as MembersResponse;
@@ -95,7 +95,7 @@ describe("authz", () => {
expect((await member.get(`/api/projects/${projectId}/models`)).status).toBe(404); expect((await member.get(`/api/projects/${projectId}/models`)).status).toBe(404);
}); });
it("Session 级路由经索引反查授权:member 可见、外人 404", async () => { it("Session-level routes authorize via index lookup: member can see, outsider 404", async () => {
await owner.put(`/api/projects/${projectId}/models`, { await owner.put(`/api/projects/${projectId}/models`, {
defaultModel: { provider: "anthropic", modelId: "claude-sonnet-4-6" }, defaultModel: { provider: "anthropic", modelId: "claude-sonnet-4-6" },
models: [{ provider: "anthropic", modelId: "claude-sonnet-4-6" }], models: [{ provider: "anthropic", modelId: "claude-sonnet-4-6" }],
@@ -113,7 +113,7 @@ describe("authz", () => {
expect((await owner.get("/api/sessions/session-unknown")).status).toBe(404); expect((await owner.get("/api/sessions/session-unknown")).status).toBe(404);
}); });
it("删除 Project 仅 owner;default_project 与最后一个可访问 Project 拒绝删除", async () => { it("deleting Projects: owner-only; default_project and the last accessible refuse", async () => {
expect((await member.delete(`/api/projects/${projectId}`)).status).toBe(403); expect((await member.delete(`/api/projects/${projectId}`)).status).toBe(403);
expect((await outsider.delete(`/api/projects/${projectId}`)).status).toBe(404); expect((await outsider.delete(`/api/projects/${projectId}`)).status).toBe(404);
// default_project is managed by admin: non-owners always get 404, and the owner (admin) is refused with 409. // default_project is managed by admin: non-owners always get 404, and the owner (admin) is refused with 409.
@@ -134,7 +134,7 @@ describe("authz", () => {
expect((await owner.get(`/api/projects/${projectId}/models`)).status).toBe(404); expect((await owner.get(`/api/projects/${projectId}/models`)).status).toBe(404);
}); });
it("PUT models 整表替换:省略 apiKey 保留、clearApiKey 清除、defaultModel 必须在 models 内", async () => { it("PUT models is a full replace: omitted apiKey kept, clearApiKey clears", async () => {
await owner.put(`/api/projects/${projectId}/models`, { await owner.put(`/api/projects/${projectId}/models`, {
defaultModel: { provider: "custom", modelId: "m-1" }, defaultModel: { provider: "custom", modelId: "m-1" },
models: [ models: [
+10 -10
View File
@@ -37,7 +37,7 @@ describe("benchmarks api", () => {
member = apiClient(t.app, b.cookie); member = apiClient(t.app, b.cookie);
outsider = apiClient(t.app, c.cookie); outsider = apiClient(t.app, c.cookie);
const created = (await ( const created = (await (
await owner.post("/api/projects", { projectId: "owner_a-bench", name: "评测项目" }) await owner.post("/api/projects", { projectId: "owner_a-bench", name: "Bench project" })
).json()) as ProjectCreateResponse; ).json()) as ProjectCreateResponse;
projectId = created.project.projectId; projectId = created.project.projectId;
// A plain Agent has no sample Benchmark pre-installed (only default_agent provides one). // A plain Agent has no sample Benchmark pre-installed (only default_agent provides one).
@@ -53,19 +53,19 @@ describe("benchmarks api", () => {
await t.cleanup(); await t.cleanup();
}); });
it("未配置时返回空列表", async () => { it("returns an empty list when unconfigured", async () => {
expect((await (await owner.get(base)).json()) as BenchmarksResponse).toEqual({ expect((await (await owner.get(base)).json()) as BenchmarksResponse).toEqual({
benchmarks: [], benchmarks: [],
}); });
}); });
it("scoreboard v2:summary 与题级 runs 透传,三指标信任文件、缺失由 runs 平均", async () => { it("scoreboard v2: summary/runs pass through; metrics trust file or average runs", async () => {
const dir = path.join(benchmarksDir(t.root, projectId, AGENT), "swe-bench-v2"); const dir = path.join(benchmarksDir(t.root, projectId, AGENT), "swe-bench-v2");
await fs.mkdir(path.join(dir, "CASE-001-excel-task", "statement"), { recursive: true }); await fs.mkdir(path.join(dir, "CASE-001-excel-task", "statement"), { recursive: true });
await fs.mkdir(path.join(dir, "CASE-002-web-task", "rubric"), { recursive: true }); await fs.mkdir(path.join(dir, "CASE-002-web-task", "rubric"), { recursive: true });
await fs.writeFile( await fs.writeFile(
path.join(dir, "benchmark_config.toml"), path.join(dir, "benchmark_config.toml"),
`title = "SWE Bench v2"\ndescription = "示例"\nruns = 2\n`, `title = "SWE Bench v2"\ndescription = "Example"\nruns = 2\n`,
"utf8", "utf8",
); );
await fs.writeFile( await fs.writeFile(
@@ -76,8 +76,8 @@ describe("benchmarks api", () => {
" version: 3", " version: 3",
' provider: "deepseek"', ' provider: "deepseek"',
' model_id: "deepseek-v4-pro"', ' model_id: "deepseek-v4-pro"',
' summary_title: "系统 Prompt 增加计划步骤"', ' summary_title: "Added planning steps to the system Prompt"',
' summary: "两题各跑两次取平均;本轮为系统 Prompt 增加了计划步骤。"', ' summary: "Each case run twice and averaged; added planning steps."',
" score: 8.0", " score: 8.0",
" cost: 0.05", " cost: 0.05",
" duration_ms: 120000", " duration_ms: 120000",
@@ -116,7 +116,7 @@ describe("benchmarks api", () => {
expect(bench).toMatchObject({ expect(bench).toMatchObject({
id: "swe-bench-v2", id: "swe-bench-v2",
title: "SWE Bench v2", title: "SWE Bench v2",
description: "示例", description: "Example",
runs: 2, runs: 2,
caseCount: 2, caseCount: 2,
}); });
@@ -127,8 +127,8 @@ describe("benchmarks api", () => {
// The evaluation entry carries this run's model (as a pair) and a summary title (curve series / title-body are displayed separately). // The evaluation entry carries this run's model (as a pair) and a summary title (curve series / title-body are displayed separately).
expect(evaluation.provider).toBe("deepseek"); expect(evaluation.provider).toBe("deepseek");
expect(evaluation.modelId).toBe("deepseek-v4-pro"); expect(evaluation.modelId).toBe("deepseek-v4-pro");
expect(evaluation.summaryTitle).toBe("系统 Prompt 增加计划步骤"); expect(evaluation.summaryTitle).toBe("Added planning steps to the system Prompt");
expect(evaluation.summary).toBe("两题各跑两次取平均;本轮为系统 Prompt 增加了计划步骤。"); expect(evaluation.summary).toBe("Each case run twice and averaged; added planning steps.");
expect(evaluation.score).toBe(8.0); expect(evaluation.score).toBe(8.0);
// Per-case metrics are all present: trust the file (4.2, not the runs average of 4.25). // Per-case metrics are all present: trust the file (4.2, not the runs average of 4.25).
const full = evaluation.cases.find((c) => c.case === "CASE-001-excel-task")!; const full = evaluation.cases.find((c) => c.case === "CASE-001-excel-task")!;
@@ -148,7 +148,7 @@ describe("benchmarks api", () => {
expect(derived.sessionId).toBeUndefined(); expect(derived.sessionId).toBeUndefined();
}); });
it("旧格式(题级单 session_id)按单次运行解析补一条 run;坏条目丢弃;外人 404", async () => { it("legacy per-case session_id parsed as one backfilled run; bad entries dropped", async () => {
const dir = path.join(benchmarksDir(t.root, projectId, AGENT), "swe-bench-v1"); const dir = path.join(benchmarksDir(t.root, projectId, AGENT), "swe-bench-v1");
await fs.mkdir(path.join(dir, "CASE-001-excel-task", "statement"), { recursive: true }); await fs.mkdir(path.join(dir, "CASE-001-excel-task", "statement"), { recursive: true });
await fs.writeFile(path.join(dir, "benchmark_config.toml"), `title = "SWE Bench v1"\n`, "utf8"); await fs.writeFile(path.join(dir, "benchmark_config.toml"), `title = "SWE Bench v1"\n`, "utf8");
+7 -7
View File
@@ -21,7 +21,7 @@ interface ProjectCreateResponse {
project: { projectId: string }; project: { projectId: string };
} }
describe("内置 Agent 供给", () => { describe("built-in Agent provisioning", () => {
let t: TestApp; let t: TestApp;
let owner: ReturnType<typeof apiClient>; let owner: ReturnType<typeof apiClient>;
@@ -61,19 +61,19 @@ describe("内置 Agent 供给", () => {
expect(defaultMd).toBe(""); expect(defaultMd).toBe("");
} }
it("建号时的初始 Project 自带 default_agent", async () => { it("the initial Project created at account setup comes with default_agent", async () => {
const projects = (await (await owner.get("/api/projects")).json()) as ProjectsResponse; const projects = (await (await owner.get("/api/projects")).json()) as ProjectsResponse;
await expectBuiltinAgents(projects.projects[0]!.projectId); await expectBuiltinAgents(projects.projects[0]!.projectId);
}); });
it("新建 Project 同样自带 default_agent", async () => { it("a newly created Project also comes with default_agent", async () => {
const created = (await ( const created = (await (
await owner.post("/api/projects", { projectId: "owner1-new", name: "新项目" }) await owner.post("/api/projects", { projectId: "owner1-new", name: "New project" })
).json()) as ProjectCreateResponse; ).json()) as ProjectCreateResponse;
await expectBuiltinAgents(created.project.projectId); await expectBuiltinAgents(created.project.projectId);
}); });
it("default_agent 附带示例 Benchmark:GET /benchmarks 可读(3 evaluations、runs=2、summary 存在)", async () => { it("default_agent ships a sample Benchmark readable via GET /benchmarks", async () => {
const projects = (await (await owner.get("/api/projects")).json()) as ProjectsResponse; const projects = (await (await owner.get("/api/projects")).json()) as ProjectsResponse;
const projectId = projects.projects[0]!.projectId; const projectId = projects.projects[0]!.projectId;
const res = await owner.get(`/api/projects/${projectId}/agents/default_agent/benchmarks`); const res = await owner.get(`/api/projects/${projectId}/agents/default_agent/benchmarks`);
@@ -102,14 +102,14 @@ describe("内置 Agent 供给", () => {
expect(scores).toEqual([...scores].sort((a, b) => a - b)); expect(scores).toEqual([...scores].sort((a, b) => a - b));
}); });
it("default_agent 不可删除(409)", async () => { it("default_agent cannot be deleted (409)", async () => {
const projects = (await (await owner.get("/api/projects")).json()) as ProjectsResponse; const projects = (await (await owner.get("/api/projects")).json()) as ProjectsResponse;
const projectId = projects.projects[0]!.projectId; const projectId = projects.projects[0]!.projectId;
const res = await owner.delete(`/api/projects/${projectId}/agents/default_agent`); const res = await owner.delete(`/api/projects/${projectId}/agents/default_agent`);
expect(res.status).toBe(409); expect(res.status).toBe(409);
}); });
it("agent_creator 等旧专用 id 不再受内置保护:可自建、可删除", async () => { it("legacy ids like agent_creator lose built-in protection: creatable, deletable", async () => {
const projects = (await (await owner.get("/api/projects")).json()) as ProjectsResponse; const projects = (await (await owner.get("/api/projects")).json()) as ProjectsResponse;
const projectId = projects.projects[0]!.projectId; const projectId = projects.projects[0]!.projectId;
const created = await owner.post(`/api/projects/${projectId}/agents`, { const created = await owner.post(`/api/projects/${projectId}/agents`, {
+11 -11
View File
@@ -13,7 +13,7 @@ function foreignEpoch(ch: Channel): string {
} }
describe("channel", () => { describe("channel", () => {
it("publish 分配 `<epoch>-<seq>` 单调 id 并广播", () => { it("publish assigns monotonic `<epoch>-<seq>` ids and broadcasts", () => {
const ch = new Channel(); const ch = new Channel();
const seen: ChannelEvent[] = []; const seen: ChannelEvent[] = [];
ch.subscribe((e) => seen.push(e)); ch.subscribe((e) => seen.push(e));
@@ -26,7 +26,7 @@ describe("channel", () => {
expect(JSON.parse(seen[0]!.data)).toEqual({ a: 1 }); expect(JSON.parse(seen[0]!.data)).toEqual({ a: 1 });
}); });
it("退订后不再接收", () => { it("no longer receives after unsubscribe", () => {
const ch = new Channel(); const ch = new Channel();
const seen: ChannelEvent[] = []; const seen: ChannelEvent[] = [];
const unsub = ch.subscribe((e) => seen.push(e)); const unsub = ch.subscribe((e) => seen.push(e));
@@ -36,7 +36,7 @@ describe("channel", () => {
expect(seen).toHaveLength(1); expect(seen).toHaveLength(1);
}); });
it("按条数上限淘汰最旧事件", () => { it("evicts the oldest events by the count cap", () => {
const ch = new Channel({ maxBufferCount: 3 }); const ch = new Channel({ maxBufferCount: 3 });
for (let i = 0; i < 5; i++) ch.publish(`m${i}`); // seq 1..5, buffer retains 3, 4, 5 for (let i = 0; i < 5; i++) ch.publish(`m${i}`); // seq 1..5, buffer retains 3, 4, 5
const miss = ch.replayAfter(`${ch.epoch}-1`); // event 2 has been evicted const miss = ch.replayAfter(`${ch.epoch}-1`); // event 2 has been evicted
@@ -46,7 +46,7 @@ describe("channel", () => {
expect(hit.events.map((e) => e.id)).toEqual([`${ch.epoch}-4`, `${ch.epoch}-5`]); expect(hit.events.map((e) => e.id)).toEqual([`${ch.epoch}-4`, `${ch.epoch}-5`]);
}); });
it("按字节上限淘汰最旧事件", () => { it("evicts the oldest events by the byte cap", () => {
const ch = new Channel({ maxBufferBytes: 30 }); const ch = new Channel({ maxBufferBytes: 30 });
ch.publish("a".repeat(20)); ch.publish("a".repeat(20));
ch.publish("b".repeat(20)); // event 1 was evicted due to the byte cap ch.publish("b".repeat(20)); // event 1 was evicted due to the byte cap
@@ -56,7 +56,7 @@ describe("channel", () => {
expect(hit.events.map((e) => e.id)).toEqual([`${ch.epoch}-2`]); expect(hit.events.map((e) => e.id)).toEqual([`${ch.epoch}-2`]);
}); });
it("Last-Event-ID 等于最新 id → 命中且无补发;超过已分配 seq → miss", () => { it("Last-Event-ID at the latest id → hit, no replay; beyond the allocated seq → miss", () => {
const ch = new Channel(); const ch = new Channel();
ch.publish("x"); // seq 1 ch.publish("x"); // seq 1
const same = ch.replayAfter(`${ch.epoch}-1`); const same = ch.replayAfter(`${ch.epoch}-1`);
@@ -65,7 +65,7 @@ describe("channel", () => {
expect(ch.replayAfter(`${ch.epoch}-99`).hit).toBe(false); expect(ch.replayAfter(`${ch.epoch}-99`).hit).toBe(false);
}); });
it("跨纪元(通道回收重建 / 进程重启)一律 miss——即使 seq 落在命中区间", () => { it("cross-epoch (channel rebuilt / process restart) always misses despite in-range seq", () => {
const ch = new Channel(); const ch = new Channel();
for (let i = 0; i < 5; i++) ch.publish(`m${i}`); // seq 1..5, all within the buffer for (let i = 0; i < 5; i++) ch.publish(`m${i}`); // seq 1..5, all within the buffer
// Same seq, different epoch: the old implementation would false-hit on integer ranges; now it must miss -> resync. // Same seq, different epoch: the old implementation would false-hit on integer ranges; now it must miss -> resync.
@@ -73,7 +73,7 @@ describe("channel", () => {
expect(ch.replayAfter(`${ch.epoch}-2`).hit).toBe(true); expect(ch.replayAfter(`${ch.epoch}-2`).hit).toBe(true);
}); });
it("非法 Last-Event-ID(无纪元 / 非整数 seq)→ miss", () => { it("invalid Last-Event-ID (no epoch / non-integer seq) → miss", () => {
const ch = new Channel(); const ch = new Channel();
ch.publish("x"); ch.publish("x");
expect(ch.replayAfter("42").hit).toBe(false); // legacy pure-integer ids are also treated as unknown expect(ch.replayAfter("42").hit).toBe(false); // legacy pure-integer ids are also treated as unknown
@@ -82,13 +82,13 @@ describe("channel", () => {
expect(ch.replayAfter("-1").hit).toBe(false); expect(ch.replayAfter("-1").hit).toBe(false);
}); });
it("全新通道(无事件)带任何 Last-Event-ID → miss;`<epoch>-0` 从头命中", () => { it("brand-new channel: any Last-Event-ID → miss; `<epoch>-0` hits from the start", () => {
const ch = new Channel(); const ch = new Channel();
expect(ch.replayAfter(`${ch.epoch}-5`).hit).toBe(false); expect(ch.replayAfter(`${ch.epoch}-5`).hit).toBe(false);
expect(ch.replayAfter(`${ch.epoch}-0`).hit).toBe(true); // 0 = from the start, hits since nothing was evicted (no events to replay) expect(ch.replayAfter(`${ch.epoch}-0`).hit).toBe(true); // 0 = from the start, hits since nothing was evicted (no events to replay)
}); });
it("sendTo 私发:占用 seq、不进缓冲、不广播", () => { it("sendTo private send: consumes a seq, skips the buffer, no broadcast", () => {
const ch = new Channel(); const ch = new Channel();
const broadcast: ChannelEvent[] = []; const broadcast: ChannelEvent[] = [];
const priv: ChannelEvent[] = []; const priv: ChannelEvent[] = [];
@@ -102,7 +102,7 @@ describe("channel", () => {
expect(ch.replayAfter(`${ch.epoch}-1`).events.map((e) => e.id)).toEqual([`${ch.epoch}-2`]); // private sends bypass the buffer expect(ch.replayAfter(`${ch.epoch}-1`).events.map((e) => e.id)).toEqual([`${ch.epoch}-2`]); // private sends bypass the buffer
}); });
it("hub:同 key 复用通道;空闲超时回收、有订阅者不回收", () => { it("hub: same key reuses the channel; idle timeout reclaims, subscribers prevent it", () => {
const hub = new ChannelHub({ idleMs: 1000 }); const hub = new ChannelHub({ idleMs: 1000 });
const ch = hub.get("s1"); const ch = hub.get("s1");
expect(hub.get("s1")).toBe(ch); expect(hub.get("s1")).toBe(ch);
@@ -120,7 +120,7 @@ describe("channel", () => {
hub.dispose(); hub.dispose();
}); });
it("hub:isActive 谓词命中的通道不回收(运行中等待审批无 publish 也保留)", () => { it("hub: isActive channels are kept (running awaiting approval with no publish)", () => {
const active = new Set(["busy"]); const active = new Set(["busy"]);
const hub = new ChannelHub({ idleMs: 1000, isActive: (key) => active.has(key) }); const hub = new ChannelHub({ idleMs: 1000, isActive: (key) => active.has(key) });
const busy = hub.get("busy"); const busy = hub.get("busy");
+5 -5
View File
@@ -10,20 +10,20 @@ import { resolveServerConfig } from "../src/config.js";
const base = { PENGUIN_HOME: "/tmp/penguin-config-test" }; const base = { PENGUIN_HOME: "/tmp/penguin-config-test" };
describe("resolveServerConfig:PORT 解析", () => { describe("resolveServerConfig: PORT parsing", () => {
it("缺省 7364;空串视为未设置(不落到端口 0)", () => { it("defaults to 7364; empty string treated as unset (does not fall to port 0)", () => {
expect(resolveServerConfig({ ...base }).port).toBe(7364); expect(resolveServerConfig({ ...base }).port).toBe(7364);
expect(resolveServerConfig({ ...base, PORT: "" }).port).toBe(7364); expect(resolveServerConfig({ ...base, PORT: "" }).port).toBe(7364);
}); });
it('显式数值生效;显式 "0" 保留(绑随机可用端口)', () => { it('explicit value takes effect; "0" is preserved (binds a random available port)', () => {
expect(resolveServerConfig({ ...base, PORT: "8930" }).port).toBe(8930); expect(resolveServerConfig({ ...base, PORT: "8930" }).port).toBe(8930);
expect(resolveServerConfig({ ...base, PORT: "0" }).port).toBe(0); expect(resolveServerConfig({ ...base, PORT: "0" }).port).toBe(0);
}); });
it("非整数或超界抛错", () => { it("non-integer or out-of-range values throw", () => {
for (const bad of ["abc", "3.14", "-1", "65536"]) { for (const bad of ["abc", "3.14", "-1", "65536"]) {
expect(() => resolveServerConfig({ ...base, PORT: bad }), bad).toThrow(/非法端口/); expect(() => resolveServerConfig({ ...base, PORT: bad }), bad).toThrow(/Invalid port/);
} }
}); });
}); });
+72 -68
View File
@@ -65,14 +65,14 @@ describe("errors-repo", () => {
}); });
afterEach(() => db.close()); afterEach(() => db.close());
it("汇总:总数与其中未预期数;expected 照记不丢", () => { it("summary: total and unexpected count; expected ones are still recorded", () => {
repo.insert(row("2026-07-06")); repo.insert(row("2026-07-06"));
repo.insert(row("2026-07-06", { kind: "expected", code: "not_found", status: 404 })); repo.insert(row("2026-07-06", { kind: "expected", code: "not_found", status: 404 }));
repo.insert(row("2026-07-06", { kind: "expected", code: "bad_request", status: 400 })); repo.insert(row("2026-07-06", { kind: "expected", code: "bad_request", status: 400 }));
expect(repo.summary("p1")).toEqual({ total: 3, unexpected: 1 }); expect(repo.summary("p1")).toEqual({ total: 3, unexpected: 1 });
}); });
it("无归属异常(登录失败 / 进程崩溃)只对管理员可见:普通成员只看本 Project", () => { it("unattributed errors (login failure / crash) are admin-only, invisible to members", () => {
const global = { projectId: null, source: "process", code: "uncaught_exception" }; const global = { projectId: null, source: "process", code: "uncaught_exception" };
repo.insert(row("2026-07-06", global)); // Unattributed: another tenant's login failure / process crash repo.insert(row("2026-07-06", global)); // Unattributed: another tenant's login failure / process crash
repo.insert(row("2026-07-06", global)); repo.insert(row("2026-07-06", global));
@@ -99,7 +99,7 @@ describe("errors-repo", () => {
expect(repo.recent("p-other").map((r) => r.code)).toEqual(["internal"]); expect(repo.recent("p-other").map((r) => r.code)).toEqual(["internal"]);
}); });
it("最常见错误码:按 source+code+kind 分组取次数最高的一条", () => { it("top error code: grouped by source+code+kind, takes the highest count", () => {
for (let i = 0; i < 3; i++) repo.insert(row("2026-07-06", { code: "internal" })); for (let i = 0; i < 3; i++) repo.insert(row("2026-07-06", { code: "internal" }));
repo.insert(row("2026-07-06", { source: "session", code: "session_run_failed" })); repo.insert(row("2026-07-06", { source: "session", code: "session_run_failed" }));
repo.insert(row("2026-07-06", { kind: "expected", code: "not_found", status: 404 })); repo.insert(row("2026-07-06", { kind: "expected", code: "not_found", status: 404 }));
@@ -116,7 +116,7 @@ describe("errors-repo", () => {
expect(repo.topCode("p1", { from: "2026-07-07" })).toBeNull(); expect(repo.topCode("p1", { from: "2026-07-07" })).toBeNull();
}); });
it("日期区间与 agent 过滤(HTTP / 进程级异常无 agent_id,按 Agent 过滤时天然只剩该 Agent)", () => { it("date range and agent filters (HTTP / process errors have no agent_id)", () => {
repo.insert(row("2026-07-05")); repo.insert(row("2026-07-05"));
repo.insert(row("2026-07-06", { kind: "expected" })); repo.insert(row("2026-07-06", { kind: "expected" }));
repo.insert(row("2026-07-06", { agentId: "a1", source: "session" })); repo.insert(row("2026-07-06", { agentId: "a1", source: "session" }));
@@ -128,15 +128,15 @@ describe("errors-repo", () => {
expect(repo.recent("p1", { agentId: "a1" })).toHaveLength(1); expect(repo.recent("p1", { agentId: "a1" })).toHaveLength(1);
}); });
it("最近异常:时间倒序取前 limit 条", () => { it("recent errors: newest first, top limit rows", () => {
repo.insert(row("2026-07-05", { message: "旧" })); repo.insert(row("2026-07-05", { message: "old" }));
repo.insert(row("2026-07-06", { message: "新" })); repo.insert(row("2026-07-06", { message: "new" }));
const recent = repo.recent("p1", {}, 1); const recent = repo.recent("p1", {}, 1);
expect(recent).toHaveLength(1); expect(recent).toHaveLength(1);
expect(recent[0]!.message).toBe("新"); expect(recent[0]!.message).toBe("new");
}); });
it("deleteByProject:只删该 Project 的行,无归属异常保留", () => { it("deleteByProject: deletes only that Project's rows, unattributed errors remain", () => {
repo.insert(row("2026-07-06")); repo.insert(row("2026-07-06"));
repo.insert(row("2026-07-06", { projectId: null })); repo.insert(row("2026-07-06", { projectId: null }));
repo.deleteByProject("p1"); repo.deleteByProject("p1");
@@ -153,14 +153,14 @@ describe("errors-repo", () => {
.all() .all()
.map((r) => r.message as string); .map((r) => r.message as string);
it("容量上限:超出后按 id 淘汰最旧的行(每 pruneEvery 次插入才检查一次)", () => { it("row cap: evicts the oldest rows by id (checked every pruneEvery inserts)", () => {
const capped = new ErrorsRepo(db, { maxRows: 5, pruneEvery: 2 }); const capped = new ErrorsRepo(db, { maxRows: 5, pruneEvery: 2 });
for (let i = 0; i < 10; i++) capped.insert(row("2026-07-06", { message: `m${i}` })); for (let i = 0; i < 10; i++) capped.insert(row("2026-07-06", { message: `m${i}` }));
// The 5 most recent rows within the cap are kept, older ones are evicted. // The 5 most recent rows within the cap are kept, older ones are evicted.
expect(messages()).toEqual(["m5", "m6", "m7", "m8", "m9"]); expect(messages()).toEqual(["m5", "m6", "m7", "m8", "m9"]);
}); });
it("淘汰按行数算:deleteByProject 留下的 id 空洞不会误删有效数据(近似式会)", () => { it("eviction counts rows: id gaps left by deleteByProject never misdelete valid data", () => {
const capped = new ErrorsRepo(db, { maxRows: 3, pruneEvery: 1 }); const capped = new ErrorsRepo(db, { maxRows: 3, pruneEvery: 1 });
capped.insert(row("2026-07-06", { message: "keep-1" })); // id 1 capped.insert(row("2026-07-06", { message: "keep-1" })); // id 1
capped.insert(row("2026-07-06", { message: "keep-2" })); // id 2 capped.insert(row("2026-07-06", { message: "keep-2" })); // id 2
@@ -188,10 +188,14 @@ describe("error-recorder", () => {
}); });
afterEach(() => db.close()); afterEach(() => db.close());
it("HttpError → expected(保留 code 与状态码)", () => { it("HttpError → expected (keeps code and status)", () => {
new ErrorRecorder(repo, now).record({ new ErrorRecorder(repo, now).record({
source: "http", source: "http",
err: new HttpError(404, "session_not_found", "Session 不存在或无权访问。"), err: new HttpError(
404,
"session_not_found",
"Session does not exist or you do not have access.",
),
ctx: { projectId: "p1" }, ctx: { projectId: "p1" },
}); });
const r = db.prepare("SELECT * FROM error_records").get()!; const r = db.prepare("SELECT * FROM error_records").get()!;
@@ -202,12 +206,12 @@ describe("error-recorder", () => {
expect(r.date).toBe("2026-07-06"); expect(r.date).toBe("2026-07-06");
}); });
it("非 HttpError → unexpected;HTTP 来源收敛 500,非 HTTP 来源 status 为 NULL", () => { it("non-HttpError → unexpected; HTTP source converges to 500, non-HTTP status is NULL", () => {
const rec = new ErrorRecorder(repo, now); const rec = new ErrorRecorder(repo, now);
rec.record({ source: "http", err: new Error("boom") }); rec.record({ source: "http", err: new Error("boom") });
rec.record({ rec.record({
source: "session", source: "session",
err: new Error("drive 挂了"), err: new Error("drive crashed"),
ctx: { projectId: "p1", agentId: "a1", sessionId: "s1" }, ctx: { projectId: "p1", agentId: "a1", sessionId: "s1" },
code: "session_run_failed", code: "session_run_failed",
}); });
@@ -222,19 +226,19 @@ describe("error-recorder", () => {
expect(rows[1]!.session_id).toBe("s1"); expect(rows[1]!.session_id).toBe("s1");
}); });
it("非 Error 抛出物与超长 message:String 化并截断到上限", () => { it("non-Error throwables and overlong messages: stringified and truncated to the cap", () => {
const rec = new ErrorRecorder(repo, now); const rec = new ErrorRecorder(repo, now);
rec.record({ source: "process", err: "字符串异常", code: "unhandled_rejection" }); rec.record({ source: "process", err: "a string error", code: "unhandled_rejection" });
rec.record({ source: "usage", err: new Error("x".repeat(MESSAGE_MAX + 100)) }); rec.record({ source: "usage", err: new Error("x".repeat(MESSAGE_MAX + 100)) });
const rows = db.prepare("SELECT message FROM error_records ORDER BY id").all(); const rows = db.prepare("SELECT message FROM error_records ORDER BY id").all();
expect(rows[0]!.message).toBe("字符串异常"); expect(rows[0]!.message).toBe("a string error");
expect((rows[1]!.message as string).length).toBe(MESSAGE_MAX); expect((rows[1]!.message as string).length).toBe(MESSAGE_MAX);
}); });
it("记录器自身出错绝不外抛(否则挂在 onError 上会无限递归)", () => { it("the recorder itself never throws (hooked on onError it would recurse forever)", () => {
const broken = { const broken = {
insert() { insert() {
throw new Error("DB 已关闭"); throw new Error("DB is closed");
}, },
} as unknown as ErrorsRepo; } as unknown as ErrorsRepo;
expect(() => expect(() =>
@@ -242,10 +246,10 @@ describe("error-recorder", () => {
).not.toThrow(); ).not.toThrow();
}); });
it("显式 kind 优先于 HttpError 推断(新来源自报「要不要人介入」)", () => { it("explicit kind wins over HttpError inference (sources self-report human need)", () => {
const rec = new ErrorRecorder(repo, now); const rec = new ErrorRecorder(repo, now);
rec.record({ source: "llm", err: "超时", code: "llm_timeout", kind: "expected" }); rec.record({ source: "llm", err: "timed out", code: "llm_timeout", kind: "expected" });
rec.record({ source: "llm", err: "鉴权失败", code: "llm_failed", kind: "unexpected" }); rec.record({ source: "llm", err: "auth failed", code: "llm_failed", kind: "unexpected" });
const rows = db.prepare("SELECT kind, source, status FROM error_records ORDER BY id").all(); const rows = db.prepare("SELECT kind, source, status FROM error_records ORDER BY id").all();
expect(rows[0]).toMatchObject({ kind: "expected", source: "llm", status: null }); expect(rows[0]).toMatchObject({ kind: "expected", source: "llm", status: null });
expect(rows[1]).toMatchObject({ kind: "unexpected", source: "llm", status: null }); expect(rows[1]).toMatchObject({ kind: "unexpected", source: "llm", status: null });
@@ -259,13 +263,13 @@ describe("error-recorder", () => {
const lastSeen = (rec: ErrorRecorder) => const lastSeen = (rec: ErrorRecorder) =>
(rec as unknown as { lastSeen: Map<string, number> }).lastSeen; (rec as unknown as { lastSeen: Map<string, number> }).lastSeen;
it("短窗去重:窗口内的同类异常只落一条,窗口外恢复记录", () => { it("short-window dedup: same-kind errors persist once per window, then resume", () => {
let t = Date.parse("2026-07-06T10:00:00Z"); let t = Date.parse("2026-07-06T10:00:00Z");
const rec = new ErrorRecorder(repo, () => new Date(t)); const rec = new ErrorRecorder(repo, () => new Date(t));
const boom = () => const boom = () =>
rec.record({ rec.record({
source: "http", source: "http",
err: new HttpError(404, "not_found", "没有"), err: new HttpError(404, "not_found", "Not found."),
ctx: { projectId: "p1" }, ctx: { projectId: "p1" },
}); });
@@ -282,7 +286,7 @@ describe("error-recorder", () => {
expect(count()).toBe(2); expect(count()).toBe(2);
}); });
it("去重不跨 source / code / Project(不同类的异常互不压制)", () => { it("dedup never crosses source / code / Project (kinds don't suppress each other)", () => {
const rec = new ErrorRecorder(repo, now); // time frozen: everything lands in the same window const rec = new ErrorRecorder(repo, now); // time frozen: everything lands in the same window
const err = new Error("boom"); const err = new Error("boom");
rec.record({ source: "http", err, ctx: { projectId: "p1" }, code: "c1" }); rec.record({ source: "http", err, ctx: { projectId: "p1" }, code: "c1" });
@@ -294,7 +298,7 @@ describe("error-recorder", () => {
expect(count()).toBe(5); expect(count()).toBe(5);
}); });
it("去重表有界:先清过期项,仍超限则整体清空——清空后照常去重与记录", () => { it("bounded dedup table: expired entries cleaned first, else wiped; works afterward", () => {
let t = Date.parse("2026-07-06T10:00:00Z"); let t = Date.parse("2026-07-06T10:00:00Z");
const rec = new ErrorRecorder(repo, () => new Date(t)); const rec = new ErrorRecorder(repo, () => new Date(t));
const boom = (code: string) => const boom = (code: string) =>
@@ -318,7 +322,7 @@ describe("error-recorder", () => {
}); });
}); });
describe("stream-error-watcher(LLM / Environment 异常)", () => { describe("stream-error-watcher (LLM / Environment errors)", () => {
let db: DatabaseSync; let db: DatabaseSync;
let repo: ErrorsRepo; let repo: ErrorsRepo;
const now = () => new Date("2026-07-06T10:00:00"); const now = () => new Date("2026-07-06T10:00:00");
@@ -365,7 +369,7 @@ describe("stream-error-watcher(LLM / Environment 异常)", () => {
// —— LLM —— // —— LLM ——
it("LLM failed → unexpected(不可重试,需人介入);message 取随后 abort 事件的真实原因", () => { it("LLM failed → unexpected (needs a human); message takes the abort reason", () => {
const got = feed([ const got = feed([
requestBegin(), requestBegin(),
requestEnd("failed"), requestEnd("failed"),
@@ -384,7 +388,7 @@ describe("stream-error-watcher(LLM / Environment 异常)", () => {
}); });
}); });
it("LLM timeout / malformed → expected(引擎自动重连重试);重试耗尽时 message 取 abort 原因", () => { it("LLM timeout / malformed → expected (engine retries); message from the abort reason", () => {
const got = feed([ const got = feed([
requestBegin(), requestBegin(),
requestEnd("timeout"), // First attempt times out → the engine retries (revealed by the next request_begin: no reason text yet) requestEnd("timeout"), // First attempt times out → the engine retries (revealed by the next request_begin: no reason text yet)
@@ -394,7 +398,7 @@ describe("stream-error-watcher(LLM / Environment 异常)", () => {
]); ]);
expect(got).toHaveLength(2); expect(got).toHaveLength(2);
expect(got[0]).toMatchObject({ source: "llm", kind: "expected", code: "llm_timeout" }); expect(got[0]).toMatchObject({ source: "llm", kind: "expected", code: "llm_timeout" });
expect(got[0]!.message).toContain("超时"); // No abort arrived: falls back to the status text expect(got[0]!.message).toContain("timed out"); // No abort arrived: falls back to the status text
expect(got[1]).toMatchObject({ expect(got[1]).toMatchObject({
source: "llm", source: "llm",
kind: "expected", kind: "expected",
@@ -403,7 +407,7 @@ describe("stream-error-watcher(LLM / Environment 异常)", () => {
}); });
}); });
it("aborted(用户点「停止」)不是异常:不记录;completed 亦不记", () => { it("aborted (user clicked Stop) is not an error: not recorded; neither is completed", () => {
expect( expect(
feed([ feed([
requestBegin(), requestBegin(),
@@ -415,7 +419,7 @@ describe("stream-error-watcher(LLM / Environment 异常)", () => {
).toHaveLength(0); ).toHaveLength(0);
}); });
it("用户在重试退避期间中断:timeout 是真实失败照记,但 message 不采信用户中断文案", () => { it("interrupt during retry backoff: timeout still recorded, interrupt text distrusted", () => {
const got = feed([ const got = feed([
requestBegin(), requestBegin(),
requestEnd("timeout"), requestEnd("timeout"),
@@ -423,11 +427,11 @@ describe("stream-error-watcher(LLM / Environment 异常)", () => {
]); ]);
expect(got).toHaveLength(1); expect(got).toHaveLength(1);
expect(got[0]).toMatchObject({ code: "llm_timeout", kind: "expected" }); expect(got[0]).toMatchObject({ code: "llm_timeout", kind: "expected" });
expect(got[0]!.message).toContain("超时"); expect(got[0]!.message).toContain("timed out");
expect(got[0]!.message).not.toContain("aborted"); expect(got[0]!.message).not.toContain("aborted");
}); });
it("失败先挂起等原因;run 结束仍未揭晓 → close 兜底落库(用 status 文案)", () => { it("failure pends for its reason; unresolved at run end → close persists (status text)", () => {
const w = watcher(); const w = watcher();
w.observe(requestBegin()); w.observe(requestBegin());
w.observe(requestEnd("failed")); w.observe(requestEnd("failed"));
@@ -436,10 +440,10 @@ describe("stream-error-watcher(LLM / Environment 异常)", () => {
const got = rows(); const got = rows();
expect(got).toHaveLength(1); expect(got).toHaveLength(1);
expect(got[0]).toMatchObject({ code: "llm_failed", kind: "unexpected" }); expect(got[0]).toMatchObject({ code: "llm_failed", kind: "unexpected" });
expect(got[0]!.message).toContain("LLM 请求失败"); expect(got[0]!.message).toContain("LLM request failed");
}); });
it("父/子会话的 LLM 失败按 origin 各自挂起,abort 原因不串味", () => { it("parent/child LLM failures pend separately by origin; abort reasons never cross over", () => {
const got = feed([ const got = feed([
requestBegin(), // parent session initiates requestBegin(), // parent session initiates
withOrigin(requestBegin(), "session-child"), withOrigin(requestBegin(), "session-child"),
@@ -463,7 +467,7 @@ describe("stream-error-watcher(LLM / Environment 异常)", () => {
const call = (name: string, id: string) => toolCall({ name, arguments: "{}", toolCallId: id }); const call = (name: string, id: string) => toolCall({ name, arguments: "{}", toolCallId: id });
it("工具 failed / timeout → environment + expected,code 带工具名(能看出是哪个工具挂了)", () => { it("tool failed / timeout → environment + expected, code carries the tool name", () => {
const got = feed([ const got = feed([
call("exec_command", "tc-1"), call("exec_command", "tc-1"),
toolCallOutput({ toolCallOutput({
@@ -491,7 +495,7 @@ describe("stream-error-watcher(LLM / Environment 异常)", () => {
expect(got[1]).toMatchObject({ code: "tool_timeout:read_file", kind: "expected" }); expect(got[1]).toMatchObject({ code: "tool_timeout:read_file", kind: "expected" });
}); });
it("工具 aborted(拒批 / 用户中断)与 completed 不记", () => { it("tool aborted (denial / user interrupt) and completed are not recorded", () => {
expect( expect(
feed([ feed([
call("exec_command", "tc-1"), call("exec_command", "tc-1"),
@@ -506,7 +510,7 @@ describe("stream-error-watcher(LLM / Environment 异常)", () => {
).toHaveLength(0); ).toHaveLength(0);
}); });
it("并行工具:tool_call_id → 工具名各自对上(输出乱序到达也不错位)", () => { it("parallel tools: each tool_call_id maps to its own name despite out-of-order outputs", () => {
const got = feed([ const got = feed([
call("exec_command", "tc-1"), call("exec_command", "tc-1"),
call("read_file", "tc-2"), call("read_file", "tc-2"),
@@ -519,7 +523,7 @@ describe("stream-error-watcher(LLM / Environment 异常)", () => {
expect(got.map((r) => r.message)).toEqual(["boom-2", "boom-1"]); expect(got.map((r) => r.message)).toEqual(["boom-2", "boom-1"]);
}); });
it("子会话(origin)的工具失败照记:工具名与父会话的同名 tool_call_id 不串味", () => { it("a child session's tool failure: no name mix-up with the parent's equal tool_call_id", () => {
const got = feed([ const got = feed([
call("exec_command", "tc-1"), // parent session call("exec_command", "tc-1"), // parent session
withOrigin(call("write_file", "tc-1"), "session-child"), // sub-session happens to share the same id withOrigin(call("write_file", "tc-1"), "session-child"), // sub-session happens to share the same id
@@ -538,7 +542,7 @@ describe("stream-error-watcher(LLM / Environment 异常)", () => {
expect(got[1]).toMatchObject({ code: "tool_failed:exec_command", message: "parent boom" }); expect(got[1]).toMatchObject({ code: "tool_failed:exec_command", message: "parent boom" });
}); });
it("超长工具输出:message 取尾部(失败原因在末尾)并截断到上限", () => { it("overlong tool output: message takes the tail (the reason is at the end)", () => {
const got = feed([ const got = feed([
call("exec_command", "tc-1"), call("exec_command", "tc-1"),
toolCallOutput({ toolCallOutput({
@@ -553,10 +557,10 @@ describe("stream-error-watcher(LLM / Environment 异常)", () => {
expect(message.endsWith("[tool error] boom")).toBe(true); // truncating from the head would cut off the reason entirely expect(message.endsWith("[tool error] boom")).toBe(true); // truncating from the head would cut off the reason entirely
}); });
it("无关消息为 no-op:正文、以及流式 partial_*(完整 tool_call_output 才是收尾)", () => { it("irrelevant messages are a no-op: body text and streaming partial_*", () => {
expect( expect(
feed([ feed([
assistantText("正常输出"), assistantText("normal output"),
call("exec_command", "tc-1"), call("exec_command", "tc-1"),
partialToolCallOutput({ eventType: "stop", toolCallId: "tc-1", stopReason: "failed" }), partialToolCallOutput({ eventType: "stop", toolCallId: "tc-1", stopReason: "failed" }),
]), ]),
@@ -565,7 +569,7 @@ describe("stream-error-watcher(LLM / Environment 异常)", () => {
// —— Attribution: an error is recorded against **the session that actually produced it** (a sub-session's failure must not be attributed to the parent Agent) —— // —— Attribution: an error is recorded against **the session that actually produced it** (a sub-session's failure must not be attributed to the parent Agent) ——
it("子会话的 LLM 失败归到子 Agent / 子 Session(不是父的)", () => { it("a child session's LLM failure attributes to the child Agent / Session", () => {
const got = feed([ const got = feed([
childMeta("session-child", "/data/agents/agent-child/agent_state"), childMeta("session-child", "/data/agents/agent-child/agent_state"),
withOrigin(requestBegin(), "session-child"), withOrigin(requestBegin(), "session-child"),
@@ -583,7 +587,7 @@ describe("stream-error-watcher(LLM / Environment 异常)", () => {
}); });
}); });
it("子会话的工具失败同样归到子会话(code 仍带工具名)", () => { it("a child session's tool failure attributes to it (code still carries the tool name)", () => {
const got = feed([ const got = feed([
childMeta("session-child", "/data/agents/agent-child/agent_state"), childMeta("session-child", "/data/agents/agent-child/agent_state"),
withOrigin(call("exec_command", "tc-1"), "session-child"), withOrigin(call("exec_command", "tc-1"), "session-child"),
@@ -603,7 +607,7 @@ describe("stream-error-watcher(LLM / Environment 异常)", () => {
}); });
}); });
it("父子交错到达:各自归各自;主会话(无 origin)的失败仍归父", () => { it("parent/child interleaving: each attributes to its own; no-origin goes to the parent", () => {
const got = feed([ const got = feed([
requestBegin(), // parent session initiates requestBegin(), // parent session initiates
childMeta("session-child", "/data/agents/agent-child/agent_state"), childMeta("session-child", "/data/agents/agent-child/agent_state"),
@@ -629,7 +633,7 @@ describe("stream-error-watcher(LLM / Environment 异常)", () => {
]); ]);
}); });
it("session_meta 还没到就先失败(边界):回退父 ctx,不崩", () => { it("failure before session_meta arrives: falls back to the parent ctx, no crash", () => {
const got = feed([ const got = feed([
withOrigin(requestEnd("failed"), "session-child"), // the sub-session's meta hasn't arrived yet withOrigin(requestEnd("failed"), "session-child"), // the sub-session's meta hasn't arrived yet
withOrigin(abortEvent("llm request error: 500"), "session-child"), withOrigin(abortEvent("llm request error: 500"), "session-child"),
@@ -638,7 +642,7 @@ describe("stream-error-watcher(LLM / Environment 异常)", () => {
expect(got[0]).toMatchObject({ code: "llm_failed", agent_id: "a1", session_id: "s1" }); expect(got[0]).toMatchObject({ code: "llm_failed", agent_id: "a1", session_id: "s1" });
}); });
it("agent_state 路径异常(空串):不登记,回退父 ctx(不写进一个不存在的 agentId)", () => { it("malformed agent_state path (empty): not registered, falls back to the parent ctx", () => {
const got = feed([ const got = feed([
childMeta("session-child", ""), // path.basename(path.dirname("")) === "." → caught by the defensive check childMeta("session-child", ""), // path.basename(path.dirname("")) === "." → caught by the defensive check
withOrigin(requestEnd("failed"), "session-child"), withOrigin(requestEnd("failed"), "session-child"),
@@ -649,7 +653,7 @@ describe("stream-error-watcher(LLM / Environment 异常)", () => {
}); });
}); });
describe("HTTP onError 落库(集成)", () => { describe("HTTP onError persistence (integration)", () => {
let t: TestApp; let t: TestApp;
let api: ReturnType<typeof apiClient>; let api: ReturnType<typeof apiClient>;
let projectId: string; let projectId: string;
@@ -659,7 +663,7 @@ describe("HTTP onError 落库(集成)", () => {
const u = await provisionUser(t.app, "err_user"); const u = await provisionUser(t.app, "err_user");
api = apiClient(t.app, u.cookie); api = apiClient(t.app, u.cookie);
const created = (await ( const created = (await (
await api.post("/api/projects", { projectId: "err_user-proj", name: "异常项目" }) await api.post("/api/projects", { projectId: "err_user-proj", name: "Error project" })
).json()) as ProjectCreateResponse; ).json()) as ProjectCreateResponse;
projectId = created.project.projectId; projectId = created.project.projectId;
}); });
@@ -672,7 +676,7 @@ describe("HTTP onError 落库(集成)", () => {
Record<string, unknown> Record<string, unknown>
>; >;
it("业务错误(HttpError 404)→ expected,带 code / 状态码 / projectId", async () => { it("business error (HttpError 404) → expected, with code / status / projectId", async () => {
const res = await api.get(`/api/projects/${projectId}/agents/agent-nope/sessions`); const res = await api.get(`/api/projects/${projectId}/agents/agent-nope/sessions`);
expect(res.status).toBe(404); expect(res.status).toBe(404);
@@ -686,11 +690,11 @@ describe("HTTP onError 落库(集成)", () => {
expect(rows[0]!.project_id).toBe(projectId); expect(rows[0]!.project_id).toBe(projectId);
}); });
it("未预期异常(服务层抛普通 Error)→ unexpected + 500", async () => { it("unexpected error (service layer throws a plain Error) → unexpected + 500", async () => {
// handleError logs the stack trace: silence it in the test so it doesn't clutter output. // handleError logs the stack trace: silence it in the test so it doesn't clutter output.
const spy = vi.spyOn(console, "error").mockImplementation(() => {}); const spy = vi.spyOn(console, "error").mockImplementation(() => {});
t.deps.usageService.query = () => { t.deps.usageService.query = () => {
throw new Error("查询炸了"); throw new Error("query blew up");
}; };
const res = await api.get(`/api/projects/${projectId}/usage?groupBy=date`); const res = await api.get(`/api/projects/${projectId}/usage?groupBy=date`);
expect(res.status).toBe(500); expect(res.status).toBe(500);
@@ -701,14 +705,14 @@ describe("HTTP onError 落库(集成)", () => {
expect(rows[0]!.kind).toBe("unexpected"); expect(rows[0]!.kind).toBe("unexpected");
expect(rows[0]!.code).toBe("internal"); expect(rows[0]!.code).toBe("internal");
expect(rows[0]!.status).toBe(500); expect(rows[0]!.status).toBe(500);
expect(rows[0]!.message).toBe("查询炸了"); expect(rows[0]!.message).toBe("query blew up");
expect(rows[0]!.project_id).toBe(projectId); expect(rows[0]!.project_id).toBe(projectId);
}); });
it("异常经 usage 端点暴露:汇总 / 最常见错误码 / 最近异常(统计中心的统计信息 + 表格)", async () => { it("errors exposed via the usage endpoint: summary / top code / recent", async () => {
// An error in another Project owned by the same owner: attributed to that Project, not this one's view. // An error in another Project owned by the same owner: attributed to that Project, not this one's view.
const other = (await ( const other = (await (
await api.post("/api/projects", { projectId: "err_user-proj_2", name: "另一个项目" }) await api.post("/api/projects", { projectId: "err_user-proj_2", name: "Another project" })
).json()) as ProjectCreateResponse; ).json()) as ProjectCreateResponse;
await api.get(`/api/projects/${other.project.projectId}/agents/agent-nope/sessions`); // 404 await api.get(`/api/projects/${other.project.projectId}/agents/agent-nope/sessions`); // 404
await api.get(`/api/projects/${projectId}/agents/agent-nope/sessions`); // 404 → expected await api.get(`/api/projects/${projectId}/agents/agent-nope/sessions`); // 404 → expected
@@ -727,7 +731,7 @@ describe("HTTP onError 落库(集成)", () => {
expect(body.errors.recent[0]).toMatchObject({ source: "http", code: "agent_not_found" }); expect(body.errors.recent[0]).toMatchObject({ source: "http", code: "agent_not_found" });
}); });
it("无归属异常(登录失败)只对管理员可见:普通成员的统计中心里看不到别的租户的异常", async () => { it("unattributed errors (login failure) are admin-only, hidden from members", async () => {
// A login failure has no Project context → produces one unattributed error (project_id is NULL). // A login failure has no Project context → produces one unattributed error (project_id is NULL).
const bad = await t.app.request("/api/auth/login", { const bad = await t.app.request("/api/auth/login", {
method: "POST", method: "POST",
@@ -742,7 +746,7 @@ describe("HTTP onError 落库(集成)", () => {
expect(plain.user.isAdmin).toBe(false); expect(plain.user.isAdmin).toBe(false);
const plainApi = apiClient(t.app, plain.cookie); const plainApi = apiClient(t.app, plain.cookie);
const own = (await ( const own = (await (
await plainApi.post("/api/projects", { projectId: "plain_user-proj", name: "普通成员的项目" }) await plainApi.post("/api/projects", { projectId: "plain_user-proj", name: "Member project" })
).json()) as ProjectCreateResponse; ).json()) as ProjectCreateResponse;
const plainBody = (await ( const plainBody = (await (
await plainApi.get(`/api/projects/${own.project.projectId}/usage?groupBy=date`) await plainApi.get(`/api/projects/${own.project.projectId}/usage?groupBy=date`)
@@ -759,7 +763,7 @@ describe("HTTP onError 落库(集成)", () => {
expect(adminBody.errors.recent[0]).toMatchObject({ code: "invalid_credentials" }); expect(adminBody.errors.recent[0]).toMatchObject({ code: "invalid_credentials" });
}); });
it("删除 Project 级联清理该 Project 的异常记录", async () => { it("Project deletion cascade-cleans that Project's error records", async () => {
await api.get(`/api/projects/${projectId}/agents/agent-nope/sessions`); await api.get(`/api/projects/${projectId}/agents/agent-nope/sessions`);
expect(errorRows().filter((r) => r.project_id === projectId)).toHaveLength(1); expect(errorRows().filter((r) => r.project_id === projectId)).toHaveLength(1);
@@ -769,7 +773,7 @@ describe("HTTP onError 落库(集成)", () => {
}); });
}); });
describe("HTTP 异常归属(仅在请求方确有该 Project 访问权时)", () => { describe("HTTP error attribution (only when the requester actually has Project access)", () => {
let t: TestApp; let t: TestApp;
/** The built-in admin: unattributed errors are visible only to them. */ /** The built-in admin: unattributed errors are visible only to them. */
let adminApi: ReturnType<typeof apiClient>; let adminApi: ReturnType<typeof apiClient>;
@@ -784,7 +788,7 @@ describe("HTTP 异常归属(仅在请求方确有该 Project 访问权时)",
adminApi = apiClient(t.app, admin.cookie); adminApi = apiClient(t.app, admin.cookie);
adminProjectId = ( adminProjectId = (
(await ( (await (
await adminApi.post("/api/projects", { projectId: "admin_proj", name: "管理员的项目" }) await adminApi.post("/api/projects", { projectId: "admin_proj", name: "Admin project" })
).json()) as ProjectCreateResponse ).json()) as ProjectCreateResponse
).project.projectId; ).project.projectId;
@@ -793,7 +797,7 @@ describe("HTTP 异常归属(仅在请求方确有该 Project 访问权时)",
ownerApi = apiClient(t.app, owner.cookie); ownerApi = apiClient(t.app, owner.cookie);
projectId = ( projectId = (
(await ( (await (
await ownerApi.post("/api/projects", { projectId: "owner_user-victim", name: "受害项目" }) await ownerApi.post("/api/projects", { projectId: "owner_user-victim", name: "Victim" })
).json()) as ProjectCreateResponse ).json()) as ProjectCreateResponse
).project.projectId; ).project.projectId;
}); });
@@ -820,7 +824,7 @@ describe("HTTP 异常归属(仅在请求方确有该 Project 访问权时)",
).json()) as UsageResponse ).json()) as UsageResponse
).errors; ).errors;
it("未登录 → 401:不归属该 Project(否则任何人都能往别人的 Project 里灌异常统计)", async () => { it("not logged in → 401: unattributed (anyone could flood another's error stats)", async () => {
const res = await t.app.request(`/api/projects/${projectId}/usage?groupBy=date`); const res = await t.app.request(`/api/projects/${projectId}/usage?groupBy=date`);
expect(res.status).toBe(401); expect(res.status).toBe(401);
@@ -842,7 +846,7 @@ describe("HTTP 异常归属(仅在请求方确有该 Project 访问权时)",
expect(admin.recent[0]).toMatchObject({ source: "http", code: "unauthorized" }); expect(admin.recent[0]).toMatchObject({ source: "http", code: "unauthorized" });
}); });
it("已登录但无权(非成员)→ 404:同样不归属", async () => { it("logged in but without access (non-member) → 404: likewise unattributed", async () => {
const outsider = await provisionUser(t.app, "outsider"); const outsider = await provisionUser(t.app, "outsider");
const res = await apiClient(t.app, outsider.cookie).get( const res = await apiClient(t.app, outsider.cookie).get(
`/api/projects/${projectId}/usage?groupBy=date`, `/api/projects/${projectId}/usage?groupBy=date`,
@@ -858,7 +862,7 @@ describe("HTTP 异常归属(仅在请求方确有该 Project 访问权时)",
expect((await adminErrors()).recent[0]).toMatchObject({ code: "project_not_found" }); expect((await adminErrors()).recent[0]).toMatchObject({ code: "project_not_found" });
}); });
it("有权成员(owner / 被授权 member)触发的业务错误 → 照常归属该 Project", async () => { it("business errors from an authorized member → attributed as usual", async () => {
// owner: an invalid groupBy → 400. // owner: an invalid groupBy → 400.
expect((await ownerApi.get(`/api/projects/${projectId}/usage?groupBy=bogus`)).status).toBe(400); expect((await ownerApi.get(`/api/projects/${projectId}/usage?groupBy=bogus`)).status).toBe(400);
@@ -880,9 +884,9 @@ describe("HTTP 异常归属(仅在请求方确有该 Project 访问权时)",
expect(await ownerErrors()).toMatchObject({ total: 2, unexpected: 0 }); expect(await ownerErrors()).toMatchObject({ total: 2, unexpected: 0 });
}); });
it("归属判断自身抛异常:onError 不被搞崩(错误响应照常,异常按无归属落库)", async () => { it("the attribution check throws: onError survives, error lands unattributed", async () => {
t.deps.projectService.canAccess = () => { t.deps.projectService.canAccess = () => {
throw new Error("授权判定炸了"); throw new Error("access check blew up");
}; };
const res = await ownerApi.get(`/api/projects/${projectId}/usage?groupBy=bogus`); const res = await ownerApi.get(`/api/projects/${projectId}/usage?groupBy=bogus`);
expect(res.status).toBe(400); // still the original business error: not turned into a 500, nor an empty response expect(res.status).toBe(400); // still the original business error: not turned into a 500, nor an empty response
+4 -4
View File
@@ -78,10 +78,10 @@ export async function loginUser(
body: JSON.stringify({ userId, password }), body: JSON.stringify({ userId, password }),
}); });
if (res.status !== 200) { if (res.status !== 200) {
throw new Error(`登录失败: ${res.status} ${await res.text()}`); throw new Error(`Login failed: ${res.status} ${await res.text()}`);
} }
const setCookie = res.headers.get("set-cookie"); const setCookie = res.headers.get("set-cookie");
if (!setCookie) throw new Error("登录响应缺少 set-cookie"); if (!setCookie) throw new Error("Login response is missing set-cookie");
const body = (await res.json()) as { user: UserInfo }; const body = (await res.json()) as { user: UserInfo };
return { cookie: setCookie.split(";")[0]!, user: body.user }; return { cookie: setCookie.split(";")[0]!, user: body.user };
} }
@@ -101,7 +101,7 @@ export async function provisionUser(
const admin = await loginAdmin(app); const admin = await loginAdmin(app);
const res = await apiClient(app, admin.cookie).post("/api/admin/users", { userId, password }); const res = await apiClient(app, admin.cookie).post("/api/admin/users", { userId, password });
if (res.status !== 201) { if (res.status !== 201) {
throw new Error(`建号失败: ${res.status} ${await res.text()}`); throw new Error(`Account creation failed: ${res.status} ${await res.text()}`);
} }
return loginUser(app, userId, password); return loginUser(app, userId, password);
} }
@@ -147,7 +147,7 @@ export async function writeTraceFile(
export async function waitFor(cond: () => boolean, timeoutMs = 2000): Promise<void> { export async function waitFor(cond: () => boolean, timeoutMs = 2000): Promise<void> {
const start = Date.now(); const start = Date.now();
while (!cond()) { while (!cond()) {
if (Date.now() - start > timeoutMs) throw new Error("waitFor 超时"); if (Date.now() - start > timeoutMs) throw new Error("waitFor timed out");
await new Promise((r) => setTimeout(r, 5)); await new Promise((r) => setTimeout(r, 5));
} }
} }
+7 -7
View File
@@ -31,11 +31,11 @@ describe("id-validation", () => {
attacker = apiClient(t.app, a.cookie); attacker = apiClient(t.app, a.cookie);
const victim = apiClient(t.app, v.cookie); const victim = apiClient(t.app, v.cookie);
const created = (await ( const created = (await (
await attacker.post("/api/projects", { projectId: "attacker-proj", name: "攻击者项目" }) await attacker.post("/api/projects", { projectId: "attacker-proj", name: "Attacker project" })
).json()) as ProjectCreateResponse; ).json()) as ProjectCreateResponse;
attackerProject = created.project.projectId; attackerProject = created.project.projectId;
const victimCreated = (await ( const victimCreated = (await (
await victim.post("/api/projects", { projectId: "victim-proj", name: "受害者项目" }) await victim.post("/api/projects", { projectId: "victim-proj", name: "Victim project" })
).json()) as ProjectCreateResponse; ).json()) as ProjectCreateResponse;
victimProject = victimCreated.project.projectId; victimProject = victimCreated.project.projectId;
}); });
@@ -43,7 +43,7 @@ describe("id-validation", () => {
await t.cleanup(); await t.cleanup();
}); });
it("GET config:穿越型 agentId → 404,不泄露他人 system_config.yaml", async () => { it("GET config: traversal agentId → 404, no leak of system_config.yaml", async () => {
// The victim Agent's config does exist (readable by the victim via the legitimate path). // The victim Agent's config does exist (readable by the victim via the legitimate path).
const victimConfigPath = path.join( const victimConfigPath = path.join(
t.root, t.root,
@@ -62,7 +62,7 @@ describe("id-validation", () => {
expect(own.status).toBe(200); expect(own.status).toBe(200);
}); });
it("PUT config:穿越型 agentId → 404,受害文件未被覆写", async () => { it("PUT config: traversal agentId → 404, victim file not overwritten", async () => {
const victimConfigPath = path.join( const victimConfigPath = path.join(
t.root, t.root,
victimProject, victimProject,
@@ -79,13 +79,13 @@ describe("id-validation", () => {
expect(await fs.readFile(victimConfigPath, "utf8")).toBe(before); expect(await fs.readFile(victimConfigPath, "utf8")).toBe(before);
}); });
it("GET traces / GET|POST sessions:穿越型 agentId → 404", async () => { it("GET traces / GET|POST sessions: traversal agentId → 404", async () => {
expect((await attacker.get(`${agentBase()}/${traversal()}/traces`)).status).toBe(404); expect((await attacker.get(`${agentBase()}/${traversal()}/traces`)).status).toBe(404);
expect((await attacker.get(`${agentBase()}/${traversal()}/sessions`)).status).toBe(404); expect((await attacker.get(`${agentBase()}/${traversal()}/sessions`)).status).toBe(404);
expect((await attacker.post(`${agentBase()}/${traversal()}/sessions`, {})).status).toBe(404); expect((await attacker.post(`${agentBase()}/${traversal()}/sessions`, {})).status).toBe(404);
}); });
it("Trace 明细端点:穿越型 sessionId / agentId → 404", async () => { it("Trace detail endpoints: traversal sessionId / agentId → 404", async () => {
expect((await attacker.get(`${agentBase()}/default_agent/traces/..%2Fx/1`)).status).toBe(404); expect((await attacker.get(`${agentBase()}/default_agent/traces/..%2Fx/1`)).status).toBe(404);
expect( expect(
(await attacker.get(`${agentBase()}/default_agent/traces/..%2Fx/1/analysis`)).status, (await attacker.get(`${agentBase()}/default_agent/traces/..%2Fx/1/analysis`)).status,
@@ -93,7 +93,7 @@ describe("id-validation", () => {
expect((await attacker.get(`${agentBase()}/${traversal()}/traces/s/1`)).status).toBe(404); expect((await attacker.get(`${agentBase()}/${traversal()}/traces/s/1`)).status).toBe(404);
}); });
it("穿越型 / 含非法字符的 projectId → 404(防御性校验)", async () => { it("traversal / invalid-character projectId → 404 (defensive validation)", async () => {
expect((await attacker.get(`/api/projects/..%2Fetc/agents`)).status).toBe(404); expect((await attacker.get(`/api/projects/..%2Fetc/agents`)).status).toBe(404);
expect((await attacker.get(`/api/projects/..%2Fetc/models`)).status).toBe(404); expect((await attacker.get(`/api/projects/..%2Fetc/models`)).status).toBe(404);
expect((await attacker.get(`/api/projects/..%2Fetc/usage`)).status).toBe(404); expect((await attacker.get(`/api/projects/..%2Fetc/usage`)).status).toBe(404);
+7 -7
View File
@@ -8,7 +8,7 @@ import type { ModelsResponse, ProjectCreateResponse } from "../src/api/types.js"
import { apiClient, createTestApp, provisionUser } from "./helpers.js"; import { apiClient, createTestApp, provisionUser } from "./helpers.js";
import type { TestApp } from "./helpers.js"; import type { TestApp } from "./helpers.js";
describe("models vision 标注", () => { describe("models vision annotation", () => {
let t: TestApp; let t: TestApp;
let owner: ReturnType<typeof apiClient>; let owner: ReturnType<typeof apiClient>;
let projectId: string; let projectId: string;
@@ -18,7 +18,7 @@ describe("models vision 标注", () => {
const a = await provisionUser(t.app, "owner_v"); const a = await provisionUser(t.app, "owner_v");
owner = apiClient(t.app, a.cookie); owner = apiClient(t.app, a.cookie);
const created = (await ( const created = (await (
await owner.post("/api/projects", { projectId: "owner_v-vision", name: "vision 项目" }) await owner.post("/api/projects", { projectId: "owner_v-vision", name: "vision project" })
).json()) as ProjectCreateResponse; ).json()) as ProjectCreateResponse;
projectId = created.project.projectId; projectId = created.project.projectId;
}); });
@@ -26,7 +26,7 @@ describe("models vision 标注", () => {
await t.cleanup(); await t.cleanup();
}); });
it("vision=false 落盘回读;目录外模型省略 = 支持(不携带字段)", async () => { it("vision=false persists and reads back; omitted on a non-catalog model = supported (no field)", async () => {
// Use a custom id outside the catalog to verify pure TOML semantics (a catalog id // Use a custom id outside the catalog to verify pure TOML semantics (a catalog id
// falls back to the catalog's own annotation — see the next test case). // falls back to the catalog's own annotation — see the next test case).
const put = await owner.put(`/api/projects/${projectId}/models`, { const put = await owner.put(`/api/projects/${projectId}/models`, {
@@ -51,7 +51,7 @@ describe("models vision 标注", () => {
expect("vision" in body2.models[0]!).toBe(false); expect("vision" in body2.models[0]!).toBe(false);
}); });
it("目录内模型无 TOML 标注时 vision 回落内置目录标注", async () => { it("a catalog model without a TOML annotation falls back to the built-in catalog's vision annotation", async () => {
const put = await owner.put(`/api/projects/${projectId}/models`, { const put = await owner.put(`/api/projects/${projectId}/models`, {
models: [ models: [
{ provider: "deepseek", modelId: "deepseek-v4-pro" }, { provider: "deepseek", modelId: "deepseek-v4-pro" },
@@ -68,7 +68,7 @@ describe("models vision 标注", () => {
).toBe(true); ).toBe(true);
}); });
it("visionModel 指针:往返、省略保留、目标失效即移除", async () => { it("visionModel pointer: round-trips, omission preserves it, removed once the target is invalid", async () => {
const put = await owner.put(`/api/projects/${projectId}/models`, { const put = await owner.put(`/api/projects/${projectId}/models`, {
visionModel: { provider: "google", modelId: "gemini-3-flash-preview" }, visionModel: { provider: "google", modelId: "gemini-3-flash-preview" },
models: [ models: [
@@ -102,7 +102,7 @@ describe("models vision 标注", () => {
expect("visionModel" in ((await put3.json()) as ModelsResponse)).toBe(false); expect("visionModel" in ((await put3.json()) as ModelsResponse)).toBe(false);
}); });
it("visionModel 不在 models 内或指向不支持图片的模型:400", async () => { it("visionModel absent from models or pointing at a model without image support: 400", async () => {
const missing = await owner.put(`/api/projects/${projectId}/models`, { const missing = await owner.put(`/api/projects/${projectId}/models`, {
visionModel: { provider: "custom", modelId: "nope" }, visionModel: { provider: "custom", modelId: "nope" },
models: [{ provider: "custom", modelId: "m-1" }], models: [{ provider: "custom", modelId: "m-1" }],
@@ -115,7 +115,7 @@ describe("models vision 标注", () => {
expect(blind.status).toBe(400); expect(blind.status).toBe(400);
}); });
it("vision 非布尔值 400", async () => { it("non-boolean vision returns 400", async () => {
const bad = await owner.put(`/api/projects/${projectId}/models`, { const bad = await owner.put(`/api/projects/${projectId}/models`, {
models: [{ provider: "custom", modelId: "m-1", vision: "no" }], models: [{ provider: "custom", modelId: "m-1", vision: "no" }],
}); });
+4 -4
View File
@@ -6,7 +6,7 @@ import { describe, expect, it } from "vitest";
import { hashPassword, verifyPassword } from "../src/auth/password.js"; import { hashPassword, verifyPassword } from "../src/auth/password.js";
describe("password", () => { describe("password", () => {
it("散列格式为 scrypt$N$r$p$salt$hash 且可校验", async () => { it("hash format is scrypt$N$r$p$salt$hash and verifies", async () => {
const stored = await hashPassword("hello-world-123"); const stored = await hashPassword("hello-world-123");
const parts = stored.split("$"); const parts = stored.split("$");
expect(parts).toHaveLength(6); expect(parts).toHaveLength(6);
@@ -15,12 +15,12 @@ describe("password", () => {
await expect(verifyPassword("hello-world-123", stored)).resolves.toBe(true); await expect(verifyPassword("hello-world-123", stored)).resolves.toBe(true);
}); });
it("错误密码校验失败", async () => { it("a wrong password fails verification", async () => {
const stored = await hashPassword("correct-password"); const stored = await hashPassword("correct-password");
await expect(verifyPassword("wrong-password", stored)).resolves.toBe(false); await expect(verifyPassword("wrong-password", stored)).resolves.toBe(false);
}); });
it("同一密码两次散列结果不同(随机盐)", async () => { it("hashing the same password twice differs (random salt)", async () => {
const a = await hashPassword("same-password"); const a = await hashPassword("same-password");
const b = await hashPassword("same-password"); const b = await hashPassword("same-password");
expect(a).not.toBe(b); expect(a).not.toBe(b);
@@ -28,7 +28,7 @@ describe("password", () => {
await expect(verifyPassword("same-password", b)).resolves.toBe(true); await expect(verifyPassword("same-password", b)).resolves.toBe(true);
}); });
it("非法存储串返回 false 而非抛异常", async () => { it("invalid stored strings return false instead of throwing", async () => {
await expect(verifyPassword("x", "not-a-hash")).resolves.toBe(false); await expect(verifyPassword("x", "not-a-hash")).resolves.toBe(false);
await expect(verifyPassword("x", "bcrypt$a$b$c$d$e")).resolves.toBe(false); await expect(verifyPassword("x", "bcrypt$a$b$c$d$e")).resolves.toBe(false);
await expect(verifyPassword("x", "scrypt$abc$8$1$!!$!!")).resolves.toBe(false); await expect(verifyPassword("x", "scrypt$abc$8$1$!!$!!")).resolves.toBe(false);
+14 -14
View File
@@ -11,7 +11,7 @@ import {
slotInWindow, slotInWindow,
} from "../src/runtime/schedule-file.js"; } from "../src/runtime/schedule-file.js";
const BASE = `prompt = "做日报"\nenabled = true\nstart_at = "2026-07-16T09:00:00Z"\n`; const BASE = `prompt = "Write the daily report"\nenabled = true\nstart_at = "2026-07-16T09:00:00Z"\n`;
function defOf(raw: string) { function defOf(raw: string) {
const r = parseScheduleFile("daily-report", raw); const r = parseScheduleFile("daily-report", raw);
@@ -20,12 +20,12 @@ function defOf(raw: string) {
} }
describe("parsePeriod", () => { describe("parsePeriod", () => {
it("解析 m/h/d 固定间隔", () => { it("parses m/h/d fixed intervals", () => {
expect(parsePeriod("30m")).toBe(30 * 60_000); expect(parsePeriod("30m")).toBe(30 * 60_000);
expect(parsePeriod("12h")).toBe(12 * 3_600_000); expect(parsePeriod("12h")).toBe(12 * 3_600_000);
expect(parsePeriod("7d")).toBe(7 * 86_400_000); expect(parsePeriod("7d")).toBe(7 * 86_400_000);
}); });
it("非法形态返回 null", () => { it("returns null for invalid shapes", () => {
for (const bad of ["", "5", "m30", "1.5h", "-1d", "10s", "1w"]) { for (const bad of ["", "5", "m30", "1.5h", "-1d", "10s", "1w"]) {
expect(parsePeriod(bad)).toBeNull(); expect(parsePeriod(bad)).toBeNull();
} }
@@ -33,13 +33,13 @@ describe("parsePeriod", () => {
}); });
describe("parseScheduleFile", () => { describe("parseScheduleFile", () => {
it("解析全字段并保留时刻原文", () => { it("parses every field and preserves instant text verbatim", () => {
const def = defOf( const def = defOf(
`${BASE}period = "30m"\nend_at = "2026-07-17T09:00:00Z"\nsession_id = "session-x"\n`, `${BASE}period = "30m"\nend_at = "2026-07-17T09:00:00Z"\nsession_id = "session-x"\n`,
); );
expect(def).toMatchObject({ expect(def).toMatchObject({
name: "daily-report", name: "daily-report",
prompt: "做日报", prompt: "Write the daily report",
enabled: true, enabled: true,
startAt: "2026-07-16T09:00:00Z", startAt: "2026-07-16T09:00:00Z",
period: "30m", period: "30m",
@@ -49,23 +49,23 @@ describe("parseScheduleFile", () => {
}); });
}); });
it("enabled 缺省为不生效;period 缺省即一次性", () => { it("enabled defaults to off; omitting period means one-shot", () => {
const def = defOf(`prompt = "p"\nstart_at = "2026-07-16T09:00:00Z"\n`); const def = defOf(`prompt = "p"\nstart_at = "2026-07-16T09:00:00Z"\n`);
expect(def.enabled).toBe(false); expect(def.enabled).toBe(false);
expect(def.periodMs).toBeUndefined(); expect(def.periodMs).toBeUndefined();
}); });
it("非法文件逐类拒绝", () => { it("rejects each class of invalid file", () => {
const cases: Array<[string, string]> = [ const cases: Array<[string, string]> = [
["not toml ===", "TOML"], ["not toml ===", "TOML"],
[`enabled = true\nstart_at = "2026-07-16T09:00:00Z"\n`, "prompt"], [`enabled = true\nstart_at = "2026-07-16T09:00:00Z"\n`, "prompt"],
[`prompt = "p"\nenabled = "yes"\nstart_at = "2026-07-16T09:00:00Z"\n`, "enabled"], [`prompt = "p"\nenabled = "yes"\nstart_at = "2026-07-16T09:00:00Z"\n`, "enabled"],
[`prompt = "p"\nstart_at = "someday"\n`, "start_at"], [`prompt = "p"\nstart_at = "someday"\n`, "start_at"],
[`${BASE}period = "4m"\n`, "下限"], [`${BASE}period = "4m"\n`, "below the 5m minimum"],
[`${BASE}period = "10s"\n`, "period"], [`${BASE}period = "10s"\n`, "period"],
[`${BASE}end_at = "2026-07-16T08:00:00Z"\n`, "end_at"], [`${BASE}end_at = "2026-07-16T08:00:00Z"\n`, "end_at"],
[`${BASE}session_id = "s"\nworkspace = "/tmp/w"\n`, "新建 Session 模式"], [`${BASE}session_id = "s"\nworkspace = "/tmp/w"\n`, "new-Session mode"],
[`${BASE}session_id = "s"\nmodel_id = "m1"\n`, "新建 Session 模式"], [`${BASE}session_id = "s"\nmodel_id = "m1"\n`, "new-Session mode"],
[`${BASE}model_id = ""\n`, "model_id"], [`${BASE}model_id = ""\n`, "model_id"],
]; ];
for (const [raw, hint] of cases) { for (const [raw, hint] of cases) {
@@ -79,20 +79,20 @@ describe("parseScheduleFile", () => {
describe("latestSlotAt / slotInWindow", () => { describe("latestSlotAt / slotInWindow", () => {
const start = Date.parse("2026-07-16T09:00:00Z"); const start = Date.parse("2026-07-16T09:00:00Z");
it("一次性任务的唯一时刻即 start_at", () => { it("a one-shot task's only slot is start_at itself", () => {
const def = defOf(BASE); const def = defOf(BASE);
expect(latestSlotAt(def, start - 1)).toBeNull(); expect(latestSlotAt(def, start - 1)).toBeNull();
expect(latestSlotAt(def, start)).toBe(start); expect(latestSlotAt(def, start)).toBe(start);
expect(latestSlotAt(def, start + 999_999)).toBe(start); expect(latestSlotAt(def, start + 999_999)).toBe(start);
}); });
it("周期任务自 start_at 按 period 步进", () => { it("a periodic task steps from start_at by period", () => {
const def = defOf(`${BASE}period = "30m"\n`); const def = defOf(`${BASE}period = "30m"\n`);
expect(latestSlotAt(def, start - 1)).toBeNull(); expect(latestSlotAt(def, start - 1)).toBeNull();
expect(latestSlotAt(def, start)).toBe(start); expect(latestSlotAt(def, start)).toBe(start);
expect(latestSlotAt(def, start + 29 * 60_000)).toBe(start); expect(latestSlotAt(def, start + 29 * 60_000)).toBe(start);
expect(latestSlotAt(def, start + 61 * 60_000)).toBe(start + 60 * 60_000); expect(latestSlotAt(def, start + 61 * 60_000)).toBe(start + 60 * 60_000);
}); });
it("nextSlotAfter:严格晚于 now 的下一时刻;一次性到点后无值;越过 end_at 无值", () => { it("nextSlotAfter: next slot strictly after now; none after a one-shot fires or past end_at", () => {
const oneShot = defOf(BASE); const oneShot = defOf(BASE);
expect(nextSlotAfter(oneShot, start - 1)).toBe(start); expect(nextSlotAfter(oneShot, start - 1)).toBe(start);
expect(nextSlotAfter(oneShot, start)).toBeNull(); expect(nextSlotAfter(oneShot, start)).toBeNull();
@@ -103,7 +103,7 @@ describe("latestSlotAt / slotInWindow", () => {
expect(nextSlotAfter(periodic, start + 60 * 60_000)).toBeNull(); // next slot passes end_at expect(nextSlotAfter(periodic, start + 60 * 60_000)).toBeNull(); // next slot passes end_at
}); });
it("end_at 窗口判定", () => { it("end_at window check", () => {
const def = defOf(`${BASE}period = "30m"\nend_at = "2026-07-16T10:00:00Z"\n`); const def = defOf(`${BASE}period = "30m"\nend_at = "2026-07-16T10:00:00Z"\n`);
expect(slotInWindow(def, start)).toBe(true); expect(slotInWindow(def, start)).toBe(true);
expect(slotInWindow(def, Date.parse("2026-07-16T10:00:00Z"))).toBe(true); expect(slotInWindow(def, Date.parse("2026-07-16T10:00:00Z"))).toBe(true);
+13 -13
View File
@@ -131,10 +131,10 @@ describe("scheduler", () => {
return new Date(ms).toISOString(); return new Date(ms).toISOString();
} }
it("周期任务:登记消化过去时刻(错过不补),到点触发一次且不重复", async () => { it("periodic task: registration consumes past slots (missed, not backfilled); fires once on time, never twice", async () => {
insertSession("session-1"); insertSession("session-1");
await writeFile("report", [ await writeFile("report", [
`prompt = "报告"`, `prompt = "Write the daily report"`,
`enabled = true`, `enabled = true`,
`start_at = "${iso(T0 - 60 * MIN)}"`, `start_at = "${iso(T0 - 60 * MIN)}"`,
`period = "30m"`, `period = "30m"`,
@@ -150,7 +150,7 @@ describe("scheduler", () => {
// Trigger input = <scheduled_task> source block + the prompt body (tells the model this is a scheduled task). // Trigger input = <scheduled_task> source block + the prompt body (tells the model this is a scheduled task).
expect(started[0]?.text).toContain("<scheduled_task>"); expect(started[0]?.text).toContain("<scheduled_task>");
expect(started[0]?.text).toContain("schedule: report"); expect(started[0]?.text).toContain("schedule: report");
expect(started[0]?.text).toContain("报告"); expect(started[0]?.text).toContain("Write the daily report");
expect(events.map((e) => e.event.type)).toContain("schedule_fired"); expect(events.map((e) => e.event.type)).toContain("schedule_fired");
expect(events[0]?.userId).toBe("owner_a"); expect(events[0]?.userId).toBe("owner_a");
@@ -161,7 +161,7 @@ describe("scheduler", () => {
expect(state?.lastFiredAt).toBe(iso(T0 + 30 * MIN)); expect(state?.lastFiredAt).toBe(iso(T0 + 30 * MIN));
}); });
it("一次性任务:未来到点触发一次;登记时已过期则标记错过且永不触发", async () => { it("one-shot task: fires once when the future slot arrives; already expired at registration is marked missed and never fires", async () => {
insertSession("session-1"); insertSession("session-1");
await writeFile("future", [ await writeFile("future", [
`prompt = "f"`, `prompt = "f"`,
@@ -189,11 +189,11 @@ describe("scheduler", () => {
expect(started).toHaveLength(1); expect(started).toHaveLength(1);
}); });
it("忙时排队:目标运行中先排队并通知,空闲后补发;重复到点不叠加", async () => { it("busy queueing: a running target queues and notifies, sends once idle; repeated slots don't stack", async () => {
insertSession("session-1"); insertSession("session-1");
busy.add("session-1"); busy.add("session-1");
await writeFile("q", [ await writeFile("q", [
`prompt = "排队"`, `prompt = "queued"`,
`enabled = true`, `enabled = true`,
`start_at = "${iso(T0)}"`, `start_at = "${iso(T0)}"`,
`period = "5m"`, `period = "5m"`,
@@ -219,7 +219,7 @@ describe("scheduler", () => {
expect(events.map((e) => e.event.type)).toEqual(["schedule_queued", "schedule_fired"]); expect(events.map((e) => e.event.type)).toEqual(["schedule_queued", "schedule_fired"]);
}); });
it("绑定 Session 不存在:记异常并标记失效;文件修改后恢复", async () => { it("missing bound Session: records an error and marks invalid; recovers after the file is edited", async () => {
await writeFile("ghost", [ await writeFile("ghost", [
`prompt = "g"`, `prompt = "g"`,
`enabled = true`, `enabled = true`,
@@ -253,7 +253,7 @@ describe("scheduler", () => {
expect(started.map((s) => s.sessionId)).toEqual(["session-2"]); expect(started.map((s) => s.sessionId)).toEqual(["session-2"]);
}); });
it("enabled=false 不触发;文件删除即清理运行状态", async () => { it("enabled=false never fires; deleting the file clears the run state", async () => {
insertSession("session-1"); insertSession("session-1");
await writeFile("off", [ await writeFile("off", [
`prompt = "o"`, `prompt = "o"`,
@@ -272,9 +272,9 @@ describe("scheduler", () => {
expect(repo.find(P, A, "off")).toBeNull(); expect(repo.find(P, A, "off")).toBeNull();
}); });
it("新建 Session 模式:每次触发开新会话并发送(透传 workspace)", async () => { it("new-Session mode: every trigger opens a fresh session and sends (passing workspace through)", async () => {
await writeFile("fresh", [ await writeFile("fresh", [
`prompt = "新会话"`, `prompt = "fresh session"`,
`enabled = true`, `enabled = true`,
`start_at = "${iso(T0 + MIN)}"`, `start_at = "${iso(T0 + MIN)}"`,
`period = "5m"`, `period = "5m"`,
@@ -299,9 +299,9 @@ describe("scheduler", () => {
expect(started.map((s) => s.sessionId)).toEqual(["session-new-1", "session-new-2"]); expect(started.map((s) => s.sessionId)).toEqual(["session-new-1", "session-new-2"]);
}); });
it("新建 Session 模式:文件给出 provider 时成对透传", async () => { it("new-Session mode: when the file gives provider, it is passed through as a pair", async () => {
await writeFile("paired", [ await writeFile("paired", [
`prompt = "成对"`, `prompt = "paired"`,
`enabled = true`, `enabled = true`,
`start_at = "${iso(T0 + MIN)}"`, `start_at = "${iso(T0 + MIN)}"`,
`provider = "custom"`, `provider = "custom"`,
@@ -314,7 +314,7 @@ describe("scheduler", () => {
expect(created[0]).toMatchObject({ provider: "custom", modelId: "m-bench" }); expect(created[0]).toMatchObject({ provider: "custom", modelId: "m-bench" });
}); });
it("非法文件跳过并记异常,不影响其余任务", async () => { it("invalid files are skipped with an error recorded, without affecting other tasks", async () => {
insertSession("session-1"); insertSession("session-1");
await writeFile("bad", [`prompt = "x"`, `enabled = true`, `start_at = "nonsense"`]); await writeFile("bad", [`prompt = "x"`, `enabled = true`, `start_at = "nonsense"`]);
await writeFile("good", [ await writeFile("good", [
+10 -10
View File
@@ -31,7 +31,7 @@ describe("schedules api", () => {
member = apiClient(t.app, b.cookie); member = apiClient(t.app, b.cookie);
outsider = apiClient(t.app, c.cookie); outsider = apiClient(t.app, c.cookie);
const created = (await ( const created = (await (
await owner.post("/api/projects", { projectId: "owner_a-sched", name: "定时任务项目" }) await owner.post("/api/projects", { projectId: "owner_a-sched", name: "schedule project" })
).json()) as ProjectCreateResponse; ).json()) as ProjectCreateResponse;
projectId = created.project.projectId; projectId = created.project.projectId;
base = `/api/projects/${projectId}/agents/default_agent/schedules`; base = `/api/projects/${projectId}/agents/default_agent/schedules`;
@@ -43,10 +43,10 @@ describe("schedules api", () => {
await t.cleanup(); await t.cleanup();
}); });
it("POST 创建(仅 owner)并落盘 TOML;GET 列表与单条;DELETE 清理", async () => { it("POST creates (owner only) and lands TOML on disk; GET list and single item; DELETE cleans up", async () => {
const body = { const body = {
name: "daily-report", name: "daily-report",
prompt: "写日报", prompt: "Write the daily report",
enabled: true, enabled: true,
startAt: FUTURE, startAt: FUTURE,
period: "30m", period: "30m",
@@ -57,7 +57,7 @@ describe("schedules api", () => {
const item = (await createdRes.json()) as ScheduleItem; const item = (await createdRes.json()) as ScheduleItem;
expect(item).toMatchObject({ expect(item).toMatchObject({
name: "daily-report", name: "daily-report",
prompt: "写日报", prompt: "Write the daily report",
enabled: true, enabled: true,
period: "30m", period: "30m",
status: "active", status: "active",
@@ -69,7 +69,7 @@ describe("schedules api", () => {
// The filename is the identifier; the TOML lands under agent_state/schedule/. // The filename is the identifier; the TOML lands under agent_state/schedule/.
const file = path.join(scheduleDir(t.root, projectId, "default_agent"), "daily-report.toml"); const file = path.join(scheduleDir(t.root, projectId, "default_agent"), "daily-report.toml");
const raw = await fs.readFile(file, "utf8"); const raw = await fs.readFile(file, "utf8");
expect(raw).toContain('prompt = "写日报"'); expect(raw).toContain('prompt = "Write the daily report"');
expect(raw).toContain('period = "30m"'); expect(raw).toContain('period = "30m"');
// Any member can read; outsiders get 404. // Any member can read; outsiders get 404.
@@ -89,7 +89,7 @@ describe("schedules api", () => {
expect((await owner.delete(`${base}/daily-report`)).status).toBe(404); expect((await owner.delete(`${base}/daily-report`)).status).toBe(404);
}); });
it("新建 Session 模式可指定 workspace 与成对模型引用并回显", async () => { it("new-Session mode can specify workspace and a paired model reference, echoed back", async () => {
// The model reference is given as a pair (provider + modelId), and it must // The model reference is given as a pair (provider + modelId), and it must
// resolve within the Project config. // resolve within the Project config.
const res = await owner.post(base, { const res = await owner.post(base, {
@@ -109,7 +109,7 @@ describe("schedules api", () => {
}); });
}); });
it("模型引用不可解析(配置里没有)即 400,不落盘", async () => { it("an unresolvable model reference (absent from config) is 400 and writes nothing", async () => {
const res = await owner.post(base, { const res = await owner.post(base, {
name: "bad-model", name: "bad-model",
prompt: "p", prompt: "p",
@@ -122,7 +122,7 @@ describe("schedules api", () => {
expect((await owner.get(`${base}/bad-model`)).status).toBe(404); expect((await owner.get(`${base}/bad-model`)).status).toBe(404);
}); });
it("PUT 整文件替换(仅 owner);不存在 404", async () => { it("PUT replaces the whole file (owner only); missing is 404", async () => {
const body = { name: "job", prompt: "p1", enabled: false, startAt: FUTURE }; const body = { name: "job", prompt: "p1", enabled: false, startAt: FUTURE };
expect((await owner.post(base, body)).status).toBe(201); expect((await owner.post(base, body)).status).toBe(201);
expect( expect(
@@ -146,7 +146,7 @@ describe("schedules api", () => {
).toBe(404); ).toBe(404);
}); });
it("校验 400:period 低于下限、时刻非法、目标二选一、任务名非法", async () => { it("validation 400: period below the minimum, invalid instant, pick-one target, invalid task name", async () => {
const ok = { prompt: "p", enabled: false, startAt: FUTURE }; const ok = { prompt: "p", enabled: false, startAt: FUTURE };
const cases = [ const cases = [
{ name: "j1", ...ok, period: "4m" }, { name: "j1", ...ok, period: "4m" },
@@ -159,7 +159,7 @@ describe("schedules api", () => {
} }
}); });
it("手编文件:非法进 invalidFiles;过期一次性任务对账标记 missed", async () => { it("hand-edited files: invalid ones land in invalidFiles; expired one-shots are marked missed during reconciliation", async () => {
const dir = scheduleDir(t.root, projectId, "default_agent"); const dir = scheduleDir(t.root, projectId, "default_agent");
await fs.mkdir(dir, { recursive: true }); await fs.mkdir(dir, { recursive: true });
await fs.writeFile(path.join(dir, "broken.toml"), "not = toml =", "utf8"); await fs.writeFile(path.join(dir, "broken.toml"), "not = toml =", "utf8");
+11 -9
View File
@@ -14,9 +14,10 @@ import type { AgentCreateResponse, ProjectCreateResponse } from "../src/api/type
import { apiClient, createTestApp, loginAdmin, provisionUser } from "./helpers.js"; import { apiClient, createTestApp, loginAdmin, provisionUser } from "./helpers.js";
import type { TestApp } from "./helpers.js"; import type { TestApp } from "./helpers.js";
// "项目" stays Chinese on purpose: ids must reject CJK (ASCII lowercase only).
const BAD_IDS = ["Foo", "1abc", "a", "-abc", "a-b", "a b", "a.b", "项目", "a".repeat(65)]; const BAD_IDS = ["Foo", "1abc", "a", "-abc", "a-b", "a b", "a.b", "项目", "a".repeat(65)];
describe("语义 id", () => { describe("semantic ids", () => {
let t: TestApp; let t: TestApp;
let admin: ReturnType<typeof apiClient>; let admin: ReturnType<typeof apiClient>;
let api: ReturnType<typeof apiClient>; let api: ReturnType<typeof apiClient>;
@@ -30,7 +31,7 @@ describe("语义 id", () => {
await t.cleanup(); await t.cleanup();
}); });
it("建 Project(admin 无前缀、不含连字符):非法 id 400;合法 id 落目录,显示名缺省为 id", async () => { it("create Project (admin: no prefix, no hyphen): invalid ids 400; a valid id lands a directory; display name defaults to the id", async () => {
for (const bad of BAD_IDS) { for (const bad of BAD_IDS) {
const res = await admin.post("/api/projects", { projectId: bad, name: "x" }); const res = await admin.post("/api/projects", { projectId: bad, name: "x" });
expect(res.status, `projectId=${bad}`).toBe(400); expect(res.status, `projectId=${bad}`).toBe(400);
@@ -44,7 +45,7 @@ describe("语义 id", () => {
await expect(fs.access(path.join(t.root, "my_proj_2"))).resolves.toBeUndefined(); await expect(fs.access(path.join(t.root, "my_proj_2"))).resolves.toBeUndefined();
}); });
it("建 Project:DB 占用、纯目录占用与保留 id 都是 409", async () => { it("create Project: DB-taken, directory-only-taken, and reserved ids are all 409", async () => {
expect((await admin.post("/api/projects", { projectId: "taken", name: "a" })).status).toBe(201); expect((await admin.post("/api/projects", { projectId: "taken", name: "a" })).status).toBe(201);
expect((await admin.post("/api/projects", { projectId: "taken", name: "b" })).status).toBe(409); expect((await admin.post("/api/projects", { projectId: "taken", name: "b" })).status).toBe(409);
// A directory that exists but isn't tracked (e.g. created by the CLI) is also considered taken. // A directory that exists but isn't tracked (e.g. created by the CLI) is also considered taken.
@@ -58,7 +59,7 @@ describe("语义 id", () => {
).toBe(409); ).toBe(409);
}); });
it("非管理员建 Project:id 强制为 <用户名>-<后缀>,后缀仅小写字母数字下划线", async () => { it("non-admin Project creation: id forced to <username>-<suffix>; the suffix allows only lowercase letters, digits, underscores", async () => {
// No prefix / prefix only / suffix with a hyphen or an invalid character: 400. // No prefix / prefix only / suffix with a hyphen or an invalid character: 400.
for (const bad of ["blog", "ida", "ida-", "idablog", "proj_ida", "ida-sub-x", "ida-Bad"]) { for (const bad of ["blog", "ida", "ida-", "idablog", "proj_ida", "ida-sub-x", "ida-Bad"]) {
const res = await api.post("/api/projects", { projectId: bad, name: "x" }); const res = await api.post("/api/projects", { projectId: bad, name: "x" });
@@ -72,14 +73,14 @@ describe("语义 id", () => {
await expect(fs.access(path.join(t.root, "ida-blog"))).resolves.toBeUndefined(); await expect(fs.access(path.join(t.root, "ida-blog"))).resolves.toBeUndefined();
}); });
it("创建 Project 中途失败:DB 行与目录回滚,同 id 重试可成功", async () => { it("mid-flight Project creation failure: the DB row and directory roll back; retrying the same id succeeds", async () => {
// Inject a config write failure (handleError logs the stack trace: silence it so it doesn't clutter output). // Inject a config write failure (handleError logs the stack trace: silence it so it doesn't clutter output).
const spy = vi.spyOn(console, "error").mockImplementation(() => {}); const spy = vi.spyOn(console, "error").mockImplementation(() => {});
const original = t.deps.projectConfigService.writeInitialConfig.bind( const original = t.deps.projectConfigService.writeInitialConfig.bind(
t.deps.projectConfigService, t.deps.projectConfigService,
); );
t.deps.projectConfigService.writeInitialConfig = async () => { t.deps.projectConfigService.writeInitialConfig = async () => {
throw new Error("写配置炸了"); throw new Error("config write blew up");
}; };
expect((await admin.post("/api/projects", { projectId: "flaky", name: "x" })).status).toBe(500); expect((await admin.post("/api/projects", { projectId: "flaky", name: "x" })).status).toBe(500);
spy.mockRestore(); spy.mockRestore();
@@ -92,11 +93,11 @@ describe("语义 id", () => {
expect((await admin.post("/api/projects", { projectId: "flaky", name: "x" })).status).toBe(201); expect((await admin.post("/api/projects", { projectId: "flaky", name: "x" })).status).toBe(201);
}); });
it("创建 Agent 中途失败:目录回滚,同 id 重试可成功", async () => { it("mid-flight Agent creation failure: the directory rolls back; retrying the same id succeeds", async () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {}); const spy = vi.spyOn(console, "error").mockImplementation(() => {});
const original = t.deps.agentConfigService.updateConfig.bind(t.deps.agentConfigService); const original = t.deps.agentConfigService.updateConfig.bind(t.deps.agentConfigService);
t.deps.agentConfigService.updateConfig = async () => { t.deps.agentConfigService.updateConfig = async () => {
throw new Error("写配置炸了"); throw new Error("config write blew up");
}; };
expect( expect(
(await api.post("/api/projects/ida-default_project/agents", { agentId: "flaky" })).status, (await api.post("/api/projects/ida-default_project/agents", { agentId: "flaky" })).status,
@@ -111,7 +112,7 @@ describe("语义 id", () => {
).toBe(201); ).toBe(201);
}); });
it("建 Agent:非法 id 400;合法 id 初始化;重复与内置 id 409;跨 Project 不冲突", async () => { it("create Agent: invalid ids 400; a valid id initializes; duplicates and built-in ids 409; no conflicts across Projects", async () => {
for (const bad of BAD_IDS) { for (const bad of BAD_IDS) {
const res = await api.post("/api/projects/ida-default_project/agents", { const res = await api.post("/api/projects/ida-default_project/agents", {
agentId: bad, agentId: bad,
@@ -120,6 +121,7 @@ describe("语义 id", () => {
expect(res.status, `agentId=${bad}`).toBe(400); expect(res.status, `agentId=${bad}`).toBe(400);
} }
// Unlike ids, display names are free-form — a CJK name must round-trip untouched.
const created = await api.post("/api/projects/ida-default_project/agents", { const created = await api.post("/api/projects/ida-default_project/agents", {
agentId: "crawler", agentId: "crawler",
name: "爬虫", name: "爬虫",
@@ -21,7 +21,7 @@ async function exists(p: string): Promise<boolean> {
} }
} }
describe("会话删除清理 scratchpad", () => { describe("session deletion cleans up the scratchpad", () => {
let t: TestApp; let t: TestApp;
let owner: ReturnType<typeof apiClient>; let owner: ReturnType<typeof apiClient>;
let projectId: string; let projectId: string;
@@ -33,7 +33,7 @@ describe("会话删除清理 scratchpad", () => {
const created = (await ( const created = (await (
await owner.post("/api/projects", { await owner.post("/api/projects", {
projectId: "owner_s-scratchpad", projectId: "owner_s-scratchpad",
name: "scratchpad 项目", name: "scratchpad project",
}) })
).json()) as ProjectCreateResponse; ).json()) as ProjectCreateResponse;
projectId = created.project.projectId; projectId = created.project.projectId;
@@ -46,7 +46,7 @@ describe("会话删除清理 scratchpad", () => {
await t.cleanup(); await t.cleanup();
}); });
it("DELETE 会话后其 scratchpad 目录一并删除", async () => { it("DELETE removes the session's scratchpad directory along with it", async () => {
const created = await owner.post( const created = await owner.post(
`/api/projects/${projectId}/agents/default_agent/sessions`, `/api/projects/${projectId}/agents/default_agent/sessions`,
{}, {},
@@ -72,7 +72,7 @@ describe("会话删除清理 scratchpad", () => {
expect((await owner.delete(`/api/sessions/${s2.sessionId}`)).status).toBe(204); expect((await owner.delete(`/api/sessions/${s2.sessionId}`)).status).toBe(204);
}); });
it("GET /sessions/:id/scratchpad/:file 按会话读取文件;缺失或非法文件名 404", async () => { it("GET /sessions/:id/scratchpad/:file reads per-session files; missing or invalid filenames 404", async () => {
const created = await owner.post( const created = await owner.post(
`/api/projects/${projectId}/agents/default_agent/sessions`, `/api/projects/${projectId}/agents/default_agent/sessions`,
{}, {},
+16 -16
View File
@@ -29,7 +29,7 @@ describe("session-index", () => {
const { cookie } = await provisionUser(t.app, "alice"); const { cookie } = await provisionUser(t.app, "alice");
api = apiClient(t.app, cookie); api = apiClient(t.app, cookie);
const created = (await ( const created = (await (
await api.post("/api/projects", { projectId: "alice-index", name: "测试项目" }) await api.post("/api/projects", { projectId: "alice-index", name: "test project" })
).json()) as ProjectCreateResponse; ).json()) as ProjectCreateResponse;
projectId = created.project.projectId; projectId = created.project.projectId;
}); });
@@ -45,7 +45,7 @@ describe("session-index", () => {
expect(res.status).toBe(200); expect(res.status).toBe(200);
} }
it("未配置默认模型时创建 Session → 400 no_default_model", async () => { it("creating a Session with no default model configured → 400 no_default_model", async () => {
// A newly created Project comes with a default model preset: first replace // A newly created Project comes with a default model preset: first replace
// the whole table to clear it (omitting defaultModel + the original default // the whole table to clear it (omitting defaultModel + the original default
// absent from models = removes default_model), then verify the // absent from models = removes default_model), then verify the
@@ -60,11 +60,11 @@ describe("session-index", () => {
expect(body.error.code).toBe("no_default_model"); expect(body.error.code).toBe("no_default_model");
}); });
it("模型没有可用 credential 时创建 Session → 400 model_credential_missing", async () => { it("creating a Session when the model has no usable credential → 400 model_credential_missing", async () => {
// A model using the OpenAI protocol: the SDK requires a credential as soon as // A model using the OpenAI protocol: the SDK requires a credential as soon as
// the client is constructed. Clear the environment variable key so none is // the client is constructed. Clear the environment variable key so none is
// available — the error must carry an **error code** (the frontend renders // available — the error must carry an **error code** (the frontend renders
// localized text from the code, not by parsing the Chinese message). // localized text from the code, not by parsing the message text).
const prev = process.env.OPENAI_API_KEY; const prev = process.env.OPENAI_API_KEY;
delete process.env.OPENAI_API_KEY; delete process.env.OPENAI_API_KEY;
try { try {
@@ -84,7 +84,7 @@ describe("session-index", () => {
} }
}); });
it("创建 Session:缺省自动临时 Workspace、默认 allow-all、进列表", async () => { it("creating a Session: auto temp Workspace by default, allow-all default, shows in the list", async () => {
await configureModels(); await configureModels();
const res = await api.post(base(), {}); const res = await api.post(base(), {});
expect(res.status).toBe(201); expect(res.status).toBe(201);
@@ -103,7 +103,7 @@ describe("session-index", () => {
expect(list.sessions.map((s) => s.sessionId)).toContain(session.sessionId); expect(list.sessions.map((s) => s.sessionId)).toContain(session.sessionId);
}); });
it("显式 Workspace 只要求已存在,可在 Project 目录之外", async () => { it("an explicit Workspace only needs to exist; it may live outside the Project directory", async () => {
await configureModels(); await configureModels();
const inside = path.join(t.root, projectId, "my-workdir"); const inside = path.join(t.root, projectId, "my-workdir");
await fs.mkdir(inside, { recursive: true }); await fs.mkdir(inside, { recursive: true });
@@ -124,7 +124,7 @@ describe("session-index", () => {
).toBe(400); ).toBe(400);
}); });
it("列表并集:Trace 目录发现未纳管 Session 并补插索引行", async () => { it("list union: Trace directory discovery finds unmanaged Sessions and backfills index rows", async () => {
await configureModels(); await configureModels();
const discovered = "session-2026-07-01-08-30-00-deadbeef"; const discovered = "session-2026-07-01-08-30-00-deadbeef";
const meta: SessionMetaPayload = { const meta: SessionMetaPayload = {
@@ -140,7 +140,7 @@ describe("session-index", () => {
}; };
await writeTraceFile(t.root, projectId, "default_agent", "2026-07-01", discovered, 1, [ await writeTraceFile(t.root, projectId, "default_agent", "2026-07-01", discovered, 1, [
sessionMeta(meta), sessionMeta(meta),
userText("cli 会话"), userText("cli session"),
]); ]);
const list = (await (await api.get(base())).json()) as SessionsResponse; const list = (await (await api.get(base())).json()) as SessionsResponse;
@@ -157,7 +157,7 @@ describe("session-index", () => {
expect(single.status).toBe(200); expect(single.status).toBe(200);
}); });
it("DELETE Session:清索引行与全部 Trace 分片,列表不复活;重删 404", async () => { it("DELETE Session: clears the index row and every Trace shard; the list doesn't resurrect it; re-delete 404", async () => {
await configureModels(); await configureModels();
const { session } = (await (await api.post(base(), {})).json()) as SessionCreateResponse; const { session } = (await (await api.post(base(), {})).json()) as SessionCreateResponse;
const sessionId = session.sessionId; const sessionId = session.sessionId;
@@ -181,7 +181,7 @@ describe("session-index", () => {
"2026-07-01", "2026-07-01",
sessionId, sessionId,
1, 1,
[sessionMeta(meta), userText("第一轮")], [sessionMeta(meta), userText("round one")],
); );
const f2 = await writeTraceFile( const f2 = await writeTraceFile(
t.root, t.root,
@@ -190,7 +190,7 @@ describe("session-index", () => {
"2026-07-02", "2026-07-02",
sessionId, sessionId,
2, 2,
[sessionMeta(meta), userText("第二轮")], [sessionMeta(meta), userText("round two")],
); );
const del = await api.delete(`/api/sessions/${sessionId}`); const del = await api.delete(`/api/sessions/${sessionId}`);
@@ -205,7 +205,7 @@ describe("session-index", () => {
expect((await api.get(`/api/sessions/${sessionId}`)).status).toBe(404); expect((await api.get(`/api/sessions/${sessionId}`)).status).toBe(404);
}); });
it("DELETE Session:Workspace 目录不随删除清除(用户自带目录必须保留)", async () => { it("DELETE Session: the Workspace directory is not removed (user-supplied directories must survive)", async () => {
await configureModels(); await configureModels();
const inside = path.join(t.root, projectId, "keep-me"); const inside = path.join(t.root, projectId, "keep-me");
await fs.mkdir(inside, { recursive: true }); await fs.mkdir(inside, { recursive: true });
@@ -217,7 +217,7 @@ describe("session-index", () => {
expect((await fs.stat(inside)).isDirectory()).toBe(true); expect((await fs.stat(inside)).isDirectory()).toBe(true);
}); });
it("列表按 createdAt 降序", async () => { it("the list is sorted by createdAt descending", async () => {
await configureModels(); await configureModels();
const older = "session-2020-01-01-00-00-00-00000001"; const older = "session-2020-01-01-00-00-00-00000001";
await writeTraceFile(t.root, projectId, "default_agent", "2020-01-01", older, 1, [ await writeTraceFile(t.root, projectId, "default_agent", "2020-01-01", older, 1, [
@@ -239,7 +239,7 @@ describe("session-index", () => {
expect(list.sessions[list.sessions.length - 1]!.sessionId).toBe(older); expect(list.sessions[list.sessions.length - 1]!.sessionId).toBe(older);
}); });
it("PATCH 审批模式即存并回读", async () => { it("PATCH approval mode persists and reads back", async () => {
await configureModels(); await configureModels();
const { session } = (await (await api.post(base(), {})).json()) as SessionCreateResponse; const { session } = (await (await api.post(base(), {})).json()) as SessionCreateResponse;
// Change from the default allow-all to a different mode, to confirm it's actually persisted. // Change from the default allow-all to a different mode, to confirm it's actually persisted.
@@ -257,7 +257,7 @@ describe("session-index", () => {
).toBe(400); ).toBe(400);
}); });
it("insertOrIgnore 幂等:并发首次发现同一 Session 不因 UNIQUE 约束抛错", async () => { it("insertOrIgnore is idempotent: concurrent first discovery of one Session doesn't throw on the UNIQUE constraint", async () => {
const row = { const row = {
sessionId: "session-2026-07-02-00-00-00-11223344", sessionId: "session-2026-07-02-00-00-00-11223344",
projectId, projectId,
@@ -277,7 +277,7 @@ describe("session-index", () => {
expect(t.deps.sessionsRepo.findById(row.sessionId)!.modelId).toBe("cli-model"); expect(t.deps.sessionsRepo.findById(row.sessionId)!.modelId).toBe("cli-model");
}); });
it("sessionIdCreatedAt:非法格式返回 null", () => { it("sessionIdCreatedAt: invalid formats return null", () => {
expect(sessionIdCreatedAt("session-2026-07-01-08-30-00-deadbeef")).toBe( expect(sessionIdCreatedAt("session-2026-07-01-08-30-00-deadbeef")).toBe(
new Date(2026, 6, 1, 8, 30, 0).toISOString(), new Date(2026, 6, 1, 8, 30, 0).toISOString(),
); );
+5 -5
View File
@@ -64,7 +64,7 @@ describe("session-loader", () => {
await fs.rm(root, { recursive: true, force: true }); await fs.rm(root, { recursive: true, force: true });
}); });
it("有 Trace 但 Workspace 已删 → 409 session_unrecoverable,保留原文", async () => { it("Trace exists but the Workspace was deleted → 409 session_unrecoverable, original message preserved", async () => {
await writeTraceFile(root, PROJECT, AGENT, "2026-07-06", SID, 1, [ await writeTraceFile(root, PROJECT, AGENT, "2026-07-06", SID, 1, [
sessionMeta(meta()), // workspace points to a nonexistent directory sessionMeta(meta()), // workspace points to a nonexistent directory
userText("hi"), userText("hi"),
@@ -74,10 +74,10 @@ describe("session-loader", () => {
expect(err).toBeInstanceOf(HttpError); expect(err).toBeInstanceOf(HttpError);
expect((err as HttpError).status).toBe(409); expect((err as HttpError).status).toBe(409);
expect((err as HttpError).code).toBe("session_unrecoverable"); expect((err as HttpError).code).toBe("session_unrecoverable");
expect((err as HttpError).message).toContain("Workspace 已不存在"); expect((err as HttpError).message).toContain("Workspace no longer exists");
}); });
it("有 Trace 但 Model 已从配置移除 → 409 session_unrecoverable", async () => { it("Trace exists but the Model was removed from config → 409 session_unrecoverable", async () => {
const ws = path.join(root, "ws"); const ws = path.join(root, "ws");
await fs.mkdir(ws, { recursive: true }); await fs.mkdir(ws, { recursive: true });
await writeTraceFile(root, PROJECT, AGENT, "2026-07-06", SID, 1, [ await writeTraceFile(root, PROJECT, AGENT, "2026-07-06", SID, 1, [
@@ -89,10 +89,10 @@ describe("session-loader", () => {
expect(err).toBeInstanceOf(HttpError); expect(err).toBeInstanceOf(HttpError);
expect((err as HttpError).status).toBe(409); expect((err as HttpError).status).toBe(409);
expect((err as HttpError).code).toBe("session_unrecoverable"); expect((err as HttpError).code).toBe("session_unrecoverable");
expect((err as HttpError).message).toContain("Model 不在 Project 配置中"); expect((err as HttpError).message).toContain("Model is not in the Project config");
}); });
it("无 Trace 且 Workspace 已删(自愈分支)→ 409 workspace_missing", async () => { it("no Trace and the Workspace deleted (self-heal branch) → 409 workspace_missing", async () => {
const loader = createCoreSessionLoader(root); const loader = createCoreSessionLoader(root);
const err = await loader.load(row("/tmp/gone-workspace-abc")).catch((e) => e); const err = await loader.load(row("/tmp/gone-workspace-abc")).catch((e) => e);
expect(err).toBeInstanceOf(HttpError); expect(err).toBeInstanceOf(HttpError);
+32 -32
View File
@@ -121,23 +121,23 @@ describe("session-manager", () => {
db.close(); db.close();
}); });
it("未知 Session → 404", async () => { it("unknown Session → 404", async () => {
const manager = makeManager(loaderOf(approvalFakeSession("session-1"))); const manager = makeManager(loaderOf(approvalFakeSession("session-1")));
const err = await manager.startTask("session-ghost", [userText("x")]).catch((e: unknown) => e); const err = await manager.startTask("session-ghost", [userText("x")]).catch((e: unknown) => e);
expect((err as { status: number }).status).toBe(404); expect((err as { status: number }).status).toBe(404);
}); });
it("startTask:先 publish 输入,驱动结束置回 idle 并推送 task_state", async () => { it("startTask: publishes the input first; when driving ends it returns to idle and pushes task_state", async () => {
sessions.updateApprovalMode("session-1", "allow-all"); sessions.updateApprovalMode("session-1", "allow-all");
const manager = makeManager(loaderOf(approvalFakeSession("session-1"))); const manager = makeManager(loaderOf(approvalFakeSession("session-1")));
const events = capture("session-1"); const events = capture("session-1");
const { sessionId } = await manager.startTask("session-1", [userText("你好")]); const { sessionId } = await manager.startTask("session-1", [userText("hello")]);
expect(sessionId).toBe("session-1"); expect(sessionId).toBe("session-1");
await waitFor(() => manager.statusOf("session-1") === "idle" && recorded.length >= 3); await waitFor(() => manager.statusOf("session-1") === "idle" && recorded.length >= 3);
// The first entry is the input message (visible to other subscribers), followed by task_state: running. // The first entry is the input message (visible to other subscribers), followed by task_state: running.
const first = JSON.parse(events[0]!.data) as { payload: { text: string } }; const first = JSON.parse(events[0]!.data) as { payload: { text: string } };
expect(first.payload.text).toBe("你好"); expect(first.payload.text).toBe("hello");
const states = serverEvents(events).filter((e) => e.type === "task_state"); const states = serverEvents(events).filter((e) => e.type === "task_state");
expect(states.map((s) => s.state)).toEqual(["running", "idle"]); expect(states.map((s) => s.state)).toEqual(["running", "idle"]);
// Outputs and events are forwarded one by one and handed to the recorder. // Outputs and events are forwarded one by one and handed to the recorder.
@@ -150,7 +150,7 @@ describe("session-manager", () => {
}); });
}); });
it("消息流里的 LLM / 工具失败经 drive 落库(source=llm / environment,带当前 Session 上下文)", async () => { it("LLM / tool failures in the message stream are persisted via drive (source=llm / environment, with the current Session context)", async () => {
sessions.updateApprovalMode("session-1", "allow-all"); sessions.updateApprovalMode("session-1", "allow-all");
const captured: ErrorRecordArgs[] = []; const captured: ErrorRecordArgs[] = [];
// core folds LLM / tool failures into the message stream (no throw): a tool // core folds LLM / tool failures into the message stream (no throw): a tool
@@ -175,7 +175,7 @@ describe("session-manager", () => {
async *compact(): AsyncGenerator<OmniMessage> {}, async *compact(): AsyncGenerator<OmniMessage> {},
}; };
const manager = makeManager(loaderOf(failing), { record: (args) => captured.push(args) }); const manager = makeManager(loaderOf(failing), { record: (args) => captured.push(args) });
await manager.startTask("session-1", [userText("跑")]); await manager.startTask("session-1", [userText("run")]);
await waitFor(() => manager.statusOf("session-1") === "idle" && captured.length >= 2); await waitFor(() => manager.statusOf("session-1") === "idle" && captured.length >= 2);
expect(captured.map((a) => [a.source, a.code, a.kind])).toEqual([ expect(captured.map((a) => [a.source, a.code, a.kind])).toEqual([
@@ -187,7 +187,7 @@ describe("session-manager", () => {
expect(String(captured[1]!.err)).toBe("llm request error: 500 upstream"); // the abort's real reason expect(String(captured[1]!.err)).toBe("llm request error: 500 upstream"); // the abort's real reason
}); });
it("互斥:运行中再次 startTask → 409 task_in_progress;compact → 409", async () => { it("mutual exclusion: startTask again while running → 409 task_in_progress; compact → 409", async () => {
const manager = makeManager(loaderOf(approvalFakeSession("session-1"))); const manager = makeManager(loaderOf(approvalFakeSession("session-1")));
await manager.startTask("session-1", [userText("go")]); await manager.startTask("session-1", [userText("go")]);
await waitFor(() => manager.pendingApprovalCount("session-1") === 1); await waitFor(() => manager.pendingApprovalCount("session-1") === 1);
@@ -202,7 +202,7 @@ describe("session-manager", () => {
await waitFor(() => manager.statusOf("session-1") === "idle"); await waitFor(() => manager.statusOf("session-1") === "idle");
}); });
it("always-ask:登记未决审批并推送 approval_request;决定后继续", async () => { it("always-ask: registers a pending approval and pushes approval_request; continues after the decision", async () => {
const manager = makeManager(loaderOf(approvalFakeSession("session-1"))); const manager = makeManager(loaderOf(approvalFakeSession("session-1")));
const events = capture("session-1"); const events = capture("session-1");
await manager.startTask("session-1", [userText("go")]); await manager.startTask("session-1", [userText("go")]);
@@ -225,7 +225,7 @@ describe("session-manager", () => {
expect(texts).toContain("decision=allow"); expect(texts).toContain("decision=allow");
}); });
it("deny-all / read-only 自动判定(不转人工)", async () => { it("deny-all / read-only decide automatically (no human handoff)", async () => {
sessions.updateApprovalMode("session-1", "deny-all"); sessions.updateApprovalMode("session-1", "deny-all");
const manager = makeManager(loaderOf(approvalFakeSession("session-1"))); const manager = makeManager(loaderOf(approvalFakeSession("session-1")));
await manager.startTask("session-1", [userText("go")]); await manager.startTask("session-1", [userText("go")]);
@@ -249,7 +249,7 @@ describe("session-manager", () => {
); );
}); });
it("子会话(origin)登记:session_meta 落库,标题交模型据子会话自己的对话生成", async () => { it("sub-session (origin) registration: session_meta persists; the title is left to the model using the sub-session's own conversation", async () => {
const fake: RuntimeSession = { const fake: RuntimeSession = {
sessionId: "session-1", sessionId: "session-1",
toolPermission: () => "rw", toolPermission: () => "rw",
@@ -259,7 +259,7 @@ describe("session-manager", () => {
// The parent-level run_subagent call (no origin): its prompt becomes the sub-session title. // The parent-level run_subagent call (no origin): its prompt becomes the sub-session title.
yield toolCall({ yield toolCall({
name: "run_subagent", name: "run_subagent",
arguments: JSON.stringify({ prompt: "研究一下这个问题的背景资料" }), arguments: JSON.stringify({ prompt: "Research the background of this question" }),
toolCallId: "sub-1", toolCallId: "sub-1",
}); });
const hop = "child-1"; const hop = "child-1";
@@ -308,17 +308,17 @@ describe("session-manager", () => {
expect(childTitle).toBeTruthy(); expect(childTitle).toBeTruthy();
// Explicit material override for the sub-session: user material = the prompt that spawned it; assistant material = the sub-session's **own** model output. // Explicit material override for the sub-session: user material = the prompt that spawned it; assistant material = the sub-session's **own** model output.
expect(childTitle!.req.material).toEqual({ expect(childTitle!.req.material).toEqual({
userText: "研究一下这个问题的背景资料", userText: "Research the background of this question",
assistantText: "child done", assistantText: "child done",
}); });
expect(childTitle!.req.fallbackText).toBe("研究一下这个问题的背景资料"); expect(childTitle!.req.fallbackText).toBe("Research the background of this question");
// Session/Agent record the sub-session, but modelId records the parent — the request runs on the parent's bare LLM. // Session/Agent record the sub-session, but modelId records the parent — the request runs on the parent's bare LLM.
expect(childTitle!.ctx).toMatchObject({ agentId: "child_agent", modelId: "m1" }); expect(childTitle!.ctx).toMatchObject({ agentId: "child_agent", modelId: "m1" });
// The sub-session has no SSE channel of its own: title events are delivered over the parent session's channel. // The sub-session has no SSE channel of its own: title events are delivered over the parent session's channel.
expect(childTitle!.req.notifyOn).toBe("session-1"); expect(childTitle!.req.notifyOn).toBe("session-1");
}); });
it("审批模式即改即生效:运行中 PATCH 后下一次决策用新模式", async () => { it("approval mode takes effect immediately: after a mid-run PATCH, the next decision uses the new mode", async () => {
// A fake Session that requests approval twice. // A fake Session that requests approval twice.
const fake: RuntimeSession = { const fake: RuntimeSession = {
sessionId: "session-1", sessionId: "session-1",
@@ -349,7 +349,7 @@ describe("session-manager", () => {
expect(manager.pendingApprovalCount("session-1")).toBe(0); expect(manager.pendingApprovalCount("session-1")).toBe(0);
}); });
it("abort:未决审批收敛为 deny 再触发 AbortSignal", async () => { it("abort: pending approvals collapse to deny before the AbortSignal fires", async () => {
const manager = makeManager(loaderOf(approvalFakeSession("session-1"))); const manager = makeManager(loaderOf(approvalFakeSession("session-1")));
expect(manager.abortTask("session-1")).toBe(false); // no Task in progress → no-op expect(manager.abortTask("session-1")).toBe(false); // no Task in progress → no-op
await manager.startTask("session-1", [userText("go")]); await manager.startTask("session-1", [userText("go")]);
@@ -363,7 +363,7 @@ describe("session-manager", () => {
expect(payloads.some((p) => p.type === "abort")).toBe(true); expect(payloads.some((p) => p.type === "abort")).toBe(true);
}); });
it("beginSessionDeletion:中断活跃运行、清出活跃表并标记删除中(新任务 409),end 后恢复", async () => { it("beginSessionDeletion: interrupts active runs, clears the active table and marks deleting (new Tasks 409); recovers after end", async () => {
const manager = makeManager(loaderOf(approvalFakeSession("session-1"))); const manager = makeManager(loaderOf(approvalFakeSession("session-1")));
expect(manager.beginSessionDeletion("session-1")).toEqual([]); // no active entry expect(manager.beginSessionDeletion("session-1")).toEqual([]); // no active entry
// While deletion is in progress: a new Task is rejected with 409 (prevents resurrection). // While deletion is in progress: a new Task is rejected with 409 (prevents resurrection).
@@ -380,7 +380,7 @@ describe("session-manager", () => {
manager.endSessionDeletion("session-1"); manager.endSessionDeletion("session-1");
}); });
it("自愈:loader 返回新 session_id 时更新索引主键并返回当前实际 id", async () => { it("self-heal: when the loader returns a new session_id, the index primary key is updated and the actual current id returned", async () => {
sessions.updateApprovalMode("session-1", "allow-all"); sessions.updateApprovalMode("session-1", "allow-all");
const manager = makeManager(loaderOf(approvalFakeSession("session-2-healed"))); const manager = makeManager(loaderOf(approvalFakeSession("session-2-healed")));
const { sessionId } = await manager.startTask("session-1", [userText("go")]); const { sessionId } = await manager.startTask("session-1", [userText("go")]);
@@ -392,7 +392,7 @@ describe("session-manager", () => {
expect(recordedCtx[0]!.sessionId).toBe("session-2-healed"); expect(recordedCtx[0]!.sessionId).toBe("session-2-healed");
}); });
it("通道被回收重建后 drive 仍发到当前通道(每次 publish 前重新 get)", async () => { it("after the channel is swept and rebuilt, drive still sends to the current channel (re-get before every publish)", async () => {
const manager = makeManager(loaderOf(approvalFakeSession("session-1"))); const manager = makeManager(loaderOf(approvalFakeSession("session-1")));
await manager.startTask("session-1", [userText("go")]); await manager.startTask("session-1", [userText("go")]);
await waitFor(() => manager.pendingApprovalCount("session-1") === 1); await waitFor(() => manager.pendingApprovalCount("session-1") === 1);
@@ -415,7 +415,7 @@ describe("session-manager", () => {
expect(states.map((s) => s.state)).toContain("idle"); expect(states.map((s) => s.state)).toContain("idle");
}); });
it("abortProject:返回进行中的驱动 Promise,等待后收尾完成", async () => { it("abortProject: returns the in-flight drive Promises; wrap-up completes after awaiting them", async () => {
const manager = makeManager(loaderOf(approvalFakeSession("session-1"))); const manager = makeManager(loaderOf(approvalFakeSession("session-1")));
expect(manager.abortProject("p1")).toEqual([]); // no active runs → empty array expect(manager.abortProject("p1")).toEqual([]); // no active runs → empty array
await manager.startTask("session-1", [userText("go")]); await manager.startTask("session-1", [userText("go")]);
@@ -429,10 +429,10 @@ describe("session-manager", () => {
expect(manager.statusOf("session-1")).toBe("idle"); expect(manager.statusOf("session-1")).toBe("idle");
}); });
it("loader 抛 HttpError(如 workspace_missing 409)→ 原样透传,不被重复包装", async () => { it("loader throwing HttpError (e.g. workspace_missing 409) → passed through as-is, not re-wrapped", async () => {
const loader: SessionLoader = { const loader: SessionLoader = {
load: async () => { load: async () => {
throw new HttpError(409, "workspace_missing", "Workspace 已不存在。"); throw new HttpError(409, "workspace_missing", "Workspace no longer exists.");
}, },
}; };
const manager = makeManager(loader); const manager = makeManager(loader);
@@ -441,7 +441,7 @@ describe("session-manager", () => {
expect((err as { code: string }).code).toBe("workspace_missing"); expect((err as { code: string }).code).toBe("workspace_missing");
}); });
it("shutdown 置位后拒收新任务(503 shutting_down)", async () => { it("rejects new Tasks once shutdown is set (503 shutting_down)", async () => {
const manager = makeManager(loaderOf(approvalFakeSession("session-1"))); const manager = makeManager(loaderOf(approvalFakeSession("session-1")));
await manager.shutdown(); await manager.shutdown();
const err = await manager.startTask("session-1", [userText("x")]).catch((e: unknown) => e); const err = await manager.startTask("session-1", [userText("x")]).catch((e: unknown) => e);
@@ -451,7 +451,7 @@ describe("session-manager", () => {
expect((compactErr as { status: number }).status).toBe(503); expect((compactErr as { status: number }).status).toBe(503);
}); });
it("sweepIdle:空闲超时的 entry 被淘汰(下次访问经 loader 重新装载)", async () => { it("sweepIdle: entries idle past the timeout are evicted (reloaded via the loader on next access)", async () => {
let loads = 0; let loads = 0;
const loader: SessionLoader = { const loader: SessionLoader = {
load: async () => { load: async () => {
@@ -475,7 +475,7 @@ describe("session-manager", () => {
expect(loads).toBe(1); expect(loads).toBe(1);
}); });
it("sweepIdle:运行中 / 有未决审批的 entry 不淘汰", async () => { it("sweepIdle: entries that are running / have pending approvals are not evicted", async () => {
const manager = makeManager(loaderOf(approvalFakeSession("session-1"))); const manager = makeManager(loaderOf(approvalFakeSession("session-1")));
await manager.startTask("session-1", [userText("go")]); await manager.startTask("session-1", [userText("go")]);
await waitFor(() => manager.pendingApprovalCount("session-1") === 1); await waitFor(() => manager.pendingApprovalCount("session-1") === 1);
@@ -486,7 +486,7 @@ describe("session-manager", () => {
await waitFor(() => manager.statusOf("session-1") === "idle"); await waitFor(() => manager.statusOf("session-1") === "idle");
}); });
it("compact:置 compacting、输出进通道、结束回 idle", async () => { it("compact: sets compacting, output goes to the channel, returns to idle at the end", async () => {
const manager = makeManager(loaderOf(approvalFakeSession("session-1"))); const manager = makeManager(loaderOf(approvalFakeSession("session-1")));
const events = capture("session-1"); const events = capture("session-1");
await manager.startCompact("session-1"); await manager.startCompact("session-1");
@@ -499,7 +499,7 @@ describe("session-manager", () => {
]); ]);
}); });
it("Task 完成后通知标题生成:兜底素材取用户 text、生成素材由 Session 自采;压缩不通知", async () => { it("notifies title generation after a Task completes: fallback material is the user text, generation material is self-collected by the Session; compaction doesn't notify", async () => {
const notified: { ctx: UsageContext; session: unknown; req: TitleRequest }[] = []; const notified: { ctx: UsageContext; session: unknown; req: TitleRequest }[] = [];
const plainSession: RuntimeSession = { const plainSession: RuntimeSession = {
sessionId: "session-1", sessionId: "session-1",
@@ -507,10 +507,10 @@ describe("session-manager", () => {
generateTitle: async () => ({ title: null, usage: null }), generateTitle: async () => ({ title: null, usage: null }),
compactability: () => "ok" as const, compactability: () => "ok" as const,
async *run() { async *run() {
yield thinkingMessage("思考中"); yield thinkingMessage("thinking");
yield assistantText("答案A"); yield assistantText("answer A");
yield withOrigin(assistantText("子会话文本"), "session-sub"); yield withOrigin(assistantText("sub-session text"), "session-sub");
yield assistantText("答案B"); yield assistantText("answer B");
}, },
async *compact() { async *compact() {
yield compactionBegin({ reason: "manual", mode: "summarize", context: 1, turns: 1 }); yield compactionBegin({ reason: "manual", mode: "summarize", context: 1, turns: 1 });
@@ -528,9 +528,9 @@ describe("session-manager", () => {
log: () => {}, log: () => {},
}); });
await manager.startTask("session-1", [userText("问题1"), userText("问题2")]); await manager.startTask("session-1", [userText("question 1"), userText("question 2")]);
await waitFor(() => notified.length === 1); await waitFor(() => notified.length === 1);
expect(notified[0]!.req.fallbackText).toBe("问题1\n问题2"); expect(notified[0]!.req.fallbackText).toBe("question 1\nquestion 2");
// No material override for the main session: material is gathered by the core Session during run. // No material override for the main session: material is gathered by the core Session during run.
expect(notified[0]!.req.material).toBeUndefined(); expect(notified[0]!.req.material).toBeUndefined();
expect(notified[0]!.session).toBe(plainSession); expect(notified[0]!.session).toBe(plainSession);
+8 -8
View File
@@ -35,7 +35,7 @@ describe("skills api", () => {
member = apiClient(t.app, b.cookie); member = apiClient(t.app, b.cookie);
outsider = apiClient(t.app, c.cookie); outsider = apiClient(t.app, c.cookie);
const created = (await ( const created = (await (
await owner.post("/api/projects", { projectId: "owner_s-skills", name: "技能项目" }) await owner.post("/api/projects", { projectId: "owner_s-skills", name: "skills project" })
).json()) as ProjectCreateResponse; ).json()) as ProjectCreateResponse;
projectId = created.project.projectId; projectId = created.project.projectId;
expect( expect(
@@ -52,7 +52,7 @@ describe("skills api", () => {
expect(res.status).toBe(201); expect(res.status).toBe(201);
} }
it("GET /api/skills:分组与 metadata、短描述、图标齐全且不下发正文", async () => { it("GET /api/skills: groups with metadata, short descriptions, and icons, without sending bodies", async () => {
const res = await member.get("/api/skills"); const res = await member.get("/api/skills");
expect(res.status).toBe(200); expect(res.status).toBe(200);
const body = (await res.json()) as SkillLibraryResponse; const body = (await res.json()) as SkillLibraryResponse;
@@ -102,7 +102,7 @@ describe("skills api", () => {
} }
}); });
it("成员可装可卸;安装原文落盘、卸载后目录消失", async () => { it("members can install and uninstall; installs land verbatim on disk, the directory disappears after uninstall", async () => {
await createPlainAgent("bare_agent"); await createPlainAgent("bare_agent");
const url = base("bare_agent"); const url = base("bare_agent");
@@ -141,7 +141,7 @@ describe("skills api", () => {
expect((await member.delete(`${url}/penguin-sdk`)).status).toBe(404); expect((await member.delete(`${url}/penguin-sdk`)).status).toBe(404);
}); });
it("重复安装幂等更新:手改落盘内容后再装,恢复为库内容", async () => { it("reinstall is an idempotent update: hand-edited on-disk content is restored to the library content", async () => {
await createPlainAgent("update_agent"); await createPlainAgent("update_agent");
const url = base("update_agent"); const url = base("update_agent");
expect((await owner.post(url, { names: ["penguin-cli"] })).status).toBe(201); expect((await owner.post(url, { names: ["penguin-cli"] })).status).toBe(201);
@@ -157,7 +157,7 @@ describe("skills api", () => {
expect(await fs.readFile(file, "utf8")).toBe(librarySkill("penguin-cli")!.content); expect(await fs.readFile(file, "utf8")).toBe(librarySkill("penguin-cli")!.content);
}); });
it("未知技能 404 unknown_skill,且不产生半安装状态", async () => { it("unknown skill 404 unknown_skill, with no half-installed state", async () => {
await createPlainAgent("strict_agent"); await createPlainAgent("strict_agent");
const url = base("strict_agent"); const url = base("strict_agent");
const res = await owner.post(url, { names: ["penguin-sdk", "no-such-skill"] }); const res = await owner.post(url, { names: ["penguin-sdk", "no-such-skill"] });
@@ -170,7 +170,7 @@ describe("skills api", () => {
expect(list.skills).toEqual([]); expect(list.skills).toEqual([]);
}); });
it("请求体校验 400:names 缺失/空数组/含非字符串", async () => { it("request body validation 400: names missing / empty array / non-string entries", async () => {
await createPlainAgent("valid_agent"); await createPlainAgent("valid_agent");
const url = base("valid_agent"); const url = base("valid_agent");
for (const body of [{}, { names: [] }, { names: ["penguin-sdk", 1] }, { names: [""] }]) { for (const body of [{}, { names: [] }, { names: ["penguin-sdk", 1] }, { names: [""] }]) {
@@ -178,7 +178,7 @@ describe("skills api", () => {
} }
}); });
it("外人一律 404(读取、安装、卸载);Agent 不存在 404", async () => { it("outsiders always get 404 (read, install, uninstall); a missing Agent is 404", async () => {
const url = base("default_agent"); const url = base("default_agent");
expect((await outsider.get(url)).status).toBe(404); expect((await outsider.get(url)).status).toBe(404);
expect((await outsider.post(url, { names: ["penguin-sdk"] })).status).toBe(404); expect((await outsider.post(url, { names: ["penguin-sdk"] })).status).toBe(404);
@@ -189,7 +189,7 @@ describe("skills api", () => {
expect((await member.get(base("no_such_agent"))).status).toBe(404); expect((await member.get(base("no_such_agent"))).status).toBe(404);
}); });
it("default_agent 初始即装库内全部技能;新建普通 Agent 技能为空", async () => { it("default_agent starts with every library skill installed; a new plain Agent has none", async () => {
const res = await member.get(base("default_agent")); const res = await member.get(base("default_agent"));
expect(res.status).toBe(200); expect(res.status).toBe(200);
const body = (await res.json()) as AgentSkillsResponse; const body = (await res.json()) as AgentSkillsResponse;
+7 -6
View File
@@ -31,7 +31,8 @@ async function readSseFrames(res: Response, count: number, timeoutMs = 3000): Pr
const deadline = Date.now() + timeoutMs; const deadline = Date.now() + timeoutMs;
try { try {
while (frames.length < count) { while (frames.length < count) {
if (Date.now() > deadline) throw new Error(`SSE 读取超时(已有 ${frames.length} 帧)`); if (Date.now() > deadline)
throw new Error(`SSE read timed out (${frames.length} frames so far)`);
const { done, value } = await reader.read(); const { done, value } = await reader.read();
if (done) break; if (done) break;
buf += decoder.decode(value, { stream: true }); buf += decoder.decode(value, { stream: true });
@@ -103,14 +104,14 @@ describe("sse-stream", () => {
const getStream = (headers: Record<string, string> = {}) => const getStream = (headers: Record<string, string> = {}) =>
t.app.request(`/api/sessions/${SID}/stream`, { headers: { cookie, ...headers } }); t.app.request(`/api/sessions/${SID}/stream`, { headers: { cookie, ...headers } });
it("FD-1:新订阅第一条恒为 task_state 快照(idle)", async () => { it("FD-1: a new subscription's first frame is always the task_state snapshot (idle)", async () => {
const frames = await readSseFrames(await getStream(), 1); const frames = await readSseFrames(await getStream(), 1);
expect(frames[0]!.event).toBe("server_event"); expect(frames[0]!.event).toBe("server_event");
expect(JSON.parse(frames[0]!.data)).toEqual({ type: "task_state", state: "idle" }); expect(JSON.parse(frames[0]!.data)).toEqual({ type: "task_state", state: "idle" });
expect(frames[0]!.id).toMatch(/^[0-9a-f]{8}-\d+$/); // FD-2: opaque string id expect(frames[0]!.id).toMatch(/^[0-9a-f]{8}-\d+$/); // FD-2: opaque string id
}); });
it("FD-1:运行中订阅收到 task_state: running,再补发未决审批", async () => { it("FD-1: subscribing while running receives task_state: running, then pending approvals are replayed", async () => {
t.deps.manager.adopt(row, approvalFakeSession(SID)); t.deps.manager.adopt(row, approvalFakeSession(SID));
await t.deps.manager.startTask(SID, [userText("go")]); await t.deps.manager.startTask(SID, [userText("go")]);
await waitFor(() => t.deps.manager.pendingApprovalCount(SID) === 1); await waitFor(() => t.deps.manager.pendingApprovalCount(SID) === 1);
@@ -128,8 +129,8 @@ describe("sse-stream", () => {
await waitFor(() => t.deps.manager.statusOf(SID) === "idle"); await waitFor(() => t.deps.manager.statusOf(SID) === "idle");
}); });
it("FD-2:Last-Event-ID 纪元不一致 → 先发 resync_required 再发 task_state 快照", async () => { it("FD-2: mismatched Last-Event-ID epoch → resync_required first, then the task_state snapshot", async () => {
t.deps.channels.get(SID).publish(userText("旧事件")); t.deps.channels.get(SID).publish(userText("old event"));
const frames = await readSseFrames( const frames = await readSseFrames(
await getStream({ "Last-Event-ID": "deadbeef-1" }), // guaranteed to differ from the current channel epoch await getStream({ "Last-Event-ID": "deadbeef-1" }), // guaranteed to differ from the current channel epoch
2, 2,
@@ -138,7 +139,7 @@ describe("sse-stream", () => {
expect(JSON.parse(frames[1]!.data)).toEqual({ type: "task_state", state: "idle" }); expect(JSON.parse(frames[1]!.data)).toEqual({ type: "task_state", state: "idle" });
}); });
it("FD-2:同纪元 Last-Event-ID 命中缓冲 → 补发其后事件,再发 task_state 快照", async () => { it("FD-2: same-epoch Last-Event-ID hitting the buffer → replays later events, then the task_state snapshot", async () => {
const channel = t.deps.channels.get(SID); const channel = t.deps.channels.get(SID);
const first = channel.publish(userText("m1")); const first = channel.publish(userText("m1"));
channel.publish(userText("m2")); channel.publish(userText("m2"));
+12 -9
View File
@@ -4,6 +4,9 @@
* persisting + event push + usage accounted as token usage, idempotency (no * persisting + event push + usage accounted as token usage, idempotency (no
* regeneration when a title already exists / generation is in flight), and * regeneration when a title already exists / generation is in flight), and
* silent failure. * silent failure.
* The Chinese conversation/title fixtures are intentional: a Session title must
* follow the conversation language (zh chat → zh title), including the fallback
* path that derives the title from the conversation text itself.
*/ */
import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { describe, it, expect, beforeEach, afterEach } from "vitest";
import type { DatabaseSync } from "node:sqlite"; import type { DatabaseSync } from "node:sqlite";
@@ -89,7 +92,7 @@ describe("title-generator", () => {
db.close(); db.close();
}); });
it("生成标题:落库 + 推送 session_title + 用量折算入账;素材缺省由 Session 自采", async () => { it("title generation: persists + pushes session_title + usage converted and recorded; material defaults to Session self-collection", async () => {
const events = captureChannel(); const events = captureChannel();
const calls = { count: 0, args: [] as unknown[] }; const calls = { count: 0, args: [] as unknown[] };
const gen = makeGenerator(); const gen = makeGenerator();
@@ -119,7 +122,7 @@ describe("title-generator", () => {
expect((usageMsg?.payload as { request?: { total: number } }).request?.total).toBe(6); expect((usageMsg?.payload as { request?: { total: number } }).request?.total).toBe(6);
}); });
it("素材覆盖(子会话场景)原样传给 generateTitle", async () => { it("material override (sub-session scenario) is passed to generateTitle verbatim", async () => {
const calls = { count: 0, args: [] as unknown[] }; const calls = { count: 0, args: [] as unknown[] };
const gen = makeGenerator(); const gen = makeGenerator();
const material = { userText: "子会话 prompt", assistantText: "子会话回答" }; const material = { userText: "子会话 prompt", assistantText: "子会话回答" };
@@ -132,18 +135,18 @@ describe("title-generator", () => {
expect(sessions.findById(ROW.sessionId)?.title).toBe("子标题"); expect(sessions.findById(ROW.sessionId)?.title).toBe("子标题");
}); });
it("已有标题不再生成(不发起单发请求)", async () => { it("an existing title is never regenerated (no one-shot request issued)", async () => {
sessions.updateTitle(ROW.sessionId, "已有标题"); sessions.updateTitle(ROW.sessionId, "existing title");
const calls = { count: 0, args: [] as unknown[] }; const calls = { count: 0, args: [] as unknown[] };
makeGenerator().maybeGenerate(CTX, fakeSession({ title: "新标题", usage: null }, calls), { makeGenerator().maybeGenerate(CTX, fakeSession({ title: "new title", usage: null }, calls), {
fallbackText: "u", fallbackText: "u",
}); });
await new Promise((r) => setTimeout(r, 20)); await new Promise((r) => setTimeout(r, 20));
expect(calls.count).toBe(0); expect(calls.count).toBe(0);
expect(sessions.findById(ROW.sessionId)?.title).toBe("已有标题"); expect(sessions.findById(ROW.sessionId)?.title).toBe("existing title");
}); });
it("LLM 返回 null(请求失败/空结果)时用兜底素材首行落库;usage 仍入账", async () => { it("when the LLM returns null (failed request / empty result), the fallback material's first line is persisted; usage still recorded", async () => {
const calls = { count: 0, args: [] as unknown[] }; const calls = { count: 0, args: [] as unknown[] };
const gen = makeGenerator(); const gen = makeGenerator();
gen.maybeGenerate( gen.maybeGenerate(
@@ -161,7 +164,7 @@ describe("title-generator", () => {
await waitFor(() => recorded.length > 0); await waitFor(() => recorded.length > 0);
}); });
it("LLM 返回 null 且兜底素材为空 → 标题保持 NULL(下次可重试)", async () => { it("LLM returns null and the fallback material is blank → the title stays NULL (retryable next time)", async () => {
const calls = { count: 0, args: [] as unknown[] }; const calls = { count: 0, args: [] as unknown[] };
const gen = makeGenerator(); const gen = makeGenerator();
gen.maybeGenerate(CTX, fakeSession({ title: null, usage: null }, calls), { gen.maybeGenerate(CTX, fakeSession({ title: null, usage: null }, calls), {
@@ -171,7 +174,7 @@ describe("title-generator", () => {
expect(sessions.findById(ROW.sessionId)?.title).toBeNull(); expect(sessions.findById(ROW.sessionId)?.title).toBeNull();
}); });
it("LLM 返回 null 且兜底素材为纯标点 → 兜底退回截断原文(不落 NULL)", async () => { it("LLM returns null and the fallback material is pure punctuation → falls back to the truncated original text (never NULL)", async () => {
const calls = { count: 0, args: [] as unknown[] }; const calls = { count: 0, args: [] as unknown[] };
const gen = makeGenerator(); const gen = makeGenerator();
// sanitizeTitle strips "???" down to empty — the fallback must revert to the truncated original text so a title is still produced. // sanitizeTitle strips "???" down to empty — the fallback must revert to the truncated original text so a title is still produced.
+40 -40
View File
@@ -73,29 +73,29 @@ describe("trace-service", () => {
await fs.rm(root, { recursive: true, force: true }); await fs.rm(root, { recursive: true, force: true });
}); });
it("messages:全部 index 文件按序拼接(跨日期目录)", async () => { it("messages: all index files concatenated in order (across date directories)", async () => {
await writeTraceFile(root, P, A, "2026-07-05", S, 1, [ await writeTraceFile(root, P, A, "2026-07-05", S, 1, [
sessionMeta(metaPayload()), sessionMeta(metaPayload()),
userText("第一个文件"), userText("first file"),
]); ]);
await writeTraceFile(root, P, A, "2026-07-06", S, 2, [ await writeTraceFile(root, P, A, "2026-07-06", S, 2, [
sessionMeta(metaPayload()), sessionMeta(metaPayload()),
userText("第二个文件"), userText("second file"),
]); ]);
const messages = await service.readMessages(P, A, S); const messages = await service.readMessages(P, A, S);
expect(messages).toHaveLength(4); expect(messages).toHaveLength(4);
expect((messages[1]!.payload as { text: string }).text).toBe("第一个文件"); expect((messages[1]!.payload as { text: string }).text).toBe("first file");
expect((messages[3]!.payload as { text: string }).text).toBe("第二个文件"); expect((messages[3]!.payload as { text: string }).text).toBe("second file");
}); });
it("messages:容忍残缺末行", async () => { it("messages: tolerates a truncated last line", async () => {
const file = await writeTraceFile(root, P, A, "2026-07-05", S, 1, [userText("ok")]); const file = await writeTraceFile(root, P, A, "2026-07-05", S, 1, [userText("ok")]);
await fs.appendFile(file, '{"timestamp":"2026', "utf8"); await fs.appendFile(file, '{"timestamp":"2026', "utf8");
const messages = await service.readMessages(P, A, S); const messages = await service.readMessages(P, A, S);
expect(messages).toHaveLength(1); expect(messages).toHaveLength(1);
}); });
it("traces 列表:index / 日期 / 大小 / mtime", async () => { it("traces listing: index / date / size / mtime", async () => {
await writeTraceFile(root, P, A, "2026-07-05", S, 1, [userText("a")]); await writeTraceFile(root, P, A, "2026-07-05", S, 1, [userText("a")]);
await writeTraceFile(root, P, A, "2026-07-06", S, 2, [userText("bb")]); await writeTraceFile(root, P, A, "2026-07-06", S, 2, [userText("bb")]);
const files = await service.listTraceFiles(P, A, S); const files = await service.listTraceFiles(P, A, S);
@@ -105,7 +105,7 @@ describe("trace-service", () => {
expect(Date.parse(files[0]!.mtime)).not.toBeNaN(); expect(Date.parse(files[0]!.mtime)).not.toBeNaN();
}); });
it("按行分页读取:offset/limit 与 total", async () => { it("paginated line reads: offset/limit and total", async () => {
const messages = Array.from({ length: 10 }, (_, i) => userText(`m${i}`)); const messages = Array.from({ length: 10 }, (_, i) => userText(`m${i}`));
await writeTraceFile(root, P, A, "2026-07-05", S, 1, messages); await writeTraceFile(root, P, A, "2026-07-05", S, 1, messages);
const page = await service.readEvents(P, A, S, 1, 3, 4); const page = await service.readEvents(P, A, S, 1, 3, 4);
@@ -117,7 +117,7 @@ describe("trace-service", () => {
expect((notFound as { status: number }).status).toBe(404); expect((notFound as { status: number }).status).toBe(404);
}); });
it("性能分析:Request 配对、工具耗时、reconnect / 压缩计数、Token 趋势", async () => { it("performance analysis: Request pairing, tool durations, reconnect / compaction counts, Token trend", async () => {
await writeTraceFile(root, P, A, "2026-07-05", S, 1, [ await writeTraceFile(root, P, A, "2026-07-05", S, 1, [
sessionMeta(metaPayload()), sessionMeta(metaPayload()),
at("2026-07-05T10:00:00.000Z", userText("hi")), at("2026-07-05T10:00:00.000Z", userText("hi")),
@@ -168,7 +168,7 @@ describe("trace-service", () => {
]); ]);
}); });
it("Task 上下文快照取该轮最后一次 Request,不是各次 Request 的累加", async () => { it("the Task context snapshot takes the turn's last Request, not a sum across its Requests", async () => {
// Two Requests within one Task (a tool call triggers another round): each // Two Requests within one Task (a tool call triggers another round): each
// input **re-carries the entire history**, so 60k → 65k is the context // input **re-carries the entire history**, so 60k → 65k is the context
// growing, not a 60k + 65k = 125k sum of usage. Summing them would double-count // growing, not a 60k + 65k = 125k sum of usage. Summing them would double-count
@@ -206,7 +206,7 @@ describe("trace-service", () => {
expect(t.llmMs).toBe(2000 + 2000); expect(t.llmMs).toBe(2000 + 2000);
}); });
it("人工审批等待不计入 LLM 生成时长(TPS 分母)", async () => { it("human approval waits don't count toward LLM generation time (the TPS denominator)", async () => {
// core does `await approve(tc)` inside the streaming loop: until approval // core does `await approve(tc)` inside the streaming loop: until approval
// returns, the next chunk isn't consumed and request_end can't fire, so the // returns, the next chunk isn't consumed and request_end can't fire, so the
// entire human wait sits between request_begin and request_end. Without // entire human wait sits between request_begin and request_end. Without
@@ -234,7 +234,7 @@ describe("trace-service", () => {
expect(a.tasks[0]!.tokens.output).toBe(1_000); // → 500 tok/s, not 31 tok/s expect(a.tasks[0]!.tokens.output).toBe(1_000); // → 500 tok/s, not 31 tok/s
}); });
it("压缩自成一轮:它的 TPS 归自己,不污染用户轮次;上下文快照仍只取非压缩 Request", async () => { it("compaction is its own turn: its TPS is its own and doesn't pollute user turns; the context snapshot still takes only non-compaction Requests", async () => {
// Compaction's request_begin/end and token_usage all sit between // Compaction's request_begin/end and token_usage all sit between
// compaction_begin and compaction_end (see core's context-engine summarize // compaction_begin and compaction_end (see core's context-engine summarize
// flow). Both sides of the Chat page exclude compaction, so Trace must use // flow). Both sides of the Chat page exclude compaction, so Trace must use
@@ -293,7 +293,7 @@ describe("trace-service", () => {
expect(a.elapsedMs).toBe(2_100 + 11_000); expect(a.elapsedMs).toBe(2_100 + 11_000);
}); });
it("压缩请求重试耗尽(以 timeout 收尾)不把下一个用户轮次并进压缩 Task", async () => { it("a compaction request exhausting retries (ending in timeout) doesn't fold the next user turn into the compaction Task", async () => {
// The intersection of "timeout → continuation" and "compaction is its own // The intersection of "timeout → continuation" and "compaction is its own
// turn": when a compaction request exhausts its retries and ends in timeout, // turn": when a compaction request exhausts its retries and ends in timeout,
// it would be classified as continuing; compaction_end must clear that // it would be classified as continuing; compaction_end must clear that
@@ -315,7 +315,7 @@ describe("trace-service", () => {
"2026-07-05T10:00:06.000Z", "2026-07-05T10:00:06.000Z",
compactionEnd({ reason: "context", mode: "summarize", status: "aborted" }), compactionEnd({ reason: "context", mode: "summarize", status: "aborted" }),
), ),
at("2026-07-05T10:00:07.000Z", userText("下一轮")), at("2026-07-05T10:00:07.000Z", userText("next turn")),
at("2026-07-05T10:00:08.000Z", requestBegin()), at("2026-07-05T10:00:08.000Z", requestBegin()),
at("2026-07-05T10:00:10.000Z", requestEnd("completed")), at("2026-07-05T10:00:10.000Z", requestEnd("completed")),
at("2026-07-05T10:00:10.100Z", tokenUsage(counts(1200), buckets(0, 0, 700))), at("2026-07-05T10:00:10.100Z", tokenUsage(counts(1200), buckets(0, 0, 700))),
@@ -335,7 +335,7 @@ describe("trace-service", () => {
expect(a.tasks[2]!.tokens.output).toBe(700); expect(a.tasks[2]!.tokens.output).toBe(700);
}); });
it("用户 Prompt 归入本轮消息区间,但用时从首个 request_begin 起算;空轮次照样在列表里", async () => { it("the user Prompt joins this turn's message range, but duration starts at the first request_begin; empty turns still make the list", async () => {
// Message **attribution** (messageFrom/To) and **duration** (startTs/endTs) // Message **attribution** (messageFrom/To) and **duration** (startTs/endTs)
// are two different things: the Prompt belongs to this turn's message range // are two different things: the Prompt belongs to this turn's message range
// (the frontend uses this to list it on this turn's card), but the duration // (the frontend uses this to list it on this turn's card), but the duration
@@ -349,19 +349,19 @@ describe("trace-service", () => {
// turn — this must be backstopped by the server-side tasks logic too. // turn — this must be backstopped by the server-side tasks logic too.
await writeTraceFile(root, P, A, "2026-07-05", S, 1, [ await writeTraceFile(root, P, A, "2026-07-05", S, 1, [
at("2026-07-05T10:00:00.000Z", sessionMeta(metaPayload())), at("2026-07-05T10:00:00.000Z", sessionMeta(metaPayload())),
at("2026-07-05T10:00:00.000Z", userText("第一问")), at("2026-07-05T10:00:00.000Z", userText("question one")),
at("2026-07-05T10:00:01.000Z", requestBegin()), at("2026-07-05T10:00:01.000Z", requestBegin()),
at("2026-07-05T10:00:02.000Z", requestEnd("completed")), at("2026-07-05T10:00:02.000Z", requestEnd("completed")),
at("2026-07-05T10:00:02.100Z", tokenUsage(counts(1000), buckets(0, 900, 100))), at("2026-07-05T10:00:02.100Z", tokenUsage(counts(1000), buckets(0, 900, 100))),
// Second turn: the Prompt precedes the Request; this turn's request fails outright, with no model output or tool call at all. // Second turn: the Prompt precedes the Request; this turn's request fails outright, with no model output or tool call at all.
at("2026-07-05T10:01:00.000Z", userText("第二问")), at("2026-07-05T10:01:00.000Z", userText("question two")),
at("2026-07-05T10:01:01.000Z", requestBegin()), at("2026-07-05T10:01:01.000Z", requestBegin()),
at("2026-07-05T10:01:04.000Z", requestEnd("failed")), at("2026-07-05T10:01:04.000Z", requestEnd("failed")),
]); ]);
const a = await service.analyze(P, A, S, 1); const a = await service.analyze(P, A, S, 1);
expect(a.tasks.map((t) => t.taskIndex)).toEqual([0, 1]); // the empty turn is present too expect(a.tasks.map((t) => t.taskIndex)).toEqual([0, 1]); // the empty turn is present too
// Message attribution: starting from "第二问" (index 5), it belongs to the second turn, not the tail of the previous one. // Message attribution: starting from "question two" (index 5), it belongs to the second turn, not the tail of the previous one.
expect(a.tasks[0]!.messageTo).toBe(4); expect(a.tasks[0]!.messageTo).toBe(4);
expect(a.tasks[1]!.messageFrom).toBe(5); expect(a.tasks[1]!.messageFrom).toBe(5);
// Duration start = request_begin (10:01:01), not the Prompt (10:01:00). // Duration start = request_begin (10:01:01), not the Prompt (10:01:00).
@@ -379,7 +379,7 @@ describe("trace-service", () => {
expect(a.elapsedMs).toBe(4_100); expect(a.elapsedMs).toBe(4_100);
}); });
it("上一轮以 timeout 收尾(重试耗尽)后,新的用户消息另起一轮", async () => { it("after the previous turn ends in timeout (retries exhausted), a new user message starts a new turn", async () => {
// "timeout → continuation" holds only for **automatic retries within the // "timeout → continuation" holds only for **automatic retries within the
// same run**. Once retries are exhausted and the engine gives up, a message // same run**. Once retries are exhausted and the engine gives up, a message
// the user sends afterward starts a new turn — if continuation were still // the user sends afterward starts a new turn — if continuation were still
@@ -388,12 +388,12 @@ describe("trace-service", () => {
// always breaks continuation, regardless of how the previous turn wrapped up. // always breaks continuation, regardless of how the previous turn wrapped up.
await writeTraceFile(root, P, A, "2026-07-05", S, 1, [ await writeTraceFile(root, P, A, "2026-07-05", S, 1, [
at("2026-07-05T10:00:00.000Z", sessionMeta(metaPayload())), at("2026-07-05T10:00:00.000Z", sessionMeta(metaPayload())),
at("2026-07-05T10:00:00.000Z", userText("第一问")), at("2026-07-05T10:00:00.000Z", userText("question one")),
at("2026-07-05T10:00:01.000Z", requestBegin()), at("2026-07-05T10:00:01.000Z", requestBegin()),
at("2026-07-05T10:00:02.000Z", requestEnd("timeout")), // retries exhausted → gives up at("2026-07-05T10:00:02.000Z", requestEnd("timeout")), // retries exhausted → gives up
at("2026-07-05T10:00:03.000Z", abortEvent()), at("2026-07-05T10:00:03.000Z", abortEvent()),
// A new user send // A new user send
at("2026-07-05T10:01:00.000Z", userText("第二问")), at("2026-07-05T10:01:00.000Z", userText("question two")),
at("2026-07-05T10:01:01.000Z", requestBegin()), at("2026-07-05T10:01:01.000Z", requestBegin()),
at("2026-07-05T10:01:03.000Z", requestEnd("completed")), at("2026-07-05T10:01:03.000Z", requestEnd("completed")),
at("2026-07-05T10:01:03.100Z", tokenUsage(counts(1000), buckets(0, 900, 100))), at("2026-07-05T10:01:03.100Z", tokenUsage(counts(1000), buckets(0, 900, 100))),
@@ -405,19 +405,19 @@ describe("trace-service", () => {
expect(a.tasks[1]!.tokens.output).toBe(100); // the second turn's usage isn't folded into the first expect(a.tasks[1]!.tokens.output).toBe(100); // the second turn's usage isn't folded into the first
}); });
it("一次发送含文本 + 多张图片:轮次归属从**第一条**消息起,不是最后一张图", async () => { it("one send with text + multiple images: turn attribution starts at the **first** message, not the last image", async () => {
// One send = multiple messages (user text + some number of image_url). If the // One send = multiple messages (user text + some number of image_url). If the
// pending index were overwritten on every message, turn attribution would // pending index were overwritten on every message, turn attribution would
// start from the last image, with the preceding text and images assigned to // start from the last image, with the preceding text and images assigned to
// the previous turn — completely at odds with "the user clicked send once". // the previous turn — completely at odds with "the user clicked send once".
await writeTraceFile(root, P, A, "2026-07-05", S, 1, [ await writeTraceFile(root, P, A, "2026-07-05", S, 1, [
at("2026-07-05T10:00:00.000Z", sessionMeta(metaPayload())), at("2026-07-05T10:00:00.000Z", sessionMeta(metaPayload())),
at("2026-07-05T10:00:00.000Z", userText("第一问")), at("2026-07-05T10:00:00.000Z", userText("question one")),
at("2026-07-05T10:00:01.000Z", requestBegin()), at("2026-07-05T10:00:01.000Z", requestBegin()),
at("2026-07-05T10:00:02.000Z", requestEnd("completed")), at("2026-07-05T10:00:02.000Z", requestEnd("completed")),
at("2026-07-05T10:00:02.100Z", tokenUsage(counts(500), buckets(0, 400, 100))), at("2026-07-05T10:00:02.100Z", tokenUsage(counts(500), buckets(0, 400, 100))),
// Second send: text + two images // Second send: text + two images
at("2026-07-05T10:01:00.000Z", userText("看这两张图")), at("2026-07-05T10:01:00.000Z", userText("look at these two images")),
at("2026-07-05T10:01:00.500Z", imageUrlMessage("data:image/png;base64,AAAA")), at("2026-07-05T10:01:00.500Z", imageUrlMessage("data:image/png;base64,AAAA")),
at("2026-07-05T10:01:01.000Z", imageUrlMessage("data:image/png;base64,BBBB")), at("2026-07-05T10:01:01.000Z", imageUrlMessage("data:image/png;base64,BBBB")),
at("2026-07-05T10:01:02.000Z", requestBegin()), at("2026-07-05T10:01:02.000Z", requestBegin()),
@@ -433,7 +433,7 @@ describe("trace-service", () => {
expect(a.tasks[0]!.endTs).toBe("2026-07-05T10:00:02.100Z"); // the second send's messages don't land in the first turn expect(a.tasks[0]!.endTs).toBe("2026-07-05T10:00:02.100Z"); // the second send's messages don't land in the first turn
}); });
it("消息逐条归属,不按时间戳猜:同一毫秒的「本轮回复 / 压缩开始 / 压缩 Prompt / 下轮 request」各归各轮", async () => { it("messages attributed one by one, never guessed from timestamps: same-millisecond \"this turn's reply / compaction begin / compaction Prompt / next turn's request\" each land on their own turn", async () => {
// Automatic compaction triggered at turn wrap-up crams these messages into // Automatic compaction triggered at turn wrap-up crams these messages into
// **the same millisecond**: this turn's last reply, compaction_begin, the // **the same millisecond**: this turn's last reply, compaction_begin, the
// compaction Prompt, and the compaction turn's request_begin. Attributing by // compaction Prompt, and the compaction turn's request_begin. Attributing by
@@ -444,9 +444,9 @@ describe("trace-service", () => {
const T = "2026-07-05T10:00:05.000Z"; // same millisecond const T = "2026-07-05T10:00:05.000Z"; // same millisecond
await writeTraceFile(root, P, A, "2026-07-05", S, 1, [ await writeTraceFile(root, P, A, "2026-07-05", S, 1, [
at("2026-07-05T10:00:00.000Z", sessionMeta(metaPayload())), at("2026-07-05T10:00:00.000Z", sessionMeta(metaPayload())),
at("2026-07-05T10:00:00.000Z", userText("问")), at("2026-07-05T10:00:00.000Z", userText("q")),
at("2026-07-05T10:00:01.000Z", requestBegin()), at("2026-07-05T10:00:01.000Z", requestBegin()),
at(T, assistantText("本轮的回复")), // ← this turn's own reply, same millisecond as the entries below at(T, assistantText("this turn's reply")), // ← this turn's own reply, same millisecond as the entries below
at(T, tokenUsage(counts(500), buckets(0, 400, 100))), at(T, tokenUsage(counts(500), buckets(0, 400, 100))),
at(T, requestEnd("completed")), at(T, requestEnd("completed")),
at(T, compactionBegin({ reason: "context", mode: "summarize", context: 500, turns: 1 })), at(T, compactionBegin({ reason: "context", mode: "summarize", context: 500, turns: 1 })),
@@ -475,7 +475,7 @@ describe("trace-service", () => {
expect(Date.parse(a.tasks[1]!.endTs) - Date.parse(a.tasks[1]!.startTs)).toBe(20_000); expect(Date.parse(a.tasks[1]!.endTs) - Date.parse(a.tasks[1]!.startTs)).toBe(20_000);
}); });
it("Agent 级逐级浏览:日期倒序、Session 倒序、文件 index 升序", async () => { it("Agent-level drill-down browsing: dates descending, Sessions descending, file index ascending", async () => {
const s2 = "session-2026-07-06-09-00-00-11112222"; const s2 = "session-2026-07-06-09-00-00-11112222";
await writeTraceFile(root, P, A, "2026-07-05", S, 1, [userText("a")]); await writeTraceFile(root, P, A, "2026-07-05", S, 1, [userText("a")]);
await writeTraceFile(root, P, A, "2026-07-05", S, 2, [userText("b")]); await writeTraceFile(root, P, A, "2026-07-05", S, 2, [userText("b")]);
@@ -486,18 +486,18 @@ describe("trace-service", () => {
expect(res.dates[1]!.sessions[0]!.files.map((f) => f.index)).toEqual([1, 2]); expect(res.dates[1]!.sessions[0]!.files.map((f) => f.index)).toEqual([1, 2]);
}); });
it("无 Trace 时各接口返回空", async () => { it("every endpoint returns empty when there is no Trace", async () => {
expect(await service.readMessages(P, A, S)).toEqual([]); expect(await service.readMessages(P, A, S)).toEqual([]);
expect(await service.listTraceFiles(P, A, S)).toEqual([]); expect(await service.listTraceFiles(P, A, S)).toEqual([]);
expect((await service.agentTraces(P, A)).dates).toEqual([]); expect((await service.agentTraces(P, A)).dates).toEqual([]);
}); });
it("执行时间线:模型串行分段(起点=上一事件)、工具审批/执行两阶段、下一轮以 request_begin 起算", async () => { it("execution timeline: serial model segments (start = previous event), tool approval/execution phases, the next round anchored on request_begin", async () => {
const T = (sec: string) => `2026-07-05T10:00:${sec}Z`; const T = (sec: string) => `2026-07-05T10:00:${sec}Z`;
await writeTraceFile(root, P, A, "2026-07-05", S, 7, [ await writeTraceFile(root, P, A, "2026-07-05", S, 7, [
sessionMeta(metaPayload()), sessionMeta(metaPayload()),
at(T("00.000"), userText("q")), // the user input is sent instantly, so it occupies no segment at(T("00.000"), userText("q")), // the user input is sent instantly, so it occupies no segment
at(T("01.000"), requestBegin()), at(T("01.000"), requestBegin()),
at(T("03.000"), thinkingMessage("想", "completed")), at(T("03.000"), thinkingMessage("think", "completed")),
at(T("04.000"), toolCall({ name: "exec_command", arguments: "{}", toolCallId: "t1" })), at(T("04.000"), toolCall({ name: "exec_command", arguments: "{}", toolCallId: "t1" })),
// Two async tools: t1 is already in approval/execution while the model keeps decoding t2 // Two async tools: t1 is already in approval/execution while the model keeps decoding t2
at(T("04.500"), toolCall({ name: "read_file", arguments: "{}", toolCallId: "t2" })), at(T("04.500"), toolCall({ name: "read_file", arguments: "{}", toolCallId: "t2" })),
@@ -508,7 +508,7 @@ describe("trace-service", () => {
at(T("08.000"), toolCallOutput({ output: "o2", toolCallId: "t2" })), at(T("08.000"), toolCallOutput({ output: "o2", toolCallId: "t2" })),
// The model starts the next round only after all outputs are back: the new segment is anchored on request_begin // The model starts the next round only after all outputs are back: the new segment is anchored on request_begin
at(T("08.500"), requestBegin()), at(T("08.500"), requestBegin()),
at(T("10.000"), assistantText("答")), at(T("10.000"), assistantText("answer")),
at(T("10.100"), requestEnd("completed")), at(T("10.100"), requestEnd("completed")),
]); ]);
const a = await service.analyze(P, A, S, 7); const a = await service.analyze(P, A, S, 7);
@@ -559,7 +559,7 @@ describe("trace-service", () => {
]); ]);
}); });
it("Task 分组:模型出纯文本后不再调工具 → 下一用户轮次进入新 Task(taskIndex 递增)", async () => { it("Task grouping: plain text with no further tool call → the next user turn enters a new Task (taskIndex increments)", async () => {
const T = (sec: string) => `2026-07-05T10:01:${sec}Z`; const T = (sec: string) => `2026-07-05T10:01:${sec}Z`;
await writeTraceFile(root, P, A, "2026-07-05", S, 9, [ await writeTraceFile(root, P, A, "2026-07-05", S, 9, [
sessionMeta(metaPayload()), sessionMeta(metaPayload()),
@@ -570,12 +570,12 @@ describe("trace-service", () => {
at(T("02.500"), requestEnd("completed")), at(T("02.500"), requestEnd("completed")),
at(T("03.000"), toolCallOutput({ output: "o", toolCallId: "t1" })), at(T("03.000"), toolCallOutput({ output: "o", toolCallId: "t1" })),
at(T("03.500"), requestBegin()), at(T("03.500"), requestBegin()),
at(T("04.000"), assistantText("答1")), at(T("04.000"), assistantText("answer 1")),
at(T("04.500"), requestEnd("completed")), at(T("04.500"), requestEnd("completed")),
// Task 1: a new user turn (the previous turn ended in plain text, not a continuation). // Task 1: a new user turn (the previous turn ended in plain text, not a continuation).
at(T("20.000"), userText("q2")), at(T("20.000"), userText("q2")),
at(T("21.000"), requestBegin()), at(T("21.000"), requestBegin()),
at(T("22.000"), assistantText("答2")), at(T("22.000"), assistantText("answer 2")),
at(T("22.500"), requestEnd("completed")), at(T("22.500"), requestEnd("completed")),
]); ]);
const a = await service.analyze(P, A, S, 9); const a = await service.analyze(P, A, S, 9);
@@ -589,7 +589,7 @@ describe("trace-service", () => {
// otherwise "continue", but compaction_begin breaks that continuation, so the // otherwise "continue", but compaction_begin breaks that continuation, so the
// compaction request lands on a new taskIndex. A successful compaction splits // compaction request lands on a new taskIndex. A successful compaction splits
// the Trace into a new file, so this file ends at compaction_end. // the Trace into a new file, so this file ends at compaction_end.
it("Task 分组:压缩请求不并入上一轮(成功压缩,本文件在 compaction_end 收尾)", async () => { it("Task grouping: the compaction request isn't folded into the previous turn (successful compaction; this file ends at compaction_end)", async () => {
const T = (sec: string) => `2026-07-05T10:02:${sec}Z`; const T = (sec: string) => `2026-07-05T10:02:${sec}Z`;
await writeTraceFile(root, P, A, "2026-07-05", S, 10, [ await writeTraceFile(root, P, A, "2026-07-05", S, 10, [
sessionMeta(metaPayload()), sessionMeta(metaPayload()),
@@ -619,7 +619,7 @@ describe("trace-service", () => {
// A failed compaction doesn't split the file: the continuation request is // A failed compaction doesn't split the file: the continuation request is
// still in the same file, and it starts a new turn (the compaction request // still in the same file, and it starts a new turn (the compaction request
// doesn't call a tool, and request_end has already broken continuation). // doesn't call a tool, and request_end has already broken continuation).
it("Task 分组:压缩失败后的续跑请求另起一轮", async () => { it("Task grouping: the continuation request after a failed compaction starts a new turn", async () => {
const T = (sec: string) => `2026-07-05T10:03:${sec}Z`; const T = (sec: string) => `2026-07-05T10:03:${sec}Z`;
await writeTraceFile(root, P, A, "2026-07-05", S, 11, [ await writeTraceFile(root, P, A, "2026-07-05", S, 11, [
sessionMeta(metaPayload()), sessionMeta(metaPayload()),
@@ -633,11 +633,11 @@ describe("trace-service", () => {
compactionBegin({ reason: "context", mode: "summarize", context: 1, turns: 1 }), compactionBegin({ reason: "context", mode: "summarize", context: 1, turns: 1 }),
), ),
at(T("04.500"), requestBegin()), at(T("04.500"), requestBegin()),
at(T("05.000"), assistantText("坏摘要")), at(T("05.000"), assistantText("bad summary")),
at(T("05.500"), requestEnd("failed")), at(T("05.500"), requestEnd("failed")),
at(T("06.000"), compactionEnd({ reason: "context", mode: "summarize", status: "failed" })), at(T("06.000"), compactionEnd({ reason: "context", mode: "summarize", status: "failed" })),
at(T("07.000"), requestBegin()), at(T("07.000"), requestBegin()),
at(T("08.000"), assistantText("答")), at(T("08.000"), assistantText("answer")),
at(T("08.500"), requestEnd("completed")), at(T("08.500"), requestEnd("completed")),
]); ]);
const a = await service.analyze(P, A, S, 11); const a = await service.analyze(P, A, S, 11);
@@ -645,7 +645,7 @@ describe("trace-service", () => {
expect(a.toolSpans.map((s) => s.taskIndex)).toEqual([0]); expect(a.toolSpans.map((s) => s.taskIndex)).toEqual([0]);
}); });
it("中断补偿 tool_call(stop_reason 非 completed)不入时间线泳道(不生成幻影执行段)", async () => { it("interrupt-compensation tool_calls (stop_reason not completed) stay out of the timeline lanes (no phantom execution segments)", async () => {
const T = (sec: string) => `2026-07-05T10:00:${sec}Z`; const T = (sec: string) => `2026-07-05T10:00:${sec}Z`;
await writeTraceFile(root, P, A, "2026-07-05", S, 8, [ await writeTraceFile(root, P, A, "2026-07-05", S, 8, [
sessionMeta(metaPayload()), sessionMeta(metaPayload()),
@@ -61,7 +61,7 @@ async function writeTrace(
); );
} }
describe("TraceService.readMessages — 子会话展开", () => { describe("TraceService.readMessages — sub-session expansion", () => {
let root: string; let root: string;
let svc: TraceService; let svc: TraceService;
@@ -73,27 +73,27 @@ describe("TraceService.readMessages — 子会话展开", () => {
await fs.rm(root, { recursive: true, force: true }); await fs.rm(root, { recursive: true, force: true });
}); });
it("按指针就地插入子会话消息,并补上 origin 链(子 Agent 按 Session id 在 Project 内定位)", async () => { it("inserts child-session messages in place per the pointer and attaches the origin chain (the child Agent is located within the Project by Session id)", async () => {
await writeTrace(root, PARENT_AGENT, PARENT, [ await writeTrace(root, PARENT_AGENT, PARENT, [
meta(PARENT, PARENT_AGENT), meta(PARENT, PARENT_AGENT),
userText("跑个子 agent"), userText("run a sub-agent"),
toolCall({ name: "run_subagent", arguments: "{}", toolCallId: "c1" }), toolCall({ name: "run_subagent", arguments: "{}", toolCallId: "c1" }),
subagentEvent(CHILD), // pointer: holds only the child Session id subagentEvent(CHILD), // pointer: holds only the child Session id
toolCallOutput({ output: "done", toolCallId: "c1" }), toolCallOutput({ output: "done", toolCallId: "c1" }),
]); ]);
await writeTrace(root, CHILD_AGENT, CHILD, [ await writeTrace(root, CHILD_AGENT, CHILD, [
meta(CHILD, CHILD_AGENT), meta(CHILD, CHILD_AGENT),
assistantText("子会话回答"), assistantText("sub-session answer"),
]); ]);
const msgs = await svc.readMessages(PROJECT, PARENT_AGENT, PARENT); const msgs = await svc.readMessages(PROJECT, PARENT_AGENT, PARENT);
const texts = msgs.map((m) => (m.payload as { text?: string; type?: string }).text ?? m.type); const texts = msgs.map((m) => (m.payload as { text?: string; type?: string }).text ?? m.type);
expect(texts).toEqual([ expect(texts).toEqual([
"session_meta", "session_meta",
"跑个子 agent", "run a sub-agent",
"model_msg", // tool_call "model_msg", // tool_call
"session_meta", // the child session's own meta (replaces the pointer, matching the live-stream forwarding shape) "session_meta", // the child session's own meta (replaces the pointer, matching the live-stream forwarding shape)
"子会话回答", "sub-session answer",
"model_msg", // tool_call_output "model_msg", // tool_call_output
]); ]);
@@ -104,7 +104,7 @@ describe("TraceService.readMessages — 子会话展开", () => {
for (const m of nested) expect(m.origin).toEqual([CHILD]); for (const m of nested) expect(m.origin).toEqual([CHILD]);
}); });
it("递归展开孙会话,origin 链逐层前缀", async () => { it("recursively expands grandchild sessions; the origin chain is prefixed level by level", async () => {
await writeTrace(root, PARENT_AGENT, PARENT, [ await writeTrace(root, PARENT_AGENT, PARENT, [
meta(PARENT, PARENT_AGENT), meta(PARENT, PARENT_AGENT),
subagentEvent(CHILD), subagentEvent(CHILD),
@@ -115,16 +115,16 @@ describe("TraceService.readMessages — 子会话展开", () => {
]); ]);
await writeTrace(root, CHILD_AGENT, GRANDCHILD, [ await writeTrace(root, CHILD_AGENT, GRANDCHILD, [
meta(GRANDCHILD, CHILD_AGENT), meta(GRANDCHILD, CHILD_AGENT),
assistantText("孙会话回答"), assistantText("grandchild answer"),
]); ]);
const msgs = await svc.readMessages(PROJECT, PARENT_AGENT, PARENT); const msgs = await svc.readMessages(PROJECT, PARENT_AGENT, PARENT);
const deepest = msgs.find((m) => (m.payload as { text?: string }).text === "孙会话回答"); const deepest = msgs.find((m) => (m.payload as { text?: string }).text === "grandchild answer");
expect(deepest?.origin).toEqual([CHILD, GRANDCHILD]); expect(deepest?.origin).toEqual([CHILD, GRANDCHILD]);
expect(msgs.filter((m) => m.type === "session_meta")).toHaveLength(3); expect(msgs.filter((m) => m.type === "session_meta")).toHaveLength(3);
}); });
it("循环指针(指向自身/祖先)不展开,保留指针事件", async () => { it("cyclic pointers (self / ancestor) are not expanded; the pointer event is kept", async () => {
// Never produced by normal operation (core only writes a direct child-session // Never produced by normal operation (core only writes a direct child-session
// pointer at the spawn point); this guards against expansion runaway on a // pointer at the spawn point); this guards against expansion runaway on a
// tampered/corrupted Trace. // tampered/corrupted Trace.
@@ -136,7 +136,7 @@ describe("TraceService.readMessages — 子会话展开", () => {
await writeTrace(root, CHILD_AGENT, CHILD, [ await writeTrace(root, CHILD_AGENT, CHILD, [
meta(CHILD, CHILD_AGENT), meta(CHILD, CHILD_AGENT),
subagentEvent(PARENT), // points back to an ancestor subagentEvent(PARENT), // points back to an ancestor
assistantText("子会话回答"), assistantText("sub-session answer"),
]); ]);
const msgs = await svc.readMessages(PROJECT, PARENT_AGENT, PARENT); const msgs = await svc.readMessages(PROJECT, PARENT_AGENT, PARENT);
@@ -144,12 +144,12 @@ describe("TraceService.readMessages — 子会话展开", () => {
const pointers = msgs.filter((m) => (m.payload as { type?: string }).type === "subagent"); const pointers = msgs.filter((m) => (m.payload as { type?: string }).type === "subagent");
expect(pointers).toHaveLength(2); expect(pointers).toHaveLength(2);
expect(msgs.filter((m) => m.type === "session_meta")).toHaveLength(2); expect(msgs.filter((m) => m.type === "session_meta")).toHaveLength(2);
expect(msgs.filter((m) => (m.payload as { text?: string }).text === "子会话回答")).toHaveLength( expect(
1, msgs.filter((m) => (m.payload as { text?: string }).text === "sub-session answer"),
); ).toHaveLength(1);
}); });
it("子 Trace 缺失时保留指针事件(子会话内容无从恢复,但派生记录不凭空消失)", async () => { it("a missing child Trace keeps the pointer event (the child content can't be recovered, but the spawn record doesn't vanish)", async () => {
await writeTrace(root, PARENT_AGENT, PARENT, [ await writeTrace(root, PARENT_AGENT, PARENT, [
meta(PARENT, PARENT_AGENT), meta(PARENT, PARENT_AGENT),
subagentEvent(CHILD), subagentEvent(CHILD),
+13 -13
View File
@@ -54,7 +54,7 @@ describe("usage-recorder", () => {
}); });
afterEach(() => db.close()); afterEach(() => db.close());
it("token_usage → 一行记录(request 桶,只落 Token 不落成本)", async () => { it("token_usage → one row (the request bucket; only Tokens persisted, never cost)", async () => {
const rec = new UsageRecorder(repo); const rec = new UsageRecorder(repo);
await rec.record(CTX, tokenUsage(counts(1000), counts(115))); await rec.record(CTX, tokenUsage(counts(1000), counts(115)));
const rows = db.prepare("SELECT * FROM usage_records").all(); const rows = db.prepare("SELECT * FROM usage_records").all();
@@ -67,7 +67,7 @@ describe("usage-recorder", () => {
expect(row.cache_read).toBe(100); expect(row.cache_read).toBe(100);
}); });
it("子会话 session_meta 登记 origin→model 映射;token_usage 按映射归因", async () => { it("a sub-session's session_meta registers the origin→model mapping; token_usage is attributed via it", async () => {
const rec = new UsageRecorder(repo); const rec = new UsageRecorder(repo);
const childMeta = withOrigin( const childMeta = withOrigin(
sessionMeta(meta("session-child", "child-model")), sessionMeta(meta("session-child", "child-model")),
@@ -81,20 +81,20 @@ describe("usage-recorder", () => {
expect(row.model_id).toBe("child-model"); expect(row.model_id).toBe("child-model");
}); });
it("origin 映射缺失时回退主 Session Model", async () => { it("falls back to the main Session's Model when the origin mapping is missing", async () => {
const rec = new UsageRecorder(repo); const rec = new UsageRecorder(repo);
await rec.record(CTX, withOrigin(tokenUsage(counts(5), counts(5)), "session-unknown")); await rec.record(CTX, withOrigin(tokenUsage(counts(5), counts(5)), "session-unknown"));
const row = db.prepare("SELECT model_id FROM usage_records").get()!; const row = db.prepare("SELECT model_id FROM usage_records").get()!;
expect(row.model_id).toBe("main-model"); expect(row.model_id).toBe("main-model");
}); });
it("非 token_usage 消息为 no-op", async () => { it("non-token_usage messages are a no-op", async () => {
const rec = new UsageRecorder(repo); const rec = new UsageRecorder(repo);
await rec.record(CTX, sessionMeta(meta("session-main", "main-model"))); await rec.record(CTX, sessionMeta(meta("session-main", "main-model")));
expect(db.prepare("SELECT COUNT(*) AS n FROM usage_records").get()!.n).toBe(0); expect(db.prepare("SELECT COUNT(*) AS n FROM usage_records").get()!.n).toBe(0);
}); });
it("origin 映射有上限:超限淘汰最早登记项,被淘汰的回退主 Session Model", async () => { it("the origin mapping is capped: past the limit the earliest entry is evicted and falls back to the main Session's Model", async () => {
const rec = new UsageRecorder(repo); const rec = new UsageRecorder(repo);
for (let i = 0; i <= ORIGIN_MODELS_MAX; i++) { for (let i = 0; i <= ORIGIN_MODELS_MAX; i++) {
// ORIGIN_MODELS_MAX + 1 entries total: the earliest, sub-0, gets evicted. // ORIGIN_MODELS_MAX + 1 entries total: the earliest, sub-0, gets evicted.
@@ -108,7 +108,7 @@ describe("usage-recorder", () => {
}); });
}); });
describe("usage-service(成本实时折算)", () => { describe("usage-service (cost computed on the fly)", () => {
let db: DatabaseSync; let db: DatabaseSync;
let repo: UsageRepo; let repo: UsageRepo;
let service: (now: Date) => UsageService; let service: (now: Date) => UsageService;
@@ -148,7 +148,7 @@ describe("usage-service(成本实时折算)", () => {
}); });
} }
it("汇总卡片:今日 / 近 7 天 / 累计;无 pricing 的 Model 标记 hasUncosted", async () => { it("summary cards: today / last 7 days / cumulative; Models without pricing flag hasUncosted", async () => {
const now = new Date("2026-07-06T10:00:00"); const now = new Date("2026-07-06T10:00:00");
const today = formatLocalDate(now); const today = formatLocalDate(now);
insert(today); insert(today);
@@ -165,7 +165,7 @@ describe("usage-service(成本实时折算)", () => {
expect(res.summary.last7d.hasUncosted).toBe(false); expect(res.summary.last7d.hasUncosted).toBe(false);
}); });
it("价格后补:插入时无 pricing,配置价格后再查询即计价", async () => { it("price added later: no pricing at insert time; once configured, queries price it immediately", async () => {
const now = new Date("2026-07-06T10:00:00"); const now = new Date("2026-07-06T10:00:00");
insert("2026-07-06", { modelId: "m-late" }); insert("2026-07-06", { modelId: "m-late" });
const svc = service(now); const svc = service(now);
@@ -180,7 +180,7 @@ describe("usage-service(成本实时折算)", () => {
expect(after.summary.total.hasUncosted).toBe(false); expect(after.summary.total.hasUncosted).toBe(false);
}); });
it("分组聚合:date 按日期倒序;agent/model/session 维度与 agentId 下钻;跨 Model 折叠", async () => { it("group aggregation: date sorted descending; agent/model/session dimensions with agentId drill-down; folded across Models", async () => {
const now = new Date("2026-07-06T10:00:00"); const now = new Date("2026-07-06T10:00:00");
pricing.m2 = { cacheRead: 1, cacheWrite: 1, output: 1 }; pricing.m2 = { cacheRead: 1, cacheWrite: 1, output: 1 };
insert("2026-07-05", { agentId: "a1", sessionId: "s1", modelId: "m1" }); insert("2026-07-05", { agentId: "a1", sessionId: "s1", modelId: "m1" });
@@ -206,7 +206,7 @@ describe("usage-service(成本实时折算)", () => {
expect(other.groups).toEqual([]); expect(other.groups).toEqual([]);
}); });
it("from/to 过滤分组;趋势固定近 30 天窗口", async () => { it("from/to filter the groups; the trend is a fixed 30-day window", async () => {
const now = new Date("2026-07-06T10:00:00"); const now = new Date("2026-07-06T10:00:00");
insert("2026-07-06"); insert("2026-07-06");
insert("2026-06-20"); insert("2026-06-20");
@@ -224,7 +224,7 @@ describe("usage-service(成本实时折算)", () => {
// —— status → success-rate pipeline —— // —— status → success-rate pipeline ——
it("成功率:completed / 非 aborted 请求,失败细分随行带出", async () => { it("success rate: completed / non-aborted requests, with the failure breakdown carried along", async () => {
const now = new Date("2026-07-06T10:00:00"); const now = new Date("2026-07-06T10:00:00");
for (let i = 0; i < 7; i++) insert("2026-07-06"); for (let i = 0; i < 7; i++) insert("2026-07-06");
insert("2026-07-06", { status: "failed", total: 0 }); insert("2026-07-06", { status: "failed", total: 0 });
@@ -243,7 +243,7 @@ describe("usage-service(成本实时折算)", () => {
}); });
}); });
it("回归:aborted(用户点「停止」)不是模型失败——不进分母,成功率不因中断下降", async () => { it('regression: aborted (the user clicked "Stop") is not a model failure — excluded from the denominator, so interrupts never drag the success rate down', async () => {
const now = new Date("2026-07-06T10:00:00"); const now = new Date("2026-07-06T10:00:00");
for (let i = 0; i < 8; i++) insert("2026-07-06"); for (let i = 0; i < 8; i++) insert("2026-07-06");
// The user clicked "Stop" twice: under the old accounting, the success rate would drop to 8/10 = 80%. // The user clicked "Stop" twice: under the old accounting, the success rate would drop to 8/10 = 80%.
@@ -263,7 +263,7 @@ describe("usage-service(成本实时折算)", () => {
expect(after.success.find((s) => s.modelId === "m1")!.total).toBe(9); expect(after.success.find((s) => s.modelId === "m1")!.total).toBe(9);
}); });
it("成功率不受 model 过滤、仍受 agent 与日期过滤(图展示全部 Model)", async () => { it("the success rate ignores the model filter but still honors agent and date filters (the chart shows all Models)", async () => {
const now = new Date("2026-07-06T10:00:00"); const now = new Date("2026-07-06T10:00:00");
insert("2026-07-06", { modelId: "m1", agentId: "a1" }); insert("2026-07-06", { modelId: "m1", agentId: "a1" });
insert("2026-07-06", { modelId: "m2", agentId: "a2", status: "failed", total: 0 }); insert("2026-07-06", { modelId: "m2", agentId: "a2", status: "failed", total: 0 });
+7 -7
View File
@@ -28,7 +28,7 @@ describe("vault api", () => {
member = apiClient(t.app, b.cookie); member = apiClient(t.app, b.cookie);
outsider = apiClient(t.app, c.cookie); outsider = apiClient(t.app, c.cookie);
const created = (await ( const created = (await (
await owner.post("/api/projects", { projectId: "owner_a-vault", name: "vault 项目" }) await owner.post("/api/projects", { projectId: "owner_a-vault", name: "vault project" })
).json()) as ProjectCreateResponse; ).json()) as ProjectCreateResponse;
projectId = created.project.projectId; projectId = created.project.projectId;
vaultPath = `/api/projects/${projectId}/agents/default_agent/vault`; vaultPath = `/api/projects/${projectId}/agents/default_agent/vault`;
@@ -39,7 +39,7 @@ describe("vault api", () => {
await t.cleanup(); await t.cleanup();
}); });
it("GET 掩码且明文不下发;member 可读、外人 404;空 vault 返回空表", async () => { it("GET masks values and never sends plaintext; members can read, outsiders 404; an empty vault returns an empty table", async () => {
// Not configured yet: an empty table. // Not configured yet: an empty table.
const empty = (await (await owner.get(vaultPath)).json()) as VaultResponse; const empty = (await (await owner.get(vaultPath)).json()) as VaultResponse;
expect(empty.entries).toEqual([]); expect(empty.entries).toEqual([]);
@@ -67,14 +67,14 @@ describe("vault api", () => {
expect((await outsider.get(vaultPath)).status).toBe(404); expect((await outsider.get(vaultPath)).status).toBe(404);
}); });
it("PUT 仅 owner:member 403、外人 404", async () => { it("PUT is owner-only: member 403, outsider 404", async () => {
expect((await member.put(vaultPath, { entries: [{ key: "K", value: "v" }] })).status).toBe(403); expect((await member.put(vaultPath, { entries: [{ key: "K", value: "v" }] })).status).toBe(403);
expect((await outsider.put(vaultPath, { entries: [{ key: "K", value: "v" }] })).status).toBe( expect((await outsider.put(vaultPath, { entries: [{ key: "K", value: "v" }] })).status).toBe(
404, 404,
); );
}); });
it("整表替换:value 省略保留原值、缺席的键删除、新键缺 value 400", async () => { it("whole-table replace: omitting value keeps the original, absent keys are deleted, a new key without value 400", async () => {
await owner.put(vaultPath, { await owner.put(vaultPath, {
entries: [ entries: [
{ key: "KEEP_ME", value: "keep-secret-000111" }, { key: "KEEP_ME", value: "keep-secret-000111" },
@@ -99,7 +99,7 @@ describe("vault api", () => {
expect(cleared.entries).toEqual([]); expect(cleared.entries).toEqual([]);
}); });
it("vault 更新不动 models/credential 等其他配置", async () => { it("vault updates leave models/credential and other config untouched", async () => {
await owner.put(`/api/projects/${projectId}/models`, { await owner.put(`/api/projects/${projectId}/models`, {
defaultModel: { provider: "custom", modelId: "m-1" }, defaultModel: { provider: "custom", modelId: "m-1" },
models: [{ provider: "custom", modelId: "m-1", apiKey: "sk-model-key-999888" }], models: [{ provider: "custom", modelId: "m-1", apiKey: "sk-model-key-999888" }],
@@ -112,7 +112,7 @@ describe("vault api", () => {
expect(modelsBody.models[0]!.credential).toBeTruthy(); expect(modelsBody.models[0]!.credential).toBeTruthy();
}); });
it("Agent 级隔离:不同 Agent 的 vault 互相独立;不存在的 Agent 404", async () => { it("Agent-level isolation: different Agents' vaults are independent; a nonexistent Agent 404", async () => {
await owner.put(vaultPath, { entries: [{ key: "ONLY_DEFAULT", value: "v-default-1" }] }); await owner.put(vaultPath, { entries: [{ key: "ONLY_DEFAULT", value: "v-default-1" }] });
// Another Agent in the same Project: empty table, and writes don't affect each other. // Another Agent in the same Project: empty table, and writes don't affect each other.
@@ -139,7 +139,7 @@ describe("vault api", () => {
).toBe(404); ).toBe(404);
}); });
it("键名与请求体形状校验 400", async () => { it("key name and request body shape validation 400", async () => {
const cases: unknown[] = [ const cases: unknown[] = [
{ entries: [{ key: "1BAD", value: "v" }] }, // starts with a digit { entries: [{ key: "1BAD", value: "v" }] }, // starts with a digit
{ entries: [{ key: "BAD-DASH", value: "v" }] }, // hyphen { entries: [{ key: "BAD-DASH", value: "v" }] }, // hyphen
+17 -17
View File
@@ -34,14 +34,14 @@ describe("workspace-files-service", () => {
await fs.rm(outside, { recursive: true, force: true }); await fs.rm(outside, { recursive: true, force: true });
}); });
it("列目录:dir 在前、按名称排序;子目录路径生效", async () => { it("directory listing: dirs first, sorted by name; subdirectory paths work", async () => {
const root = await svc.list(ws, ""); const root = await svc.list(ws, "");
expect(root.entries.map((e) => `${e.kind}:${e.name}`)).toEqual(["dir:sub", "file:b.txt"]); expect(root.entries.map((e) => `${e.kind}:${e.name}`)).toEqual(["dir:sub", "file:b.txt"]);
const sub = await svc.list(ws, "sub"); const sub = await svc.list(ws, "sub");
expect(sub.entries.map((e) => e.name)).toEqual(["c.md"]); expect(sub.entries.map((e) => e.name)).toEqual(["c.md"]);
}); });
it("读文件:内容与 content-type;目录/不存在报错", async () => { it("file read: content and content-type; directories / missing files error", async () => {
const file = await svc.read(ws, "sub/c.md"); const file = await svc.read(ws, "sub/c.md");
expect(file.data.toString()).toBe("# md"); expect(file.data.toString()).toBe("# md");
expect(file.contentType).toContain("markdown"); expect(file.contentType).toContain("markdown");
@@ -49,14 +49,14 @@ describe("workspace-files-service", () => {
await expect(svc.read(ws, "nope.txt")).rejects.toMatchObject({ status: 404 }); await expect(svc.read(ws, "nope.txt")).rejects.toMatchObject({ status: 404 });
}); });
it("写文件:覆盖写入;父目录缺失时自动补建(上传文件夹保留目录结构)", async () => { it("file write: overwrites; missing parent directories are auto-created (folder uploads keep their structure)", async () => {
await svc.write(ws, "sub/new.txt", Buffer.from("data")); await svc.write(ws, "sub/new.txt", Buffer.from("data"));
expect(await fs.readFile(path.join(ws, "sub", "new.txt"), "utf8")).toBe("data"); expect(await fs.readFile(path.join(ws, "sub", "new.txt"), "utf8")).toBe("data");
await svc.write(ws, "missing/deep/x.txt", Buffer.from("d")); await svc.write(ws, "missing/deep/x.txt", Buffer.from("d"));
expect(await fs.readFile(path.join(ws, "missing", "deep", "x.txt"), "utf8")).toBe("d"); expect(await fs.readFile(path.join(ws, "missing", "deep", "x.txt"), "utf8")).toBe("d");
}); });
it("路径限域:`..` 穿越与绝对路径均拒绝", async () => { it("path confinement: `..` traversal and absolute paths are both rejected", async () => {
await expect(svc.list(ws, "../")).rejects.toMatchObject({ status: 400 }); await expect(svc.list(ws, "../")).rejects.toMatchObject({ status: 400 });
await expect(svc.read(ws, `../${path.basename(outside)}/secret.txt`)).rejects.toMatchObject({ await expect(svc.read(ws, `../${path.basename(outside)}/secret.txt`)).rejects.toMatchObject({
status: 400, status: 400,
@@ -66,13 +66,13 @@ describe("workspace-files-service", () => {
}); });
}); });
it("Workspace 为文件系统根:子目录可正常下钻(前缀拼接 '//' 回归)", async () => { it("Workspace at the filesystem root: subdirectories still drill down (prefix-joining '//' regression)", async () => {
const root = path.parse(ws).root; const root = path.parse(ws).root;
const sub = await svc.list(root, path.relative(root, path.join(ws, "sub"))); const sub = await svc.list(root, path.relative(root, path.join(ws, "sub")));
expect(sub.entries.map((e) => e.name)).toEqual(["c.md"]); expect(sub.entries.map((e) => e.name)).toEqual(["c.md"]);
}); });
it("指向 Workspace 内目录的符号链接:kind 为 dir 且可下钻", async () => { it("a symlink to a directory inside the Workspace: kind is dir and it can be drilled into", async () => {
await fs.symlink(path.join(ws, "sub"), path.join(ws, "link-sub")); await fs.symlink(path.join(ws, "sub"), path.join(ws, "link-sub"));
const root = await svc.list(ws, ""); const root = await svc.list(ws, "");
expect(root.entries.map((e) => `${e.kind}:${e.name}`)).toEqual([ expect(root.entries.map((e) => `${e.kind}:${e.name}`)).toEqual([
@@ -84,7 +84,7 @@ describe("workspace-files-service", () => {
expect(viaLink.entries.map((e) => e.name)).toEqual(["c.md"]); expect(viaLink.entries.map((e) => e.name)).toEqual(["c.md"]);
}); });
it("符号链接逃逸:链接指向 Workspace 外时读写均拒绝", async () => { it("symlink escape: reads and writes are both rejected when the link points outside the Workspace", async () => {
await fs.symlink(outside, path.join(ws, "link-out")); await fs.symlink(outside, path.join(ws, "link-out"));
await expect(svc.list(ws, "link-out")).rejects.toMatchObject({ status: 400 }); await expect(svc.list(ws, "link-out")).rejects.toMatchObject({ status: 400 });
await expect(svc.read(ws, "link-out/secret.txt")).rejects.toMatchObject({ status: 400 }); await expect(svc.read(ws, "link-out/secret.txt")).rejects.toMatchObject({ status: 400 });
@@ -105,7 +105,7 @@ describe("workspace-files-service", () => {
).toBe(false); ).toBe(false);
}); });
it("末段符号链接写入:O_NOFOLLOW 拒绝借刀覆盖域外文件", async () => { it("writing through a last-segment symlink: O_NOFOLLOW refuses to overwrite files outside the Workspace by proxy", async () => {
// The Agent has a symlink inside the Workspace pointing to an outside file; an upload attempts to overwrite it. // The Agent has a symlink inside the Workspace pointing to an outside file; an upload attempts to overwrite it.
const victim = path.join(outside, "secret.txt"); const victim = path.join(outside, "secret.txt");
await fs.symlink(victim, path.join(ws, "report.pdf")); await fs.symlink(victim, path.join(ws, "report.pdf"));
@@ -117,7 +117,7 @@ describe("workspace-files-service", () => {
}); });
}); });
describe("files/stat 路由(批量存在性检查)", () => { describe("files/stat route (batch existence check)", () => {
let t: TestApp; let t: TestApp;
let owner: ReturnType<typeof apiClient>; let owner: ReturnType<typeof apiClient>;
let outsider: ReturnType<typeof apiClient>; let outsider: ReturnType<typeof apiClient>;
@@ -130,7 +130,7 @@ describe("files/stat 路由(批量存在性检查)", () => {
owner = apiClient(t.app, a.cookie); owner = apiClient(t.app, a.cookie);
outsider = apiClient(t.app, b.cookie); outsider = apiClient(t.app, b.cookie);
const created = (await ( const created = (await (
await owner.post("/api/projects", { projectId: "owner-stat", name: "项目" }) await owner.post("/api/projects", { projectId: "owner-stat", name: "project" })
).json()) as ProjectCreateResponse; ).json()) as ProjectCreateResponse;
const projectId = created.project.projectId; const projectId = created.project.projectId;
await owner.put(`/api/projects/${projectId}/models`, { await owner.put(`/api/projects/${projectId}/models`, {
@@ -149,7 +149,7 @@ describe("files/stat 路由(批量存在性检查)", () => {
await t.cleanup(); await t.cleanup();
}); });
it("存在的文件保序去重返回;不存在 / 目录 / 越界一律按不存在计且恒 200", async () => { it("existing files return in order, deduplicated; missing / directory / out-of-bounds all count as nonexistent, always 200", async () => {
const res = await owner.post(`/api/sessions/${sessionId}/files/stat`, { const res = await owner.post(`/api/sessions/${sessionId}/files/stat`, {
paths: ["sub/b.md", "a.txt", "sub/b.md", "nope.txt", "sub", "../escape.txt", "/etc/passwd"], paths: ["sub/b.md", "a.txt", "sub/b.md", "nope.txt", "sub", "../escape.txt", "/etc/passwd"],
}); });
@@ -161,7 +161,7 @@ describe("files/stat 路由(批量存在性检查)", () => {
expect(await empty.json()).toEqual({ existing: [] }); expect(await empty.json()).toEqual({ existing: [] });
}); });
it("非法 body → 400:非数组 / 非字符串项 / 超量 / 超长", async () => { it("invalid body → 400: non-array / non-string entries / too many / too long", async () => {
const url = `/api/sessions/${sessionId}/files/stat`; const url = `/api/sessions/${sessionId}/files/stat`;
expect((await owner.post(url, { paths: "a.txt" })).status).toBe(400); expect((await owner.post(url, { paths: "a.txt" })).status).toBe(400);
expect((await owner.post(url, { paths: [1] })).status).toBe(400); expect((await owner.post(url, { paths: [1] })).status).toBe(400);
@@ -170,7 +170,7 @@ describe("files/stat 路由(批量存在性检查)", () => {
expect((await owner.post(url, { paths: ["x".repeat(513)] })).status).toBe(400); expect((await owner.post(url, { paths: ["x".repeat(513)] })).status).toBe(400);
}); });
it("外人访问 → 404(不泄露存在性)", async () => { it("outsider access → 404 (no existence leak)", async () => {
const res = await outsider.post(`/api/sessions/${sessionId}/files/stat`, { const res = await outsider.post(`/api/sessions/${sessionId}/files/stat`, {
paths: ["a.txt"], paths: ["a.txt"],
}); });
@@ -178,7 +178,7 @@ describe("files/stat 路由(批量存在性检查)", () => {
}); });
}); });
describe("agent 删除路由", () => { describe("agent delete route", () => {
let t: TestApp; let t: TestApp;
let owner: ReturnType<typeof apiClient>; let owner: ReturnType<typeof apiClient>;
let outsider: ReturnType<typeof apiClient>; let outsider: ReturnType<typeof apiClient>;
@@ -191,7 +191,7 @@ describe("agent 删除路由", () => {
owner = apiClient(t.app, a.cookie); owner = apiClient(t.app, a.cookie);
outsider = apiClient(t.app, b.cookie); outsider = apiClient(t.app, b.cookie);
const created = (await ( const created = (await (
await owner.post("/api/projects", { projectId: "owner-ws", name: "项目" }) await owner.post("/api/projects", { projectId: "owner-ws", name: "project" })
).json()) as ProjectCreateResponse; ).json()) as ProjectCreateResponse;
projectId = created.project.projectId; projectId = created.project.projectId;
}); });
@@ -199,9 +199,9 @@ describe("agent 删除路由", () => {
await t.cleanup(); await t.cleanup();
}); });
it("owner 删除 Agent:204,目录与列表项消失;default_agent 409;外人 404", async () => { it("owner deletes an Agent: 204, the directory and list entry disappear; default_agent 409; outsiders 404", async () => {
const created = (await ( const created = (await (
await owner.post(`/api/projects/${projectId}/agents`, { agentId: "temp_agent", name: "临时" }) await owner.post(`/api/projects/${projectId}/agents`, { agentId: "temp_agent", name: "temp" })
).json()) as AgentCreateResponse; ).json()) as AgentCreateResponse;
const agentId = created.agent.agentId; const agentId = created.agent.agentId;
const dir = path.join(t.root, projectId, "agents", agentId); const dir = path.join(t.root, projectId, "agents", agentId);
+5 -5
View File
@@ -25,18 +25,18 @@ describe("workspace-guard", () => {
const guard = (workspace: string) => assertWorkspaceAllowed({ workspace }); const guard = (workspace: string) => assertWorkspaceAllowed({ workspace });
it("已存在的目录放行并返回 realpath", async () => { it("an existing directory is allowed and its realpath returned", async () => {
const ws = await guard(path.join(projectA, "workdir")); const ws = await guard(path.join(projectA, "workdir"));
expect(ws).toBe(await fs.realpath(path.join(projectA, "workdir"))); expect(ws).toBe(await fs.realpath(path.join(projectA, "workdir")));
}); });
it("Project 目录之外的任意目录同样放行", async () => { it("any directory outside the Project directory is likewise allowed", async () => {
const outside = path.join(root, "elsewhere"); const outside = path.join(root, "elsewhere");
await fs.mkdir(outside, { recursive: true }); await fs.mkdir(outside, { recursive: true });
await expect(guard(outside)).resolves.toBe(await fs.realpath(outside)); await expect(guard(outside)).resolves.toBe(await fs.realpath(outside));
}); });
it("符号链接解析为 realpath 后返回", async () => { it("symlinks resolve to their realpath before returning", async () => {
const outside = path.join(root, "linked"); const outside = path.join(root, "linked");
await fs.mkdir(outside, { recursive: true }); await fs.mkdir(outside, { recursive: true });
const link = path.join(projectA, "escape"); const link = path.join(projectA, "escape");
@@ -44,14 +44,14 @@ describe("workspace-guard", () => {
await expect(guard(link)).resolves.toBe(await fs.realpath(outside)); await expect(guard(link)).resolves.toBe(await fs.realpath(outside));
}); });
it("不存在的路径 → 400 workspace_not_found", async () => { it("a nonexistent path → 400 workspace_not_found", async () => {
const err = await guard(path.join(projectA, "ghost")).catch((e: unknown) => e); const err = await guard(path.join(projectA, "ghost")).catch((e: unknown) => e);
expect(err).toBeInstanceOf(HttpError); expect(err).toBeInstanceOf(HttpError);
expect((err as HttpError).status).toBe(400); expect((err as HttpError).status).toBe(400);
expect((err as HttpError).code).toBe("workspace_not_found"); expect((err as HttpError).code).toBe("workspace_not_found");
}); });
it("文件(非目录)→ 400 workspace_not_found", async () => { it("a file (not a directory) → 400 workspace_not_found", async () => {
const file = path.join(projectA, "a-file.txt"); const file = path.join(projectA, "a-file.txt");
await fs.writeFile(file, "x", "utf8"); await fs.writeFile(file, "x", "utf8");
const err = await guard(file).catch((e: unknown) => e); const err = await guard(file).catch((e: unknown) => e);
+16 -16
View File
@@ -29,7 +29,7 @@ const fakeSkill = (name: string): LibrarySkill => ({
}); });
describe("loadLibrarySkills", () => { describe("loadLibrarySkills", () => {
it("按 name 排序读出技能,metadata 齐全(含中文描述与短描述)", async () => { it("loads skills sorted by name with complete metadata (zh and short descriptions)", async () => {
const skills = loadLibrarySkills(); const skills = loadLibrarySkills();
const names = skills.map((skill) => skill.name); const names = skills.map((skill) => skill.name);
expect(names).toEqual([...names].sort()); expect(names).toEqual([...names].sort());
@@ -48,7 +48,7 @@ describe("loadLibrarySkills", () => {
} }
}); });
it("每个技能都带定制 icon.svg(原文读入,站内线稿风,无脚本)", async () => { it("every skill has a custom icon.svg (read verbatim, line-art style, no scripts)", async () => {
for (const skill of loadLibrarySkills()) { for (const skill of loadLibrarySkills()) {
const raw = await fs.readFile(path.join(skillsRoot, skill.name, "icon.svg"), "utf8"); const raw = await fs.readFile(path.join(skillsRoot, skill.name, "icon.svg"), "utf8");
// The icon field is the raw icon.svg content in the directory (the file is the sole source). // The icon field is the raw icon.svg content in the directory (the file is the sole source).
@@ -62,7 +62,7 @@ describe("loadLibrarySkills", () => {
} }
}); });
it("name 以目录名为准,content 与 skills/ 下 SKILL.md 原文一致", async () => { it("name is the directory name, content matches the raw SKILL.md under skills/", async () => {
const dirs = (await fs.readdir(skillsRoot, { withFileTypes: true })) const dirs = (await fs.readdir(skillsRoot, { withFileTypes: true }))
.filter((entry) => entry.isDirectory()) .filter((entry) => entry.isDirectory())
.map((entry) => entry.name) .map((entry) => entry.name)
@@ -77,7 +77,7 @@ describe("loadLibrarySkills", () => {
} }
}); });
it("每个技能正文都有 `## Before you start` 一节(无具体需求时先反问)", () => { it("every skill body has a `## Before you start` section (ask first if no concrete need)", () => {
for (const skill of loadLibrarySkills()) { for (const skill of loadLibrarySkills()) {
expect(skill.content, skill.name).toContain("## Before you start"); expect(skill.content, skill.name).toContain("## Before you start");
} }
@@ -85,7 +85,7 @@ describe("loadLibrarySkills", () => {
}); });
describe("loadSkillGroups / groupSkills", () => { describe("loadSkillGroups / groupSkills", () => {
it("按 Skill 组清单解组,成员齐全且带中文组名,无 Other 组", () => { it("loads groups per SKILL_GROUPS, members complete with Chinese titles, no Other group", () => {
const groups = loadSkillGroups(); const groups = loadSkillGroups();
expect(groups.map((g) => g.id)).toEqual([ expect(groups.map((g) => g.id)).toEqual([
"agent-development", "agent-development",
@@ -122,7 +122,7 @@ describe("loadSkillGroups / groupSkills", () => {
} }
}); });
it("groupSkills:未列入任何组的技能追加 Other 组(含中英文组名)", () => { it("groupSkills: appends an Other group for unlisted skills (Chinese and English titles)", () => {
const stray = fakeSkill("stray-skill"); const stray = fakeSkill("stray-skill");
const groups = groupSkills([fakeSkill("agent-creation"), stray]); const groups = groupSkills([fakeSkill("agent-creation"), stray]);
expect(groups.map((g) => g.id)).toEqual([ expect(groups.map((g) => g.id)).toEqual([
@@ -139,7 +139,7 @@ describe("loadSkillGroups / groupSkills", () => {
expect(other.skills).toEqual([stray]); expect(other.skills).toEqual([stray]);
}); });
it("groupSkills:成员名缺失则跳过;全部入组时不出现 Other 组", () => { it("groupSkills: missing members are skipped; no Other group when all are grouped", () => {
const groups = groupSkills([fakeSkill("penguin-cli")]); const groups = groupSkills([fakeSkill("penguin-cli")]);
expect(groups.map((g) => g.id)).toEqual([ expect(groups.map((g) => g.id)).toEqual([
"agent-development", "agent-development",
@@ -155,7 +155,7 @@ describe("loadSkillGroups / groupSkills", () => {
expect(groups[4]!.skills).toEqual([]); expect(groups[4]!.skills).toEqual([]);
}); });
it("SKILL_GROUPS 清单硬编码为成员名(库文件之外唯一的组信息真源)", () => { it("SKILL_GROUPS hardcodes member names (sole group info source outside library files)", () => {
expect(SKILL_GROUPS.map((g) => ({ id: g.id, skills: g.skills }))).toEqual([ expect(SKILL_GROUPS.map((g) => ({ id: g.id, skills: g.skills }))).toEqual([
{ {
id: "agent-development", id: "agent-development",
@@ -170,12 +170,12 @@ describe("loadSkillGroups / groupSkills", () => {
}); });
describe("librarySkill", () => { describe("librarySkill", () => {
it("按名称读单个技能,未知名称返回 undefined", () => { it("reads a single skill by name, returns undefined for unknown names", () => {
expect(librarySkill("penguin-sdk")?.name).toBe("penguin-sdk"); expect(librarySkill("penguin-sdk")?.name).toBe("penguin-sdk");
expect(librarySkill("no-such-skill")).toBeUndefined(); expect(librarySkill("no-such-skill")).toBeUndefined();
}); });
it("非法字符名一律拒绝(防路径穿越),不触达文件系统", () => { it("rejects illegal-character names (path traversal guard) and never hits the filesystem", () => {
for (const name of ["../penguin-sdk", "..", "penguin-sdk/SKILL.md", "a/../b", ".", ""]) { for (const name of ["../penguin-sdk", "..", "penguin-sdk/SKILL.md", "a/../b", ".", ""]) {
expect(librarySkill(name), name).toBeUndefined(); expect(librarySkill(name), name).toBeUndefined();
} }
@@ -183,7 +183,7 @@ describe("librarySkill", () => {
}); });
describe("parseSkillFrontmatter", () => { describe("parseSkillFrontmatter", () => {
it("解析 name/description/version/updated,值允许含冒号", () => { it("parses name/description/version/updated, values may contain colons", () => {
const meta = parseSkillFrontmatter( const meta = parseSkillFrontmatter(
"---\nname: demo\ndescription: How to use x: y and z\nversion: 3\nupdated: 2026-07-16\n---\n\nBody", "---\nname: demo\ndescription: How to use x: y and z\nversion: 3\nupdated: 2026-07-16\n---\n\nBody",
); );
@@ -195,7 +195,7 @@ describe("parseSkillFrontmatter", () => {
}); });
}); });
it("short_description_zh 可选:有则解析,缺省不带该字段", () => { it("short_description_zh is optional: parsed when present, omitted when absent", () => {
const withZh = parseSkillFrontmatter( const withZh = parseSkillFrontmatter(
"---\nname: demo\ndescription: Do x\nshort_description_zh: 做 x\n---\nBody", "---\nname: demo\ndescription: Do x\nshort_description_zh: 做 x\n---\nBody",
); );
@@ -205,7 +205,7 @@ describe("parseSkillFrontmatter", () => {
expect(withoutZh && "shortDescriptionZh" in withoutZh).toBe(false); expect(withoutZh && "shortDescriptionZh" in withoutZh).toBe(false);
}); });
it("short_description(_zh) 可选:有则解析为 shortDescription(Zh),缺省不带该字段", () => { it("short_description(_zh) is optional: parsed as shortDescription(Zh), else omitted", () => {
const withShort = parseSkillFrontmatter( const withShort = parseSkillFrontmatter(
"---\nname: demo\ndescription: Do x in detail\nshort_description: Do x\nshort_description_zh: 做 x\n---\nBody", "---\nname: demo\ndescription: Do x in detail\nshort_description: Do x\nshort_description_zh: 做 x\n---\nBody",
); );
@@ -216,21 +216,21 @@ describe("parseSkillFrontmatter", () => {
expect(without && "shortDescriptionZh" in without).toBe(false); expect(without && "shortDescriptionZh" in without).toBe(false);
}); });
it("UTF-8 BOM 与 CRLF 换行照常解析(手改文件的编辑器可能引入)", () => { it("parses UTF-8 BOM and CRLF newlines normally (hand-edited files may introduce them)", () => {
const bom = parseSkillFrontmatter("\uFEFF---\nname: demo\ndescription: Do x\n---\nBody"); const bom = parseSkillFrontmatter("\uFEFF---\nname: demo\ndescription: Do x\n---\nBody");
expect(bom?.name).toBe("demo"); expect(bom?.name).toBe("demo");
const crlf = parseSkillFrontmatter("---\r\nname: demo\r\ndescription: Do x\r\n---\r\nBody"); const crlf = parseSkillFrontmatter("---\r\nname: demo\r\ndescription: Do x\r\n---\r\nBody");
expect(crlf?.description).toBe("Do x"); expect(crlf?.description).toBe("Do x");
}); });
it("缺 --- 块或缺 name 返回 null", () => { it("returns null when the --- block or name is missing", () => {
expect(parseSkillFrontmatter("# No frontmatter")).toBeNull(); expect(parseSkillFrontmatter("# No frontmatter")).toBeNull();
expect(parseSkillFrontmatter("---\ndescription: only desc\n---\nBody")).toBeNull(); expect(parseSkillFrontmatter("---\ndescription: only desc\n---\nBody")).toBeNull();
// A block that isn't at the start doesn't count as frontmatter either. // A block that isn't at the start doesn't count as frontmatter either.
expect(parseSkillFrontmatter("Body\n---\nname: x\n---")).toBeNull(); expect(parseSkillFrontmatter("Body\n---\nname: x\n---")).toBeNull();
}); });
it("version 非自然数回退 1,updated 缺省空串", () => { it("version falls back to 1 when not a natural number, updated defaults to empty string", () => {
expect(parseSkillFrontmatter("---\nname: a\nversion: zero\n---")?.version).toBe(1); expect(parseSkillFrontmatter("---\nname: a\nversion: zero\n---")?.version).toBe(1);
expect(parseSkillFrontmatter("---\nname: a\nversion: 0\n---")?.version).toBe(1); expect(parseSkillFrontmatter("---\nname: a\nversion: 0\n---")?.version).toBe(1);
expect(parseSkillFrontmatter("---\nname: a\n---")).toEqual({ expect(parseSkillFrontmatter("---\nname: a\n---")).toEqual({
+9 -8
View File
@@ -1,13 +1,14 @@
# Web E2E(Playwright) # Web E2E (Playwright)
浏览器端到端:对话(thinking + 工具审批 + 工具执行 + 二轮答复)、图标化统计(成本折算 / Browser end-to-end tests: chat (thinking + tool approval + tool execution + second-turn
复制回复)、轨迹观测(分 Task 时间线 + 图例 + 悬停联动高亮)、Workspace 文件预览(HTML reply), the iconified stats line (cost conversion / copy reply), Traces (per-Task timeline +
sandbox 渲染、路径默认隐藏)。LLM 由 `mock-llm.mjs`(mock Anthropic Messages SSE)驱动, legend + hover-linked highlighting), Workspace file preview (sandboxed HTML rendering, path
不联网。 hidden by default). The LLM is driven by `mock-llm.mjs` (a mock Anthropic Messages SSE
endpoint) — no network access.
```sh ```sh
pnpm --filter @prismshadow/penguin-web test:e2e # 构建 + 起服务 + 跑用例 pnpm --filter @prismshadow/penguin-web test:e2e # build + start the server + run the tests
SKIP_BUILD=1 pnpm --filter @prismshadow/penguin-web test:e2e # 跳过构建 SKIP_BUILD=1 pnpm --filter @prismshadow/penguin-web test:e2e # skip the build
``` ```
首次需 `npx playwright install chromium`。 The first run requires `npx playwright install chromium`.
+16 -16
View File
@@ -52,7 +52,7 @@ test("chat + tool approval + stats/cost/copy + traces + files", async ({ page })
// Approval-mode is a custom dropdown (button), NOT a native <select>. // Approval-mode is a custom dropdown (button), NOT a native <select>.
await expect(page.locator("select")).toHaveCount(0); await expect(page.locator("select")).toHaveCount(0);
await ta.fill("帮我配置 @theme"); await ta.fill("Help me set up @theme");
await page.getByRole("button", { name: "发送" }).click(); await page.getByRole("button", { name: "发送" }).click();
// Tool name is shown on the collapsed tool row + in the pending-approval block. // Tool name is shown on the collapsed tool row + in the pending-approval block.
@@ -84,7 +84,7 @@ test("chat + tool approval + stats/cost/copy + traces + files", async ({ page })
await page.getByRole("button", { name: "允许" }).click(); await page.getByRole("button", { name: "允许" }).click();
// Final assistant answer (turn 2 from mock). // Final assistant answer (turn 2 from mock).
await expect(page.getByText("命令已执行完成,结果符合预期。")).toBeVisible(); await expect(page.getByText("Command finished; the result looks as expected.")).toBeVisible();
// Regression: after entering a session and rendering messages, the page must not overflow // Regression: after entering a session and rendering messages, the page must not overflow
// horizontally (previously, with the Files dock panel closed, the sr-only upload input was // horizontally (previously, with the Files dock panel closed, the sr-only upload input was
@@ -124,12 +124,12 @@ test("chat + tool approval + stats/cost/copy + traces + files", async ({ page })
// Hovering the REPLY ITSELF must reveal it — the reply and its stats line share a `group` wrapper // Hovering the REPLY ITSELF must reveal it — the reply and its stats line share a `group` wrapper
// for exactly this. Requiring a hover on the number row would make the footer undiscoverable. // for exactly this. Requiring a hover on the number row would make the footer undiscoverable.
await page.getByText("命令已执行完成,结果符合预期。").first().hover(); await page.getByText("Command finished; the result looks as expected.").first().hover();
await expect(statsLine).toHaveCSS("opacity", "1"); await expect(statsLine).toHaveCSS("opacity", "1");
await expect(statsLine.locator("text=/\\d{1,2}:\\d{2}/")).toBeVisible(); await expect(statsLine.locator("text=/\\d{1,2}:\\d{2}/")).toBeVisible();
await copyBtn.click(); await copyBtn.click();
const clip = await page.evaluate(() => navigator.clipboard.readText()); const clip = await page.evaluate(() => navigator.clipboard.readText());
expect(clip).toContain("命令已执行完成"); expect(clip).toContain("Command finished");
// The AI reply's time + copy live on the stats line (it sits right under the reply and IS its // The AI reply's time + copy live on the stats line (it sits right under the reply and IS its
// footer) — the assistant message must NOT render a second footer, or one spot grows two copy // footer) — the assistant message must NOT render a second footer, or one spot grows two copy
@@ -149,17 +149,17 @@ test("chat + tool approval + stats/cost/copy + traces + files", async ({ page })
).not.toBeNull(); ).not.toBeNull();
// Hover the user bubble → its footer fades in (human-readable time + copy). // Hover the user bubble → its footer fades in (human-readable time + copy).
await page.getByText("帮我配置 @theme").first().hover(); await page.getByText("Help me set up @theme").first().hover();
await expect(meta).toHaveCSS("opacity", "1"); await expect(meta).toHaveCSS("opacity", "1");
await expect(meta.locator("text=/\\d{1,2}:\\d{2}/")).toBeVisible(); await expect(meta.locator("text=/\\d{1,2}:\\d{2}/")).toBeVisible();
await msgCopy.click(); await msgCopy.click();
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("帮我配置 @theme"); expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("Help me set up @theme");
// --- traces --- (scope to <main>; the global sidebar also lists the session title) // --- traces --- (scope to <main>; the global sidebar also lists the session title)
await page.goto(`${BASE}/traces`); await page.goto(`${BASE}/traces`);
const main = page.locator("main"); const main = page.locator("main");
// Auto-generated title should appear in the traces tree (agents default-expanded). // Auto-generated title should appear in the traces tree (agents default-expanded).
const titleNode = main.getByText("配置 Tailwind 主题").first(); const titleNode = main.getByText("Configure Tailwind theme").first();
await expect(titleNode).toBeVisible(); await expect(titleNode).toBeVisible();
await titleNode.click(); await titleNode.click();
await expect(main.getByText("轨迹观测").first()).toBeVisible(); await expect(main.getByText("轨迹观测").first()).toBeVisible();
@@ -236,9 +236,9 @@ test("chat + tool approval + stats/cost/copy + traces + files", async ({ page })
// The mock reply mentions two backtick paths: demo.html was truly uploaded above via // The mock reply mentions two backtick paths: demo.html was truly uploaded above via
// files/content; missing-report.pdf exists nowhere. The card must render after the // files/content; missing-report.pdf exists nowhere. The card must render after the
// files/stat round-trip with a single row for the existing file only. // files/stat round-trip with a single row for the existing file only.
await ta.fill("文件卡测试"); await ta.fill("files card test");
await page.getByRole("button", { name: "发送" }).click(); await page.getByRole("button", { name: "发送" }).click();
await expect(page.getByText(/报告已生成/)).toBeVisible(); await expect(page.getByText(/Report generated/)).toBeVisible();
await expect(page.getByText("1 个文件")).toBeVisible(); await expect(page.getByText("1 个文件")).toBeVisible();
// Card rows carry the 点击预览 ("click to preview") affordance — that distinguishes them from // Card rows carry the 点击预览 ("click to preview") affordance — that distinguishes them from
// the Files panel tree rows, which also use title=<name> on their buttons. // the Files panel tree rows, which also use title=<name> on their buttons.
@@ -260,11 +260,11 @@ test("chat + tool approval + stats/cost/copy + traces + files", async ({ page })
// --- agent group collapse / expand (header button shows the agent name, so its state // --- agent group collapse / expand (header button shows the agent name, so its state
// lives on aria-label rather than a duplicate title tooltip) --- // lives on aria-label rather than a duplicate title tooltip) ---
await expect(sidebar.getByText("配置 Tailwind 主题")).toBeVisible(); await expect(sidebar.getByText("Configure Tailwind theme")).toBeVisible();
await sidebar.locator('button[aria-label="折叠"]').first().click(); await sidebar.locator('button[aria-label="折叠"]').first().click();
await expect(sidebar.getByText("配置 Tailwind 主题")).toHaveCount(0); await expect(sidebar.getByText("Configure Tailwind theme")).toHaveCount(0);
await sidebar.locator('button[aria-label="展开"]').first().click(); await sidebar.locator('button[aria-label="展开"]').first().click();
await expect(sidebar.getByText("配置 Tailwind 主题")).toBeVisible(); await expect(sidebar.getByText("Configure Tailwind theme")).toBeVisible();
// --- settings: adjustable accent color + font size (default gray/white) --- // --- settings: adjustable accent color + font size (default gray/white) ---
// The username button shares its name with the Project switcher (the initial Project's // The username button shares its name with the Project switcher (the initial Project's
@@ -281,14 +281,14 @@ test("chat + tool approval + stats/cost/copy + traces + files", async ({ page })
await page.keyboard.press("Escape"); await page.keyboard.press("Escape");
// --- session rename (manual title wins over the auto-generated one) --- // --- session rename (manual title wins over the auto-generated one) ---
const renameTarget = sidebar.locator("li", { hasText: "配置 Tailwind 主题" }).first(); const renameTarget = sidebar.locator("li", { hasText: "Configure Tailwind theme" }).first();
await renameTarget.hover(); await renameTarget.hover();
await renameTarget.getByRole("button", { name: "重命名对话" }).click(); await renameTarget.getByRole("button", { name: "重命名对话" }).click();
await page.getByLabel("标题").fill("我改的标题"); await page.getByLabel("标题").fill("My renamed title");
await page.getByRole("button", { name: "保存" }).click(); await page.getByRole("button", { name: "保存" }).click();
await expect(sidebar.getByText("我改的标题")).toBeVisible(); await expect(sidebar.getByText("My renamed title")).toBeVisible();
await page.reload(); await page.reload();
await expect(sidebar.getByText("我改的标题")).toBeVisible(); await expect(sidebar.getByText("My renamed title")).toBeVisible();
// --- session archive + delete (throwaway session) --- // --- session archive + delete (throwaway session) ---
await page.request.post(`${BASE}/api/projects/${projectId}/agents/${agentId}/sessions`, { await page.request.post(`${BASE}/api/projects/${projectId}/agents/${agentId}/sessions`, {
+15 -9
View File
@@ -56,7 +56,7 @@ test("aborting before any turn completes: /compact says so instead of doing noth
await page.goto(`${BASE}/chat/${sessionId}`); await page.goto(`${BASE}/chat/${sessionId}`);
const ta = page.getByPlaceholder(/输入消息/); const ta = page.getByPlaceholder(/输入消息/);
await ta.waitFor(); await ta.waitFor();
await ta.fill("帮我配置 @theme"); await ta.fill("Help me set up @theme");
await page.getByRole("button", { name: "发送" }).click(); await page.getByRole("button", { name: "发送" }).click();
// Wait for the approval to be pending before aborting: at this point the LLM stream has // Wait for the approval to be pending before aborting: at this point the LLM stream has
// already finished sending, the engine is blocked on `await approve(tc)`, and the generator is // already finished sending, the engine is blocked on `await approve(tc)`, and the generator is
@@ -86,8 +86,8 @@ test("aborting before any turn completes: /compact says so instead of doing noth
await ta.press("Enter"); await ta.press("Enter");
// There must be feedback — silently doing nothing is exactly the bug being tested for. // There must be feedback — silently doing nothing is exactly the bug being tested for.
await expect(page.getByRole("button", { name: /没有可压缩的内容/ })).toBeVisible(); await expect(page.getByRole("button", { name: /nothing to compact/ })).toBeVisible();
expect(nativeDialogs, "反馈必须走应用内轻提示,不得回退到原生 alert").toHaveLength(0); expect(nativeDialogs, "feedback must be an in-app toast, not a native alert").toHaveLength(0);
// And indeed no compaction was started (no banner). // And indeed no compaction was started (no banner).
await expect(page.getByText(/\[压缩\]/)).toHaveCount(0); await expect(page.getByText(/\[压缩\]/)).toHaveCount(0);
}); });
@@ -123,9 +123,9 @@ test("compacting twice in a row: says the context was just compacted, not that n
await page.goto(`${BASE}/chat/${sess.session.sessionId}`); await page.goto(`${BASE}/chat/${sess.session.sessionId}`);
const ta = page.getByPlaceholder(/输入消息/); const ta = page.getByPlaceholder(/输入消息/);
await ta.waitFor(); await ta.waitFor();
await ta.fill("帮我配置 @theme"); await ta.fill("Help me set up @theme");
await page.getByRole("button", { name: "发送" }).click(); await page.getByRole("button", { name: "发送" }).click();
await expect(page.getByText("命令已执行完成,结果符合预期。")).toBeVisible(); await expect(page.getByText("Command finished; the result looks as expected.")).toBeVisible();
const nativeDialogs = []; const nativeDialogs = [];
page.on("dialog", (d) => { page.on("dialog", (d) => {
@@ -137,14 +137,20 @@ test("compacting twice in a row: says the context was just compacted, not that n
await ta.fill("/compact"); await ta.fill("/compact");
await ta.press("Enter"); await ta.press("Enter");
await expect(page.getByText(/\[压缩\]/)).toBeVisible(); await expect(page.getByText(/\[压缩\]/)).toBeVisible();
await expect(page.getByRole("button", { name: /压缩/ })).toHaveCount(0); // No compact-unavailable toast (match the 409 reasons, not bare "compact" — usernames like
// "dblcompact" would collide).
await expect(
page.getByRole("button", { name: /compaction configured|nothing to compact|just compacted/ }),
).toHaveCount(0);
// Second /compact: there's no conversation yet in the new context -> tell the user clearly, and the wording must match reality. // Second /compact: there's no conversation yet in the new context -> tell the user clearly, and the wording must match reality.
await ta.fill("/compact"); await ta.fill("/compact");
await ta.press("Enter"); await ta.press("Enter");
await expect(page.getByRole("button", { name: /刚压缩过/ })).toBeVisible(); await expect(page.getByRole("button", { name: /just compacted/ })).toBeVisible();
// Having just finished a full round, saying "no completed conversation round yet" would be absurd. // Having just finished a full round, saying "no completed conversation round yet" would be absurd.
await expect(page.getByRole("button", { name: /尚无已完成的对话轮次/ })).toHaveCount(0); await expect(
expect(nativeDialogs, "反馈必须走应用内轻提示,不得回退到原生 alert").toHaveLength(0); page.getByRole("button", { name: /no completed conversation turns yet/ }),
).toHaveCount(0);
expect(nativeDialogs, "feedback must be an in-app toast, not a native alert").toHaveLength(0);
await expect(page.getByText(/\[压缩\]/)).toHaveCount(1); // no second compaction banner await expect(page.getByText(/\[压缩\]/)).toHaveCount(1); // no second compaction banner
}); });
+10 -10
View File
@@ -53,10 +53,10 @@ test("compaction mid-turn: the reply's stats line is still reachable by hovering
await page.goto(`${BASE}/chat/${sess.session.sessionId}`); await page.goto(`${BASE}/chat/${sess.session.sessionId}`);
const ta = page.getByPlaceholder(/输入消息/); const ta = page.getByPlaceholder(/输入消息/);
await ta.waitFor(); await ta.waitFor();
await ta.fill("帮我配置 @theme"); await ta.fill("Help me set up @theme");
await page.getByRole("button", { name: "发送" }).click(); await page.getByRole("button", { name: "发送" }).click();
const reply = page.getByText("命令已执行完成,结果符合预期。").first(); const reply = page.getByText("Command finished; the result looks as expected.").first();
await expect(reply).toBeVisible(); await expect(reply).toBeVisible();
const banner = page.getByText(/\[压缩\]/).first(); const banner = page.getByText(/\[压缩\]/).first();
await expect(banner).toBeVisible(); await expect(banner).toBeVisible();
@@ -97,7 +97,7 @@ test("compaction mid-turn: the reply's stats line is still reachable by hovering
// fast the summary was generated), not a "—". // fast the summary was generated), not a "—".
await page.goto(`${BASE}/traces`); await page.goto(`${BASE}/traces`);
const main = page.locator("main"); const main = page.locator("main");
const node = main.getByText(/配置|新对话/).first(); const node = main.getByText(/Configure|新对话/).first();
await expect(node).toBeVisible(); await expect(node).toBeVisible();
await node.click(); await node.click();
await expect(main.getByText("第 1 轮")).toBeVisible(); await expect(main.getByText("第 1 轮")).toBeVisible();
@@ -191,9 +191,9 @@ test("manual /compact between turns: reloading must not fold the compaction into
await page.goto(`${BASE}/chat/${sess.session.sessionId}`); await page.goto(`${BASE}/chat/${sess.session.sessionId}`);
const ta = page.getByPlaceholder(/输入消息/); const ta = page.getByPlaceholder(/输入消息/);
await ta.waitFor(); await ta.waitFor();
await ta.fill("帮我配置 @theme"); await ta.fill("Help me set up @theme");
await page.getByRole("button", { name: "发送" }).click(); await page.getByRole("button", { name: "发送" }).click();
await expect(page.getByText("命令已执行完成,结果符合预期。")).toBeVisible(); await expect(page.getByText("Command finished; the result looks as expected.")).toBeVisible();
// The whole stats line is transparent by default, but innerText still reads fine (no need to hover). // The whole stats line is transparent by default, but innerText still reads fine (no need to hover).
const statsText = async () => const statsText = async () =>
@@ -207,7 +207,7 @@ test("manual /compact between turns: reloading must not fold the compaction into
}; };
const live = await statsText(); const live = await statsText();
expect(costOf(live), `统计行应有成本:${live}`).not.toBeNull(); expect(costOf(live), `stats line should include a cost: ${live}`).not.toBeNull();
expect(elapsedMsOf(live)).toBeLessThan(1500); expect(elapsedMsOf(live)).toBeLessThan(1500);
// The user reads the reply, then 3 seconds later remembers to compact. // The user reads the reply, then 3 seconds later remembers to compact.
@@ -216,14 +216,14 @@ test("manual /compact between turns: reloading must not fold the compaction into
await ta.press("Enter"); await ta.press("Enter");
await expect(page.getByText(/\[压缩\]/)).toBeVisible(); await expect(page.getByText(/\[压缩\]/)).toBeVisible();
await expect(page.getByRole("button", { name: "停止" })).toHaveCount(0); await expect(page.getByRole("button", { name: "停止" })).toHaveCount(0);
expect(await statsText(), "实时流里上一轮早已收口,压缩不该改动它").toBe(live); expect(await statsText(), "compaction must not touch the already-closed prior turn").toBe(live);
await page.reload(); await page.reload();
await ta.waitFor(); await ta.waitFor();
await expect(page.getByText("命令已执行完成,结果符合预期。")).toBeVisible(); await expect(page.getByText("Command finished; the result looks as expected.")).toBeVisible();
const rebuilt = await statsText(); const rebuilt = await statsText();
// Cost is derived from Token counts: if compaction's usage were folded in, cost would double out of nowhere. // Cost is derived from Token counts: if compaction's usage were folded in, cost would double out of nowhere.
expect(costOf(rebuilt), `刷新前后成本必须一致(前:${live}|后:${rebuilt})`).toBe(costOf(live)); expect(costOf(rebuilt), `cost must survive reload (${live} -> ${rebuilt})`).toBe(costOf(live));
// Elapsed time should include neither the 3-second thinking gap nor compaction itself. // Elapsed time should include neither the 3-second thinking gap nor compaction itself.
expect(elapsedMsOf(rebuilt), `刷新后用时不得吃进压缩(${rebuilt})`).toBeLessThan(1500); expect(elapsedMsOf(rebuilt), `elapsed must not eat compaction (${rebuilt})`).toBeLessThan(1500);
}); });
Loaded 100 of 136 files, more files were not shown because too many files have changed in this diff. Show more