chore: make non-i18n code English-only (#117)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yaowei Zheng
2026-07-29 18:03:41 +08:00
committed by GitHub
parent f63037866e
commit 4681135423
19 changed files with 113 additions and 106 deletions
+4 -2
View File
@@ -147,12 +147,14 @@ describe("StreamRenderer", () => {
partialToolCall({
eventType: "delta",
name: "",
arguments: '"description":"列出当前目录的文件"}',
arguments: '"description":"List files in the current directory"}',
toolCallId: "c9",
}),
);
r.handle(partialToolCall({ eventType: "stop", name: "", toolCallId: "c9" }));
expect(stripAnsi(text())).toBe("[tool-c9] exec_command <- 列出当前目录的文件 ($ ls -la)\n");
expect(stripAnsi(text())).toBe(
"[tool-c9] exec_command <- List files in the current directory ($ ls -la)\n",
);
});
it("streams the command live when the schema has no description argument", () => {
+7 -7
View File
@@ -129,10 +129,10 @@ describe("describe_image (the text-only-model variant of read_image)", () => {
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 { llm, calls } = fakeLLM("The image shows a penguin.");
const describer: VisionDescriberService = { modelId: "vis-1", createLLM: () => llm };
const { messages, result, text } = await run(
{ source: "a.png", prompt: "图里是什么动物?" },
{ source: "a.png", prompt: "What animal is in the image?" },
tmp,
describer,
);
@@ -143,17 +143,17 @@ describe("describe_image (the text-only-model variant of read_image)", () => {
(m) => m.payload as { type: string; text?: string; image_url?: string },
);
expect(payloads[0]!.type).toBe("text");
expect(payloads[0]!.text).toBe("图里是什么动物?");
expect(payloads[0]!.text).toBe("What animal is in the image?");
expect(payloads[1]!.type).toBe("image_url");
expect(payloads[1]!.image_url).toBe(`data:image/png;base64,${PNG_1X1.toString("base64")}`);
expect(text).toContain("described by vis-1");
expect(text).toContain("图里是一只企鹅。");
expect(text).toContain("The image shows a penguin.");
// Streaming forward: the header line and description deltas are emitted as separate chunks (not buffered as a whole), and the complete text is not forwarded again.
const outputs = messages.map((m) => (m.payload as { output?: string }).output ?? "");
expect(outputs.length).toBeGreaterThanOrEqual(3); // header + >=2 description delta chunks
expect(outputs[0]).toContain("described by vis-1");
expect(outputs.slice(1).join("")).toBe("图里是一只企鹅。");
expect(outputs.slice(1).join("")).toBe("The image shows a penguin.");
// Text-based description: the result carries no images (images never enter session history).
expect(result?.images).toBeUndefined();
expect(result?.stopReason).toBeUndefined(); // defaults to completed
@@ -200,7 +200,7 @@ describe("describe_image (the text-only-model variant of read_image)", () => {
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 { llm } = fakeLLM("delegated description result");
const env = new Environment({
workspaceDir: tmp,
toolConfig: {
@@ -235,7 +235,7 @@ describe("describe_image (the text-only-model variant of read_image)", () => {
};
expect(complete.type).toBe("tool_call_output");
expect(complete.stop_reason).toBe("completed");
expect(complete.output).toContain("代读结果");
expect(complete.output).toContain("delegated description result");
expect(complete.images).toBeUndefined();
});
});
+18 -14
View File
@@ -79,19 +79,19 @@ describe("stripConversationMarkers (whole-message title cleaning)", () => {
// The skill-invocation block that wraps a first user message must not reach the title.
expect(
stripConversationMarkers(
"[use_skills]\nskills: penguin-sdk, web-design\n[/use_skills]\n做一个 RAG 应用",
"[use_skills]\nskills: penguin-sdk, web-design\n[/use_skills]\nBuild a RAG app",
),
).toBe("做一个 RAG 应用");
).toBe("Build a RAG app");
// Handoff and scheduled-task markers are stripped too; ordinary bracketed text stays.
expect(stripConversationMarkers("[handoff_from]data_analyst[/handoff_from]继续分析")).toBe(
"继续分析",
);
expect(
stripConversationMarkers("[handoff_from]data_analyst[/handoff_from]continue the analysis"),
).toBe("continue the analysis");
// The /model switch origin block (the new session's first message) must not leak into the title either.
expect(
stripConversationMarkers(
"[model_switch_from]\nsession: session-01\ntrace: /t/x_001.jsonl\n[/model_switch_from]\n继续这个任务",
"[model_switch_from]\nsession: session-01\ntrace: /t/x_001.jsonl\n[/model_switch_from]\ncontinue this task",
),
).toBe("继续这个任务");
).toBe("continue this task");
expect(stripConversationMarkers("render a <div> element")).toBe("render a <div> element");
expect(stripConversationMarkers("check the [config] section")).toBe(
"check the [config] section",
@@ -100,14 +100,18 @@ describe("stripConversationMarkers (whole-message title cleaning)", () => {
it("the old angle-bracket marker form is still stripped (material from old Traces)", () => {
expect(
stripConversationMarkers("<use_skills>\nskills: web-design\n</use_skills>\n做一个落地页"),
).toBe("做一个落地页");
expect(stripConversationMarkers("<handoff_from>data_analyst</handoff_from>继续分析")).toBe(
"继续分析",
);
stripConversationMarkers(
"<use_skills>\nskills: web-design\n</use_skills>\nBuild a landing page",
),
).toBe("Build a landing page");
expect(
stripConversationMarkers("<model_switch_from>session: s1</model_switch_from>继续这个任务"),
).toBe("继续这个任务");
stripConversationMarkers("<handoff_from>data_analyst</handoff_from>continue the analysis"),
).toBe("continue the analysis");
expect(
stripConversationMarkers(
"<model_switch_from>session: s1</model_switch_from>continue this task",
),
).toBe("continue this task");
});
});
+14 -14
View File
@@ -64,18 +64,18 @@ describe("session-title", () => {
const result = await generateTitleWithLLM(
fakeLLM(
[
thinkingMessage("想一下"), // thinking does not count
assistantText("「Tailwind 主题配置」。"),
thinkingMessage("thinking"), // thinking does not count
assistantText("「Tailwind theme setup」。"),
tokenUsage(emptyTokenCounts(), { cache_read: 1, cache_write: 2, output: 3, total: 6 }),
],
{ status: "completed" },
seen,
),
{ userText: "解释 @theme", assistantText: "好的……" },
{ userText: "explain @theme", assistantText: "sure thing." },
);
expect(result.title).toBe("Tailwind 主题配置");
expect(result.title).toBe("Tailwind theme setup");
expect(result.usage).toEqual({ cache_read: 1, cache_write: 2, output: 3, total: 6 });
expect(seen[0]).toBe(buildTitlePrompt("解释 @theme", "好的……"));
expect(seen[0]).toBe(buildTitlePrompt("explain @theme", "sure thing."));
expect(seen[0]).toContain("SAME language");
});
@@ -105,23 +105,23 @@ describe("session-title", () => {
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),
{ userText: "帮我配置 @theme", assistantText: "" },
fakeLLM([assistantText("Configure the Tailwind theme")], { status: "completed" }, seen),
{ userText: "help me configure @theme", assistantText: "" },
);
expect(result.title).toBe("配置 Tailwind 主题");
expect(seen[0]).toBe(buildTitlePrompt("帮我配置 @theme", ""));
expect(result.title).toBe("Configure the Tailwind theme");
expect(seen[0]).toBe(buildTitlePrompt("help me configure @theme", ""));
expect(seen[0]).not.toContain("[Assistant]");
});
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("“ Build config notes 。”")).toBe("Build config notes");
expect(sanitizeTitle("『Title』!")).toBe("Title");
expect(sanitizeTitle(" \n ")).toBeNull();
expect(sanitizeTitle("x".repeat(50))).toHaveLength(30);
// A leaked [use_skills] block is stripped from the model output.
expect(sanitizeTitle("[use_skills]\nskills: web-design\n[/use_skills]\n构建落地页")).toBe(
"构建落地页",
);
expect(
sanitizeTitle("[use_skills]\nskills: web-design\n[/use_skills]\nBuild a landing page"),
).toBe("Build a landing page");
});
it("Session.generateTitle: sends via createBareLLM; returns null when no factory is provided", async () => {
+3 -2
View File
@@ -36,7 +36,7 @@ export function parseAuthors(raw: string | undefined): string[] {
return authors.length > 0 ? authors : [DEFAULT_AUTHOR];
}
/** Join authors for display: 顿号 in Chinese, comma otherwise. */
/** Join authors for display: an ideographic comma in Chinese, an ASCII comma otherwise. */
export function formatAuthors(authors: string[], locale: Locale): string {
return authors.join(locale === "zh" ? "、" : ", ");
}
@@ -82,7 +82,8 @@ export function comparePosts(
}
/**
* Format a YYYY-MM-DD post date for display ("July 20, 2026" / "2026年7月20日").
* Format a YYYY-MM-DD post date for display (e.g. "July 20, 2026" in English, the
* year-month-day form in Chinese).
* Parsed as UTC and formatted in UTC so the calendar day never shifts with the
* viewer's timezone; unexpected input falls back to the raw string.
*/
+1 -1
View File
@@ -1,7 +1,7 @@
/**
* Hero: enlarged logo + product name, the slogan inside the pill badge, then the
* one-line headline whose rotating word crossfades through a gaussian blur
* (Desktop <-> Server / 桌面 <-> 服务器), three keywords, the install one-liner
* (Desktop <-> Server, localized per dictionary), three keywords, the install one-liner
* and stats. The rotating word is a stacked inline-grid so line width never jumps.
*/
import { Fragment, useEffect, useState } from "react";
+3 -3
View File
@@ -36,7 +36,7 @@ describe("formatPostDate", () => {
expect(formatPostDate("2026-07-20", "en")).toBe("July 20, 2026");
});
it("formats zh dates as 年月日", () => {
it("formats zh dates as year-month-day", () => {
expect(formatPostDate("2026-06-18", "zh")).toBe("2026年6月18日");
expect(formatPostDate("2026-07-20", "zh")).toBe("2026年7月20日");
});
@@ -68,9 +68,9 @@ describe("parseAuthors / formatAuthors", () => {
expect(parseAuthors(" ")).toEqual([DEFAULT_AUTHOR]);
});
it("joins with a comma in English and 顿号 in Chinese", () => {
it("joins with a comma in English and an ideographic comma in Chinese", () => {
expect(formatAuthors(["A", "B"], "en")).toBe("A, B");
expect(formatAuthors(["甲", "乙"], "zh")).toBe("甲、乙");
expect(formatAuthors(["A", "B"], "zh")).toBe("A、B");
expect(formatAuthors(["A"], "en")).toBe("A");
});
});
+23 -23
View File
@@ -45,7 +45,7 @@ describe("models preset & catalog enrichment", () => {
const { cookie } = await provisionUser(t.app, "alice");
api = apiClient(t.app, cookie);
const created = (await (
await api.post("/api/projects", { projectId: "alice-preset", name: "预置项目" })
await api.post("/api/projects", { projectId: "alice-preset", name: "Preset project" })
).json()) as ProjectCreateResponse;
projectId = created.project.projectId;
});
@@ -53,7 +53,7 @@ describe("models preset & catalog enrichment", () => {
await t.cleanup();
});
it("credential 内联单文件:PUT 的 apiKey 落 .project_config.toml(0600),GET 只回掩码", async () => {
it("credentials are inlined in one file: the apiKey from PUT lands in .project_config.toml (0600), GET returns only a mask", async () => {
const put = await api.put(url(), {
defaultModel: { provider: "custom", modelId: "m-inline" },
models: [
@@ -93,7 +93,7 @@ describe("models preset & catalog enrichment", () => {
await expect(readFile(path.join(projectDir, "project_config.toml"), "utf8")).rejects.toThrow();
});
it("新建 Project 即预置全部内置模型(provider 与 model_id 分列 + 目录信息)", async () => {
it("a new Project is preset with every built-in model (provider and model_id as separate fields + catalog info)", async () => {
const res = await api.get(url());
expect(res.status).toBe(200);
const body = (await res.json()) as ModelsResponse;
@@ -125,7 +125,7 @@ describe("models preset & catalog enrichment", () => {
expect(mimo.credential?.apiKeyMasked).toBeUndefined();
});
it("PUT 自定义模型:持久化 vision 标注;openai 协议给 OPENAI_API_KEY 兜底;provider 必填", async () => {
it("PUT a custom model: the vision flag persists; the openai protocol falls back to OPENAI_API_KEY; provider is required", async () => {
const put = await api.put(url(), {
defaultModel: { provider: "custom", modelId: "my-model" },
models: [
@@ -167,7 +167,7 @@ describe("models preset & catalog enrichment", () => {
expect(noProvider.status).toBe(400);
});
it("PUT maxTokens:落盘为 max_tokens 并经 GET 回读;整表省略即清除;0/负数/非数字 400", async () => {
it("PUT maxTokens: persisted as max_tokens and read back through GET; omitting it table-wide clears it; 0 / negative / non-numeric 400", async () => {
const put = await api.put(url(), {
models: [
{ provider: "custom", modelId: "local-qwen", clientType: "openai", maxTokens: 8000 },
@@ -204,7 +204,7 @@ describe("models preset & catalog enrichment", () => {
}
});
it("同名 model_id 可在不同 provider 下并存(成对键,互不覆盖)", async () => {
it("the same model_id can coexist under different providers (paired keys, neither overwrites the other)", async () => {
const put = await api.put(url(), {
models: [
{ provider: "moonshot", modelId: "kimi-k2.6", apiKey: "sk-official-aaaa1111" },
@@ -231,7 +231,7 @@ describe("models preset & catalog enrichment", () => {
});
});
describe("default_project 预置", () => {
describe("default_project presets", () => {
let t: TestApp;
let prevKey: string | undefined;
@@ -247,7 +247,7 @@ describe("default_project 预置", () => {
await t.cleanup();
});
it("种子 admin 纳管 default_project 时补齐预置模型与默认模型(此前无 .project_config.toml)", async () => {
it("when the seeded admin adopts default_project, the preset models and the default model are filled in (no prior .project_config.toml)", async () => {
// The default_project shared by admin seeding and the CLI (the dir already exists, so writeInitialConfig is skipped).
t = await createTestApp();
const { cookie } = await loginAdmin(t.app);
@@ -269,7 +269,7 @@ describe("default_project 预置", () => {
expect(session.modelId).toBe("deepseek-v4-pro");
});
it("已配置过模型的 default_project 原样保留(不覆盖 CLI 既有配置)", async () => {
it("a default_project that already has models configured is left untouched (existing CLI config is not overwritten)", async () => {
// First have the "CLI" write a config with a single custom model, then admin seeding adopts it.
t = await createTestApp({
beforeSeed: async (root) => {
@@ -290,7 +290,7 @@ describe("default_project 预置", () => {
});
});
describe("模型引用改键与连通性测试", () => {
describe("model-reference rekeying and the connectivity test", () => {
let t: TestApp;
let api: ReturnType<typeof apiClient>;
let projectId: string;
@@ -302,7 +302,7 @@ describe("模型引用改键与连通性测试", () => {
const { cookie } = await provisionUser(t.app, "carol");
api = apiClient(t.app, cookie);
const created = (await (
await api.post("/api/projects", { projectId: "carol-rename", name: "改名项目" })
await api.post("/api/projects", { projectId: "carol-rename", name: "Rename project" })
).json()) as ProjectCreateResponse;
projectId = created.project.projectId;
});
@@ -310,7 +310,7 @@ describe("模型引用改键与连通性测试", () => {
await t.cleanup();
});
it("renamedFrom 迁移 credential 与配置,默认/视觉模型指针跟随改键", async () => {
it("renamedFrom migrates the credential and the config; the default / vision model pointers follow the rekey", async () => {
await api.put(url(), {
defaultModel: { provider: "custom", modelId: "old-id" },
visionModel: { provider: "custom", modelId: "old-id" },
@@ -347,7 +347,7 @@ describe("模型引用改键与连通性测试", () => {
expect(again.defaultModel).toEqual({ provider: "custom", modelId: "new-id" });
});
it("分组变更也是改键:credential 随迁;envKey 按 client 解析、不随分组(PRN-021)", async () => {
it("a group change is a rekey too: the credential migrates with it; envKey resolves by client, not by group (PRN-021)", async () => {
// Move the preset DeepSeek model to another group (changing provider is a key change; the paired renamedFrom migrates it).
const put = await api.put(url(), {
models: [
@@ -384,28 +384,28 @@ describe("模型引用改键与连通性测试", () => {
expect(moved.envKey).toBe("DEEPSEEK_API_KEY");
});
it("展示名可编辑:与内置目录一致时不落盘;provider 恒作为条目字段落盘", async () => {
it("the display name is editable: not persisted when it matches the built-in catalog; provider is always persisted as an entry field", async () => {
// Preset model: saved as-is → the display name falls back to the built-in catalog, and display_name isn't written to the config file.
await api.put(url(), {
models: [
{ provider: "openai", modelId: "gpt-5.5", displayName: "GPT-5.5" },
{ provider: "openai", modelId: "gpt-5.4", displayName: "我的 GPT" },
{ provider: "openai", modelId: "gpt-5.4", displayName: "My GPT" },
],
});
const body = (await (await api.get(url())).json()) as ModelsResponse;
expect(pick(body, "openai", "gpt-5.5").displayName).toBe("GPT-5.5");
// Edited one: the display name takes effect per the user's setting.
expect(pick(body, "openai", "gpt-5.4").displayName).toBe("我的 GPT");
expect(pick(body, "openai", "gpt-5.4").displayName).toBe("My GPT");
// Clean on disk: unchanged preset models don't write display_name; provider is stored as a separate column, no concatenated string.
const toml = await readFile(path.join(t.root, projectId, ".project_config.toml"), "utf8");
expect(toml).not.toContain('display_name = "GPT-5.5"');
expect(toml).toContain('display_name = "我的 GPT"');
expect(toml).toContain('display_name = "My GPT"');
expect(toml).toContain('provider = "openai"');
expect(toml).not.toContain("openai/gpt-5.5");
});
it("renamedFrom 非法值 400;不带 renamedFrom 改键等于删旧建新(credential 不迁移)", async () => {
it("an invalid renamedFrom is 400; rekeying without renamedFrom equals delete-old-then-create-new (the credential does not migrate)", async () => {
await api.put(url(), {
models: [{ provider: "custom", modelId: "m-a", apiKey: "sk-secret-abcd1234" }],
});
@@ -426,7 +426,7 @@ describe("模型引用改键与连通性测试", () => {
expect(body.models[0]!.credential).toBeUndefined();
});
it("连通性测试发的是条目的上游 model_id(引用成对随请求体)", async () => {
it("the connectivity test sends the entry's upstream model_id (the reference pair travels with the request body)", async () => {
// Local openai-compatible endpoint: records the model field from the request body, then always rejects with 401 (never hits the network).
const seenModels: string[] = [];
const server = createServer((req, res) => {
@@ -472,7 +472,7 @@ describe("模型引用改键与连通性测试", () => {
}
});
it("连通性测试请求体不含 tools 与 tool_choice(空工具列表整体省略,vLLM 等严格端点不再 400)", async () => {
it("the connectivity-test request body carries no tools or tool_choice (an empty tool list is omitted entirely, so strict endpoints such as vLLM no longer 400)", async () => {
// The probe runs with an empty tool list. The wire body must omit `tools` entirely —
// `tools: []` is rejected by strict OpenAI-compatible servers (vLLM: "tools must not be an
// empty array") — and must never carry `tool_choice`.
@@ -512,7 +512,7 @@ describe("模型引用改键与连通性测试", () => {
}
});
it("连通性测试:已保存的模型与**尚未保存**的自定义模型都可测(LLM 层不抛异常,一律收敛)", async () => {
it("connectivity test: both a saved model and a **not-yet-saved** custom model can be tested (the LLM layer throws nothing, every outcome converges)", async () => {
await api.put(url(), {
models: [{ provider: "openai", modelId: "gpt-5.5", apiKey: "sk-invalid-key-for-test" }],
});
@@ -536,7 +536,7 @@ describe("模型引用改键与连通性测试", () => {
expect(typeof unsavedBody.message).toBe("string");
}, 40_000);
it("连通性测试:模型完全没有 credential 时收敛为 ok:false,而非 500", async () => {
it("connectivity test: a model with no credential at all converges to ok:false instead of 500", async () => {
// A model using the OpenAI protocol: the provider SDK throws at **client construction** because
// the key is missing — if that construction were outside the try it would bubble up as a 500. Clear the env-var key so there's nowhere to get one (no real network request).
const prev = process.env.OPENAI_API_KEY;
@@ -555,7 +555,7 @@ describe("模型引用改键与连通性测试", () => {
}
});
it("连通性测试:clearApiKey 时不回落已存 key(照当前草稿测)", async () => {
it("connectivity test: clearApiKey does not fall back to the stored key (the current draft is what gets tested)", async () => {
// A key is already saved, but the test request carries clearApiKey — the server must **not** use
// the saved key. Clear the env var so "don't use the saved key" == no credential at all, so construction synchronously throws missing-credential (no network request, deterministic).
const prev = process.env.OPENAI_API_KEY;
+14 -14
View File
@@ -100,21 +100,21 @@ describe("title-generator", () => {
CTX,
fakeSession(
{
title: "Tailwind 主题配置",
title: "Tailwind theme setup",
usage: { cache_read: 1, cache_write: 2, output: 3, total: 6 },
},
calls,
),
{ fallbackText: "解释一下 @theme" },
{ fallbackText: "explain @theme" },
);
await waitFor(() => sessions.findById(ROW.sessionId)?.title !== null);
expect(sessions.findById(ROW.sessionId)?.title).toBe("Tailwind 主题配置");
expect(sessions.findById(ROW.sessionId)?.title).toBe("Tailwind theme setup");
// No material override passed: generateTitle is called with no argument, and the core Session gathers its own material.
expect(calls.args[0]).toBeUndefined();
expect(
serverEvents(events).some(
(e) => e.type === "session_title" && e.title === "Tailwind 主题配置",
(e) => e.type === "session_title" && e.title === "Tailwind theme setup",
),
).toBe(true);
// usage is converted into token_usage and handed to the recorder (metered normally, same as a real call).
@@ -125,14 +125,14 @@ describe("title-generator", () => {
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: "子会话回答" };
gen.maybeGenerate(CTX, fakeSession({ title: "子标题", usage: null }, calls), {
fallbackText: "子会话 prompt",
const material = { userText: "sub-session prompt", assistantText: "sub-session reply" };
gen.maybeGenerate(CTX, fakeSession({ title: "Sub title", usage: null }, calls), {
fallbackText: "sub-session prompt",
material,
});
await waitFor(() => sessions.findById(ROW.sessionId)?.title !== null);
expect(calls.args[0]).toEqual({ material });
expect(sessions.findById(ROW.sessionId)?.title).toBe("子标题");
expect(sessions.findById(ROW.sessionId)?.title).toBe("Sub title");
});
it("an existing title is never regenerated (no one-shot request issued)", async () => {
@@ -155,11 +155,11 @@ describe("title-generator", () => {
{ title: null, usage: { cache_read: 0, cache_write: 0, output: 1, total: 1 } },
calls,
),
{ fallbackText: "配置 Tailwind 主题\n第二行" },
{ fallbackText: "Configure the Tailwind theme\nsecond line" },
);
// Fallback = the material's first non-empty line, sanitized and truncated, guaranteeing a title is always produced.
await waitFor(() => sessions.findById(ROW.sessionId)?.title !== null);
expect(sessions.findById(ROW.sessionId)?.title).toBe("配置 Tailwind 主题");
expect(sessions.findById(ROW.sessionId)?.title).toBe("Configure the Tailwind theme");
// The one-off request's usage is still recorded normally.
await waitFor(() => recorded.length > 0);
});
@@ -168,21 +168,21 @@ describe("title-generator", () => {
const calls = { count: 0, args: [] as unknown[] };
const gen = makeGenerator();
gen.maybeGenerate(CTX, fakeSession({ title: null, usage: null }, calls), {
fallbackText: "[use_skills]\nskills: web-design\n[/use_skills]\n做一个落地页",
fallbackText: "[use_skills]\nskills: web-design\n[/use_skills]\nBuild a landing page",
});
await waitFor(() => sessions.findById(ROW.sessionId)?.title !== null);
// Not "[use_skills]" — the marker block is removed before the first line is taken.
expect(sessions.findById(ROW.sessionId)?.title).toBe("做一个落地页");
expect(sessions.findById(ROW.sessionId)?.title).toBe("Build a landing page");
});
it("fallback also strips the legacy angle-bracket <use_skills> block (material from old Traces)", async () => {
const calls = { count: 0, args: [] as unknown[] };
const gen = makeGenerator();
gen.maybeGenerate(CTX, fakeSession({ title: null, usage: null }, calls), {
fallbackText: "<use_skills>\nskills: web-design\n</use_skills>\n做一个落地页",
fallbackText: "<use_skills>\nskills: web-design\n</use_skills>\nBuild a landing page",
});
await waitFor(() => sessions.findById(ROW.sessionId)?.title !== null);
expect(sessions.findById(ROW.sessionId)?.title).toBe("做一个落地页");
expect(sessions.findById(ROW.sessionId)?.title).toBe("Build a landing page");
});
it("LLM returns null and the fallback material is blank → the title stays NULL (retryable next time)", async () => {
@@ -1236,7 +1236,7 @@ export function Sidebar({
* pinned indicator. The header row carries the `group/header` scope so the reveal only
* reacts to its own row, not to the session rows' plain `group` scope.
* The accessible name stays STATIC and aria-pressed alone carries the state (the toggle
* pattern the grouping-mode buttons use) — a name that swaps 置顶/取消置顶 alongside
* pattern the grouping-mode buttons use) — a name that swaps Pin/Unpin alongside
* aria-pressed reads as "Unpin group, pressed", saying the state twice in conflicting
* ways. The title tooltip may still swap: it is presentation for pointer users and does
* not feed the accessible name while aria-label is present.
@@ -44,7 +44,7 @@ type TabKey = "overview" | "prompt" | "runtime" | "tools" | "vault" | "schedules
* The "" (not-overridden / inherit) row is filtered out per review — the menus offer only
* concrete values in dictionary order, the user picks explicitly. An unset stored value
* simply matches no row, so the OptionMenu trigger falls back to its placeholder
* ((缺省)/(default), the same convention as the tools-table permission menu); nothing is
* ("(default)", the same convention as the tools-table permission menu); nothing is
* ever written silently, and the reset link next to each menu rewinds a local pick back to "".
*/
export function optionRows(
@@ -24,7 +24,7 @@ export function ApprovalButtons({
};
// Text at every breakpoint (per review): action buttons the user presses must read as words —
// "允许/Allow" and "拒绝/Deny" — never as bare glyphs; iconic shorthand is reserved for
// "Allow" and "Deny" — never as bare glyphs; iconic shorthand is reserved for
// passive indicators. The buttons live on their own row under the (wrapping) argument
// preview, so the words cost no width the pending card doesn't already have.
return (
@@ -683,8 +683,8 @@ const versionBadgeClass =
"ml-1.5 inline-block rounded-full bg-[var(--accent-bg)] px-1.5 align-super text-[10px] font-medium leading-4 text-[var(--accent-fg)] transition-opacity duration-150 hover:opacity-80";
/**
* Quiet version line under the brand subtitle: `vX.Y.Z · 最近更新日期 7 月 26 日` /
* `… · Last updated Jul 26`. The product name is not repeated here — the brand wordmark
* Quiet version line under the brand subtitle: `vX.Y.Z · Last updated Jul 26`
* (localized per dictionary). The product name is not repeated here — the brand wordmark
* sits directly above, and the sidebar's version footer is bare `vX.Y.Z` too. The date is
* the running version's release
* date, stamped into core's BUILD_DATE at build time — displayed as-is, no network;
@@ -205,7 +205,7 @@ export function ToolCallCard({ item, ctx }: { item: ToolCallItem; ctx: StreamRen
: failed
? "failed"
: "done";
// Decision wording ("Approved · manual" / "已拒绝 · 手动" …): carried ONLY by the left status
// Decision wording ("Approved · manual", "Denied · manual", …): carried ONLY by the left status
// icon's title/aria-label — per review the row shows no visible decision text at any
// breakpoint; the icon is the single source of truth for how the call was decided.
const decisionText = item.decision
@@ -21,16 +21,16 @@
* Agent's v_old → v_new and warns the overwriting reinstall drops local
* edits), then reinstalls the current library copy on every outdated Agent
* (install-again-is-update semantics), with a single success toast; the
* manage-installs Modal marks outdated rows with an accent "更新"/"Update"
* button doing the same per Agent (through the same confirm);
* manage-installs Modal marks outdated rows with an accent "Update" button
* doing the same per Agent (through the same confirm);
* - Paper plane "quick invoke": enters /chat/new draft mode with default_agent,
* pre-selects the skill, and pre-fills the invocation text per UI language
* (zh "使用 X 技能" / en "use the X skill", overwriting any existing draft body);
* ("use the X skill" in the active dictionary, overwriting any existing draft body);
* - Download "manage installs": a Modal listing every Agent in the current
* Project — not-installed shows "安装"/"Install", installed shows
* "已安装"/"Installed" (hover switches to "卸载"/"Uninstall", click to
* uninstall); any member can operate it; optimistic update, a top-level
* toast on success for install/uninstall, rollback plus a toast on failure.
* Project — not-installed shows "Install", installed shows "Installed"
* (hover switches to "Uninstall", click to uninstall); any member can
* operate it; optimistic update, a top-level toast on success for
* install/uninstall, rollback plus a toast on failure.
*/
import { useEffect, useState } from "react";
import { useNavigate } from "react-router";
@@ -550,11 +550,11 @@ function SkillCard({
/**
* One Agent row in the "manage installs" Modal: not-installed shows
* "安装"/"Install"; installed shows "已安装"/"Installed", switching to
* "卸载"/"Uninstall" on hover (same button, click to uninstall); an installed
* copy older than the library additionally shows an accent "更新"/"Update"
* button (reinstall = update). Install and uninstall go through optimistic
* updates (toggleInstall), rolling back on failure.
* "Install"; installed shows "Installed", switching to "Uninstall" on hover
* (same button, click to uninstall); an installed copy older than the library
* additionally shows an accent "Update" button (reinstall = update). Install
* and uninstall go through optimistic updates (toggleInstall), rolling back on
* failure.
*/
function InstallRow({
agentId,
@@ -589,7 +589,7 @@ function InstallRow({
</Button>
)}
{installed ? (
// group: on hover the button's copy switches "已安装"/"Installed" → "卸载"/"Uninstall" (the same button carries the uninstall action).
// group: on hover the button's copy switches "Installed" → "Uninstall" (the same button carries the uninstall action).
<Button
size="sm"
variant="ghost"
+4 -4
View File
@@ -334,13 +334,13 @@ export function liveSessionElapsedMs(
/** Localized labels for {@link formatTaskStats} (supplied by the view layer's active dictionary). */
export interface TaskStatsLabels {
/** Row prefix, e.g. "Stats" / "统计信息". */
/** Row prefix, e.g. "Stats". */
stats: string;
/** Input-tokens label, e.g. "Input tokens" / "输入 tokens". */
/** Input-tokens label, e.g. "Input tokens". */
input: string;
/** Cached-amount label, e.g. "cached" / "已缓存". */
/** Cached-amount label, e.g. "cached". */
cached: string;
/** Output-tokens label, e.g. "Output tokens" / "输出 tokens". */
/** Output-tokens label, e.g. "Output tokens". */
output: string;
/** Opening wrapper before the cached amount — keeps per-locale typography (e.g. " (" / "("). */
parenOpen: string;
+1 -1
View File
@@ -21,7 +21,7 @@ export const en: Strings = {
benchmark: "Evaluation Center",
// Collapsed-rail tooltips (product-specified wording; new chat reuses chat.newSessionMenu, the other pages reuse the page names above).
lastConversation: "Last conversation",
// Deliberately equal to nav.agents: the key exists for the zh-only wording difference (智能体 vs 智能体仓库).
// Deliberately equal to nav.agents: the key exists only because the zh dictionary words the rail entry differently ("Agents" vs "Agent library").
railAgents: "Agents",
collapseSidebar: "Collapse sidebar",
expandSidebar: "Expand sidebar",
+1 -1
View File
@@ -122,7 +122,7 @@ describe("skillSlashItems (slash skill command item assembly)", () => {
});
describe("quickInvokeText (prefill text for skill-library quick invoke, zh/en dictionaries)", () => {
it('same convention as the empty-body auto-invoke text: zh "使用 X 技能" / en "use the X skill"', () => {
it('same convention as the empty-body auto-invoke text: "use the X skill", localized per dictionary', () => {
expect(zh.skills.quickInvokeText("agent-creation")).toBe("使用 agent-creation 技能");
expect(en.skills.quickInvokeText("agent-creation")).toBe("use the agent-creation skill");
});
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* Unit tests for task-stats.ts: the stats-row convention matches the CLI's "统计信息"
* Unit tests for task-stats.ts: the stats-row convention matches the CLI's stats-row
* output item for item.
*/
import { describe, expect, it } from "vitest";