Make English the repository working language (#5)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yaowei Zheng
2026-07-20 17:45:02 +08:00
committed by GitHub
parent e80cde0ea6
commit 0eaaa54e25
136 changed files with 1515 additions and 1352 deletions
+1 -1
View File
@@ -165,7 +165,7 @@ describe("listInstalledSkills", () => {
});
});
describe("skillMetadataSection / assembleSystemPrompt 注入", () => {
describe("skillMetadataSection / assembleSystemPrompt injection", () => {
it("renders one `- \\`name\\` — description` line per skill; empty input renders empty", () => {
expect(skillMetadataSection([])).toBe("");
expect(
+9 -7
View File
@@ -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/,
);
});
});
+9 -9
View File
@@ -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",
+1 -1
View File
@@ -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
+9 -9
View File
@@ -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({
+1 -1
View File
@@ -817,7 +817,7 @@ describe("Environment structure invariant on tool throw (PRN-012)", () => {
});
});
describe("Environment abort handling (打断 → aborted, PRN-012)", () => {
describe("Environment abort handling (interrupt -> aborted, PRN-012)", () => {
it("labels a thrown error as aborted (not failed) when the signal is aborted", async () => {
const NAME = "__abort_throw_tool__";
BUILTIN_TOOL_FACTORIES[NAME] = (definition) => ({
+7 -7
View File
@@ -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,
+2 -2
View File
@@ -77,9 +77,9 @@ describe(`GenerativeModel live e2e (${provider?.modelId ?? "skipped"})`, () => {
const runGemini = process.env.PENGUIN_E2E === "1" && process.env.GEMINI_API_KEY !== undefined;
const 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",
+11 -11
View File
@@ -873,7 +873,7 @@ describe("translateEvents", () => {
});
});
describe("EventTranslator.finishInterrupted (PRN-012 结构闭合)", () => {
describe("EventTranslator.finishInterrupted (PRN-012 structural closure)", () => {
it("closes an open text segment with a stop + complete text marked with the interruption reason, and emits no token_usage", () => {
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: [] });
+13 -13
View File
@@ -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();
+1 -1
View File
@@ -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" }),
+11 -11
View File
@@ -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 })),
+8 -4
View File
@@ -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 () => {
+15 -15
View File
@@ -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 });
});
+15 -11
View File
@@ -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/);
});
});
+8 -8
View File
@@ -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");
+3 -3
View File
@@ -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"));