Make English the repository working language (#5)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -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",
|
||||
|
||||
@@ -354,7 +354,7 @@ function confirmYes(prompt: string): Promise<boolean> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" }],
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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());
|
||||
|
||||
+18
-12
@@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: [] });
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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 })),
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -254,7 +254,7 @@ export function createApp(deps: AppDeps): Hono<AppEnv> {
|
||||
});
|
||||
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<AppEnv> {
|
||||
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<AppEnv>, 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<AppEnv>, 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), {
|
||||
|
||||
@@ -33,7 +33,7 @@ export function authMiddleware(auth: AuthService): MiddlewareHandler<AppEnv> {
|
||||
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();
|
||||
|
||||
@@ -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<void> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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>/<id> 拼接)
|
||||
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 <provider>/<id>)
|
||||
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
|
||||
);
|
||||
`;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ export function adminUsersRoutes(deps: AppDeps): Hono<AppEnv> {
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -33,7 +33,7 @@ export function agentConfigRoutes(deps: AppDeps): Hono<AppEnv> {
|
||||
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"];
|
||||
}
|
||||
|
||||
@@ -42,10 +42,11 @@ export function agentTransferRoutes(deps: AppDeps): Hono<AppEnv> {
|
||||
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);
|
||||
|
||||
@@ -31,18 +31,22 @@ export function dirsRoutes(deps: AppDeps): Hono<AppEnv> {
|
||||
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[] = [];
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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<string, unknown>): ModelsUpdateRequest {
|
||||
if (!Array.isArray(body.models)) throw badRequest("models 必须是数组。");
|
||||
if (!Array.isArray(body.models)) throw badRequest("models must be an array.");
|
||||
const models: ModelUpdateEntry[] = body.models.map((item, i) => {
|
||||
if (item === null || typeof item !== "object" || Array.isArray(item)) {
|
||||
throw badRequest(`models[${i}] 必须是对象。`);
|
||||
throw badRequest(`models[${i}] must be an object.`);
|
||||
}
|
||||
const m = item as Record<string, unknown>;
|
||||
const entry: ModelUpdateEntry = {
|
||||
@@ -49,7 +49,7 @@ function parseModelsUpdate(body: Record<string, unknown>): 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<string, unknown>): 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<string, unknown>;
|
||||
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<string, unknown>): 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<AppEnv> {
|
||||
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));
|
||||
|
||||
@@ -48,7 +48,7 @@ function parseUpsertBody(body: Record<string, unknown>): {
|
||||
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<AppEnv> {
|
||||
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<AppEnv> {
|
||||
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<AppEnv> {
|
||||
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}`);
|
||||
}
|
||||
|
||||
@@ -59,16 +59,16 @@ const APPROVAL_MODES: readonly ApprovalMode[] = [
|
||||
function parseTaskInput(body: Record<string, unknown>): 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<string, unknown>;
|
||||
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<string, unknown>): 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<AppEnv> {
|
||||
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<AppEnv> {
|
||||
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<AppEnv> {
|
||||
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<AppEnv> {
|
||||
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<AppEnv> {
|
||||
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<string, string> = {
|
||||
".png": "image/png",
|
||||
@@ -309,7 +327,11 @@ export function sessionsRoutes(deps: AppDeps): Hono<AppEnv> {
|
||||
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<AppEnv> {
|
||||
!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<AppEnv> {
|
||||
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);
|
||||
|
||||
@@ -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, unknown>): 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<AppEnv> {
|
||||
// Verify all names up front before writing anything: if any name isn't in the library, reject the whole request rather than leaving a half-installed state.
|
||||
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<AppEnv> {
|
||||
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);
|
||||
|
||||
@@ -20,7 +20,7 @@ export function usageRoutes(deps: AppDeps): Hono<AppEnv> {
|
||||
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");
|
||||
|
||||
@@ -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<string, unknown>): VaultUpdateRequest {
|
||||
if (!Array.isArray(body.entries)) throw badRequest("entries 必须是数组。");
|
||||
if (!Array.isArray(body.entries)) throw badRequest("entries must be an array.");
|
||||
const entries: VaultEntryUpdate[] = body.entries.map((item, i) => {
|
||||
if (item === null || typeof item !== "object" || Array.isArray(item)) {
|
||||
throw badRequest(`entries[${i}] 必须是对象。`);
|
||||
throw badRequest(`entries[${i}] must be an object.`);
|
||||
}
|
||||
const e = item as Record<string, unknown>;
|
||||
const entry: VaultEntryUpdate = {
|
||||
@@ -22,7 +22,7 @@ function parseVaultUpdate(body: Record<string, unknown>): 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;
|
||||
}
|
||||
|
||||
@@ -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<Record<string, unknown>> {
|
||||
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<string, unknown>;
|
||||
}
|
||||
@@ -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 extends string>(
|
||||
): 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;
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
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",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<CompactAvailability, "ok">): HttpError {
|
||||
const messages: Record<typeof why, string> = {
|
||||
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 {
|
||||
|
||||
@@ -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<LlmFailure, { code: string; kind: ErrorKind; text: string }> = {
|
||||
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))}`;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -41,13 +41,17 @@ export class AdminService {
|
||||
|
||||
async createUser(userId: string, password: string): Promise<UserInfo> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
|
||||
@@ -68,7 +68,7 @@ export class AgentConfigService {
|
||||
|
||||
async requireExists(projectId: string, agentId: string): Promise<void> {
|
||||
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;
|
||||
});
|
||||
|
||||
@@ -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<AgentListItem> {
|
||||
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 });
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<void> {
|
||||
if (rel === "" || rel.endsWith("/")) throw badRequest("path 必须是文件路径。");
|
||||
if (rel === "" || rel.endsWith("/")) throw badRequest("path must be a file path.");
|
||||
if (data.length > MAX_UPLOAD_BYTES) {
|
||||
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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<typeof apiClient>;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 <userId>-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.
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -21,7 +21,7 @@ interface ProjectCreateResponse {
|
||||
project: { projectId: string };
|
||||
}
|
||||
|
||||
describe("内置 Agent 供给", () => {
|
||||
describe("built-in Agent provisioning", () => {
|
||||
let t: TestApp;
|
||||
let owner: ReturnType<typeof apiClient>;
|
||||
|
||||
@@ -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`, {
|
||||
|
||||
@@ -13,7 +13,7 @@ function foreignEpoch(ch: Channel): string {
|
||||
}
|
||||
|
||||
describe("channel", () => {
|
||||
it("publish 分配 `<epoch>-<seq>` 单调 id 并广播", () => {
|
||||
it("publish assigns monotonic `<epoch>-<seq>` 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;`<epoch>-0` 从头命中", () => {
|
||||
it("brand-new channel: any Last-Event-ID → miss; `<epoch>-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");
|
||||
|
||||
@@ -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/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, number> }).lastSeen;
|
||||
|
||||
it("短窗去重:窗口内的同类异常只落一条,窗口外恢复记录", () => {
|
||||
it("short-window dedup: same-kind errors persist once per window, then resume", () => {
|
||||
let t = Date.parse("2026-07-06T10:00:00Z");
|
||||
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<typeof apiClient>;
|
||||
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<string, unknown>
|
||||
>;
|
||||
|
||||
it("业务错误(HttpError 404)→ expected,带 code / 状态码 / projectId", async () => {
|
||||
it("business error (HttpError 404) → expected, with code / status / projectId", async () => {
|
||||
const res = await api.get(`/api/projects/${projectId}/agents/agent-nope/sessions`);
|
||||
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<typeof apiClient>;
|
||||
@@ -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
|
||||
|
||||
@@ -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<void> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<typeof apiClient>;
|
||||
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" }],
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = <scheduled_task> source block + the prompt body (tells the model this is a scheduled task).
|
||||
expect(started[0]?.text).toContain("<scheduled_task>");
|
||||
expect(started[0]?.text).toContain("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", [
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<typeof apiClient>;
|
||||
let api: ReturnType<typeof apiClient>;
|
||||
@@ -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 <username>-<suffix>; 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: "爬虫",
|
||||
|
||||
@@ -21,7 +21,7 @@ async function exists(p: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
describe("会话删除清理 scratchpad", () => {
|
||||
describe("session deletion cleans up the scratchpad", () => {
|
||||
let t: TestApp;
|
||||
let owner: ReturnType<typeof apiClient>;
|
||||
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`,
|
||||
{},
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<OmniMessage> {},
|
||||
};
|
||||
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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, string> = {}) =>
|
||||
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"));
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<typeof apiClient>;
|
||||
let outsider: ReturnType<typeof apiClient>;
|
||||
@@ -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<typeof apiClient>;
|
||||
let outsider: ReturnType<typeof apiClient>;
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -52,7 +52,7 @@ test("chat + tool approval + stats/cost/copy + traces + files", async ({ page })
|
||||
// Approval-mode is a custom dropdown (button), NOT a native <select>.
|
||||
await expect(page.locator("select")).toHaveCount(0);
|
||||
|
||||
await ta.fill("帮我配置 @theme");
|
||||
await ta.fill("Help me set up @theme");
|
||||
await page.getByRole("button", { name: "发送" }).click();
|
||||
|
||||
// Tool name is shown on the collapsed tool row + in the pending-approval block.
|
||||
@@ -84,7 +84,7 @@ test("chat + tool approval + stats/cost/copy + traces + files", async ({ page })
|
||||
await page.getByRole("button", { name: "允许" }).click();
|
||||
|
||||
// Final assistant answer (turn 2 from mock).
|
||||
await expect(page.getByText("命令已执行完成,结果符合预期。")).toBeVisible();
|
||||
await expect(page.getByText("Command finished; the result looks as expected.")).toBeVisible();
|
||||
|
||||
// Regression: after entering a session and rendering messages, the page must not overflow
|
||||
// horizontally (previously, with the Files dock panel closed, the sr-only upload input was
|
||||
@@ -124,12 +124,12 @@ test("chat + tool approval + stats/cost/copy + traces + files", async ({ page })
|
||||
|
||||
// Hovering the REPLY ITSELF must reveal it — the reply and its stats line share a `group` wrapper
|
||||
// for exactly this. Requiring a hover on the number row would make the footer undiscoverable.
|
||||
await page.getByText("命令已执行完成,结果符合预期。").first().hover();
|
||||
await page.getByText("Command finished; the result looks as expected.").first().hover();
|
||||
await expect(statsLine).toHaveCSS("opacity", "1");
|
||||
await expect(statsLine.locator("text=/\\d{1,2}:\\d{2}/")).toBeVisible();
|
||||
await copyBtn.click();
|
||||
const clip = await page.evaluate(() => navigator.clipboard.readText());
|
||||
expect(clip).toContain("命令已执行完成");
|
||||
expect(clip).toContain("Command finished");
|
||||
|
||||
// The AI reply's time + copy live on the stats line (it sits right under the reply and IS its
|
||||
// footer) — the assistant message must NOT render a second footer, or one spot grows two copy
|
||||
@@ -149,17 +149,17 @@ test("chat + tool approval + stats/cost/copy + traces + files", async ({ page })
|
||||
).not.toBeNull();
|
||||
|
||||
// Hover the user bubble → its footer fades in (human-readable time + copy).
|
||||
await page.getByText("帮我配置 @theme").first().hover();
|
||||
await page.getByText("Help me set up @theme").first().hover();
|
||||
await expect(meta).toHaveCSS("opacity", "1");
|
||||
await expect(meta.locator("text=/\\d{1,2}:\\d{2}/")).toBeVisible();
|
||||
await msgCopy.click();
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("帮我配置 @theme");
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("Help me set up @theme");
|
||||
|
||||
// --- traces --- (scope to <main>; the global sidebar also lists the session title)
|
||||
await page.goto(`${BASE}/traces`);
|
||||
const main = page.locator("main");
|
||||
// Auto-generated title should appear in the traces tree (agents default-expanded).
|
||||
const titleNode = main.getByText("配置 Tailwind 主题").first();
|
||||
const titleNode = main.getByText("Configure Tailwind theme").first();
|
||||
await expect(titleNode).toBeVisible();
|
||||
await titleNode.click();
|
||||
await expect(main.getByText("轨迹观测").first()).toBeVisible();
|
||||
@@ -236,9 +236,9 @@ test("chat + tool approval + stats/cost/copy + traces + files", async ({ page })
|
||||
// The mock reply mentions two backtick paths: demo.html was truly uploaded above via
|
||||
// files/content; missing-report.pdf exists nowhere. The card must render after the
|
||||
// files/stat round-trip with a single row for the existing file only.
|
||||
await ta.fill("文件卡测试");
|
||||
await ta.fill("files card test");
|
||||
await page.getByRole("button", { name: "发送" }).click();
|
||||
await expect(page.getByText(/报告已生成/)).toBeVisible();
|
||||
await expect(page.getByText(/Report generated/)).toBeVisible();
|
||||
await expect(page.getByText("1 个文件")).toBeVisible();
|
||||
// Card rows carry the 点击预览 ("click to preview") affordance — that distinguishes them from
|
||||
// the Files panel tree rows, which also use title=<name> on their buttons.
|
||||
@@ -260,11 +260,11 @@ test("chat + tool approval + stats/cost/copy + traces + files", async ({ page })
|
||||
|
||||
// --- agent group collapse / expand (header button shows the agent name, so its state
|
||||
// lives on aria-label rather than a duplicate title tooltip) ---
|
||||
await expect(sidebar.getByText("配置 Tailwind 主题")).toBeVisible();
|
||||
await expect(sidebar.getByText("Configure Tailwind theme")).toBeVisible();
|
||||
await sidebar.locator('button[aria-label="折叠"]').first().click();
|
||||
await expect(sidebar.getByText("配置 Tailwind 主题")).toHaveCount(0);
|
||||
await expect(sidebar.getByText("Configure Tailwind theme")).toHaveCount(0);
|
||||
await sidebar.locator('button[aria-label="展开"]').first().click();
|
||||
await expect(sidebar.getByText("配置 Tailwind 主题")).toBeVisible();
|
||||
await expect(sidebar.getByText("Configure Tailwind theme")).toBeVisible();
|
||||
|
||||
// --- settings: adjustable accent color + font size (default gray/white) ---
|
||||
// The username button shares its name with the Project switcher (the initial Project's
|
||||
@@ -281,14 +281,14 @@ test("chat + tool approval + stats/cost/copy + traces + files", async ({ page })
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
// --- session rename (manual title wins over the auto-generated one) ---
|
||||
const renameTarget = sidebar.locator("li", { hasText: "配置 Tailwind 主题" }).first();
|
||||
const renameTarget = sidebar.locator("li", { hasText: "Configure Tailwind theme" }).first();
|
||||
await renameTarget.hover();
|
||||
await renameTarget.getByRole("button", { name: "重命名对话" }).click();
|
||||
await page.getByLabel("标题").fill("我改的标题");
|
||||
await page.getByLabel("标题").fill("My renamed title");
|
||||
await page.getByRole("button", { name: "保存" }).click();
|
||||
await expect(sidebar.getByText("我改的标题")).toBeVisible();
|
||||
await expect(sidebar.getByText("My renamed title")).toBeVisible();
|
||||
await page.reload();
|
||||
await expect(sidebar.getByText("我改的标题")).toBeVisible();
|
||||
await expect(sidebar.getByText("My renamed title")).toBeVisible();
|
||||
|
||||
// --- session archive + delete (throwaway session) ---
|
||||
await page.request.post(`${BASE}/api/projects/${projectId}/agents/${agentId}/sessions`, {
|
||||
|
||||
@@ -56,7 +56,7 @@ test("aborting before any turn completes: /compact says so instead of doing noth
|
||||
await page.goto(`${BASE}/chat/${sessionId}`);
|
||||
const ta = page.getByPlaceholder(/输入消息/);
|
||||
await ta.waitFor();
|
||||
await ta.fill("帮我配置 @theme");
|
||||
await ta.fill("Help me set up @theme");
|
||||
await page.getByRole("button", { name: "发送" }).click();
|
||||
// Wait for the approval to be pending before aborting: at this point the LLM stream has
|
||||
// already finished sending, the engine is blocked on `await approve(tc)`, and the generator is
|
||||
@@ -86,8 +86,8 @@ test("aborting before any turn completes: /compact says so instead of doing noth
|
||||
await ta.press("Enter");
|
||||
|
||||
// There must be feedback — silently doing nothing is exactly the bug being tested for.
|
||||
await expect(page.getByRole("button", { name: /没有可压缩的内容/ })).toBeVisible();
|
||||
expect(nativeDialogs, "反馈必须走应用内轻提示,不得回退到原生 alert").toHaveLength(0);
|
||||
await expect(page.getByRole("button", { name: /nothing to compact/ })).toBeVisible();
|
||||
expect(nativeDialogs, "feedback must be an in-app toast, not a native alert").toHaveLength(0);
|
||||
// And indeed no compaction was started (no banner).
|
||||
await expect(page.getByText(/\[压缩\]/)).toHaveCount(0);
|
||||
});
|
||||
@@ -123,9 +123,9 @@ test("compacting twice in a row: says the context was just compacted, not that n
|
||||
await page.goto(`${BASE}/chat/${sess.session.sessionId}`);
|
||||
const ta = page.getByPlaceholder(/输入消息/);
|
||||
await ta.waitFor();
|
||||
await ta.fill("帮我配置 @theme");
|
||||
await ta.fill("Help me set up @theme");
|
||||
await page.getByRole("button", { name: "发送" }).click();
|
||||
await expect(page.getByText("命令已执行完成,结果符合预期。")).toBeVisible();
|
||||
await expect(page.getByText("Command finished; the result looks as expected.")).toBeVisible();
|
||||
|
||||
const nativeDialogs = [];
|
||||
page.on("dialog", (d) => {
|
||||
@@ -137,14 +137,20 @@ test("compacting twice in a row: says the context was just compacted, not that n
|
||||
await ta.fill("/compact");
|
||||
await ta.press("Enter");
|
||||
await expect(page.getByText(/\[压缩\]/)).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /压缩/ })).toHaveCount(0);
|
||||
// No compact-unavailable toast (match the 409 reasons, not bare "compact" — usernames like
|
||||
// "dblcompact" would collide).
|
||||
await expect(
|
||||
page.getByRole("button", { name: /compaction configured|nothing to compact|just compacted/ }),
|
||||
).toHaveCount(0);
|
||||
|
||||
// Second /compact: there's no conversation yet in the new context -> tell the user clearly, and the wording must match reality.
|
||||
await ta.fill("/compact");
|
||||
await ta.press("Enter");
|
||||
await expect(page.getByRole("button", { name: /刚压缩过/ })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /just compacted/ })).toBeVisible();
|
||||
// Having just finished a full round, saying "no completed conversation round yet" would be absurd.
|
||||
await expect(page.getByRole("button", { name: /尚无已完成的对话轮次/ })).toHaveCount(0);
|
||||
expect(nativeDialogs, "反馈必须走应用内轻提示,不得回退到原生 alert").toHaveLength(0);
|
||||
await expect(
|
||||
page.getByRole("button", { name: /no completed conversation turns yet/ }),
|
||||
).toHaveCount(0);
|
||||
expect(nativeDialogs, "feedback must be an in-app toast, not a native alert").toHaveLength(0);
|
||||
await expect(page.getByText(/\[压缩\]/)).toHaveCount(1); // no second compaction banner
|
||||
});
|
||||
|
||||
@@ -53,10 +53,10 @@ test("compaction mid-turn: the reply's stats line is still reachable by hovering
|
||||
await page.goto(`${BASE}/chat/${sess.session.sessionId}`);
|
||||
const ta = page.getByPlaceholder(/输入消息/);
|
||||
await ta.waitFor();
|
||||
await ta.fill("帮我配置 @theme");
|
||||
await ta.fill("Help me set up @theme");
|
||||
await page.getByRole("button", { name: "发送" }).click();
|
||||
|
||||
const reply = page.getByText("命令已执行完成,结果符合预期。").first();
|
||||
const reply = page.getByText("Command finished; the result looks as expected.").first();
|
||||
await expect(reply).toBeVisible();
|
||||
const banner = page.getByText(/\[压缩\]/).first();
|
||||
await expect(banner).toBeVisible();
|
||||
@@ -97,7 +97,7 @@ test("compaction mid-turn: the reply's stats line is still reachable by hovering
|
||||
// fast the summary was generated), not a "—".
|
||||
await page.goto(`${BASE}/traces`);
|
||||
const main = page.locator("main");
|
||||
const node = main.getByText(/配置|新对话/).first();
|
||||
const node = main.getByText(/Configure|新对话/).first();
|
||||
await expect(node).toBeVisible();
|
||||
await node.click();
|
||||
await expect(main.getByText("第 1 轮")).toBeVisible();
|
||||
@@ -191,9 +191,9 @@ test("manual /compact between turns: reloading must not fold the compaction into
|
||||
await page.goto(`${BASE}/chat/${sess.session.sessionId}`);
|
||||
const ta = page.getByPlaceholder(/输入消息/);
|
||||
await ta.waitFor();
|
||||
await ta.fill("帮我配置 @theme");
|
||||
await ta.fill("Help me set up @theme");
|
||||
await page.getByRole("button", { name: "发送" }).click();
|
||||
await expect(page.getByText("命令已执行完成,结果符合预期。")).toBeVisible();
|
||||
await expect(page.getByText("Command finished; the result looks as expected.")).toBeVisible();
|
||||
|
||||
// The whole stats line is transparent by default, but innerText still reads fine (no need to hover).
|
||||
const statsText = async () =>
|
||||
@@ -207,7 +207,7 @@ test("manual /compact between turns: reloading must not fold the compaction into
|
||||
};
|
||||
|
||||
const live = await statsText();
|
||||
expect(costOf(live), `统计行应有成本:${live}`).not.toBeNull();
|
||||
expect(costOf(live), `stats line should include a cost: ${live}`).not.toBeNull();
|
||||
expect(elapsedMsOf(live)).toBeLessThan(1500);
|
||||
|
||||
// The user reads the reply, then 3 seconds later remembers to compact.
|
||||
@@ -216,14 +216,14 @@ test("manual /compact between turns: reloading must not fold the compaction into
|
||||
await ta.press("Enter");
|
||||
await expect(page.getByText(/\[压缩\]/)).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "停止" })).toHaveCount(0);
|
||||
expect(await statsText(), "实时流里上一轮早已收口,压缩不该改动它").toBe(live);
|
||||
expect(await statsText(), "compaction must not touch the already-closed prior turn").toBe(live);
|
||||
|
||||
await page.reload();
|
||||
await ta.waitFor();
|
||||
await expect(page.getByText("命令已执行完成,结果符合预期。")).toBeVisible();
|
||||
await expect(page.getByText("Command finished; the result looks as expected.")).toBeVisible();
|
||||
const rebuilt = await statsText();
|
||||
// Cost is derived from Token counts: if compaction's usage were folded in, cost would double out of nowhere.
|
||||
expect(costOf(rebuilt), `刷新前后成本必须一致(前:${live}|后:${rebuilt})`).toBe(costOf(live));
|
||||
expect(costOf(rebuilt), `cost must survive reload (${live} -> ${rebuilt})`).toBe(costOf(live));
|
||||
// Elapsed time should include neither the 3-second thinking gap nor compaction itself.
|
||||
expect(elapsedMsOf(rebuilt), `刷新后用时不得吃进压缩(${rebuilt})`).toBeLessThan(1500);
|
||||
expect(elapsedMsOf(rebuilt), `elapsed must not eat compaction (${rebuilt})`).toBeLessThan(1500);
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user