From 0eaaa54e2509158192cddd95c01fadf503b58d60 Mon Sep 17 00:00:00 2001 From: Yaowei Zheng Date: Mon, 20 Jul 2026 17:45:02 +0800 Subject: [PATCH] Make English the repository working language (#5) Co-authored-by: Claude Fable 5 --- package.json | 2 +- packages/cli/src/commands/config.ts | 4 +- packages/cli/test/chat.test.ts | 16 +- packages/cli/test/config-format.test.ts | 8 +- packages/cli/test/config-model.test.ts | 20 +- packages/cli/test/config-vault.test.ts | 8 +- packages/cli/test/render.test.ts | 6 +- packages/cli/test/serve.test.ts | 26 +- packages/core/src/agent.ts | 30 +- packages/core/src/state/agent-state.ts | 4 +- packages/core/src/state/project-config.ts | 22 +- packages/core/test/agent-skills.test.ts | 2 +- packages/core/test/agent.test.ts | 16 +- packages/core/test/builtin-agents.test.ts | 18 +- packages/core/test/compaction.test.ts | 2 +- packages/core/test/describe-image.test.ts | 18 +- packages/core/test/environment.test.ts | 2 +- packages/core/test/input-images.test.ts | 14 +- packages/core/test/llm.e2e.test.ts | 4 +- packages/core/test/llm.test.ts | 22 +- packages/core/test/model-catalog.test.ts | 26 +- packages/core/test/omnimessage.test.ts | 2 +- packages/core/test/read-image.test.ts | 22 +- packages/core/test/resume.test.ts | 12 +- packages/core/test/session-title.test.ts | 30 +- packages/core/test/state.test.ts | 26 +- packages/core/test/validate-id.test.ts | 16 +- packages/core/test/workspace.test.ts | 6 +- packages/landing/package.json | 2 +- packages/server/package.json | 2 +- packages/server/src/app.ts | 8 +- packages/server/src/auth/middleware.ts | 8 +- packages/server/src/auth/service.ts | 6 +- packages/server/src/config.ts | 2 +- packages/server/src/db/repos/schedules.ts | 2 +- packages/server/src/db/schema.ts | 76 ++--- packages/server/src/http/errors.ts | 8 +- packages/server/src/http/routes/admin.ts | 2 +- .../server/src/http/routes/agent-config.ts | 2 +- .../server/src/http/routes/agent-transfer.ts | 7 +- packages/server/src/http/routes/dirs.ts | 10 +- packages/server/src/http/routes/models.ts | 32 +- packages/server/src/http/routes/schedules.ts | 19 +- packages/server/src/http/routes/sessions.ts | 56 +++- packages/server/src/http/routes/skills.ts | 8 +- packages/server/src/http/routes/usage.ts | 2 +- packages/server/src/http/routes/vault.ts | 6 +- packages/server/src/http/validate.ts | 35 +- packages/server/src/index.ts | 10 +- packages/server/src/runtime/schedule-file.ts | 38 ++- .../server/src/runtime/session-manager.ts | 47 ++- .../src/runtime/stream-error-watcher.ts | 12 +- .../server/src/runtime/title-generator.ts | 8 +- packages/server/src/services/admin-service.ts | 18 +- .../src/services/agent-config-service.ts | 28 +- packages/server/src/services/agent-service.ts | 10 +- packages/server/src/services/ids.ts | 3 +- .../src/services/project-config-service.ts | 8 +- .../server/src/services/project-service.ts | 40 ++- .../server/src/services/session-service.ts | 2 +- .../server/src/services/snapshot-service.ts | 12 +- packages/server/src/services/trace-service.ts | 2 +- .../src/services/workspace-files-service.ts | 35 +- .../server/src/services/workspace-guard.ts | 8 +- packages/server/test/admin-users.test.ts | 18 +- .../server/test/agent-trace-detail.test.ts | 16 +- packages/server/test/agent-transfer.test.ts | 8 +- packages/server/test/auth.test.ts | 18 +- packages/server/test/authz.test.ts | 14 +- packages/server/test/benchmarks.test.ts | 20 +- packages/server/test/builtin-agents.test.ts | 14 +- packages/server/test/channel.test.ts | 22 +- packages/server/test/config.test.ts | 10 +- packages/server/test/errors.test.ts | 140 ++++---- packages/server/test/helpers.ts | 8 +- packages/server/test/id-validation.test.ts | 14 +- packages/server/test/models-vision.test.ts | 14 +- packages/server/test/password.test.ts | 8 +- packages/server/test/schedule-file.test.ts | 28 +- packages/server/test/scheduler.test.ts | 26 +- packages/server/test/schedules.test.ts | 20 +- packages/server/test/semantic-ids.test.ts | 20 +- .../test/session-delete-scratchpad.test.ts | 8 +- packages/server/test/session-index.test.ts | 32 +- packages/server/test/session-loader.test.ts | 10 +- packages/server/test/session-manager.test.ts | 64 ++-- packages/server/test/skills.test.ts | 16 +- packages/server/test/sse-stream.test.ts | 13 +- packages/server/test/title-generator.test.ts | 21 +- packages/server/test/trace-service.test.ts | 80 ++--- .../server/test/trace-subagent-expand.test.ts | 30 +- packages/server/test/usage.test.ts | 26 +- packages/server/test/vault.test.ts | 14 +- packages/server/test/workspace-files.test.ts | 34 +- packages/server/test/workspace-guard.test.ts | 10 +- packages/skills/test/skills.test.ts | 32 +- packages/web/e2e/README.md | 17 +- packages/web/e2e/chat.spec.mjs | 32 +- packages/web/e2e/compact-abort.spec.mjs | 24 +- packages/web/e2e/compaction.spec.mjs | 20 +- packages/web/e2e/draft-user-scope.spec.mjs | 14 +- packages/web/e2e/draft.spec.mjs | 24 +- packages/web/e2e/malformed.spec.mjs | 6 +- packages/web/e2e/mock-llm.mjs | 35 +- packages/web/e2e/project-switch.spec.mjs | 4 +- packages/web/e2e/sheet.spec.mjs | 4 +- packages/web/e2e/skills.spec.mjs | 4 +- packages/web/e2e/subagent.spec.mjs | 10 +- packages/web/package.json | 2 +- packages/web/src/main.tsx | 2 +- packages/web/src/state/auth.tsx | 2 +- packages/web/src/state/locale.tsx | 2 +- packages/web/src/state/project.tsx | 2 +- packages/web/src/state/sessions.tsx | 2 +- packages/web/src/state/theme.tsx | 2 +- packages/web/test/agent-mentions.test.ts | 60 ++-- packages/web/test/attachments.test.ts | 16 +- packages/web/test/benchmark-metrics.test.ts | 32 +- packages/web/test/context.test.ts | 8 +- packages/web/test/draft-cache.test.ts | 52 +-- packages/web/test/file-path.test.ts | 44 +-- packages/web/test/format.test.ts | 52 +-- packages/web/test/locale.test.ts | 6 +- packages/web/test/model-grouping.test.ts | 18 +- packages/web/test/models-input.test.ts | 30 +- packages/web/test/sheet-physics.test.ts | 14 +- packages/web/test/skill-icon.test.ts | 16 +- packages/web/test/skill-slash.test.ts | 46 +-- packages/web/test/skill-use.test.ts | 29 +- packages/web/test/spring.test.ts | 12 +- packages/web/test/stream-controller.test.ts | 48 +-- packages/web/test/stream-follow.test.ts | 14 +- packages/web/test/stream-model.test.ts | 312 +++++++++--------- packages/web/test/task-stats.test.ts | 22 +- packages/web/test/usage-charts.test.ts | 48 +-- packages/web/test/work-summary.test.ts | 14 +- 136 files changed, 1515 insertions(+), 1352 deletions(-) diff --git a/package.json b/package.json index 8e98f69..3595e42 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "test": "pnpm -r test", "test:e2e": "pnpm --filter @prismshadow/penguin-core test:e2e", "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", "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", diff --git a/packages/cli/src/commands/config.ts b/packages/cli/src/commands/config.ts index c3642ce..187cc78 100644 --- a/packages/cli/src/commands/config.ts +++ b/packages/cli/src/commands/config.ts @@ -354,7 +354,7 @@ function confirmYes(prompt: string): Promise { function parseIntArg(value: string): number { const n = Number.parseInt(value, 10); if (Number.isNaN(n)) { - throw new Error(`无效的整数:${value}`); + throw new Error(`Invalid integer: ${value}`); } return n; } @@ -362,7 +362,7 @@ function parseIntArg(value: string): number { function parseFloatArg(value: string): number { const n = Number.parseFloat(value); if (Number.isNaN(n)) { - throw new Error(`无效的数值:${value}`); + throw new Error(`Invalid number: ${value}`); } return n; } diff --git a/packages/cli/test/chat.test.ts b/packages/cli/test/chat.test.ts index 8091550..0388d90 100644 --- a/packages/cli/test/chat.test.ts +++ b/packages/cli/test/chat.test.ts @@ -2,40 +2,40 @@ import { describe, expect, it } from "vitest"; import { decideSigint } from "../src/commands/chat.js"; import { parseApprovalAnswer } from "../src/approval.js"; -describe("decideSigint (Ctrl-C 行为状态机)", () => { - it("approving → deny(无论缓冲区是否有内容)", () => { +describe("decideSigint (Ctrl-C behavior state machine)", () => { + it("approving -> deny (whether or not the buffer has content)", () => { expect(decideSigint("approving", false)).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", true)).toBe("abort"); }); - it("idle + 有输入 → clear(清空缓冲区)", () => { + it("idle + input -> clear (empties the buffer)", () => { 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"); }); - 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", true)).toBe("exit"); }); }); 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(" YES \n")).toBe("allow"); expect(parseApprovalAnswer("Y")).toBe("allow"); expect(parseApprovalAnswer("n")).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("nope")).toBe("deny"); expect(parseApprovalAnswer("", "allow")).toBe("allow"); // tool approval defaults to allow diff --git a/packages/cli/test/config-format.test.ts b/packages/cli/test/config-format.test.ts index 25e09b5..7297252 100644 --- a/packages/cli/test/config-format.test.ts +++ b/packages/cli/test/config-format.test.ts @@ -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); expect(lines).toHaveLength(2); 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"); }); - 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); expect(lines[1]).toMatch(/^ {2}custom\s+my-proxy-model\s+vision=-/); expect(lines[1]).toContain("client_type=openai"); @@ -47,7 +47,7 @@ describe("formatModelRows", () => { 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({ default_model: { provider: "deepseek", model_id: "m1" }, models: [ @@ -59,7 +59,7 @@ describe("formatModelRows", () => { 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({ models: [{ provider: "custom", model_id: "m1" }], }); diff --git a/packages/cli/test/config-model.test.ts b/packages/cli/test/config-model.test.ts index 98e0f44..0f084c7 100644 --- a/packages/cli/test/config-model.test.ts +++ b/packages/cli/test/config-model.test.ts @@ -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 分列存储)", () => { - it("--root 优先于 PENGUIN_HOME:落盘到指定根目录的隐藏 .project_config.toml(0600)", async () => { +describe("penguin config model add/list (--root plus provider / model_id stored as separate fields)", () => { + it("--root beats PENGUIN_HOME: written to the hidden .project_config.toml under the given root (0600)", async () => { const add = await runModel([ "add", "--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"); }); - 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([ "add", "--model-id", @@ -144,7 +144,7 @@ describe("penguin config model add/list(--root 与 provider / model_id 分列 ).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([ "add", "--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); }); - 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. await runModel(["add", "--model-id", "my-openai-proxy", "--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"); }); - 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([ "default", "--model-id", @@ -241,14 +241,14 @@ describe("penguin config model add/list(--root 与 provider / model_id 分列 }); }); -describe("model default/vision:--provider 必填,(provider, model_id) 成对引用", () => { - it("缺 --provider:commander 用法报错,非零退出码", async () => { +describe("model default/vision: --provider is required, (provider, model_id) pair reference", () => { + it("missing --provider: commander usage error, nonzero exit code", async () => { const bad = await runModel(["default", "--model-id", "deepseek-v4-flash", "--root", tmpRoot]); expect(bad.code).not.toBe(0); 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([ "default", "--model-id", @@ -276,7 +276,7 @@ describe("model default/vision:--provider 必填,(provider, model_id) 成对 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([ "vision", "--model-id", diff --git a/packages/cli/test/config-vault.test.ts b/packages/cli/test/config-vault.test.ts index 9012ff7..eeaa908 100644 --- a/packages/cli/test/config-vault.test.ts +++ b/packages/cli/test/config-vault.test.ts @@ -57,7 +57,7 @@ async function runVault(args: string[]): Promise<{ out: string; err: string; cod } 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"]); expect(set.code).toBe(0); 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."); }); - 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([ "set", "--key", @@ -99,7 +99,7 @@ describe("penguin config vault", () => { 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-")); try { 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"]); expect(badKey.code).toBe(1); expect(badKey.err).toContain("Invalid vault key"); diff --git a/packages/cli/test/render.test.ts b/packages/cli/test/render.test.ts index 219af55..de604e6 100644 --- a/packages/cli/test/render.test.ts +++ b/packages/cli/test/render.test.ts @@ -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 r = new StreamRenderer(stream, t); // Ordinary request: context 5000. @@ -325,7 +325,7 @@ describe("StreamRenderer", () => { 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 r = new StreamRenderer(stream, t); // own1: ordinary request, request 5000, 00:00 -> 00:02. @@ -362,7 +362,7 @@ describe("StreamRenderer", () => { 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 r = new StreamRenderer(stream, t); // own1: 00:00 -> 00:03. diff --git a/packages/cli/test/serve.test.ts b/packages/cli/test/serve.test.ts index 07ab3f3..3cd9702 100644 --- a/packages/cli/test/serve.test.ts +++ b/packages/cli/test/serve.test.ts @@ -10,19 +10,19 @@ import { } from "../src/commands/serve.js"; import { getMessages } from "../src/i18n.js"; -describe("resolvePort(选项 > 环境变量 > 缺省 7364)", () => { - it("都未给时用缺省 7364", () => { +describe("resolvePort (option > env var > default 7364)", () => { + it("uses the default 7364 when neither is given", () => { expect(DEFAULT_PORT).toBe(7364); expect(resolvePort(undefined, undefined)).toBe(7364); 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); }); - it("选项优先于环境变量", () => { + it("the option beats the env var", () => { 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("3.14", 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/"; it("darwin → open", () => { 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({ command: "cmd", 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("freebsd", url)).toEqual({ command: "xdg-open", args: [url] }); }); }); -describe("browserUrl(通配监听地址转 127.0.0.1)", () => { - it("常规 host 原样拼接", () => { +describe("browserUrl (wildcard listen addresses map to 127.0.0.1)", () => { + it("a regular host is joined as-is", () => { 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/"); }); - 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("::", 7364)).toBe("http://127.0.0.1:7364/"); }); }); -describe("registerServeCommands(命令注册)", () => { - it("注册 server 与 web 两个顶层命令,web 缺省 open=true(--no-open 可关)", () => { +describe("registerServeCommands (command registration)", () => { + it("registers the server and web top-level commands; web defaults to open=true (--no-open turns it off)", () => { const program = new Command(); registerServeCommands(program, getMessages("en")); const names = program.commands.map((c) => c.name()); diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index 167c87a..5613d64 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -140,7 +140,7 @@ export class Agent { // credentials, pricing, and the context window would all be unavailable. if (opts.modelId === undefined && opts.provider !== undefined) { 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; @@ -151,13 +151,13 @@ export class Agent { ref = this.projectConfig.default_model; } else { 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); if (!modelEntry) { 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 @@ -177,11 +177,11 @@ export class Agent { stat = await fs.stat(workspaceDir); } catch { 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()) { - throw new Error(`Workspace 不是目录:${workspaceDir}。`); + throw new Error(`Workspace is not a directory: ${workspaceDir}.`); } } else { workspaceDir = await createTempWorkspace( @@ -284,17 +284,19 @@ export class Agent { const dir = tracesDir(this.state.root, this.state.projectId, this.state.agentId); const located = await findLatestTraceFile(dir, sessionId); 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)); 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; // 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") { 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 { stat = await fs.stat(workspaceDir); } catch { - throw new Error(`原 Session 的 Workspace 已不存在:${workspaceDir},无法恢复。`); + throw new Error( + `The original Session's Workspace no longer exists: ${workspaceDir}; cannot resume.`, + ); } 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. @@ -315,7 +321,7 @@ export class Agent { const modelEntry = getModel(this.projectConfig, ref); if (!modelEntry) { 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; @@ -349,7 +355,7 @@ export class Agent { } catch (err) { const detail = err instanceof Error ? err.message : String(err); 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}`, ); } } diff --git a/packages/core/src/state/agent-state.ts b/packages/core/src/state/agent-state.ts index 0f9ef09..450c228 100644 --- a/packages/core/src/state/agent-state.ts +++ b/packages/core/src/state/agent-state.ts @@ -130,7 +130,9 @@ export async function loadOrInitAgentState(opts?: { typeof parsed !== "object" || 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; agentsMd = (await fileExists(mdPath)) ? await fs.readFile(mdPath, "utf8") : defaultAgentsMd(); diff --git a/packages/core/src/state/project-config.ts b/packages/core/src/state/project-config.ts index 9dc4b07..0780070 100644 --- a/packages/core/src/state/project-config.ts +++ b/packages/core/src/state/project-config.ts @@ -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). */ 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. */ 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" ) { 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 }; @@ -155,7 +155,7 @@ function assertModelEntry(file: string, entry: unknown): ModelEntry { typeof m.model_id !== "string" ) { 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; @@ -359,7 +359,7 @@ export async function setDefaultModel( const cfg = await loadProjectConfig(root, projectId); if (!getModel(cfg, ref)) { 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 }; @@ -381,11 +381,13 @@ export async function setVisionModel( const entry = getModel(cfg, ref); if (!entry) { 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) { - 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 }; await saveProjectConfig(root, projectId, cfg); @@ -410,7 +412,7 @@ export function resolveModelRef(cfg: ProjectConfig, modelId: string, provider?: const ref: ModelRef = { provider, model_id: modelId }; if (!getModel(cfg, ref)) { 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; @@ -421,12 +423,12 @@ export function resolveModelRef(cfg: ProjectConfig, modelId: string, provider?: } if (candidates.length === 0) { 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( - `模型引用有歧义: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 })) - .join("、")}。请补充 provider 以给出成对引用。`, + .join(", ")}. Specify provider to give a paired reference.`, ); } diff --git a/packages/core/test/agent-skills.test.ts b/packages/core/test/agent-skills.test.ts index b88ec92..ca12f8d 100644 --- a/packages/core/test/agent-skills.test.ts +++ b/packages/core/test/agent-skills.test.ts @@ -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", () => { expect(skillMetadataSection([])).toBe(""); expect( diff --git a/packages/core/test/agent.test.ts b/packages/core/test/agent.test.ts index f4f9203..23a5c66 100644 --- a/packages/core/test/agent.test.ts +++ b/packages/core/test/agent.test.ts @@ -43,7 +43,7 @@ afterEach(async () => { 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", () => { expect(effectiveMaxContextLength(128000, 32768)).toBe(24576); // small window: clamp to 75% 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 () => { const agent = await createAgent(); 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. await expect(fs.stat(ws)).rejects.toThrow(); }); @@ -66,7 +66,9 @@ describe("Agent.createSession workspace handling", () => { const agent = await createAgent(); const filePath = path.join(tmpRoot, "a-file"); 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 () => { @@ -86,7 +88,7 @@ describe("Agent.createSession workspace handling", () => { // model_id); the error is thrown before creating the temp Workspace. await expect( agent.createSession({ workspaceDir: ws, modelId: "not-configured-model" }), - ).rejects.toThrow(/不在 Project 配置中/); + ).rejects.toThrow(/is not in the Project config/); await expect( agent.createSession({ workspaceDir: ws, modelId: "deepseek-v4-pro", provider: "openai" }), ).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 () => { const agent = await createAgent(); 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 expect( 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. const session = await agent.createSession({ 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). await expect(agent.createSession({ workspaceDir: ws, provider: "anthropic" })).rejects.toThrow( - /provider 不能单独使用/, + /provider cannot be used alone/, ); }); }); diff --git a/packages/core/test/builtin-agents.test.ts b/packages/core/test/builtin-agents.test.ts index d5c0f82..0efe145 100644 --- a/packages/core/test/builtin-agents.test.ts +++ b/packages/core/test/builtin-agents.test.ts @@ -42,8 +42,8 @@ afterEach(async () => { const skillMdPath = (agentId: string, skillName: string): string => path.join(skillsDir(tmpRoot, DEFAULT_PROJECT_ID, agentId), skillName, "SKILL.md"); -describe("Skill 安装策略", () => { - it("普通新建 Agent 不预装任何 Skill,AGENTS.md 为空文件(指导在模板 Suggested workflows)", async () => { +describe("Skill installation policy", () => { + 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" }); expect(await listInstalledSkills(tmpRoot, DEFAULT_PROJECT_ID, "some_agent")).toEqual([]); @@ -58,7 +58,7 @@ describe("Skill 安装策略", () => { 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 }); const names = (await listInstalledSkills(tmpRoot, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID)).map( (s) => s.name, @@ -68,7 +68,7 @@ describe("Skill 安装策略", () => { }); 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(); expect(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); }); - it("provision 幂等:重复执行不改变结果", async () => { + it("provision is idempotent: running it again does not change the result", async () => { await provisionProjectAgents(); const ids = await provisionProjectAgents(); 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)); }); - it("已存在的 Agent 不被覆盖(preset 仅初始化生效)", async () => { - const custom = "# AGENTS.md\n\n用户自己改过的内容\n"; + it("an existing Agent is not overwritten (the preset only applies at initialization)", async () => { + const custom = "# AGENTS.md\n\nContent the user edited themselves\n"; await loadOrInitAgentState({ agentId: DEFAULT_AGENT_ID }); await fs.writeFile(agentsMdPath(tmpRoot, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID), custom, "utf8"); @@ -114,8 +114,8 @@ describe("provisionProjectAgents", () => { }); }); -describe("Project Dir / Agent ID 占位符", () => { - it("assembleSystemPrompt 注入 Project Dir 与 Agent ID(Skill 定位改走项目相对路径,不依赖 .penguin)", async () => { +describe("Project Dir / Agent ID placeholders", () => { + 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 prompt = assembleSystemPrompt(state, { sessionId: "session-x", diff --git a/packages/core/test/compaction.test.ts b/packages/core/test/compaction.test.ts index 0aa0eed..05d9514 100644 --- a/packages/core/test/compaction.test.ts +++ b/packages/core/test/compaction.test.ts @@ -592,7 +592,7 @@ describe("context compaction", () => { 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) // 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 diff --git a/packages/core/test/describe-image.test.ts b/packages/core/test/describe-image.test.ts index 26f3795..01fdcc2 100644 --- a/packages/core/test/describe-image.test.ts +++ b/packages/core/test/describe-image.test.ts @@ -101,14 +101,14 @@ afterEach(async () => { await rm(tmp, { recursive: true, force: true }); }); -describe("describe_image(read_image 的纯文本模型版)", () => { - it("定义原样取自配置条目(不做运行期改写)", () => { +describe("describe_image (the text-only-model variant of read_image)", () => { + it("the definition is taken verbatim from the config entry (no runtime rewriting)", () => { const tool = createDescribeImageTool(definition, { modelId: "vis-1" }); expect(tool.name).toBe(DESCRIBE_IMAGE_NAME); 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]!; await writeFile(path.join(tmp, "a.png"), PNG_1X1); const describeTool = factory(definition, undefined); @@ -127,7 +127,7 @@ describe("describe_image(read_image 的纯文本模型版)", () => { 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); const { llm, calls } = fakeLLM("图里是一只企鹅。"); const describer: VisionDescriberService = { modelId: "vis-1", createLLM: () => llm }; @@ -159,7 +159,7 @@ describe("describe_image(read_image 的纯文本模型版)", () => { 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); const { llm, calls } = fakeLLM("desc"); 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"); }); - 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); const { result, text } = await run({ source: "a.png" }, tmp, { modelId: null }); expect(result?.stopReason).toBe("failed"); @@ -175,7 +175,7 @@ describe("describe_image(read_image 的纯文本模型版)", () => { 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); const { llm } = fakeLLM("", { status: "failed", message: "401 unauthorized" }); const { result, text } = await run({ source: "a.png" }, tmp, { @@ -187,7 +187,7 @@ describe("describe_image(read_image 的纯文本模型版)", () => { 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"); const { llm, calls } = fakeLLM("desc"); const { result } = await run({ source: "a.txt" }, tmp, { @@ -198,7 +198,7 @@ describe("describe_image(read_image 的纯文本模型版)", () => { 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); const { llm } = fakeLLM("代读结果"); const env = new Environment({ diff --git a/packages/core/test/environment.test.ts b/packages/core/test/environment.test.ts index 345fb2d..1b3e835 100644 --- a/packages/core/test/environment.test.ts +++ b/packages/core/test/environment.test.ts @@ -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 () => { const NAME = "__abort_throw_tool__"; BUILTIN_TOOL_FACTORIES[NAME] = (definition) => ({ diff --git a/packages/core/test/input-images.test.ts b/packages/core/test/input-images.test.ts index 4f2494f..2220104 100644 --- a/packages/core/test/input-images.test.ts +++ b/packages/core/test/input-images.test.ts @@ -29,10 +29,10 @@ afterEach(async () => { }); 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 out = await imagesToScratchpadPaths( - [userText("看看这两张图"), imageUrlMessage(DATA_URL), imageUrlMessage(DATA_URL)], + [userText("Look at these two images"), imageUrlMessage(DATA_URL), imageUrlMessage(DATA_URL)], dir, ); @@ -40,7 +40,7 @@ describe("imagesToScratchpadPaths", () => { const p = out[0]!.payload as TextPayload; expect(p.type).toBe("text"); 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]!); expect(paths).toHaveLength(2); @@ -56,7 +56,7 @@ describe("imagesToScratchpadPaths", () => { 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); expect(out).toHaveLength(1); const p = out[0]!.payload as TextPayload; @@ -65,13 +65,13 @@ describe("imagesToScratchpadPaths", () => { expect(await readdir(tmp)).toHaveLength(0); }); - it("无图片输入原样返回(不触碰文件系统)", async () => { - const input = [userText("纯文本")]; + it("input without images is returned as-is (never touches the filesystem)", async () => { + const input = [userText("plain text")]; const out = await imagesToScratchpadPaths(input, path.join(tmp, "untouched")); expect(out).toBe(input); }); - it("无法解析的图片以说明行代替,不静默丢失", async () => { + it("an unparsable image is replaced with an explanatory line, never silently dropped", async () => { const out = await imagesToScratchpadPaths( [userText("hi"), imageUrlMessage("data:text/plain,oops")], tmp, diff --git a/packages/core/test/llm.e2e.test.ts b/packages/core/test/llm.e2e.test.ts index 50be7f2..f894477 100644 --- a/packages/core/test/llm.e2e.test.ts +++ b/packages/core/test/llm.e2e.test.ts @@ -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 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( - "连续两轮同名工具调用拿到不同 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 () => { const model = new GenerativeModel({ modelId: "gemini-3.5-flash", diff --git a/packages/core/test/llm.test.ts b/packages/core/test/llm.test.ts index 06f3682..ce52b86 100644 --- a/packages/core/test/llm.test.ts +++ b/packages/core/test/llm.test.ts @@ -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", () => { const tr = new EventTranslator(); const out: OmniMessage[] = []; @@ -1196,7 +1196,7 @@ describe("GenerativeModel.streamGenerate outcome classification (PRN-013)", () = 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 // 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 @@ -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[] => messages .filter((m) => (m.payload as { type: string }).type === "tool_call") .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([ ev({ 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); }); - it("不同 id 的并行调用不受影响(原样透传,无后缀)", () => { + it("parallel calls with distinct ids are unaffected (passed through as-is, no suffix)", () => { const { messages } = translateEvents([ ev({ 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"]); }); - 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 round = (city: string): string[] => { 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"]); }); - 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(); ids.markUsed("exec"); // this provider id was already taken in the previous turn const translator = new EventTranslator(ids); @@ -1576,7 +1576,7 @@ describe("tool_call_id 唯一化(name-as-id provider,如 Gemini 以函数名 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([ 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#12")).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"); }); - it("ToolCallIdAllocator:探测跳过已占用后缀;markUsed 播种生效", () => { + it("ToolCallIdAllocator: probing skips occupied suffixes; markUsed seeding takes effect", () => { const ids = new ToolCallIdAllocator(); ids.markUsed("t"); ids.markUsed("t#2"); @@ -1612,7 +1612,7 @@ describe("tool_call_id 唯一化(name-as-id provider,如 Gemini 以函数名 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 { constructor() { super({ modelId: "claude-sonnet-4-6", tools: [] }); diff --git a/packages/core/test/model-catalog.test.ts b/packages/core/test/model-catalog.test.ts index a618dd8..2e5e82b 100644 --- a/packages/core/test/model-catalog.test.ts +++ b/packages/core/test/model-catalog.test.ts @@ -14,7 +14,7 @@ import { } from "../src/state/index.js"; 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); expect(new Set(ids).size).toBe(ids.length); expect(MODEL_CATALOG[0]!.provider).toBe("deepseek"); @@ -36,7 +36,7 @@ describe("model-catalog", () => { 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)); for (const m of MODEL_CATALOG) { 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) { expect(m.pricing, m.modelId).toBeDefined(); 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("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) // pairs, so an identically named upstream id never matches across the wrong group. expect(catalogEntryFor("anthropic", "claude-sonnet-4-6")?.displayName).toBe( @@ -95,7 +95,7 @@ describe("model-catalog", () => { 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(); expect(entries).toHaveLength(MODEL_CATALOG.length); 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"); expect(or.map((m) => m.modelId)).toEqual([ "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 pro = MODEL_CATALOG.find((m) => m.modelId === "deepseek-v4-pro")!.pricing!; 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 路由规则解析)", () => { - it("一方厂商 id 按子串路由到厂商客户端的环境变量", () => { +describe("resolveModelEnv (PRN-021: env fallback resolved by AgentHub routing rules)", () => { + 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("claude-opus-4-8")?.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"); }); - 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("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("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) { const env = resolveModelEnv(m.modelId, m.clientType); expect(env, `${m.provider}/${m.modelId}`).toBeDefined(); diff --git a/packages/core/test/omnimessage.test.ts b/packages/core/test/omnimessage.test.ts index 2152ff1..440151c 100644 --- a/packages/core/test/omnimessage.test.ts +++ b/packages/core/test/omnimessage.test.ts @@ -161,7 +161,7 @@ describe("PartialAggregator", () => { 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 out = aggregateAll([ partialToolCallOutput({ eventType: "start", toolCallId: "c9" }), diff --git a/packages/core/test/read-image.test.ts b/packages/core/test/read-image.test.ts index 0d8f1fe..70b0fee 100644 --- a/packages/core/test/read-image.test.ts +++ b/packages/core/test/read-image.test.ts @@ -61,8 +61,8 @@ afterEach(async () => { await rm(tmp, { recursive: true, force: true }); }); -describe("read_image — 本地文件", () => { - it("按相对路径读取 png,输出 data URL 与一行 mime/大小说明", async () => { +describe("read_image — local files", () => { + 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); const { result, text } = await run({ source: "img.png" }, tmp); expect(result?.stopReason).toBeUndefined(); // Defaults to completed @@ -70,20 +70,20 @@ describe("read_image — 本地文件", () => { 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 const { result } = await run({ source: "photo.jpg" }, tmp); 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); expect(result?.stopReason).toBe("failed"); expect(result?.images).toBeUndefined(); 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); 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); @@ -92,14 +92,14 @@ describe("read_image — 本地文件", () => { 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")); const { result, text } = await run({ source: "img.bmp" }, tmp); expect(result?.stopReason).toBe("failed"); 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")); const { result, text } = await run({ source: "subdir" }, tmp); expect(result?.stopReason).toBe("failed"); @@ -107,14 +107,14 @@ describe("read_image — 本地文件", () => { 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)); const { result, text } = await run({ source: "empty.png" }, tmp); expect(result?.stopReason).toBe("failed"); 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); expect(result?.stopReason).toBe("failed"); expect(text).toContain('"source"'); @@ -122,7 +122,7 @@ describe("read_image — 本地文件", () => { }); 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( async (_input: unknown) => 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"); }); - it("非 2xx 响应以 failed 收尾并带状态码", async () => { + it("finishes as failed with the status code on a non-2xx response", async () => { vi.stubGlobal( "fetch", vi.fn(async () => new Response("nope", { status: 404 })), diff --git a/packages/core/test/resume.test.ts b/packages/core/test/resume.test.ts index 99ee637..8cc28e5 100644 --- a/packages/core/test/resume.test.ts +++ b/packages/core/test/resume.test.ts @@ -188,7 +188,7 @@ describe("agent.resumeSession", () => { it("errors when the session does not exist", async () => { const agent = await createAgent({}); 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 gone = path.join(workspace, "gone"); 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 () => { @@ -205,7 +207,9 @@ describe("agent.resumeSession", () => { metaFor(SID, workspace, { provider: "custom", model_id: "vanished-model" }), 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 () => { @@ -214,7 +218,7 @@ describe("agent.resumeSession", () => { const legacy = metaFor(SID, workspace); delete (legacy.payload as { provider?: string }).provider; 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 () => { diff --git a/packages/core/test/session-title.test.ts b/packages/core/test/session-title.test.ts index 823686e..960da86 100644 --- a/packages/core/test/session-title.test.ts +++ b/packages/core/test/session-title.test.ts @@ -60,7 +60,7 @@ const META: SessionMetaPayload = { }; describe("session-title", () => { - it("generateTitleWithLLM:收集模型 text 与用量,清洗后返回", async () => { + it("generateTitleWithLLM: collects model text and usage, returns the sanitized result", async () => { const seen: string[] = []; const result = await generateTitleWithLLM( fakeLLM( @@ -80,7 +80,7 @@ describe("session-title", () => { 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 empty = await generateTitleWithLLM(fakeLLM([], { status: "completed" }, seen), { userText: " ", @@ -92,7 +92,7 @@ describe("session-title", () => { const failed = await generateTitleWithLLM( fakeLLM( [ - assistantText("半截"), + assistantText("partial"), tokenUsage(emptyTokenCounts(), { cache_read: 0, cache_write: 0, output: 1, total: 1 }), ], { status: "failed", message: "401" }, @@ -103,7 +103,7 @@ describe("session-title", () => { 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 result = await generateTitleWithLLM( fakeLLM([assistantText("配置 Tailwind 主题")], { status: "completed" }, seen), @@ -114,24 +114,24 @@ describe("session-title", () => { 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(" \n ")).toBeNull(); 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({ meta: META, llm: fakeLLM([]), environment: fakeEnvironment, - createBareLLM: () => fakeLLM([assistantText("标题 A")]), + createBareLLM: () => fakeLLM([assistantText("Title A")]), }); expect( await withFactory.generateTitle({ material: { userText: "u", assistantText: "a" } }), ).toEqual({ - title: "标题 A", + title: "Title A", 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 session = new Session({ meta: META, - llm: fakeLLM([thinkingMessage("想想"), assistantText("答案正文")]), + llm: fakeLLM([thinkingMessage("thinking"), assistantText("answer body")]), 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 } 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 // 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). const idle = new Session({ meta: META, llm: fakeLLM([]), environment: fakeEnvironment, - createBareLLM: () => fakeLLM([assistantText("不应产生")]), + createBareLLM: () => fakeLLM([assistantText("must not be produced")]), }); expect(await idle.generateTitle()).toEqual({ title: null, usage: null }); }); diff --git a/packages/core/test/state.test.ts b/packages/core/test/state.test.ts index e356995..540a9d3 100644 --- a/packages/core/test/state.test.ts +++ b/packages/core/test/state.test.ts @@ -621,7 +621,7 @@ describe("project-config round trip", () => { ).rejects.toThrow(/model_id=nope/); await expect( 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 () => { @@ -763,7 +763,7 @@ describe("project-config round trip", () => { ).rejects.toThrow(/\(provider=custom, model_id=nope\).*model list/); await expect( 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); }); @@ -780,7 +780,9 @@ describe("project-config round trip", () => { await fs.mkdir(path.dirname(file), { recursive: true }); // Old format 1: default_model is a concatenated storage id string. 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 + // request_model_id). await fs.writeFile( @@ -792,11 +794,13 @@ describe("project-config round trip", () => { ].join("\n"), "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 = { models: [ { provider: "deepseek", model_id: "deepseek-v4-pro" }, @@ -811,7 +815,7 @@ describe("resolveModelRef(唯一的「省略 provider」解析入口)", () = model_id: "deepseek-v4-pro", }); // 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", () => { @@ -827,19 +831,19 @@ describe("resolveModelRef(唯一的「省略 provider」解析入口)", () = it("errors clearly on zero matches", () => { 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", () => { - expect(() => resolveModelRef(cfg, "shared-id")).toThrow(/歧义/); + expect(() => resolveModelRef(cfg, "shared-id")).toThrow(/Ambiguous/); expect(() => resolveModelRef(cfg, "shared-id")).toThrow( /\(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 () => { await addModel(tmpRoot, DEFAULT_PROJECT_ID, { provider: "custom", @@ -980,9 +984,9 @@ describe("defensive config parsing", () => { await loadOrInitAgentState(); const cfgPath = systemConfigPath(tmpRoot, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID); 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 expect(loadOrInitAgentState()).rejects.toThrow(/system_prompt|非法|损坏/); + await expect(loadOrInitAgentState()).rejects.toThrow(/system_prompt|Invalid|corrupted/); }); }); diff --git a/packages/core/test/validate-id.test.ts b/packages/core/test/validate-id.test.ts index 29f6730..a308c08 100644 --- a/packages/core/test/validate-id.test.ts +++ b/packages/core/test/validate-id.test.ts @@ -27,25 +27,25 @@ describe("isValidId / assertValidId", () => { "emoji😀", ]; - it.each(valid)("接受合法单段目录名:%j", (id) => { + it.each(valid)("accepts a valid single-segment directory name: %j", (id) => { expect(isValidId(id)).toBe(true); expect(() => assertValidId("project_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(() => 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(/a\/b/); expect(() => assertValidId("project_id", "..")).toThrow(/project_id/); }); }); -describe("loadOrInitAgentState id 校验", () => { +describe("loadOrInitAgentState id validation", () => { let tmpRoot: string; let prevHome: string | undefined; @@ -64,19 +64,19 @@ describe("loadOrInitAgentState id 校验", () => { 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/); }); - it("projectId 为 .. 时抛错", async () => { + it("throws when projectId is ..", async () => { 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/); }); - it("默认 id 合法,可正常初始化", async () => { + it("the default ids are valid and initialization succeeds", async () => { const state = await loadOrInitAgentState(); expect(state.projectId).toBe("default_project"); expect(state.agentId).toBe("default_agent"); diff --git a/packages/core/test/workspace.test.ts b/packages/core/test/workspace.test.ts index e8d7c80..ce42330 100644 --- a/packages/core/test/workspace.test.ts +++ b/packages/core/test/workspace.test.ts @@ -38,14 +38,14 @@ afterEach(async () => { }); 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"); expect(path.dirname(dir)).toBe(workspacesDir(tmpRoot, "proj", "agent")); expect(path.basename(dir)).toMatch(/^tmp-[0-9a-f]{8}$/); 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"); await fs.mkdir(path.join(base, "tmp-aaaaaaaa"), { recursive: true }); 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"); await fs.mkdir(path.join(base, "tmp-cccccccc"), { recursive: true }); mocked.uuids.push(...Array.from({ length: 16 }, () => "cccccccc-3333-4333-8333-333333333333")); diff --git a/packages/landing/package.json b/packages/landing/package.json index 271c660..f60b76d 100644 --- a/packages/landing/package.json +++ b/packages/landing/package.json @@ -3,7 +3,7 @@ "version": "0.0.1", "private": true, "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": { "dev": "vite", "build": "vite build && node scripts/postbuild.mjs", diff --git a/packages/server/package.json b/packages/server/package.json index e094696..3c44f6b 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -2,7 +2,7 @@ "name": "@prismshadow/penguin-server", "version": "0.0.1", "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", "repository": { "type": "git", diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index 0e6a45e..7188b51 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -254,7 +254,7 @@ export function createApp(deps: AppDeps): Hono { }); 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). app.use("*", async (c, next) => { @@ -268,7 +268,7 @@ export function createApp(deps: AppDeps): Hono { app.use("/api/*", async (c, next) => { const contentLength = Number(c.req.header("content-length") ?? 0); 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(); }); @@ -362,7 +362,7 @@ function registerStaticRoutes(app: Hono, webDist: string): void { app.get("*", async (c) => { const reqPath = decodeURIComponent(c.req.path); 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 resolved = path.resolve(webDist, rel === "" ? "index.html" : rel); @@ -384,7 +384,7 @@ function registerStaticRoutes(app: Hono, webDist: string): void { try { content = await fsp.readFile(file); } 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"; return new Response(new Uint8Array(content), { diff --git a/packages/server/src/auth/middleware.ts b/packages/server/src/auth/middleware.ts index 1258889..8487b92 100644 --- a/packages/server/src/auth/middleware.ts +++ b/packages/server/src/auth/middleware.ts @@ -33,7 +33,7 @@ export function authMiddleware(auth: AuthService): MiddlewareHandler { const token = getCookie(c, SESSION_COOKIE); const user = token ? auth.authenticate(token) : null; 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); await next(); @@ -51,7 +51,11 @@ export const jsonOnlyWrites: MiddlewareHandler = async (c, next) => { if (WRITE_METHODS.has(c.req.method)) { const contentType = c.req.header("content-type"); 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(); diff --git a/packages/server/src/auth/service.ts b/packages/server/src/auth/service.ts index d0291d3..279521b 100644 --- a/packages/server/src/auth/service.ts +++ b/packages/server/src/auth/service.ts @@ -81,7 +81,7 @@ export class AuthService { const row = this.deps.users.findById(userId); const ok = row !== null && (await verifyPassword(password, row.passwordHash)); 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()); 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 { const row = this.deps.users.findById(userId); 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) { - 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); } diff --git a/packages/server/src/config.ts b/packages/server/src/config.ts index d5f9cb1..5644530 100644 --- a/packages/server/src/config.ts +++ b/packages/server/src/config.ts @@ -54,7 +54,7 @@ export function resolveServerConfig(env: NodeJS.ProcessEnv = process.env): Serve // port; this matches the CLI's resolvePort convention. const port = Number(env.PORT || 7364); if (!Number.isInteger(port) || port < 0 || port > 65535) { - throw new Error(`非法端口配置 PORT=${env.PORT}`); + throw new Error(`Invalid port configuration PORT=${env.PORT}`); } return { root, diff --git a/packages/server/src/db/repos/schedules.ts b/packages/server/src/db/repos/schedules.ts index d743ce7..203cc29 100644 --- a/packages/server/src/db/repos/schedules.ts +++ b/packages/server/src/db/repos/schedules.ts @@ -114,7 +114,7 @@ export class SchedulesRepo { .run(args.defHash, 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 }; } diff --git a/packages/server/src/db/schema.ts b/packages/server/src/db/schema.ts index 1ecb89a..b7304c8 100644 --- a/packages/server/src/db/schema.ts +++ b/packages/server/src/db/schema.ts @@ -9,30 +9,30 @@ export const SCHEMA_SQL = ` 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) - is_admin INTEGER NOT NULL DEFAULT 0, -- 内置 admin(启动时种子)为 1 - password_is_initial INTEGER NOT NULL DEFAULT 0, -- 1=初始密码(种子/管理员设置);本人改密后清 0 + 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=initial password (seeded/admin-set); cleared once the user changes it created_at TEXT NOT NULL ); 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, 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 ( - 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), 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, user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, created_at TEXT NOT NULL, 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, agent_id TEXT NOT NULL, created_at TEXT NOT NULL, @@ -42,63 +42,63 @@ CREATE TABLE IF NOT EXISTS sessions ( session_id TEXT PRIMARY KEY, project_id TEXT NOT NULL, agent_id TEXT NOT NULL, - provider TEXT NOT NULL, -- 会话模型的厂商分组(与 model_id 成对构成模型引用) - model_id TEXT NOT NULL, -- 上游模型 id(原样发给 AgentHub;禁止 / 拼接) + provider TEXT NOT NULL, -- provider group of the session model (paired with model_id as a model reference) + model_id TEXT NOT NULL, -- upstream model id (sent to AgentHub as-is; never concatenate /) workspace TEXT NOT NULL, approval_mode TEXT NOT NULL DEFAULT 'allow-all', -- allow-all|deny-all|read-only|always-ask - title TEXT, -- 首次对话后由模型自动生成;NULL=未生成(前端显示「新对话」) - archived_at TEXT, -- 归档时刻;NULL=未归档(默认展示;归档后收进「已归档」) - source TEXT, -- 会话来源:NULL=用户创建 | schedule(定时任务)| subagent(子会话) + title TEXT, -- auto-generated by the model after the first exchange; NULL=not yet (frontend shows "New chat") + archived_at TEXT, -- archive time; NULL=not archived (shown by default; archived ones move under "Archived") + source TEXT, -- session origin: NULL=user-created | schedule (scheduled task) | subagent (child session) created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS usage_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, 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, agent_id TEXT NOT NULL, - session_id TEXT NOT NULL, -- 顶层 Session(子会话消耗计入所属主 Session) - origin_session_id TEXT, -- 直接来源子 Session(origin 链末项);NULL=主会话 - provider TEXT NOT NULL, -- 厂商分组:与 model_id 成对构成归因键,聚合一律 GROUP BY provider, model_id - model_id TEXT NOT NULL, -- 上游模型 id(与 provider 成对;同名 model_id 跨厂商分开聚合) + session_id TEXT NOT NULL, -- top-level Session (child-session usage counts toward its main Session) + origin_session_id TEXT, -- immediate source child Session (last of the origin chain); NULL=main session + 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, -- upstream model id (paired with provider; same model_id across providers aggregates separately) cache_read INTEGER NOT NULL, cache_write INTEGER NOT NULL, output INTEGER NOT NULL, - total INTEGER NOT NULL, -- 取 token_usage.request(每 Request 一条) - status TEXT NOT NULL DEFAULT 'completed' -- 请求结局:completed=成功(含 token);其余=失败(0 token,供成功率) -); -- 成本不落库:查询时按当前 pricing 实时折算 + total INTEGER NOT NULL, -- from token_usage.request (one row per Request) + status TEXT NOT NULL DEFAULT 'completed' -- request outcome: completed=success (with tokens); others=failure (0 tokens, for success rate) +); -- 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_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, ts TEXT NOT NULL, - date TEXT NOT NULL, -- 本地日期 yyyy-mm-dd(与 usage_records 同口径) - project_id TEXT, -- 可空:登录/注册、进程级异常没有 Project 上下文 + date TEXT NOT NULL, -- local date yyyy-mm-dd (same convention as usage_records) + project_id TEXT, -- nullable: sign-in/registration and process-level errors have no Project context agent_id TEXT, session_id TEXT, 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 / ... - status INTEGER, -- HTTP 状态码;非 HTTP 来源为 NULL - message TEXT NOT NULL -- 截断 500 字符(不存堆栈:堆栈只进日志) + status INTEGER, -- HTTP status code; NULL for non-HTTP sources + 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 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, agent_id TEXT NOT NULL, - name TEXT NOT NULL, -- 文件名(去 .toml)即标识 - creator_user_id TEXT, -- 创建者(API 创建时记;手编文件对账登记回退 Project owner) - start_at_ms INTEGER NOT NULL, -- 定义身份:start_at 变更视为新任务实例,重置触发状态 - def_hash TEXT NOT NULL, -- 文件内容指纹:变更即清除失效标记(文件修改后重新生效) - last_slot_ms INTEGER, -- 已消化的最近应触发时刻(触发或跳过都推进;重启不重复触发) - last_fired_at TEXT, -- 最近实际发送时刻(展示用) - fired_once INTEGER NOT NULL DEFAULT 0, -- 一次性任务已触发 - missed INTEGER NOT NULL DEFAULT 0, -- 一次性任务已错过(启动/登记对账标记,错过不补) - invalid_reason TEXT, -- 失效原因(如绑定 Session 已删除);NULL 即正常 + name TEXT NOT NULL, -- file name (without .toml) doubles as the identifier + 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, -- defines identity: a start_at change counts as a new task instance, resetting fire state + 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, -- latest due slot already consumed (advances on fire or skip; no re-fire across restarts) + last_fired_at TEXT, -- last actual fire time (for display) + fired_once INTEGER NOT NULL DEFAULT 0, -- one-shot task has fired + missed INTEGER NOT NULL DEFAULT 0, -- one-shot task was missed (flagged by startup/registration reconciliation; not backfilled) + invalid_reason TEXT, -- invalidation reason (e.g. the bound Session was deleted); NULL=healthy PRIMARY KEY (project_id, agent_id, name) ); CREATE TABLE IF NOT EXISTS ui_prefs ( 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 ); `; diff --git a/packages/server/src/http/errors.ts b/packages/server/src/http/errors.ts index a9da343..983614f 100644 --- a/packages/server/src/http/errors.ts +++ b/packages/server/src/http/errors.ts @@ -1,6 +1,6 @@ /** * 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 * 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( 400, "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); } // Unknown error: print the stack for diagnosis, but never expose details externally. - console.error(`[server] 未处理异常: ${err.stack ?? err.message}`); - return c.json(errorBody("internal", "服务器内部错误。"), 500); + console.error(`[server] Unhandled exception: ${err.stack ?? err.message}`); + return c.json(errorBody("internal", "Internal server error."), 500); } diff --git a/packages/server/src/http/routes/admin.ts b/packages/server/src/http/routes/admin.ts index 68ef49b..73f6a5d 100644 --- a/packages/server/src/http/routes/admin.ts +++ b/packages/server/src/http/routes/admin.ts @@ -14,7 +14,7 @@ export function adminUsersRoutes(deps: AppDeps): Hono { app.use("*", async (c, next) => { 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(); }); diff --git a/packages/server/src/http/routes/agent-config.ts b/packages/server/src/http/routes/agent-config.ts index 311dfe5..c9c7957 100644 --- a/packages/server/src/http/routes/agent-config.ts +++ b/packages/server/src/http/routes/agent-config.ts @@ -33,7 +33,7 @@ export function agentConfigRoutes(deps: AppDeps): Hono { if (agentsMd !== undefined) req.agentsMd = agentsMd; if (body.config !== undefined) { 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"]; } diff --git a/packages/server/src/http/routes/agent-transfer.ts b/packages/server/src/http/routes/agent-transfer.ts index 2f8a896..5c08d9e 100644 --- a/packages/server/src/http/routes/agent-transfer.ts +++ b/packages/server/src/http/routes/agent-transfer.ts @@ -42,10 +42,11 @@ export function agentTransferRoutes(deps: AppDeps): Hono { try { archive = Buffer.from(dataBase64, "base64"); } catch { - throw badRequest("dataBase64 不是合法的 base64。"); + throw badRequest("dataBase64 is not valid base64."); } - if (archive.byteLength === 0) throw badRequest("导入包为空。"); - if (archive.byteLength > MAX_ARCHIVE_BYTES) throw badRequest("导入包超过 14MB 上限。"); + if (archive.byteLength === 0) throw badRequest("Import package is empty."); + 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 res: AgentImportResponse = { version }; return c.json(res); diff --git a/packages/server/src/http/routes/dirs.ts b/packages/server/src/http/routes/dirs.ts index e94fa18..4683cd1 100644 --- a/packages/server/src/http/routes/dirs.ts +++ b/packages/server/src/http/routes/dirs.ts @@ -31,18 +31,22 @@ export function dirsRoutes(deps: AppDeps): Hono { const raw = c.req.query("path"); const target = raw && raw.trim() ? raw.trim() : os.homedir(); 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; try { real = await fs.realpath(target); } 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); 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[] = []; diff --git a/packages/server/src/http/routes/models.ts b/packages/server/src/http/routes/models.ts index 3d0a82e..93d199f 100644 --- a/packages/server/src/http/routes/models.ts +++ b/packages/server/src/http/routes/models.ts @@ -18,7 +18,7 @@ import type { AppDeps } from "../../app.js"; /** Validate a paired reference object ({ provider, modelId }); shape mismatch throws 400. */ function parseRef(value: unknown, label: string): ModelRefDto { 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; 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). */ function parseModelsUpdate(body: Record): 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) => { 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; const entry: ModelUpdateEntry = { @@ -49,7 +49,7 @@ function parseModelsUpdate(body: Record): ModelsUpdateRequest { }; if (m.displayName !== undefined) { 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; } @@ -59,32 +59,32 @@ function parseModelsUpdate(body: Record): ModelsUpdateRequest { } if (m.contextWindow !== undefined) { 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; } if (m.clientType !== undefined) { 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. if (m.clientType) entry.clientType = m.clientType; } if (m.vision !== undefined) { if (typeof m.vision !== "boolean") { - throw badRequest(`models[${i}].vision 必须是布尔值。`); + throw badRequest(`models[${i}].vision must be a boolean.`); } entry.vision = m.vision; } if (m.pricing !== undefined) { const p = m.pricing as Record; 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) { const v = p[key]; 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 = { @@ -95,19 +95,19 @@ function parseModelsUpdate(body: Record): ModelsUpdateRequest { } if (m.apiKey !== undefined) { 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; } if (m.clearApiKey !== undefined) { if (typeof m.clearApiKey !== "boolean") { - throw badRequest(`models[${i}].clearApiKey 必须是布尔值。`); + throw badRequest(`models[${i}].clearApiKey must be a boolean.`); } entry.clearApiKey = m.clearApiKey; } if (m.baseUrl !== undefined) { 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; } @@ -153,22 +153,22 @@ export function modelsRoutes(deps: AppDeps): Hono { modelId: requireString(body, "modelId", { minLen: 1, maxLen: 200 }), }; 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.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; } // 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 !== 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; } 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; } return c.json(await deps.projectConfigService.testModel(projectId, req)); diff --git a/packages/server/src/http/routes/schedules.ts b/packages/server/src/http/routes/schedules.ts index 8929308..7786b20 100644 --- a/packages/server/src/http/routes/schedules.ts +++ b/packages/server/src/http/routes/schedules.ts @@ -48,7 +48,7 @@ function parseUpsertBody(body: Record): { modelId?: 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 startAt = requireString(body, "startAt", { minLen: 1, maxLen: 100 }); 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. */ function requireScheduleName(raw: string | undefined): string { - if (!raw || !isValidId(raw)) throw badRequest("定时任务名非法。"); + if (!raw || !isValidId(raw)) throw badRequest("Invalid schedule name."); return raw; } @@ -157,7 +157,7 @@ export function scheduleRoutes(deps: AppDeps): Hono { const body = await readJson(c); const name = requireScheduleName(requireString(body, "name", { minLen: 1, maxLen: 100 })); 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); return c.json(await readItem(deps, projectId, agentId, name), 201); @@ -178,7 +178,7 @@ export function scheduleRoutes(deps: AppDeps): Hono { deps.projectService.requireProjectOwner(c.var.user.userId, projectId); const name = requireScheduleName(c.req.param("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); await upsert(deps, c.var.user.userId, projectId, agentId, name, body); @@ -191,7 +191,8 @@ export function scheduleRoutes(deps: AppDeps): Hono { deps.projectService.requireProjectOwner(c.var.user.userId, projectId); const name = requireScheduleName(c.req.param("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); return c.body(null, 204); }); @@ -211,10 +212,10 @@ async function upsert( const fields = parseUpsertBody(body); const raw = serializeSchedule(fields); 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. 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); // Creator attribution: the API writer is the creator (falls back to the Project owner only for hand-edited files). deps.schedulesRepo.registerOrSync({ @@ -238,6 +239,6 @@ async function readItem( const entry = entries.find((e) => e.def.name === name); if (entry) return toItem(entry.def, entry.state, entry.queued, Date.now()); const bad = invalid.find((i) => i.name === name); - if (bad) throw badRequest(`定时任务文件非法:${bad.error}`); - throw new HttpError(404, "schedule_not_found", `定时任务不存在:${name}`); + if (bad) throw badRequest(`Invalid schedule file: ${bad.error}`); + throw new HttpError(404, "schedule_not_found", `Schedule does not exist: ${name}`); } diff --git a/packages/server/src/http/routes/sessions.ts b/packages/server/src/http/routes/sessions.ts index 5459846..8c7f7c5 100644 --- a/packages/server/src/http/routes/sessions.ts +++ b/packages/server/src/http/routes/sessions.ts @@ -59,16 +59,16 @@ const APPROVAL_MODES: readonly ApprovalMode[] = [ function parseTaskInput(body: Record): OmniMessage[] { const input = body.input; 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) => { 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; if (part.type === "text") { 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); } @@ -78,11 +78,11 @@ function parseTaskInput(body: Record): OmniMessage[] { typeof url !== "string" || !(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); } - 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 { 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). 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); let workspace = optionalString(body, "workspace", { minLen: 1, label: "workspace" }); @@ -141,12 +143,20 @@ export function sessionsRoutes(deps: AppDeps): Hono { const sessionId = c.req.param("sessionId"); const row = sessionId ? deps.sessionsRepo.findById(sessionId) : null; 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 { deps.projectService.requireProjectAccess(c.var.user.userId, row.projectId); } 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; }; @@ -167,15 +177,23 @@ export function sessionsRoutes(deps: AppDeps): Hono { let title: string | undefined; if (titleRaw !== undefined) { 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(); 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) { - 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 }; if (title !== undefined) { @@ -239,7 +257,7 @@ export function sessionsRoutes(deps: AppDeps): Hono { const row = resolveSession(c); const fileName = c.req.param("fileName") ?? ""; 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( scratchpadDir(deps.config.root, row.projectId, row.agentId), @@ -250,7 +268,7 @@ export function sessionsRoutes(deps: AppDeps): Hono { try { bytes = await fs.readFile(filePath); } catch { - throw new HttpError(404, "file_not_found", "文件不存在。"); + throw new HttpError(404, "file_not_found", "File does not exist."); } const MIME_BY_EXT: Record = { ".png": "image/png", @@ -309,7 +327,11 @@ export function sessionsRoutes(deps: AppDeps): Hono { const decision = requireEnum(body, "decision", ["allow", "deny"] as const); const ok = deps.manager.decideApproval(row.sessionId, pathParam(c, "toolCallId"), decision); 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); }); @@ -374,7 +396,7 @@ export function sessionsRoutes(deps: AppDeps): Hono { !paths.every((p) => typeof p === "string" && p.length <= STAT_MAX_PATH_LEN) ) { 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[]); @@ -386,11 +408,11 @@ export function sessionsRoutes(deps: AppDeps): Hono { const rel = c.req.query("path") ?? ""; const body = await readJson(c); if (typeof body.dataBase64 !== "string") { - throw badRequest("dataBase64 必须是 base64 字符串。"); + throw badRequest("dataBase64 must be a base64 string."); } const data = Buffer.from(body.dataBase64, "base64"); 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); return c.body(null, 204); diff --git a/packages/server/src/http/routes/skills.ts b/packages/server/src/http/routes/skills.ts index fe4debd..e8a9759 100644 --- a/packages/server/src/http/routes/skills.ts +++ b/packages/server/src/http/routes/skills.ts @@ -64,11 +64,11 @@ function libraryResponse(): SkillLibraryResponse { /** Validate the POST request body: names must be a non-empty array of strings. */ function parseInstallNames(body: Record): string[] { 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) => { if (typeof v !== "string" || v.length === 0) { - throw badRequest(`names[${i}] 必须是非空字符串。`); + throw badRequest(`names[${i}] must be a non-empty string.`); } return v; }); @@ -110,7 +110,7 @@ export function agentSkillsRoutes(deps: AppDeps): Hono { // 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 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; }); for (const skill of skills) { @@ -129,7 +129,7 @@ export function agentSkillsRoutes(deps: AppDeps): Hono { try { await fs.access(file); } 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); return c.body(null, 204); diff --git a/packages/server/src/http/routes/usage.ts b/packages/server/src/http/routes/usage.ts index f98fdde..e1d00b4 100644 --- a/packages/server/src/http/routes/usage.ts +++ b/packages/server/src/http/routes/usage.ts @@ -20,7 +20,7 @@ export function usageRoutes(deps: AppDeps): Hono { deps.projectService.requireProjectAccess(c.var.user.userId, projectId); const groupByRaw = c.req.query("groupBy") ?? "date"; 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 to = optionalDateParam(c.req.query("to"), "to"); diff --git a/packages/server/src/http/routes/vault.ts b/packages/server/src/http/routes/vault.ts index 90495ed..5010f3c 100644 --- a/packages/server/src/http/routes/vault.ts +++ b/packages/server/src/http/routes/vault.ts @@ -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). */ function parseVaultUpdate(body: Record): 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) => { 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; const entry: VaultEntryUpdate = { @@ -22,7 +22,7 @@ function parseVaultUpdate(body: Record): VaultUpdateRequest { }; if (e.value !== undefined) { 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; } diff --git a/packages/server/src/http/validate.ts b/packages/server/src/http/validate.ts index 3125611..409e0fa 100644 --- a/packages/server/src/http/validate.ts +++ b/packages/server/src/http/validate.ts @@ -19,7 +19,7 @@ export function badRequest(message: string): HttpError { export function pathParam(c: Context, name: string): string { const v = c.req.param(name); if (v === undefined || v === "") { - throw new HttpError(404, "not_found", "路径参数缺失。"); + throw new HttpError(404, "not_found", "Missing path parameter."); } return v; } @@ -35,7 +35,7 @@ export function pathParam(c: Context, name: string): string { export function requireValidId(c: Context, name: string): string { const v = pathParam(c, name); 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; } @@ -43,7 +43,7 @@ export function requireValidId(c: Context, name: string): string { /** Parse a positive-integer path parameter (e.g. Trace file index). */ export function positiveIntParam(c: Context, name: string): number { 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; } @@ -51,9 +51,10 @@ export function positiveIntParam(c: Context, name: string): number { export function paginationQuery(c: Context): { offset: number; limit: number } { const offset = Number.parseInt(c.req.query("offset") ?? "0", 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) { - throw badRequest("limit 必须是 1~1000 的整数。"); + throw badRequest("limit must be an integer between 1 and 1000."); } return { offset, limit }; } @@ -64,10 +65,10 @@ export async function readJson(c: Context): Promise> { try { body = await c.req.json(); } catch { - throw badRequest("请求体必须是合法 JSON。"); + throw badRequest("Request body must be valid JSON."); } 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; } @@ -87,15 +88,15 @@ export function requireString( ): string { const v = obj[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) { - throw badRequest(`${label} 长度至少 ${rule.minLen} 个字符。`); + throw badRequest(`${label} must be at least ${rule.minLen} characters.`); } 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)) { - throw badRequest(`${label} 格式不合法。`); + throw badRequest(`${label} has an invalid format.`); } return v; } @@ -117,7 +118,7 @@ export function requireEnum( ): T { const v = obj[key]; 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; } @@ -150,12 +151,12 @@ export function optionalNumber( const v = obj[key]; if (v === undefined) return undefined; const label = rule.label ?? key; - if (typeof v !== "number" || !Number.isFinite(v)) throw badRequest(`${label} 必须是数字。`); - if (rule.integer && !Number.isInteger(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} must be an integer.`); 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; } @@ -163,7 +164,7 @@ export function optionalNumber( export function optionalDateParam(value: string | undefined, label: string): string | undefined { if (value === undefined || value === "") return undefined; 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; } diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index c1f0da2..41d366c 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -27,8 +27,8 @@ await deps.authService.seedAdmin(); await deps.scheduler.start(); const server = serve({ fetch: app.fetch, hostname: config.host, port: config.port }, (info) => { - console.log(`penguin-server 已启动: http://${config.host}:${info.port}`); - console.log(`数据根目录: ${config.root}`); + console.log(`penguin-server started: http://${config.host}:${info.port}`); + console.log(`Data root: ${config.root}`); console.log(`SQLite: ${config.dbPath}`); }); @@ -36,7 +36,7 @@ let shuttingDown = false; async function shutdown(signal: string, exitCode = 0): Promise { if (shuttingDown) return; shuttingDown = true; - console.log(`收到 ${signal},正在关停…`); + console.log(`Received ${signal}, shutting down…`); deps.scheduler.stop(); await deps.manager.shutdown(5000); 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 // record, then handle each case according to its nature. 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" }); // 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 @@ -68,7 +68,7 @@ process.on("uncaughtException", (err) => { }); process.on("unhandledRejection", (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" }); // 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 diff --git a/packages/server/src/runtime/schedule-file.ts b/packages/server/src/runtime/schedule-file.ts index 18463ca..d73d6e0 100644 --- a/packages/server/src/runtime/schedule-file.ts +++ b/packages/server/src/runtime/schedule-file.ts @@ -82,30 +82,31 @@ export function parseScheduleFile(name: string, raw: string): ScheduleParseResul } catch (err) { return { 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") - return { ok: false, error: "内容不是 TOML 表" }; + return { ok: false, error: "Content is not a TOML table" }; const t = parsed as Record; const prompt = t["prompt"]; 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"]; - 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"]); - 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 periodMs: number | 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"]); - if (ms === null) return { ok: false, error: "period 必须形如 30m / 12h / 7d" }; - if (ms < MIN_PERIOD_MS) return { ok: false, error: "period 低于下限 5m" }; + if (ms === null) return { ok: false, error: "period must look like 30m / 12h / 7d" }; + if (ms < MIN_PERIOD_MS) return { ok: false, error: "period is below the 5m minimum" }; period = t["period"].trim(); periodMs = ms; } @@ -113,41 +114,46 @@ export function parseScheduleFile(name: string, raw: string): ScheduleParseResul let endAt: { ms: number; raw: string } | undefined; if (t["end_at"] !== undefined) { const parsedEnd = parseInstant(t["end_at"]); - if (parsedEnd === null) return { ok: false, error: "end_at 不是合法的 ISO 8601 时刻" }; - if (parsedEnd.ms <= startAt.ms) return { ok: false, error: "end_at 必须晚于 start_at" }; + 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 must be later than start_at" }; endAt = parsedEnd; } let sessionId: string | undefined; if (t["session_id"] !== undefined) { 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"]; } let workspace: string | undefined; if (t["workspace"] !== undefined) { 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"]; } let modelId: string | undefined; if (t["model_id"] !== undefined) { 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"]; } let provider: string | undefined; if (t["provider"] !== undefined) { 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"]; } 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 ( sessionId !== undefined && @@ -155,7 +161,7 @@ export function parseScheduleFile(name: string, raw: string): ScheduleParseResul ) { return { ok: false, - error: "目标二选一:workspace 与 provider / model_id 仅用于新建 Session 模式", + error: "Pick one target: workspace and provider / model_id are only for new-Session mode", }; } diff --git a/packages/server/src/runtime/session-manager.ts b/packages/server/src/runtime/session-manager.ts index 3d85309..b3d077a 100644 --- a/packages/server/src/runtime/session-manager.ts +++ b/packages/server/src/runtime/session-manager.ts @@ -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. */ function compactUnavailable(why: Exclude): HttpError { const messages: Record = { - unsupported: "该 Agent 未配置上下文压缩能力。", - empty: "当前上下文没有可压缩的内容(尚无已完成的对话轮次)。", - just_compacted: "上下文刚压缩过,此后还没有新的对话,无需再次压缩。", + unsupported: "This Agent does not have context compaction configured.", + empty: "The current context has nothing to compact (no completed conversation turns yet).", + 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]); } @@ -129,7 +130,7 @@ export function createCoreSessionLoader(root: string): SessionLoader { throw new HttpError( 409, "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 { @@ -533,7 +534,11 @@ export class SessionManager { private assertOpen(): void { 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 { const row = this.deps.sessions.findById(sessionId); 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). */ private assertSessionNotDeleting(sessionId: string): void { 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 { 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") { - 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; const row = this.deps.sessions.findById(sessionId); 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); // The Session/Agent was marked for deletion while loading: discard the load result, @@ -676,7 +697,7 @@ export class SessionManager { } } catch (err) { 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({ source: "subagent", @@ -702,7 +723,7 @@ export class SessionManager { try { await this.deps.recorder.record(ctx, msg); } 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" }); } } @@ -710,7 +731,7 @@ export class SessionManager { // 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.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" }); } finally { diff --git a/packages/server/src/runtime/stream-error-watcher.ts b/packages/server/src/runtime/stream-error-watcher.ts index 1fcc7b2..d360171 100644 --- a/packages/server/src/runtime/stream-error-watcher.ts +++ b/packages/server/src/runtime/stream-error-watcher.ts @@ -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). */ const LLM_FAILURES: Record = { - failed: { code: "llm_failed", kind: "unexpected", text: "LLM 请求失败(不可重试)。" }, - timeout: { code: "llm_timeout", kind: "expected", text: "LLM 请求超时(引擎重连重试)。" }, + failed: { code: "llm_failed", kind: "unexpected", text: "LLM request failed (not retryable)." }, + timeout: { + code: "llm_timeout", + kind: "expected", + text: "LLM request timed out (the engine reconnects and retries).", + }, malformed: { code: "llm_malformed", 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. */ function toolFailureText(output: string): string { - if (!output) return "工具执行失败(无输出)。"; + if (!output) return "Tool execution failed (no output)."; if (output.length <= MESSAGE_MAX) return output; return `…${output.slice(output.length - (MESSAGE_MAX - 1))}`; } diff --git a/packages/server/src/runtime/title-generator.ts b/packages/server/src/runtime/title-generator.ts index 601ccd8..33ed640 100644 --- a/packages/server/src/runtime/title-generator.ts +++ b/packages/server/src/runtime/title-generator.ts @@ -71,7 +71,7 @@ export class TitleGenerator implements TitleNotifier { this.inflight.add(ctx.sessionId); void this.generate(ctx, session, req) .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" }); }) .finally(() => { @@ -97,7 +97,9 @@ export class TitleGenerator implements TitleNotifier { try { await this.deps.recorder.record(ctx, tokenUsage(emptyTokenCounts(), res.usage)); } 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({ source: "title", err, @@ -109,7 +111,7 @@ export class TitleGenerator implements TitleNotifier { } catch (err) { // A model request error (rate limit / timeout / network, etc.) shouldn't leave the // 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" }); } // When the LLM produces no usable title (failure / empty result), truncate the fallback material's first line — this guarantees a title is always generated. diff --git a/packages/server/src/services/admin-service.ts b/packages/server/src/services/admin-service.ts index c9f3de2..cf386cb 100644 --- a/packages/server/src/services/admin-service.ts +++ b/packages/server/src/services/admin-service.ts @@ -41,13 +41,17 @@ export class AdminService { async createUser(userId: string, password: string): Promise { 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) { - 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)) { - throw new HttpError(409, "user_exists", `用户已存在:${userId}。`); + throw new HttpError(409, "user_exists", `User already exists: ${userId}.`); } const user: UserRow = { 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). */ async resetPassword(userId: string, password: string): Promise { 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) { - 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.authSessions.deleteByUser(userId); @@ -83,10 +87,10 @@ export class AdminService { async deleteUser(userId: string): Promise { const target = this.deps.users.findById(userId); 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) { - 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)) { await this.deps.projectService.destroyProject(project.projectId); diff --git a/packages/server/src/services/agent-config-service.ts b/packages/server/src/services/agent-config-service.ts index 22599b3..40adfd4 100644 --- a/packages/server/src/services/agent-config-service.ts +++ b/packages/server/src/services/agent-config-service.ts @@ -68,7 +68,7 @@ export class AgentConfigService { async requireExists(projectId: string, agentId: string): Promise { 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) { if (!isValidVaultKey(entry.key)) { 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)) { - throw badRequest(`entries 中存在重复的键名:${entry.key}。`); + throw badRequest(`entries contains a duplicate key: ${entry.key}.`); } seen.add(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 // make exec spawn fail (E2BIG), so we reject it on write (same limit as core). 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; } else if (prevValue !== undefined) { nextVault[entry.key] = prevValue; } 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[] { - if (!Array.isArray(value)) throw badRequest("toolsBuiltin 必须是数组。"); + if (!Array.isArray(value)) throw badRequest("toolsBuiltin must be an array."); return value.map((item, i) => { const t = asRecord(item); 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") { - throw badRequest(`toolsBuiltin[${i}].description 必须是字符串。`); + throw badRequest(`toolsBuiltin[${i}].description must be a string.`); } 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") { - throw badRequest(`toolsBuiltin[${i}].forModel 必须是 vision / text-only 之一。`); + throw badRequest(`toolsBuiltin[${i}].forModel must be one of vision / text-only.`); } optionalNumber(t, "timeoutMs", { integer: true, @@ -327,14 +329,14 @@ function validateToolsBuiltin(value: unknown): ToolDefinitionConfig[] { } 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) => { const s = asRecord(item); 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)) { - throw badRequest(`mcpServers[${i}].config 必须是对象。`); + throw badRequest(`mcpServers[${i}].config must be an object.`); } return s as unknown as MCPServerConfig; }); diff --git a/packages/server/src/services/agent-service.ts b/packages/server/src/services/agent-service.ts index 421b244..9343995 100644 --- a/packages/server/src/services/agent-service.ts +++ b/packages/server/src/services/agent-service.ts @@ -155,7 +155,7 @@ export class AgentService { throw new HttpError( 409, "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 }); @@ -175,7 +175,11 @@ export class AgentService { description?: string, ): Promise { 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 = this.agents.exists(projectId, agentId) || @@ -184,7 +188,7 @@ export class AgentService { () => false, )); 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; await coreCreateAgent({ root: this.root, projectId, agentId }); diff --git a/packages/server/src/services/ids.ts b/packages/server/src/services/ids.ts index 4bdee87..34fd365 100644 --- a/packages/server/src/services/ids.ts +++ b/packages/server/src/services/ids.ts @@ -31,4 +31,5 @@ export const PROJECT_SUFFIX_PATTERN = /^[a-z0-9_]+$/; export const PROJECT_ID_MAX_LENGTH = 64; /** 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"; diff --git a/packages/server/src/services/project-config-service.ts b/packages/server/src/services/project-config-service.ts index a95bf63..312573a 100644 --- a/packages/server/src/services/project-config-service.ts +++ b/packages/server/src/services/project-config-service.ts @@ -364,7 +364,7 @@ export class ProjectConfigService { const key = refKey(entry.provider, entry.modelId); if (seen.has(key)) { throw badRequest( - `models 中存在重复的模型引用:${showRef(entry.provider, entry.modelId)}。`, + `models contains a duplicate model reference: ${showRef(entry.provider, entry.modelId)}.`, ); } seen.add(key); @@ -436,7 +436,7 @@ export class ProjectConfigService { if (req.defaultModel !== undefined) { if (!seen.has(refKey(req.defaultModel.provider, req.defaultModel.modelId))) { 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; @@ -459,12 +459,12 @@ export class ProjectConfigService { if (req.visionModel !== undefined) { if (!seen.has(refKey(req.visionModel.provider, req.visionModel.modelId))) { 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) { 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; diff --git a/packages/server/src/services/project-service.ts b/packages/server/src/services/project-service.ts index 6fa230c..ea8af57 100644 --- a/packages/server/src/services/project-service.ts +++ b/packages/server/src/services/project-service.ts @@ -84,7 +84,11 @@ export class ProjectService { requireProjectAccess(userId: string, projectId: string): ProjectRow & { role: ProjectRole } { const row = this.resolveAccess(userId, projectId); 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; } @@ -103,7 +107,11 @@ export class ProjectService { requireProjectOwner(userId: string, projectId: string): ProjectRow { const row = this.requireProjectAccess(userId, projectId); 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; } @@ -149,7 +157,7 @@ export class ProjectService { throw new HttpError( 400, "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 { @@ -159,7 +167,7 @@ export class ProjectService { throw new HttpError( 400, "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 || (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 createdAt = new Date().toISOString(); @@ -179,7 +187,7 @@ export class ProjectService { this.deps.projects.insert({ projectId, ownerUserId: owner.userId, createdAt }); } catch (err) { 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; } @@ -248,14 +256,14 @@ export class ProjectService { throw new HttpError( 409, "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) { throw new HttpError( 409, "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); @@ -306,13 +314,21 @@ export class ProjectService { const project = this.requireProjectOwner(userId, projectId); const target = this.deps.users.findById(targetUserId); 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) { - 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)) { - 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(); this.deps.members.insert({ projectId, userId: target.userId, createdAt }); @@ -323,7 +339,7 @@ export class ProjectService { removeMember(userId: string, projectId: string, targetUserId: string): void { this.requireProjectOwner(userId, projectId); 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); } diff --git a/packages/server/src/services/session-service.ts b/packages/server/src/services/session-service.ts index 418bac8..c8758d1 100644 --- a/packages/server/src/services/session-service.ts +++ b/packages/server/src/services/session-service.ts @@ -168,7 +168,7 @@ export class SessionService { throw new HttpError( 400, "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; diff --git a/packages/server/src/services/snapshot-service.ts b/packages/server/src/services/snapshot-service.ts index bde969b..9728958 100644 --- a/packages/server/src/services/snapshot-service.ts +++ b/packages/server/src/services/snapshot-service.ts @@ -42,7 +42,7 @@ export class SnapshotService { try { raw = await fs.readFile(systemConfigPath(this.root, projectId, agentId), "utf8"); } 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; return agentStateVersion({ @@ -117,7 +117,7 @@ export class SnapshotService { filter: (p) => !isVaultEntry(p), }); } 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. @@ -126,21 +126,21 @@ export class SnapshotService { try { parsed = parseYaml(await fs.readFile(configPath, "utf8")); } catch { - throw badRequest("导入失败:包内缺少 agent_state/system_config.yaml。"); + throw badRequest("Import failed: the package is missing agent_state/system_config.yaml."); } if ( parsed === null || typeof parsed !== "object" || 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; if ( incomingRaw !== undefined && (!Number.isInteger(incomingRaw) || (incomingRaw as number) < 1) ) { - throw badRequest("导入失败:包内 version 非法。"); + throw badRequest("Import failed: the package's version is invalid."); } const incoming = agentStateVersion({ version: typeof incomingRaw === "number" ? incomingRaw : undefined, @@ -149,7 +149,7 @@ export class SnapshotService { throw new HttpError( 409, "version_conflict", - `包版本 v${incoming} 不高于当前 v${current},需要确认后导入。`, + `Package version v${incoming} is not higher than the current v${current}; confirmation is required to import.`, ); } diff --git a/packages/server/src/services/trace-service.ts b/packages/server/src/services/trace-service.ts index b9f2691..465d11d 100644 --- a/packages/server/src/services/trace-service.ts +++ b/packages/server/src/services/trace-service.ts @@ -675,7 +675,7 @@ export class TraceService { throw new HttpError( 404, "trace_not_found", - `该 Session 没有 index 为 ${index} 的 Trace 文件。`, + `This Session has no Trace file with index ${index}.`, ); } return readTraceTolerant(file.path); diff --git a/packages/server/src/services/workspace-files-service.ts b/packages/server/src/services/workspace-files-service.ts index 32be382..139fecb 100644 --- a/packages/server/src/services/workspace-files-service.ts +++ b/packages/server/src/services/workspace-files-service.ts @@ -56,7 +56,7 @@ export class WorkspaceFilesService { try { return await fs.realpath(path.resolve(workspace)); } 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. */ 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); if (!this.isInside(target, base)) { - throw badRequest("path 必须位于 Workspace 内。"); + throw badRequest("path must be inside the Workspace."); } return target; } private assertInside(real: string, realBase: string): void { 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); } catch (err) { 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; } @@ -132,7 +132,7 @@ export class WorkspaceFilesService { const realBase = await this.realBase(workspace); const target = this.lexicalTarget(path.resolve(workspace), rel); 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); let canonicalParent: string; try { @@ -147,7 +147,7 @@ export class WorkspaceFilesService { } catch (probeErr) { if ((probeErr as NodeJS.ErrnoException).code !== "ENOENT") throw probeErr; const up = path.dirname(probe); - if (up === probe) throw badRequest("path 非法。"); + if (up === probe) throw badRequest("path is invalid."); probe = up; } } @@ -189,10 +189,10 @@ export class WorkspaceFilesService { dirents = await fs.readdir(dir, { withFileTypes: true }); } catch (err) { 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") { - throw badRequest("path 不是目录。"); + throw badRequest("path is not a directory."); } throw err; } @@ -231,11 +231,11 @@ export class WorkspaceFilesService { try { stat = await fs.stat(file); } 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) { - 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 ext = path.extname(file).toLowerCase(); @@ -256,9 +256,9 @@ export class WorkspaceFilesService { * sandbox escapes). */ async write(workspace: string, rel: string, data: Buffer): Promise { - 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) { - 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 file = path.join(dir, name); @@ -269,9 +269,10 @@ export class WorkspaceFilesService { handle = await fs.open(file, flags, 0o644); } catch (err) { const code = (err as NodeJS.ErrnoException).code; - if (code === "ELOOP") throw badRequest("path 不能是符号链接。"); - if (code === "ENOENT") throw new HttpError(404, "path_not_found", "父目录不存在。"); - if (code === "EISDIR") throw badRequest("path 是目录。"); + if (code === "ELOOP") throw badRequest("path must not be a symlink."); + if (code === "ENOENT") + throw new HttpError(404, "path_not_found", "Parent directory does not exist."); + if (code === "EISDIR") throw badRequest("path is a directory."); throw err; } try { diff --git a/packages/server/src/services/workspace-guard.ts b/packages/server/src/services/workspace-guard.ts index daaa8fe..d61eb4c 100644 --- a/packages/server/src/services/workspace-guard.ts +++ b/packages/server/src/services/workspace-guard.ts @@ -26,12 +26,16 @@ export async function assertWorkspaceAllowed(args: { workspace: string }): Promi throw new HttpError( 400, "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); 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; } diff --git a/packages/server/test/admin-users.test.ts b/packages/server/test/admin-users.test.ts index bf671af..192858e 100644 --- a/packages/server/test/admin-users.test.ts +++ b/packages/server/test/admin-users.test.ts @@ -10,7 +10,7 @@ import type { AdminUsersResponse, MembersResponse } from "../src/api/types.js"; import { apiClient, createTestApp, loginAdmin, loginUser, provisionUser } from "./helpers.js"; import type { TestApp } from "./helpers.js"; -describe("admin 用户后台", () => { +describe("admin users backend", () => { let t: TestApp; let admin: ReturnType; @@ -22,7 +22,7 @@ describe("admin 用户后台", () => { await t.cleanup(); }); - it("非管理员访问一律 403", async () => { + it("non-admin access is always 403", async () => { const { cookie } = await provisionUser(t.app, "norm"); const api = apiClient(t.app, cookie); 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); }); - 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)]) { const res = await admin.post("/api/admin/users", { userId: bad, password: "password-123" }); expect(res.status, `userId=${bad}`).toBe(400); @@ -56,7 +56,7 @@ describe("admin 用户后台", () => { ).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). 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" }); @@ -66,7 +66,7 @@ describe("admin 用户后台", () => { 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"); const list = (await (await admin.get("/api/admin/users")).json()) as AdminUsersResponse; expect(list.users.map((u) => u.userId)).toEqual(["admin", "kate"]); @@ -77,7 +77,7 @@ describe("admin 用户后台", () => { 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 rexApi = apiClient(t.app, rex.cookie); expect((await rexApi.get("/api/me")).status).toBe(200); @@ -99,11 +99,11 @@ describe("admin 用户后台", () => { 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 goneApi = apiClient(t.app, gone.cookie); expect( - (await goneApi.post("/api/projects", { projectId: "gone-extra", name: "多余项目" })).status, + (await goneApi.post("/api/projects", { projectId: "gone-extra", name: "Extra" })).status, ).toBe(201); // gone is also a member of admin's default_project. expect( @@ -131,7 +131,7 @@ describe("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/ghost")).status).toBe(404); }); diff --git a/packages/server/test/agent-trace-detail.test.ts b/packages/server/test/agent-trace-detail.test.ts index f658d10..9585fe5 100644 --- a/packages/server/test/agent-trace-detail.test.ts +++ b/packages/server/test/agent-trace-detail.test.ts @@ -48,12 +48,12 @@ describe("agent-trace-detail", () => { owner = apiClient(t.app, a.cookie); outsider = apiClient(t.app, b.cookie); 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; projectId = created.project.projectId; await writeTraceFile(t.root, projectId, "default_agent", "2026-07-06", UNMANAGED, 1, [ sessionMeta(metaPayload()), - userText("子会话输入"), + userText("child session input"), requestBegin(), requestEnd("completed"), ]); @@ -62,7 +62,7 @@ describe("agent-trace-detail", () => { 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. expect((await owner.get(`/api/sessions/${UNMANAGED}/traces/1`)).status).toBe(404); // Agent-level detail: locates the Trace file directly -> 200. @@ -70,10 +70,10 @@ describe("agent-trace-detail", () => { expect(res.status).toBe(200); const body = (await res.json()) as TraceEventsResponse; 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`); expect(res.status).toBe(200); 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); }); - it("Agent 级性能分析端点派生 Request 配对", async () => { + it("Agent-level analysis endpoint derives Request pairings", async () => { const res = await owner.get(`${base()}/${UNMANAGED}/1/analysis`); expect(res.status).toBe(200); const body = (await res.json()) as TraceAnalysisResponse; @@ -93,11 +93,11 @@ describe("agent-trace-detail", () => { 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); }); - 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/analysis`)).status).toBe(404); }); diff --git a/packages/server/test/agent-transfer.test.ts b/packages/server/test/agent-transfer.test.ts index 83f8f4a..7bff3ec 100644 --- a/packages/server/test/agent-transfer.test.ts +++ b/packages/server/test/agent-transfer.test.ts @@ -31,7 +31,7 @@ describe("agent export/import", () => { owner = apiClient(t.app, a.cookie); member = apiClient(t.app, b.cookie); 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; projectId = created.project.projectId; base = `/api/projects/${projectId}/agents/default_agent`; @@ -43,7 +43,7 @@ describe("agent export/import", () => { 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). expect( (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"]); }); - 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. const configPath = path.join( agentStateDir(t.root, projectId, "default_agent"), @@ -101,7 +101,7 @@ describe("agent export/import", () => { ).toBe(400); }); - it("包内混入 .vault.toml 会被忽略,导入后仍是现行 vault", async () => { + it("a .vault.toml in the package is ignored; the current vault survives import", async () => { expect( (await owner.put(`${base}/vault`, { entries: [{ key: "TOKEN", value: "current" }] })).status, ).toBe(200); diff --git a/packages/server/test/auth.test.ts b/packages/server/test/auth.test.ts index e352e87..2c1f9ee 100644 --- a/packages/server/test/auth.test.ts +++ b/packages/server/test/auth.test.ts @@ -19,14 +19,14 @@ describe("auth", () => { 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"); expect(res.status).toBe(401); const body = (await res.json()) as { error: { code: string } }; 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. const anon = await t.app.request("/api/auth/register", { method: "POST", @@ -43,7 +43,7 @@ describe("auth", () => { 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); expect(admin.user.isAdmin).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); }); - it("管理员建号:默认 Project 为 <用户名>-default_project,显示名缺省为用户名", async () => { + it("admin-created: default Project is -default_project, name defaults", async () => { const bob = await provisionUser(t.app, "bob"); expect(bob.user.isAdmin).toBe(false); 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"); const wrong = await t.app.request("/api/auth/login", { method: "POST", @@ -105,7 +105,7 @@ describe("auth", () => { 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 api = apiClient(t.app, cookie); @@ -138,7 +138,7 @@ describe("auth", () => { 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", { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, @@ -147,7 +147,7 @@ describe("auth", () => { expect(res.status).toBe(415); }); - it("ui prefs 读写", async () => { + it("ui prefs read/write", async () => { const { cookie } = await provisionUser(t.app, "erin"); const api = apiClient(t.app, cookie); 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"); }); - it("PUT prefs 浅合并,不覆盖其他写入方的字段", async () => { + it("PUT prefs shallow-merges without clobbering other writers' fields", async () => { const { cookie } = await provisionUser(t.app, "fred"); const api = apiClient(t.app, cookie); // Simulate two independent writers: switching Project writes lastProjectId, and onboarding writes credentialGuideSeen. diff --git a/packages/server/test/authz.test.ts b/packages/server/test/authz.test.ts index 385c6f5..67edad1 100644 --- a/packages/server/test/authz.test.ts +++ b/packages/server/test/authz.test.ts @@ -30,7 +30,7 @@ describe("authz", () => { member = apiClient(t.app, b.cookie); outsider = apiClient(t.app, c.cookie); 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; projectId = created.project.projectId; const add = await owner.post(`/api/projects/${projectId}/members`, { userId: "member_b" }); @@ -40,14 +40,14 @@ describe("authz", () => { 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}/agents`)).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); }); - 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`, { defaultModel: { provider: "custom", modelId: "m-1" }, models: [{ provider: "custom", modelId: "m-1", apiKey: "sk-super-secret-key-123456" }], @@ -68,7 +68,7 @@ describe("authz", () => { 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 ( await member.get(`/api/projects/${projectId}/members`) ).json()) as MembersResponse; @@ -95,7 +95,7 @@ describe("authz", () => { 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`, { defaultModel: { 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); }); - 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 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. @@ -134,7 +134,7 @@ describe("authz", () => { 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`, { defaultModel: { provider: "custom", modelId: "m-1" }, models: [ diff --git a/packages/server/test/benchmarks.test.ts b/packages/server/test/benchmarks.test.ts index 1530f95..fc45865 100644 --- a/packages/server/test/benchmarks.test.ts +++ b/packages/server/test/benchmarks.test.ts @@ -37,7 +37,7 @@ describe("benchmarks api", () => { member = apiClient(t.app, b.cookie); outsider = apiClient(t.app, c.cookie); 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; projectId = created.project.projectId; // A plain Agent has no sample Benchmark pre-installed (only default_agent provides one). @@ -53,19 +53,19 @@ describe("benchmarks api", () => { await t.cleanup(); }); - it("未配置时返回空列表", async () => { + it("returns an empty list when unconfigured", async () => { expect((await (await owner.get(base)).json()) as BenchmarksResponse).toEqual({ 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"); 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.writeFile( path.join(dir, "benchmark_config.toml"), - `title = "SWE Bench v2"\ndescription = "示例"\nruns = 2\n`, + `title = "SWE Bench v2"\ndescription = "Example"\nruns = 2\n`, "utf8", ); await fs.writeFile( @@ -76,8 +76,8 @@ describe("benchmarks api", () => { " version: 3", ' provider: "deepseek"', ' model_id: "deepseek-v4-pro"', - ' summary_title: "系统 Prompt 增加计划步骤"', - ' summary: "两题各跑两次取平均;本轮为系统 Prompt 增加了计划步骤。"', + ' summary_title: "Added planning steps to the system Prompt"', + ' summary: "Each case run twice and averaged; added planning steps."', " score: 8.0", " cost: 0.05", " duration_ms: 120000", @@ -116,7 +116,7 @@ describe("benchmarks api", () => { expect(bench).toMatchObject({ id: "swe-bench-v2", title: "SWE Bench v2", - description: "示例", + description: "Example", runs: 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). expect(evaluation.provider).toBe("deepseek"); expect(evaluation.modelId).toBe("deepseek-v4-pro"); - expect(evaluation.summaryTitle).toBe("系统 Prompt 增加计划步骤"); - expect(evaluation.summary).toBe("两题各跑两次取平均;本轮为系统 Prompt 增加了计划步骤。"); + expect(evaluation.summaryTitle).toBe("Added planning steps to the system Prompt"); + expect(evaluation.summary).toBe("Each case run twice and averaged; added planning steps."); expect(evaluation.score).toBe(8.0); // 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")!; @@ -148,7 +148,7 @@ describe("benchmarks api", () => { 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"); 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"); diff --git a/packages/server/test/builtin-agents.test.ts b/packages/server/test/builtin-agents.test.ts index 17f9c1f..7a9c3fc 100644 --- a/packages/server/test/builtin-agents.test.ts +++ b/packages/server/test/builtin-agents.test.ts @@ -21,7 +21,7 @@ interface ProjectCreateResponse { project: { projectId: string }; } -describe("内置 Agent 供给", () => { +describe("built-in Agent provisioning", () => { let t: TestApp; let owner: ReturnType; @@ -61,19 +61,19 @@ describe("内置 Agent 供给", () => { 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; 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 ( - await owner.post("/api/projects", { projectId: "owner1-new", name: "新项目" }) + await owner.post("/api/projects", { projectId: "owner1-new", name: "New project" }) ).json()) as ProjectCreateResponse; 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 projectId = projects.projects[0]!.projectId; 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)); }); - 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 projectId = projects.projects[0]!.projectId; const res = await owner.delete(`/api/projects/${projectId}/agents/default_agent`); 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 projectId = projects.projects[0]!.projectId; const created = await owner.post(`/api/projects/${projectId}/agents`, { diff --git a/packages/server/test/channel.test.ts b/packages/server/test/channel.test.ts index 0d0fdf5..a64855e 100644 --- a/packages/server/test/channel.test.ts +++ b/packages/server/test/channel.test.ts @@ -13,7 +13,7 @@ function foreignEpoch(ch: Channel): string { } describe("channel", () => { - it("publish 分配 `-` 单调 id 并广播", () => { + it("publish assigns monotonic `-` ids and broadcasts", () => { const ch = new Channel(); const seen: ChannelEvent[] = []; ch.subscribe((e) => seen.push(e)); @@ -26,7 +26,7 @@ describe("channel", () => { expect(JSON.parse(seen[0]!.data)).toEqual({ a: 1 }); }); - it("退订后不再接收", () => { + it("no longer receives after unsubscribe", () => { const ch = new Channel(); const seen: ChannelEvent[] = []; const unsub = ch.subscribe((e) => seen.push(e)); @@ -36,7 +36,7 @@ describe("channel", () => { expect(seen).toHaveLength(1); }); - it("按条数上限淘汰最旧事件", () => { + it("evicts the oldest events by the count cap", () => { 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 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`]); }); - it("按字节上限淘汰最旧事件", () => { + it("evicts the oldest events by the byte cap", () => { const ch = new Channel({ maxBufferBytes: 30 }); ch.publish("a".repeat(20)); 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`]); }); - 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(); ch.publish("x"); // seq 1 const same = ch.replayAfter(`${ch.epoch}-1`); @@ -65,7 +65,7 @@ describe("channel", () => { 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(); 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. @@ -73,7 +73,7 @@ describe("channel", () => { 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(); ch.publish("x"); 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); }); - it("全新通道(无事件)带任何 Last-Event-ID → miss;`-0` 从头命中", () => { + it("brand-new channel: any Last-Event-ID → miss; `-0` hits from the start", () => { const ch = new Channel(); 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) }); - it("sendTo 私发:占用 seq、不进缓冲、不广播", () => { + it("sendTo private send: consumes a seq, skips the buffer, no broadcast", () => { const ch = new Channel(); const broadcast: 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 }); - it("hub:同 key 复用通道;空闲超时回收、有订阅者不回收", () => { + it("hub: same key reuses the channel; idle timeout reclaims, subscribers prevent it", () => { const hub = new ChannelHub({ idleMs: 1000 }); const ch = hub.get("s1"); expect(hub.get("s1")).toBe(ch); @@ -120,7 +120,7 @@ describe("channel", () => { hub.dispose(); }); - it("hub:isActive 谓词命中的通道不回收(运行中等待审批无 publish 也保留)", () => { + it("hub: isActive channels are kept (running awaiting approval with no publish)", () => { const active = new Set(["busy"]); const hub = new ChannelHub({ idleMs: 1000, isActive: (key) => active.has(key) }); const busy = hub.get("busy"); diff --git a/packages/server/test/config.test.ts b/packages/server/test/config.test.ts index c980ad6..9479c00 100644 --- a/packages/server/test/config.test.ts +++ b/packages/server/test/config.test.ts @@ -10,20 +10,20 @@ import { resolveServerConfig } from "../src/config.js"; const base = { PENGUIN_HOME: "/tmp/penguin-config-test" }; -describe("resolveServerConfig:PORT 解析", () => { - it("缺省 7364;空串视为未设置(不落到端口 0)", () => { +describe("resolveServerConfig: PORT parsing", () => { + 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: "" }).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: "0" }).port).toBe(0); }); - it("非整数或超界抛错", () => { + it("non-integer or out-of-range values throw", () => { 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/); } }); }); diff --git a/packages/server/test/errors.test.ts b/packages/server/test/errors.test.ts index 46d904a..5c5fa78 100644 --- a/packages/server/test/errors.test.ts +++ b/packages/server/test/errors.test.ts @@ -65,14 +65,14 @@ describe("errors-repo", () => { }); 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", { kind: "expected", code: "not_found", status: 404 })); repo.insert(row("2026-07-06", { kind: "expected", code: "bad_request", status: 400 })); 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" }; repo.insert(row("2026-07-06", global)); // Unattributed: another tenant's login failure / process crash 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"]); }); - 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" })); 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 })); @@ -116,7 +116,7 @@ describe("errors-repo", () => { 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-06", { kind: "expected" })); 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); }); - it("最近异常:时间倒序取前 limit 条", () => { - repo.insert(row("2026-07-05", { message: "旧" })); - repo.insert(row("2026-07-06", { message: "新" })); + it("recent errors: newest first, top limit rows", () => { + repo.insert(row("2026-07-05", { message: "old" })); + repo.insert(row("2026-07-06", { message: "new" })); const recent = repo.recent("p1", {}, 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", { projectId: null })); repo.deleteByProject("p1"); @@ -153,14 +153,14 @@ describe("errors-repo", () => { .all() .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 }); 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. 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 }); capped.insert(row("2026-07-06", { message: "keep-1" })); // id 1 capped.insert(row("2026-07-06", { message: "keep-2" })); // id 2 @@ -188,10 +188,14 @@ describe("error-recorder", () => { }); afterEach(() => db.close()); - it("HttpError → expected(保留 code 与状态码)", () => { + it("HttpError → expected (keeps code and status)", () => { new ErrorRecorder(repo, now).record({ 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" }, }); const r = db.prepare("SELECT * FROM error_records").get()!; @@ -202,12 +206,12 @@ describe("error-recorder", () => { 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); rec.record({ source: "http", err: new Error("boom") }); rec.record({ source: "session", - err: new Error("drive 挂了"), + err: new Error("drive crashed"), ctx: { projectId: "p1", agentId: "a1", sessionId: "s1" }, code: "session_run_failed", }); @@ -222,19 +226,19 @@ describe("error-recorder", () => { 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); - 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)) }); 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); }); - it("记录器自身出错绝不外抛(否则挂在 onError 上会无限递归)", () => { + it("the recorder itself never throws (hooked on onError it would recurse forever)", () => { const broken = { insert() { - throw new Error("DB 已关闭"); + throw new Error("DB is closed"); }, } as unknown as ErrorsRepo; expect(() => @@ -242,10 +246,10 @@ describe("error-recorder", () => { ).not.toThrow(); }); - it("显式 kind 优先于 HttpError 推断(新来源自报「要不要人介入」)", () => { + it("explicit kind wins over HttpError inference (sources self-report human need)", () => { const rec = new ErrorRecorder(repo, now); - rec.record({ source: "llm", err: "超时", code: "llm_timeout", kind: "expected" }); - rec.record({ source: "llm", err: "鉴权失败", code: "llm_failed", kind: "unexpected" }); + rec.record({ source: "llm", err: "timed out", code: "llm_timeout", kind: "expected" }); + 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(); expect(rows[0]).toMatchObject({ kind: "expected", 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) => (rec as unknown as { lastSeen: Map }).lastSeen; - it("短窗去重:窗口内的同类异常只落一条,窗口外恢复记录", () => { + it("short-window dedup: same-kind errors persist once per window, then resume", () => { let t = Date.parse("2026-07-06T10:00:00Z"); const rec = new ErrorRecorder(repo, () => new Date(t)); const boom = () => rec.record({ source: "http", - err: new HttpError(404, "not_found", "没有"), + err: new HttpError(404, "not_found", "Not found."), ctx: { projectId: "p1" }, }); @@ -282,7 +286,7 @@ describe("error-recorder", () => { 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 err = new Error("boom"); rec.record({ source: "http", err, ctx: { projectId: "p1" }, code: "c1" }); @@ -294,7 +298,7 @@ describe("error-recorder", () => { 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"); const rec = new ErrorRecorder(repo, () => new Date(t)); 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 repo: ErrorsRepo; const now = () => new Date("2026-07-06T10:00:00"); @@ -365,7 +369,7 @@ describe("stream-error-watcher(LLM / Environment 异常)", () => { // —— LLM —— - it("LLM failed → unexpected(不可重试,需人介入);message 取随后 abort 事件的真实原因", () => { + it("LLM failed → unexpected (needs a human); message takes the abort reason", () => { const got = feed([ requestBegin(), 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([ requestBegin(), 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[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({ source: "llm", 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( feed([ requestBegin(), @@ -415,7 +419,7 @@ describe("stream-error-watcher(LLM / Environment 异常)", () => { ).toHaveLength(0); }); - it("用户在重试退避期间中断:timeout 是真实失败照记,但 message 不采信用户中断文案", () => { + it("interrupt during retry backoff: timeout still recorded, interrupt text distrusted", () => { const got = feed([ requestBegin(), requestEnd("timeout"), @@ -423,11 +427,11 @@ describe("stream-error-watcher(LLM / Environment 异常)", () => { ]); expect(got).toHaveLength(1); 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"); }); - it("失败先挂起等原因;run 结束仍未揭晓 → close 兜底落库(用 status 文案)", () => { + it("failure pends for its reason; unresolved at run end → close persists (status text)", () => { const w = watcher(); w.observe(requestBegin()); w.observe(requestEnd("failed")); @@ -436,10 +440,10 @@ describe("stream-error-watcher(LLM / Environment 异常)", () => { const got = rows(); expect(got).toHaveLength(1); 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([ requestBegin(), // parent session initiates 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 }); - it("工具 failed / timeout → environment + expected,code 带工具名(能看出是哪个工具挂了)", () => { + it("tool failed / timeout → environment + expected, code carries the tool name", () => { const got = feed([ call("exec_command", "tc-1"), toolCallOutput({ @@ -491,7 +495,7 @@ describe("stream-error-watcher(LLM / Environment 异常)", () => { 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( feed([ call("exec_command", "tc-1"), @@ -506,7 +510,7 @@ describe("stream-error-watcher(LLM / Environment 异常)", () => { ).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([ call("exec_command", "tc-1"), 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"]); }); - 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([ call("exec_command", "tc-1"), // parent session 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" }); }); - it("超长工具输出:message 取尾部(失败原因在末尾)并截断到上限", () => { + it("overlong tool output: message takes the tail (the reason is at the end)", () => { const got = feed([ call("exec_command", "tc-1"), 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 }); - it("无关消息为 no-op:正文、以及流式 partial_*(完整 tool_call_output 才是收尾)", () => { + it("irrelevant messages are a no-op: body text and streaming partial_*", () => { expect( feed([ - assistantText("正常输出"), + assistantText("normal output"), call("exec_command", "tc-1"), 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) —— - it("子会话的 LLM 失败归到子 Agent / 子 Session(不是父的)", () => { + it("a child session's LLM failure attributes to the child Agent / Session", () => { const got = feed([ childMeta("session-child", "/data/agents/agent-child/agent_state"), 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([ childMeta("session-child", "/data/agents/agent-child/agent_state"), 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([ requestBegin(), // parent session initiates 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([ withOrigin(requestEnd("failed"), "session-child"), // the sub-session's meta hasn't arrived yet 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" }); }); - it("agent_state 路径异常(空串):不登记,回退父 ctx(不写进一个不存在的 agentId)", () => { + it("malformed agent_state path (empty): not registered, falls back to the parent ctx", () => { const got = feed([ childMeta("session-child", ""), // path.basename(path.dirname("")) === "." → caught by the defensive check 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 api: ReturnType; let projectId: string; @@ -659,7 +663,7 @@ describe("HTTP onError 落库(集成)", () => { const u = await provisionUser(t.app, "err_user"); api = apiClient(t.app, u.cookie); 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; projectId = created.project.projectId; }); @@ -672,7 +676,7 @@ describe("HTTP onError 落库(集成)", () => { Record >; - 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`); expect(res.status).toBe(404); @@ -686,11 +690,11 @@ describe("HTTP onError 落库(集成)", () => { 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. const spy = vi.spyOn(console, "error").mockImplementation(() => {}); t.deps.usageService.query = () => { - throw new Error("查询炸了"); + throw new Error("query blew up"); }; const res = await api.get(`/api/projects/${projectId}/usage?groupBy=date`); expect(res.status).toBe(500); @@ -701,14 +705,14 @@ describe("HTTP onError 落库(集成)", () => { expect(rows[0]!.kind).toBe("unexpected"); expect(rows[0]!.code).toBe("internal"); 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); }); - 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. 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; 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 @@ -727,7 +731,7 @@ describe("HTTP onError 落库(集成)", () => { 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). const bad = await t.app.request("/api/auth/login", { method: "POST", @@ -742,7 +746,7 @@ describe("HTTP onError 落库(集成)", () => { expect(plain.user.isAdmin).toBe(false); const plainApi = apiClient(t.app, plain.cookie); 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; const plainBody = (await ( 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" }); }); - 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`); 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; /** The built-in admin: unattributed errors are visible only to them. */ let adminApi: ReturnType; @@ -784,7 +788,7 @@ describe("HTTP 异常归属(仅在请求方确有该 Project 访问权时)", adminApi = apiClient(t.app, admin.cookie); adminProjectId = ( (await ( - await adminApi.post("/api/projects", { projectId: "admin_proj", name: "管理员的项目" }) + await adminApi.post("/api/projects", { projectId: "admin_proj", name: "Admin project" }) ).json()) as ProjectCreateResponse ).project.projectId; @@ -793,7 +797,7 @@ describe("HTTP 异常归属(仅在请求方确有该 Project 访问权时)", ownerApi = apiClient(t.app, owner.cookie); projectId = ( (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 ).project.projectId; }); @@ -820,7 +824,7 @@ describe("HTTP 异常归属(仅在请求方确有该 Project 访问权时)", ).json()) as UsageResponse ).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`); expect(res.status).toBe(401); @@ -842,7 +846,7 @@ describe("HTTP 异常归属(仅在请求方确有该 Project 访问权时)", 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 res = await apiClient(t.app, outsider.cookie).get( `/api/projects/${projectId}/usage?groupBy=date`, @@ -858,7 +862,7 @@ describe("HTTP 异常归属(仅在请求方确有该 Project 访问权时)", 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. 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 }); }); - it("归属判断自身抛异常:onError 不被搞崩(错误响应照常,异常按无归属落库)", async () => { + it("the attribution check throws: onError survives, error lands unattributed", async () => { 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`); expect(res.status).toBe(400); // still the original business error: not turned into a 500, nor an empty response diff --git a/packages/server/test/helpers.ts b/packages/server/test/helpers.ts index 646f350..0433264 100644 --- a/packages/server/test/helpers.ts +++ b/packages/server/test/helpers.ts @@ -78,10 +78,10 @@ export async function loginUser( body: JSON.stringify({ userId, password }), }); 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"); - 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 }; return { cookie: setCookie.split(";")[0]!, user: body.user }; } @@ -101,7 +101,7 @@ export async function provisionUser( const admin = await loginAdmin(app); const res = await apiClient(app, admin.cookie).post("/api/admin/users", { userId, password }); 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); } @@ -147,7 +147,7 @@ export async function writeTraceFile( export async function waitFor(cond: () => boolean, timeoutMs = 2000): Promise { const start = Date.now(); 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)); } } diff --git a/packages/server/test/id-validation.test.ts b/packages/server/test/id-validation.test.ts index 7addb25..27fc2cb 100644 --- a/packages/server/test/id-validation.test.ts +++ b/packages/server/test/id-validation.test.ts @@ -31,11 +31,11 @@ describe("id-validation", () => { attacker = apiClient(t.app, a.cookie); const victim = apiClient(t.app, v.cookie); 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; attackerProject = created.project.projectId; 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; victimProject = victimCreated.project.projectId; }); @@ -43,7 +43,7 @@ describe("id-validation", () => { 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). const victimConfigPath = path.join( t.root, @@ -62,7 +62,7 @@ describe("id-validation", () => { 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( t.root, victimProject, @@ -79,13 +79,13 @@ describe("id-validation", () => { 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()}/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/analysis`)).status, @@ -93,7 +93,7 @@ describe("id-validation", () => { 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/models`)).status).toBe(404); expect((await attacker.get(`/api/projects/..%2Fetc/usage`)).status).toBe(404); diff --git a/packages/server/test/models-vision.test.ts b/packages/server/test/models-vision.test.ts index 7f09e21..78e8358 100644 --- a/packages/server/test/models-vision.test.ts +++ b/packages/server/test/models-vision.test.ts @@ -8,7 +8,7 @@ import type { ModelsResponse, ProjectCreateResponse } from "../src/api/types.js" import { apiClient, createTestApp, provisionUser } from "./helpers.js"; import type { TestApp } from "./helpers.js"; -describe("models vision 标注", () => { +describe("models vision annotation", () => { let t: TestApp; let owner: ReturnType; let projectId: string; @@ -18,7 +18,7 @@ describe("models vision 标注", () => { const a = await provisionUser(t.app, "owner_v"); owner = apiClient(t.app, a.cookie); 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; projectId = created.project.projectId; }); @@ -26,7 +26,7 @@ describe("models vision 标注", () => { 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 // falls back to the catalog's own annotation — see the next test case). const put = await owner.put(`/api/projects/${projectId}/models`, { @@ -51,7 +51,7 @@ describe("models vision 标注", () => { 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`, { models: [ { provider: "deepseek", modelId: "deepseek-v4-pro" }, @@ -68,7 +68,7 @@ describe("models vision 标注", () => { ).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`, { visionModel: { provider: "google", modelId: "gemini-3-flash-preview" }, models: [ @@ -102,7 +102,7 @@ describe("models vision 标注", () => { 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`, { visionModel: { provider: "custom", modelId: "nope" }, models: [{ provider: "custom", modelId: "m-1" }], @@ -115,7 +115,7 @@ describe("models vision 标注", () => { 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`, { models: [{ provider: "custom", modelId: "m-1", vision: "no" }], }); diff --git a/packages/server/test/password.test.ts b/packages/server/test/password.test.ts index 961fcf1..4053af4 100644 --- a/packages/server/test/password.test.ts +++ b/packages/server/test/password.test.ts @@ -6,7 +6,7 @@ import { describe, expect, it } from "vitest"; import { hashPassword, verifyPassword } from "../src/auth/password.js"; 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 parts = stored.split("$"); expect(parts).toHaveLength(6); @@ -15,12 +15,12 @@ describe("password", () => { 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"); 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 b = await hashPassword("same-password"); expect(a).not.toBe(b); @@ -28,7 +28,7 @@ describe("password", () => { 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", "bcrypt$a$b$c$d$e")).resolves.toBe(false); await expect(verifyPassword("x", "scrypt$abc$8$1$!!$!!")).resolves.toBe(false); diff --git a/packages/server/test/schedule-file.test.ts b/packages/server/test/schedule-file.test.ts index 193f804..bd32fd6 100644 --- a/packages/server/test/schedule-file.test.ts +++ b/packages/server/test/schedule-file.test.ts @@ -11,7 +11,7 @@ import { slotInWindow, } 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) { const r = parseScheduleFile("daily-report", raw); @@ -20,12 +20,12 @@ function defOf(raw: string) { } describe("parsePeriod", () => { - it("解析 m/h/d 固定间隔", () => { + it("parses m/h/d fixed intervals", () => { expect(parsePeriod("30m")).toBe(30 * 60_000); expect(parsePeriod("12h")).toBe(12 * 3_600_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"]) { expect(parsePeriod(bad)).toBeNull(); } @@ -33,13 +33,13 @@ describe("parsePeriod", () => { }); describe("parseScheduleFile", () => { - it("解析全字段并保留时刻原文", () => { + it("parses every field and preserves instant text verbatim", () => { const def = defOf( `${BASE}period = "30m"\nend_at = "2026-07-17T09:00:00Z"\nsession_id = "session-x"\n`, ); expect(def).toMatchObject({ name: "daily-report", - prompt: "做日报", + prompt: "Write the daily report", enabled: true, startAt: "2026-07-16T09:00:00Z", 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`); expect(def.enabled).toBe(false); expect(def.periodMs).toBeUndefined(); }); - it("非法文件逐类拒绝", () => { + it("rejects each class of invalid file", () => { const cases: Array<[string, string]> = [ ["not toml ===", "TOML"], [`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"\nstart_at = "someday"\n`, "start_at"], - [`${BASE}period = "4m"\n`, "下限"], + [`${BASE}period = "4m"\n`, "below the 5m minimum"], [`${BASE}period = "10s"\n`, "period"], [`${BASE}end_at = "2026-07-16T08:00:00Z"\n`, "end_at"], - [`${BASE}session_id = "s"\nworkspace = "/tmp/w"\n`, "新建 Session 模式"], - [`${BASE}session_id = "s"\nmodel_id = "m1"\n`, "新建 Session 模式"], + [`${BASE}session_id = "s"\nworkspace = "/tmp/w"\n`, "new-Session mode"], + [`${BASE}session_id = "s"\nmodel_id = "m1"\n`, "new-Session mode"], [`${BASE}model_id = ""\n`, "model_id"], ]; for (const [raw, hint] of cases) { @@ -79,20 +79,20 @@ describe("parseScheduleFile", () => { describe("latestSlotAt / slotInWindow", () => { 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); expect(latestSlotAt(def, start - 1)).toBeNull(); expect(latestSlotAt(def, start)).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`); expect(latestSlotAt(def, start - 1)).toBeNull(); expect(latestSlotAt(def, start)).toBe(start); expect(latestSlotAt(def, start + 29 * 60_000)).toBe(start); 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); expect(nextSlotAfter(oneShot, start - 1)).toBe(start); 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 }); - it("end_at 窗口判定", () => { + it("end_at window check", () => { const def = defOf(`${BASE}period = "30m"\nend_at = "2026-07-16T10:00:00Z"\n`); expect(slotInWindow(def, start)).toBe(true); expect(slotInWindow(def, Date.parse("2026-07-16T10:00:00Z"))).toBe(true); diff --git a/packages/server/test/scheduler.test.ts b/packages/server/test/scheduler.test.ts index 6c1a0ea..c9361a1 100644 --- a/packages/server/test/scheduler.test.ts +++ b/packages/server/test/scheduler.test.ts @@ -131,10 +131,10 @@ describe("scheduler", () => { 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"); await writeFile("report", [ - `prompt = "报告"`, + `prompt = "Write the daily report"`, `enabled = true`, `start_at = "${iso(T0 - 60 * MIN)}"`, `period = "30m"`, @@ -150,7 +150,7 @@ describe("scheduler", () => { // Trigger input = source block + the prompt body (tells the model this is a scheduled task). expect(started[0]?.text).toContain(""); 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[0]?.userId).toBe("owner_a"); @@ -161,7 +161,7 @@ describe("scheduler", () => { 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"); await writeFile("future", [ `prompt = "f"`, @@ -189,11 +189,11 @@ describe("scheduler", () => { 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"); busy.add("session-1"); await writeFile("q", [ - `prompt = "排队"`, + `prompt = "queued"`, `enabled = true`, `start_at = "${iso(T0)}"`, `period = "5m"`, @@ -219,7 +219,7 @@ describe("scheduler", () => { 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", [ `prompt = "g"`, `enabled = true`, @@ -253,7 +253,7 @@ describe("scheduler", () => { 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"); await writeFile("off", [ `prompt = "o"`, @@ -272,9 +272,9 @@ describe("scheduler", () => { 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", [ - `prompt = "新会话"`, + `prompt = "fresh session"`, `enabled = true`, `start_at = "${iso(T0 + MIN)}"`, `period = "5m"`, @@ -299,9 +299,9 @@ describe("scheduler", () => { 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", [ - `prompt = "成对"`, + `prompt = "paired"`, `enabled = true`, `start_at = "${iso(T0 + MIN)}"`, `provider = "custom"`, @@ -314,7 +314,7 @@ describe("scheduler", () => { 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"); await writeFile("bad", [`prompt = "x"`, `enabled = true`, `start_at = "nonsense"`]); await writeFile("good", [ diff --git a/packages/server/test/schedules.test.ts b/packages/server/test/schedules.test.ts index 0e651eb..60ba576 100644 --- a/packages/server/test/schedules.test.ts +++ b/packages/server/test/schedules.test.ts @@ -31,7 +31,7 @@ describe("schedules api", () => { member = apiClient(t.app, b.cookie); outsider = apiClient(t.app, c.cookie); 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; projectId = created.project.projectId; base = `/api/projects/${projectId}/agents/default_agent/schedules`; @@ -43,10 +43,10 @@ describe("schedules api", () => { 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 = { name: "daily-report", - prompt: "写日报", + prompt: "Write the daily report", enabled: true, startAt: FUTURE, period: "30m", @@ -57,7 +57,7 @@ describe("schedules api", () => { const item = (await createdRes.json()) as ScheduleItem; expect(item).toMatchObject({ name: "daily-report", - prompt: "写日报", + prompt: "Write the daily report", enabled: true, period: "30m", status: "active", @@ -69,7 +69,7 @@ describe("schedules api", () => { // 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 raw = await fs.readFile(file, "utf8"); - expect(raw).toContain('prompt = "写日报"'); + expect(raw).toContain('prompt = "Write the daily report"'); expect(raw).toContain('period = "30m"'); // Any member can read; outsiders get 404. @@ -89,7 +89,7 @@ describe("schedules api", () => { 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 // resolve within the Project config. 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, { name: "bad-model", prompt: "p", @@ -122,7 +122,7 @@ describe("schedules api", () => { 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 }; expect((await owner.post(base, body)).status).toBe(201); expect( @@ -146,7 +146,7 @@ describe("schedules api", () => { ).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 cases = [ { 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"); await fs.mkdir(dir, { recursive: true }); await fs.writeFile(path.join(dir, "broken.toml"), "not = toml =", "utf8"); diff --git a/packages/server/test/semantic-ids.test.ts b/packages/server/test/semantic-ids.test.ts index 58396c7..7a306a9 100644 --- a/packages/server/test/semantic-ids.test.ts +++ b/packages/server/test/semantic-ids.test.ts @@ -14,9 +14,10 @@ import type { AgentCreateResponse, ProjectCreateResponse } from "../src/api/type import { apiClient, createTestApp, loginAdmin, provisionUser } 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)]; -describe("语义 id", () => { +describe("semantic ids", () => { let t: TestApp; let admin: ReturnType; let api: ReturnType; @@ -30,7 +31,7 @@ describe("语义 id", () => { 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) { const res = await admin.post("/api/projects", { projectId: bad, name: "x" }); 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(); }); - 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: "b" })).status).toBe(409); // 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); }); - it("非管理员建 Project:id 强制为 <用户名>-<后缀>,后缀仅小写字母数字下划线", async () => { + it("non-admin Project creation: id forced to -; the suffix allows only lowercase letters, digits, underscores", async () => { // 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"]) { 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(); }); - 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). const spy = vi.spyOn(console, "error").mockImplementation(() => {}); const original = t.deps.projectConfigService.writeInitialConfig.bind( t.deps.projectConfigService, ); 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); spy.mockRestore(); @@ -92,11 +93,11 @@ describe("语义 id", () => { 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 original = t.deps.agentConfigService.updateConfig.bind(t.deps.agentConfigService); t.deps.agentConfigService.updateConfig = async () => { - throw new Error("写配置炸了"); + throw new Error("config write blew up"); }; expect( (await api.post("/api/projects/ida-default_project/agents", { agentId: "flaky" })).status, @@ -111,7 +112,7 @@ describe("语义 id", () => { ).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) { const res = await api.post("/api/projects/ida-default_project/agents", { agentId: bad, @@ -120,6 +121,7 @@ describe("语义 id", () => { 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", { agentId: "crawler", name: "爬虫", diff --git a/packages/server/test/session-delete-scratchpad.test.ts b/packages/server/test/session-delete-scratchpad.test.ts index 95da9aa..fc36957 100644 --- a/packages/server/test/session-delete-scratchpad.test.ts +++ b/packages/server/test/session-delete-scratchpad.test.ts @@ -21,7 +21,7 @@ async function exists(p: string): Promise { } } -describe("会话删除清理 scratchpad", () => { +describe("session deletion cleans up the scratchpad", () => { let t: TestApp; let owner: ReturnType; let projectId: string; @@ -33,7 +33,7 @@ describe("会话删除清理 scratchpad", () => { const created = (await ( await owner.post("/api/projects", { projectId: "owner_s-scratchpad", - name: "scratchpad 项目", + name: "scratchpad project", }) ).json()) as ProjectCreateResponse; projectId = created.project.projectId; @@ -46,7 +46,7 @@ describe("会话删除清理 scratchpad", () => { await t.cleanup(); }); - it("DELETE 会话后其 scratchpad 目录一并删除", async () => { + it("DELETE removes the session's scratchpad directory along with it", async () => { const created = await owner.post( `/api/projects/${projectId}/agents/default_agent/sessions`, {}, @@ -72,7 +72,7 @@ describe("会话删除清理 scratchpad", () => { 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( `/api/projects/${projectId}/agents/default_agent/sessions`, {}, diff --git a/packages/server/test/session-index.test.ts b/packages/server/test/session-index.test.ts index e8273b4..278ad71 100644 --- a/packages/server/test/session-index.test.ts +++ b/packages/server/test/session-index.test.ts @@ -29,7 +29,7 @@ describe("session-index", () => { const { cookie } = await provisionUser(t.app, "alice"); api = apiClient(t.app, cookie); 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; projectId = created.project.projectId; }); @@ -45,7 +45,7 @@ describe("session-index", () => { 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 // the whole table to clear it (omitting defaultModel + the original default // absent from models = removes default_model), then verify the @@ -60,11 +60,11 @@ describe("session-index", () => { 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 // the client is constructed. Clear the environment variable key so none is // 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; delete process.env.OPENAI_API_KEY; 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(); const res = await api.post(base(), {}); expect(res.status).toBe(201); @@ -103,7 +103,7 @@ describe("session-index", () => { 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(); const inside = path.join(t.root, projectId, "my-workdir"); await fs.mkdir(inside, { recursive: true }); @@ -124,7 +124,7 @@ describe("session-index", () => { ).toBe(400); }); - it("列表并集:Trace 目录发现未纳管 Session 并补插索引行", async () => { + it("list union: Trace directory discovery finds unmanaged Sessions and backfills index rows", async () => { await configureModels(); const discovered = "session-2026-07-01-08-30-00-deadbeef"; const meta: SessionMetaPayload = { @@ -140,7 +140,7 @@ describe("session-index", () => { }; await writeTraceFile(t.root, projectId, "default_agent", "2026-07-01", discovered, 1, [ sessionMeta(meta), - userText("cli 会话"), + userText("cli session"), ]); const list = (await (await api.get(base())).json()) as SessionsResponse; @@ -157,7 +157,7 @@ describe("session-index", () => { 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(); const { session } = (await (await api.post(base(), {})).json()) as SessionCreateResponse; const sessionId = session.sessionId; @@ -181,7 +181,7 @@ describe("session-index", () => { "2026-07-01", sessionId, 1, - [sessionMeta(meta), userText("第一轮")], + [sessionMeta(meta), userText("round one")], ); const f2 = await writeTraceFile( t.root, @@ -190,7 +190,7 @@ describe("session-index", () => { "2026-07-02", sessionId, 2, - [sessionMeta(meta), userText("第二轮")], + [sessionMeta(meta), userText("round two")], ); 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); }); - it("DELETE Session:Workspace 目录不随删除清除(用户自带目录必须保留)", async () => { + it("DELETE Session: the Workspace directory is not removed (user-supplied directories must survive)", async () => { await configureModels(); const inside = path.join(t.root, projectId, "keep-me"); await fs.mkdir(inside, { recursive: true }); @@ -217,7 +217,7 @@ describe("session-index", () => { expect((await fs.stat(inside)).isDirectory()).toBe(true); }); - it("列表按 createdAt 降序", async () => { + it("the list is sorted by createdAt descending", async () => { await configureModels(); const older = "session-2020-01-01-00-00-00-00000001"; 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); }); - it("PATCH 审批模式即存并回读", async () => { + it("PATCH approval mode persists and reads back", async () => { await configureModels(); 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. @@ -257,7 +257,7 @@ describe("session-index", () => { ).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 = { sessionId: "session-2026-07-02-00-00-00-11223344", projectId, @@ -277,7 +277,7 @@ describe("session-index", () => { 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( new Date(2026, 6, 1, 8, 30, 0).toISOString(), ); diff --git a/packages/server/test/session-loader.test.ts b/packages/server/test/session-loader.test.ts index 010ba8a..d2f6284 100644 --- a/packages/server/test/session-loader.test.ts +++ b/packages/server/test/session-loader.test.ts @@ -64,7 +64,7 @@ describe("session-loader", () => { 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, [ sessionMeta(meta()), // workspace points to a nonexistent directory userText("hi"), @@ -74,10 +74,10 @@ describe("session-loader", () => { expect(err).toBeInstanceOf(HttpError); expect((err as HttpError).status).toBe(409); 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"); await fs.mkdir(ws, { recursive: true }); await writeTraceFile(root, PROJECT, AGENT, "2026-07-06", SID, 1, [ @@ -89,10 +89,10 @@ describe("session-loader", () => { expect(err).toBeInstanceOf(HttpError); expect((err as HttpError).status).toBe(409); 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 err = await loader.load(row("/tmp/gone-workspace-abc")).catch((e) => e); expect(err).toBeInstanceOf(HttpError); diff --git a/packages/server/test/session-manager.test.ts b/packages/server/test/session-manager.test.ts index 9c32fad..a31fdf9 100644 --- a/packages/server/test/session-manager.test.ts +++ b/packages/server/test/session-manager.test.ts @@ -121,23 +121,23 @@ describe("session-manager", () => { db.close(); }); - it("未知 Session → 404", async () => { + it("unknown Session → 404", async () => { const manager = makeManager(loaderOf(approvalFakeSession("session-1"))); const err = await manager.startTask("session-ghost", [userText("x")]).catch((e: unknown) => e); 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"); const manager = makeManager(loaderOf(approvalFakeSession("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"); 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. 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"); expect(states.map((s) => s.state)).toEqual(["running", "idle"]); // 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"); const captured: ErrorRecordArgs[] = []; // core folds LLM / tool failures into the message stream (no throw): a tool @@ -175,7 +175,7 @@ describe("session-manager", () => { async *compact(): AsyncGenerator {}, }; 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); 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 }); - 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"))); await manager.startTask("session-1", [userText("go")]); await waitFor(() => manager.pendingApprovalCount("session-1") === 1); @@ -202,7 +202,7 @@ describe("session-manager", () => { 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 events = capture("session-1"); await manager.startTask("session-1", [userText("go")]); @@ -225,7 +225,7 @@ describe("session-manager", () => { 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"); const manager = makeManager(loaderOf(approvalFakeSession("session-1"))); 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 = { sessionId: "session-1", toolPermission: () => "rw", @@ -259,7 +259,7 @@ describe("session-manager", () => { // The parent-level run_subagent call (no origin): its prompt becomes the sub-session title. yield toolCall({ name: "run_subagent", - arguments: JSON.stringify({ prompt: "研究一下这个问题的背景资料" }), + arguments: JSON.stringify({ prompt: "Research the background of this question" }), toolCallId: "sub-1", }); const hop = "child-1"; @@ -308,17 +308,17 @@ describe("session-manager", () => { 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. expect(childTitle!.req.material).toEqual({ - userText: "研究一下这个问题的背景资料", + userText: "Research the background of this question", 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. 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. 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. const fake: RuntimeSession = { sessionId: "session-1", @@ -349,7 +349,7 @@ describe("session-manager", () => { 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"))); expect(manager.abortTask("session-1")).toBe(false); // no Task in progress → no-op await manager.startTask("session-1", [userText("go")]); @@ -363,7 +363,7 @@ describe("session-manager", () => { 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"))); expect(manager.beginSessionDeletion("session-1")).toEqual([]); // no active entry // 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"); }); - 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"); const manager = makeManager(loaderOf(approvalFakeSession("session-2-healed"))); const { sessionId } = await manager.startTask("session-1", [userText("go")]); @@ -392,7 +392,7 @@ describe("session-manager", () => { 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"))); await manager.startTask("session-1", [userText("go")]); await waitFor(() => manager.pendingApprovalCount("session-1") === 1); @@ -415,7 +415,7 @@ describe("session-manager", () => { 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"))); expect(manager.abortProject("p1")).toEqual([]); // no active runs → empty array await manager.startTask("session-1", [userText("go")]); @@ -429,10 +429,10 @@ describe("session-manager", () => { 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 = { load: async () => { - throw new HttpError(409, "workspace_missing", "Workspace 已不存在。"); + throw new HttpError(409, "workspace_missing", "Workspace no longer exists."); }, }; const manager = makeManager(loader); @@ -441,7 +441,7 @@ describe("session-manager", () => { 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"))); await manager.shutdown(); 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); }); - 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; const loader: SessionLoader = { load: async () => { @@ -475,7 +475,7 @@ describe("session-manager", () => { 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"))); await manager.startTask("session-1", [userText("go")]); await waitFor(() => manager.pendingApprovalCount("session-1") === 1); @@ -486,7 +486,7 @@ describe("session-manager", () => { 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 events = capture("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 plainSession: RuntimeSession = { sessionId: "session-1", @@ -507,10 +507,10 @@ describe("session-manager", () => { generateTitle: async () => ({ title: null, usage: null }), compactability: () => "ok" as const, async *run() { - yield thinkingMessage("思考中"); - yield assistantText("答案A"); - yield withOrigin(assistantText("子会话文本"), "session-sub"); - yield assistantText("答案B"); + yield thinkingMessage("thinking"); + yield assistantText("answer A"); + yield withOrigin(assistantText("sub-session text"), "session-sub"); + yield assistantText("answer B"); }, async *compact() { yield compactionBegin({ reason: "manual", mode: "summarize", context: 1, turns: 1 }); @@ -528,9 +528,9 @@ describe("session-manager", () => { 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); - 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. expect(notified[0]!.req.material).toBeUndefined(); expect(notified[0]!.session).toBe(plainSession); diff --git a/packages/server/test/skills.test.ts b/packages/server/test/skills.test.ts index b154cd0..6f33ede 100644 --- a/packages/server/test/skills.test.ts +++ b/packages/server/test/skills.test.ts @@ -35,7 +35,7 @@ describe("skills api", () => { member = apiClient(t.app, b.cookie); outsider = apiClient(t.app, c.cookie); 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; projectId = created.project.projectId; expect( @@ -52,7 +52,7 @@ describe("skills api", () => { 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"); expect(res.status).toBe(200); 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"); const url = base("bare_agent"); @@ -141,7 +141,7 @@ describe("skills api", () => { 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"); const url = base("update_agent"); 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); }); - it("未知技能 404 unknown_skill,且不产生半安装状态", async () => { + it("unknown skill 404 unknown_skill, with no half-installed state", async () => { await createPlainAgent("strict_agent"); const url = base("strict_agent"); const res = await owner.post(url, { names: ["penguin-sdk", "no-such-skill"] }); @@ -170,7 +170,7 @@ describe("skills api", () => { 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"); const url = base("valid_agent"); 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"); expect((await outsider.get(url)).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); }); - 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")); expect(res.status).toBe(200); const body = (await res.json()) as AgentSkillsResponse; diff --git a/packages/server/test/sse-stream.test.ts b/packages/server/test/sse-stream.test.ts index 555693b..25d7c51 100644 --- a/packages/server/test/sse-stream.test.ts +++ b/packages/server/test/sse-stream.test.ts @@ -31,7 +31,8 @@ async function readSseFrames(res: Response, count: number, timeoutMs = 3000): Pr const deadline = Date.now() + timeoutMs; try { 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(); if (done) break; buf += decoder.decode(value, { stream: true }); @@ -103,14 +104,14 @@ describe("sse-stream", () => { const getStream = (headers: Record = {}) => 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); expect(frames[0]!.event).toBe("server_event"); 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 }); - 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)); await t.deps.manager.startTask(SID, [userText("go")]); await waitFor(() => t.deps.manager.pendingApprovalCount(SID) === 1); @@ -128,8 +129,8 @@ describe("sse-stream", () => { await waitFor(() => t.deps.manager.statusOf(SID) === "idle"); }); - it("FD-2:Last-Event-ID 纪元不一致 → 先发 resync_required 再发 task_state 快照", async () => { - t.deps.channels.get(SID).publish(userText("旧事件")); + it("FD-2: mismatched Last-Event-ID epoch → resync_required first, then the task_state snapshot", async () => { + t.deps.channels.get(SID).publish(userText("old event")); const frames = await readSseFrames( await getStream({ "Last-Event-ID": "deadbeef-1" }), // guaranteed to differ from the current channel epoch 2, @@ -138,7 +139,7 @@ describe("sse-stream", () => { 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 first = channel.publish(userText("m1")); channel.publish(userText("m2")); diff --git a/packages/server/test/title-generator.test.ts b/packages/server/test/title-generator.test.ts index cf615aa..5844662 100644 --- a/packages/server/test/title-generator.test.ts +++ b/packages/server/test/title-generator.test.ts @@ -4,6 +4,9 @@ * persisting + event push + usage accounted as token usage, idempotency (no * regeneration when a title already exists / generation is in flight), and * 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 type { DatabaseSync } from "node:sqlite"; @@ -89,7 +92,7 @@ describe("title-generator", () => { 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 calls = { count: 0, args: [] as unknown[] }; const gen = makeGenerator(); @@ -119,7 +122,7 @@ describe("title-generator", () => { 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 gen = makeGenerator(); const material = { userText: "子会话 prompt", assistantText: "子会话回答" }; @@ -132,18 +135,18 @@ describe("title-generator", () => { expect(sessions.findById(ROW.sessionId)?.title).toBe("子标题"); }); - it("已有标题不再生成(不发起单发请求)", async () => { - sessions.updateTitle(ROW.sessionId, "已有标题"); + it("an existing title is never regenerated (no one-shot request issued)", async () => { + sessions.updateTitle(ROW.sessionId, "existing title"); 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", }); await new Promise((r) => setTimeout(r, 20)); 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 gen = makeGenerator(); gen.maybeGenerate( @@ -161,7 +164,7 @@ describe("title-generator", () => { 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 gen = makeGenerator(); gen.maybeGenerate(CTX, fakeSession({ title: null, usage: null }, calls), { @@ -171,7 +174,7 @@ describe("title-generator", () => { 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 gen = makeGenerator(); // sanitizeTitle strips "???" down to empty — the fallback must revert to the truncated original text so a title is still produced. diff --git a/packages/server/test/trace-service.test.ts b/packages/server/test/trace-service.test.ts index 22c4840..13a1568 100644 --- a/packages/server/test/trace-service.test.ts +++ b/packages/server/test/trace-service.test.ts @@ -73,29 +73,29 @@ describe("trace-service", () => { 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, [ sessionMeta(metaPayload()), - userText("第一个文件"), + userText("first file"), ]); await writeTraceFile(root, P, A, "2026-07-06", S, 2, [ sessionMeta(metaPayload()), - userText("第二个文件"), + userText("second file"), ]); const messages = await service.readMessages(P, A, S); expect(messages).toHaveLength(4); - expect((messages[1]!.payload as { text: string }).text).toBe("第一个文件"); - expect((messages[3]!.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("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")]); await fs.appendFile(file, '{"timestamp":"2026', "utf8"); const messages = await service.readMessages(P, A, S); 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-06", S, 2, [userText("bb")]); const files = await service.listTraceFiles(P, A, S); @@ -105,7 +105,7 @@ describe("trace-service", () => { 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}`)); await writeTraceFile(root, P, A, "2026-07-05", S, 1, messages); 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); }); - 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, [ sessionMeta(metaPayload()), 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 // 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 @@ -206,7 +206,7 @@ describe("trace-service", () => { 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 // 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 @@ -234,7 +234,7 @@ describe("trace-service", () => { 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_begin and compaction_end (see core's context-engine summarize // 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); }); - 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 // turn": when a compaction request exhausts its retries and ends in timeout, // it would be classified as continuing; compaction_end must clear that @@ -315,7 +315,7 @@ describe("trace-service", () => { "2026-07-05T10:00:06.000Z", 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:10.000Z", requestEnd("completed")), 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); }); - 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) // 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 @@ -349,19 +349,19 @@ describe("trace-service", () => { // turn — this must be backstopped by the server-side tasks logic too. 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", userText("第一问")), + at("2026-07-05T10:00:00.000Z", userText("question one")), at("2026-07-05T10:00:01.000Z", requestBegin()), at("2026-07-05T10:00:02.000Z", requestEnd("completed")), 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. - 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:04.000Z", requestEnd("failed")), ]); 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 - // 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[1]!.messageFrom).toBe(5); // 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); }); - 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 // 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 @@ -388,12 +388,12 @@ describe("trace-service", () => { // always breaks continuation, regardless of how the previous turn wrapped up. 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", userText("第一问")), + at("2026-07-05T10:00:00.000Z", userText("question one")), 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:03.000Z", abortEvent()), // 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:03.000Z", requestEnd("completed")), 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 }); - 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 // pending index were overwritten on every message, turn attribution would // 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". 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", userText("第一问")), + at("2026-07-05T10:00:00.000Z", userText("question one")), at("2026-07-05T10:00:01.000Z", requestBegin()), at("2026-07-05T10:00:02.000Z", requestEnd("completed")), at("2026-07-05T10:00:02.100Z", tokenUsage(counts(500), buckets(0, 400, 100))), // 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:01.000Z", imageUrlMessage("data:image/png;base64,BBBB")), 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 }); - 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 // **the same millisecond**: this turn's last reply, compaction_begin, the // 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 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", userText("问")), + at("2026-07-05T10:00:00.000Z", userText("q")), 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, requestEnd("completed")), 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); }); - 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"; await writeTraceFile(root, P, A, "2026-07-05", S, 1, [userText("a")]); 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]); }); - 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.listTraceFiles(P, A, S)).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`; await writeTraceFile(root, P, A, "2026-07-05", S, 7, [ sessionMeta(metaPayload()), at(T("00.000"), userText("q")), // the user input is sent instantly, so it occupies no segment 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" })), // 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" })), @@ -508,7 +508,7 @@ describe("trace-service", () => { 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 at(T("08.500"), requestBegin()), - at(T("10.000"), assistantText("答")), + at(T("10.000"), assistantText("answer")), at(T("10.100"), requestEnd("completed")), ]); 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`; await writeTraceFile(root, P, A, "2026-07-05", S, 9, [ sessionMeta(metaPayload()), @@ -570,12 +570,12 @@ describe("trace-service", () => { at(T("02.500"), requestEnd("completed")), at(T("03.000"), toolCallOutput({ output: "o", toolCallId: "t1" })), at(T("03.500"), requestBegin()), - at(T("04.000"), assistantText("答1")), + at(T("04.000"), assistantText("answer 1")), at(T("04.500"), requestEnd("completed")), // Task 1: a new user turn (the previous turn ended in plain text, not a continuation). at(T("20.000"), userText("q2")), at(T("21.000"), requestBegin()), - at(T("22.000"), assistantText("答2")), + at(T("22.000"), assistantText("answer 2")), at(T("22.500"), requestEnd("completed")), ]); 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 // compaction request lands on a new taskIndex. A successful compaction splits // 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`; await writeTraceFile(root, P, A, "2026-07-05", S, 10, [ sessionMeta(metaPayload()), @@ -619,7 +619,7 @@ describe("trace-service", () => { // 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 // 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`; await writeTraceFile(root, P, A, "2026-07-05", S, 11, [ sessionMeta(metaPayload()), @@ -633,11 +633,11 @@ describe("trace-service", () => { compactionBegin({ reason: "context", mode: "summarize", context: 1, turns: 1 }), ), 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("06.000"), compactionEnd({ reason: "context", mode: "summarize", status: "failed" })), at(T("07.000"), requestBegin()), - at(T("08.000"), assistantText("答")), + at(T("08.000"), assistantText("answer")), at(T("08.500"), requestEnd("completed")), ]); 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]); }); - 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`; await writeTraceFile(root, P, A, "2026-07-05", S, 8, [ sessionMeta(metaPayload()), diff --git a/packages/server/test/trace-subagent-expand.test.ts b/packages/server/test/trace-subagent-expand.test.ts index 487bb19..8cf16ed 100644 --- a/packages/server/test/trace-subagent-expand.test.ts +++ b/packages/server/test/trace-subagent-expand.test.ts @@ -61,7 +61,7 @@ async function writeTrace( ); } -describe("TraceService.readMessages — 子会话展开", () => { +describe("TraceService.readMessages — sub-session expansion", () => { let root: string; let svc: TraceService; @@ -73,27 +73,27 @@ describe("TraceService.readMessages — 子会话展开", () => { 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, [ meta(PARENT, PARENT_AGENT), - userText("跑个子 agent"), + userText("run a sub-agent"), toolCall({ name: "run_subagent", arguments: "{}", toolCallId: "c1" }), subagentEvent(CHILD), // pointer: holds only the child Session id toolCallOutput({ output: "done", toolCallId: "c1" }), ]); await writeTrace(root, CHILD_AGENT, CHILD, [ meta(CHILD, CHILD_AGENT), - assistantText("子会话回答"), + assistantText("sub-session answer"), ]); const msgs = await svc.readMessages(PROJECT, PARENT_AGENT, PARENT); const texts = msgs.map((m) => (m.payload as { text?: string; type?: string }).text ?? m.type); expect(texts).toEqual([ "session_meta", - "跑个子 agent", + "run a sub-agent", "model_msg", // tool_call "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 ]); @@ -104,7 +104,7 @@ describe("TraceService.readMessages — 子会话展开", () => { 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, [ meta(PARENT, PARENT_AGENT), subagentEvent(CHILD), @@ -115,16 +115,16 @@ describe("TraceService.readMessages — 子会话展开", () => { ]); await writeTrace(root, CHILD_AGENT, GRANDCHILD, [ meta(GRANDCHILD, CHILD_AGENT), - assistantText("孙会话回答"), + assistantText("grandchild answer"), ]); 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(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 // pointer at the spawn point); this guards against expansion runaway on a // tampered/corrupted Trace. @@ -136,7 +136,7 @@ describe("TraceService.readMessages — 子会话展开", () => { await writeTrace(root, CHILD_AGENT, CHILD, [ meta(CHILD, CHILD_AGENT), subagentEvent(PARENT), // points back to an ancestor - assistantText("子会话回答"), + assistantText("sub-session answer"), ]); 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"); expect(pointers).toHaveLength(2); expect(msgs.filter((m) => m.type === "session_meta")).toHaveLength(2); - expect(msgs.filter((m) => (m.payload as { text?: string }).text === "子会话回答")).toHaveLength( - 1, - ); + expect( + 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, [ meta(PARENT, PARENT_AGENT), subagentEvent(CHILD), diff --git a/packages/server/test/usage.test.ts b/packages/server/test/usage.test.ts index 1dafa77..279b772 100644 --- a/packages/server/test/usage.test.ts +++ b/packages/server/test/usage.test.ts @@ -54,7 +54,7 @@ describe("usage-recorder", () => { }); 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); await rec.record(CTX, tokenUsage(counts(1000), counts(115))); const rows = db.prepare("SELECT * FROM usage_records").all(); @@ -67,7 +67,7 @@ describe("usage-recorder", () => { 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 childMeta = withOrigin( sessionMeta(meta("session-child", "child-model")), @@ -81,20 +81,20 @@ describe("usage-recorder", () => { 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); await rec.record(CTX, withOrigin(tokenUsage(counts(5), counts(5)), "session-unknown")); const row = db.prepare("SELECT model_id FROM usage_records").get()!; 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); await rec.record(CTX, sessionMeta(meta("session-main", "main-model"))); 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); for (let i = 0; i <= ORIGIN_MODELS_MAX; i++) { // 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 repo: UsageRepo; 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 today = formatLocalDate(now); insert(today); @@ -165,7 +165,7 @@ describe("usage-service(成本实时折算)", () => { 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"); insert("2026-07-06", { modelId: "m-late" }); const svc = service(now); @@ -180,7 +180,7 @@ describe("usage-service(成本实时折算)", () => { 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"); pricing.m2 = { cacheRead: 1, cacheWrite: 1, output: 1 }; insert("2026-07-05", { agentId: "a1", sessionId: "s1", modelId: "m1" }); @@ -206,7 +206,7 @@ describe("usage-service(成本实时折算)", () => { 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"); insert("2026-07-06"); insert("2026-06-20"); @@ -224,7 +224,7 @@ describe("usage-service(成本实时折算)", () => { // —— 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"); for (let i = 0; i < 7; i++) insert("2026-07-06"); 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"); 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%. @@ -263,7 +263,7 @@ describe("usage-service(成本实时折算)", () => { 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"); insert("2026-07-06", { modelId: "m1", agentId: "a1" }); insert("2026-07-06", { modelId: "m2", agentId: "a2", status: "failed", total: 0 }); diff --git a/packages/server/test/vault.test.ts b/packages/server/test/vault.test.ts index 5f0bfc6..3180854 100644 --- a/packages/server/test/vault.test.ts +++ b/packages/server/test/vault.test.ts @@ -28,7 +28,7 @@ describe("vault api", () => { member = apiClient(t.app, b.cookie); outsider = apiClient(t.app, c.cookie); 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; projectId = created.project.projectId; vaultPath = `/api/projects/${projectId}/agents/default_agent/vault`; @@ -39,7 +39,7 @@ describe("vault api", () => { 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. const empty = (await (await owner.get(vaultPath)).json()) as VaultResponse; expect(empty.entries).toEqual([]); @@ -67,14 +67,14 @@ describe("vault api", () => { 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 outsider.put(vaultPath, { entries: [{ key: "K", value: "v" }] })).status).toBe( 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, { entries: [ { key: "KEEP_ME", value: "keep-secret-000111" }, @@ -99,7 +99,7 @@ describe("vault api", () => { 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`, { defaultModel: { provider: "custom", modelId: "m-1" }, models: [{ provider: "custom", modelId: "m-1", apiKey: "sk-model-key-999888" }], @@ -112,7 +112,7 @@ describe("vault api", () => { 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" }] }); // Another Agent in the same Project: empty table, and writes don't affect each other. @@ -139,7 +139,7 @@ describe("vault api", () => { ).toBe(404); }); - it("键名与请求体形状校验 400", async () => { + it("key name and request body shape validation 400", async () => { const cases: unknown[] = [ { entries: [{ key: "1BAD", value: "v" }] }, // starts with a digit { entries: [{ key: "BAD-DASH", value: "v" }] }, // hyphen diff --git a/packages/server/test/workspace-files.test.ts b/packages/server/test/workspace-files.test.ts index a1827ca..7178803 100644 --- a/packages/server/test/workspace-files.test.ts +++ b/packages/server/test/workspace-files.test.ts @@ -34,14 +34,14 @@ describe("workspace-files-service", () => { 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, ""); expect(root.entries.map((e) => `${e.kind}:${e.name}`)).toEqual(["dir:sub", "file:b.txt"]); const sub = await svc.list(ws, "sub"); 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"); expect(file.data.toString()).toBe("# md"); expect(file.contentType).toContain("markdown"); @@ -49,14 +49,14 @@ describe("workspace-files-service", () => { 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")); expect(await fs.readFile(path.join(ws, "sub", "new.txt"), "utf8")).toBe("data"); 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"); }); - 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.read(ws, `../${path.basename(outside)}/secret.txt`)).rejects.toMatchObject({ 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 sub = await svc.list(root, path.relative(root, path.join(ws, "sub"))); 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")); const root = await svc.list(ws, ""); 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"]); }); - 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 expect(svc.list(ws, "link-out")).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); }); - 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. const victim = path.join(outside, "secret.txt"); 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 owner: ReturnType; let outsider: ReturnType; @@ -130,7 +130,7 @@ describe("files/stat 路由(批量存在性检查)", () => { owner = apiClient(t.app, a.cookie); outsider = apiClient(t.app, b.cookie); 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; const projectId = created.project.projectId; await owner.put(`/api/projects/${projectId}/models`, { @@ -149,7 +149,7 @@ describe("files/stat 路由(批量存在性检查)", () => { 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`, { 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: [] }); }); - 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`; expect((await owner.post(url, { paths: "a.txt" })).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); }); - it("外人访问 → 404(不泄露存在性)", async () => { + it("outsider access → 404 (no existence leak)", async () => { const res = await outsider.post(`/api/sessions/${sessionId}/files/stat`, { paths: ["a.txt"], }); @@ -178,7 +178,7 @@ describe("files/stat 路由(批量存在性检查)", () => { }); }); -describe("agent 删除路由", () => { +describe("agent delete route", () => { let t: TestApp; let owner: ReturnType; let outsider: ReturnType; @@ -191,7 +191,7 @@ describe("agent 删除路由", () => { owner = apiClient(t.app, a.cookie); outsider = apiClient(t.app, b.cookie); 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; projectId = created.project.projectId; }); @@ -199,9 +199,9 @@ describe("agent 删除路由", () => { 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 ( - 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; const agentId = created.agent.agentId; const dir = path.join(t.root, projectId, "agents", agentId); diff --git a/packages/server/test/workspace-guard.test.ts b/packages/server/test/workspace-guard.test.ts index ae44851..1fc73f9 100644 --- a/packages/server/test/workspace-guard.test.ts +++ b/packages/server/test/workspace-guard.test.ts @@ -25,18 +25,18 @@ describe("workspace-guard", () => { 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")); 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"); await fs.mkdir(outside, { recursive: true }); 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"); await fs.mkdir(outside, { recursive: true }); const link = path.join(projectA, "escape"); @@ -44,14 +44,14 @@ describe("workspace-guard", () => { 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); expect(err).toBeInstanceOf(HttpError); expect((err as HttpError).status).toBe(400); 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"); await fs.writeFile(file, "x", "utf8"); const err = await guard(file).catch((e: unknown) => e); diff --git a/packages/skills/test/skills.test.ts b/packages/skills/test/skills.test.ts index 582322c..0260b0f 100644 --- a/packages/skills/test/skills.test.ts +++ b/packages/skills/test/skills.test.ts @@ -29,7 +29,7 @@ const fakeSkill = (name: string): LibrarySkill => ({ }); describe("loadLibrarySkills", () => { - it("按 name 排序读出技能,metadata 齐全(含中文描述与短描述)", async () => { + it("loads skills sorted by name with complete metadata (zh and short descriptions)", async () => { const skills = loadLibrarySkills(); const names = skills.map((skill) => skill.name); 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()) { 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). @@ -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 })) .filter((entry) => entry.isDirectory()) .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()) { expect(skill.content, skill.name).toContain("## Before you start"); } @@ -85,7 +85,7 @@ describe("loadLibrarySkills", () => { }); describe("loadSkillGroups / groupSkills", () => { - it("按 Skill 组清单解组,成员齐全且带中文组名,无 Other 组", () => { + it("loads groups per SKILL_GROUPS, members complete with Chinese titles, no Other group", () => { const groups = loadSkillGroups(); expect(groups.map((g) => g.id)).toEqual([ "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 groups = groupSkills([fakeSkill("agent-creation"), stray]); expect(groups.map((g) => g.id)).toEqual([ @@ -139,7 +139,7 @@ describe("loadSkillGroups / groupSkills", () => { 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")]); expect(groups.map((g) => g.id)).toEqual([ "agent-development", @@ -155,7 +155,7 @@ describe("loadSkillGroups / groupSkills", () => { 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([ { id: "agent-development", @@ -170,12 +170,12 @@ describe("loadSkillGroups / groupSkills", () => { }); 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("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", ".", ""]) { expect(librarySkill(name), name).toBeUndefined(); } @@ -183,7 +183,7 @@ describe("librarySkill", () => { }); describe("parseSkillFrontmatter", () => { - it("解析 name/description/version/updated,值允许含冒号", () => { + it("parses name/description/version/updated, values may contain colons", () => { const meta = parseSkillFrontmatter( "---\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( "---\nname: demo\ndescription: Do x\nshort_description_zh: 做 x\n---\nBody", ); @@ -205,7 +205,7 @@ describe("parseSkillFrontmatter", () => { 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( "---\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); }); - 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"); expect(bom?.name).toBe("demo"); const crlf = parseSkillFrontmatter("---\r\nname: demo\r\ndescription: Do x\r\n---\r\nBody"); 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("---\ndescription: only desc\n---\nBody")).toBeNull(); // A block that isn't at the start doesn't count as frontmatter either. 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: 0\n---")?.version).toBe(1); expect(parseSkillFrontmatter("---\nname: a\n---")).toEqual({ diff --git a/packages/web/e2e/README.md b/packages/web/e2e/README.md index 635eb1c..1b28521 100644 --- a/packages/web/e2e/README.md +++ b/packages/web/e2e/README.md @@ -1,13 +1,14 @@ -# Web E2E(Playwright) +# Web E2E (Playwright) -浏览器端到端:对话(thinking + 工具审批 + 工具执行 + 二轮答复)、图标化统计(成本折算 / -复制回复)、轨迹观测(分 Task 时间线 + 图例 + 悬停联动高亮)、Workspace 文件预览(HTML -sandbox 渲染、路径默认隐藏)。LLM 由 `mock-llm.mjs`(mock Anthropic Messages SSE)驱动, -不联网。 +Browser end-to-end tests: chat (thinking + tool approval + tool execution + second-turn +reply), the iconified stats line (cost conversion / copy reply), Traces (per-Task timeline + +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 -pnpm --filter @prismshadow/penguin-web test:e2e # 构建 + 起服务 + 跑用例 -SKIP_BUILD=1 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 the build ``` -首次需 `npx playwright install chromium`。 +The first run requires `npx playwright install chromium`. diff --git a/packages/web/e2e/chat.spec.mjs b/packages/web/e2e/chat.spec.mjs index 8721a55..6ce0d07 100644 --- a/packages/web/e2e/chat.spec.mjs +++ b/packages/web/e2e/chat.spec.mjs @@ -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