Make English the repository working language (#5)

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

No files matched your search

+9 -8
View File
@@ -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`.
+16 -16
View File
@@ -52,7 +52,7 @@ test("chat + tool approval + stats/cost/copy + traces + files", async ({ page })
// Approval-mode is a custom dropdown (button), NOT a native <select>.
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`, {
+15 -9
View File
@@ -56,7 +56,7 @@ test("aborting before any turn completes: /compact says so instead of doing noth
await page.goto(`${BASE}/chat/${sessionId}`);
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
});
+10 -10
View File
@@ -53,10 +53,10 @@ test("compaction mid-turn: the reply's stats line is still reachable by hovering
await page.goto(`${BASE}/chat/${sess.session.sessionId}`);
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);
});
+7 -7
View File
@@ -15,7 +15,7 @@ const P = "password123";
const UA = "draftowner";
const UB = "draftguest";
test("切换账号:B 不恢复 A 的草稿,两人草稿并存", async ({ page }) => {
test("switching accounts: B does not restore A's draft; both drafts coexist", async ({ page }) => {
// A is provisioned and logged in (page's cookie belongs to A); B is only provisioned, logging in via the UI later. userId is the username.
const userA = (await provisionAndLogin(page.request, UA, P)).userId;
await provisionUser(UB, P);
@@ -49,13 +49,13 @@ test("切换账号:B 不恢复 A 的草稿,两人草稿并存", async ({ pag
// A types a body on the draft page and waits for debounce to persist it under A's key; after a reload it restores as usual (confirming it was persisted).
await page.goto(`${BASE}/chat`);
const ta = page.getByPlaceholder(/输入消息/);
await ta.fill("A 的机密草稿");
await ta.fill("A's secret draft");
const keyA = `penguin.chatDraft.${userA}.${projectId}`;
await expect
.poll(() => page.evaluate((k) => localStorage.getItem(k), keyA))
.toContain("A 的机密草稿");
.toContain("A's secret draft");
await page.reload();
await expect(ta).toHaveValue("A 的机密草稿");
await expect(ta).toHaveValue("A's secret draft");
// A logs out: the bottom-left user menu (the username button shares its name with the top
// Project switcher — the initial Project's display name defaults to the username — so take
@@ -77,10 +77,10 @@ test("切换账号:B 不恢复 A 的草稿,两人草稿并存", async ({ pag
// B writes their own draft: it lands under B's key (the key includes projectId, confirming B
// is on the same shared Project), while A's draft is left untouched — neither overwrites the other.
await ta.fill("B 自己的草稿");
await ta.fill("B's own draft");
const keyB = `penguin.chatDraft.${userB}.${projectId}`;
await expect
.poll(() => page.evaluate((k) => localStorage.getItem(k), keyB))
.toContain("B 自己的草稿");
expect(await page.evaluate((k) => localStorage.getItem(k), keyA)).toContain("A 的机密草稿");
.toContain("B's own draft");
expect(await page.evaluate((k) => localStorage.getItem(k), keyA)).toContain("A's secret draft");
});
+13 -11
View File
@@ -15,7 +15,9 @@ const MOCK = process.env.MOCK_URL;
const U = "draftuser";
const P = "password123";
test("draft: 选模型/审批 → 刷新保留 → 发送建会话并清缓存 → 侧栏 + 指定 Agent", async ({ page }) => {
test("draft: pick model/approval -> reload restores them -> send creates the session and clears the cache -> sidebar + scopes the Agent", async ({
page,
}) => {
// Draft keys are isolated by user x Project/Session (#68); building the key needs userId (i.e. the username).
const userId = (await provisionAndLogin(page.request, U, P)).userId;
@@ -57,14 +59,14 @@ test("draft: 选模型/审批 → 刷新保留 → 发送建会话并清缓存
await expect(page.getByRole("heading", { name: "PenguinHarness" })).toBeVisible();
const ta = page.getByPlaceholder(/输入消息/);
await ta.fill("草稿正文不能丢");
await ta.fill("Draft body must not be lost");
// The @ delegation target is also draft content: typing an @ prefix at the end summons the
// menu, selecting it turns into a chip (the @token in the body text is stripped out).
await ta.fill("草稿正文不能丢 @agent_hel");
await ta.fill("Draft body must not be lost @agent_hel");
await page.getByRole("button", { name: /@agent_helper/ }).click();
await expect(page.getByText("@agent_helper")).toBeVisible();
await expect(ta).toHaveValue("草稿正文不能丢");
await expect(ta).toHaveValue("Draft body must not be lost");
// Switch the model: the selector sits to the left of the send button, opens downward, with a quick-search field at the top.
await page.getByRole("button", { name: "选择模型" }).click();
@@ -80,10 +82,10 @@ test("draft: 选模型/审批 → 刷新保留 → 发送建会话并清缓存
const draftKey = `penguin.chatDraft.${userId}.${projectId}`;
await expect
.poll(() => page.evaluate((k) => localStorage.getItem(k), draftKey))
.toContain("草稿正文不能丢");
.toContain("Draft body must not be lost");
await page.reload();
await expect(ta).toHaveValue("草稿正文不能丢");
await expect(ta).toHaveValue("Draft body must not be lost");
// After restoring, the cursor lands at the end of the draft (focus defaults to the start, so it must be explicitly moved to the end to keep typing).
await expect
.poll(() =>
@@ -120,7 +122,7 @@ test("draft: 选模型/审批 → 刷新保留 → 发送建会话并清缓存
await groupRow.getByRole("button", { name: "新建对话" }).click();
await expect(page.getByLabel("选择 Agent")).toContainText("Helper Agent");
await ta.fill("给 helper 的第一条");
await ta.fill("First message for helper");
await page.getByRole("button", { name: "发送" }).click();
await page.waitForURL(/\/chat\/session-/);
const secondSessionId = page.url().split("/chat/")[1];
@@ -133,17 +135,17 @@ test("draft: 选模型/审批 → 刷新保留 → 发送建会话并清缓存
expect(second.session.approvalMode).toBe("allow-all");
// Under allow-all, the mock's exec_command is auto-approved, so the round runs to completion (turn 2 text lands).
await expect(page.getByText("命令已执行完成,结果符合预期。")).toBeVisible();
await expect(page.getByText("Command finished; the result looks as expected.")).toBeVisible();
// —— Input draft for an existing session: cached by user x Session, restored on reload, cleared once sending succeeds ——
await ta.fill("会话里的草稿");
await ta.fill("Draft inside the session");
const sessionKey = `penguin.chatDraft.session.${userId}.${secondSessionId}`;
await expect
.poll(() => page.evaluate((k) => localStorage.getItem(k), sessionKey))
.toContain("会话里的草稿");
.toContain("Draft inside the session");
await page.reload();
await expect(ta).toHaveValue("会话里的草稿");
await expect(ta).toHaveValue("Draft inside the session");
await page.getByRole("button", { name: "发送" }).click();
await expect.poll(() => page.evaluate((k) => localStorage.getItem(k), sessionKey)).toBeNull();
});
+4 -2
View File
@@ -40,12 +40,14 @@ test("a malformed tool_call settles unpaired; the retry line shows and the retry
const sessionId = sess.session.sessionId;
await page.goto(`${BASE}/chat/${sessionId}`);
await page.getByPlaceholder(/输入消息/).fill("坏流测试");
await page.getByPlaceholder(/输入消息/).fill("bad stream test");
await page.getByRole("button", { name: "发送" }).click();
// The retried exec_command finishes → final answer, proving the retry and the following
// tool call complete normally.
await expect(page.getByText("命令已执行完成,结果符合预期。")).toBeVisible({ timeout: 20000 });
await expect(page.getByText("Command finished; the result looks as expected.")).toBeVisible({
timeout: 20000,
});
// Retry hint line: set to "retry attempt 1 started" once request_begin arrives.
await expect(page.getByText(/已发起第 1 次重试/)).toBeVisible();
+19 -16
View File
@@ -2,16 +2,16 @@
* Mock Anthropic Messages API (streaming SSE) for E2E.
* Branches on request body:
* - title request (prompt contains "concise title") -> short text
* - files-card probe ("文件卡测试") -> text with two backtick paths (one real, one missing)
* - files-card probe ("files card test") -> text with two backtick paths (one real, one missing)
* - subagent's own turn (its prompt is the only user text) -> final text
* - parent asked to delegate ("跑子agent") -> tool_use(run_subagent)
* - parent asked to delegate ("run a subagent") -> tool_use(run_subagent)
* - last message has tool_result -> final text (turn 2)
* - otherwise (first user turn) -> thinking + text + tool_use(exec_command)
*/
import http from "node:http";
/** The run_subagent prompt; also the marker the mock uses to detect "this is the child session's own request". */
const SUBAGENT_PROMPT = "统计仓库里的 TODO 数量";
const SUBAGENT_PROMPT = "Count the TODO items in the repository";
const PORT = Number(process.env.MOCK_PORT || 8931);
@@ -87,15 +87,15 @@ const server = http.createServer((req, res) => {
const msgCount = messages.length;
const hasToolResult = flat.includes("tool_result");
// Child-session request: the context has only the prompt handed down by run_subagent, no parent user input.
const isSubagentTurn = flat.includes(SUBAGENT_PROMPT) && !flat.includes("跑子agent");
const wantsSubagent = flat.includes("跑子agent");
const isSubagentTurn = flat.includes(SUBAGENT_PROMPT) && !flat.includes("run a subagent");
const wantsSubagent = flat.includes("run a subagent");
// "Bad stream" test case: the first request streams half the tool_use arguments then cuts
// the connection (no message_stop), so AgentHub reports "stream incomplete" -> GenerativeModel
// resolves it as malformed. On reconnect the engine **resends the input verbatim** — in this
// scenario the failed attempt only has a half tool_call (never committed to the ledger), so
// the retry request carries no <turn_retried> block and is byte-for-byte identical to the
// first request; the mock can only tell them apart by request count (see the malformedTurns counter).
const wantsMalformed = flat.includes("坏流测试");
const wantsMalformed = flat.includes("bad stream test");
res.writeHead(200, {
"content-type": "text/event-stream",
@@ -109,9 +109,12 @@ const server = http.createServer((req, res) => {
// assistant material; respond with a distinguishable title based on that, so the E2E
// test can prove the child session's title really comes from its own conversation,
// not from the run_subagent prompt.
const forSubagent = flat.includes("子 agent 报告");
const forSubagent = flat.includes("Subagent report");
block(res, 0, { type: "text", text: "" }, [
{ type: "text_delta", text: forSubagent ? "子 agent 的 TODO 统计" : "配置 Tailwind 主题" },
{
type: "text_delta",
text: forSubagent ? "Subagent TODO summary" : "Configure Tailwind theme",
},
]);
messageStop(res, "end_turn", 8);
return;
@@ -122,11 +125,11 @@ const server = http.createServer((req, res) => {
// missing-report.pdf doesn't exist; the card should only list the former. This branch
// must be checked before hasToolResult (the same session's history already has a
// first-round tool_result).
if (flat.includes("文件卡测试")) {
if (flat.includes("files card test")) {
block(res, 0, { type: "text", text: "" }, [
{
type: "text_delta",
text: "报告已生成:`demo.html`;另一个文件 `missing-report.pdf` 并不存在。",
text: "Report generated: `demo.html`; the other file `missing-report.pdf` does not exist.",
},
]);
messageStop(res, "end_turn", 18);
@@ -166,7 +169,7 @@ const server = http.createServer((req, res) => {
if (isSubagentTurn) {
block(res, 0, { type: "text", text: "" }, [
{ type: "text_delta", text: "子 agent 报告:3 处 TODO" },
{ type: "text_delta", text: "Subagent report: 3 TODOs" },
]);
messageStop(res, "end_turn", 12);
return;
@@ -184,8 +187,8 @@ const server = http.createServer((req, res) => {
if (hasToolResult) {
// Turn 2: final answer text.
block(res, 0, { type: "text", text: "" }, [
{ type: "text_delta", text: "命令已执行完成," },
{ type: "text_delta", text: "结果符合预期。" },
{ type: "text_delta", text: "Command finished; " },
{ type: "text_delta", text: "the result looks as expected." },
]);
messageStop(res, "end_turn", 20);
return;
@@ -199,15 +202,15 @@ const server = http.createServer((req, res) => {
0,
{ type: "thinking", thinking: "" },
[
{ type: "thinking_delta", thinking: "先看看" },
{ type: "thinking_delta", thinking: "目录结构" },
{ type: "thinking_delta", thinking: "Let me look at " },
{ type: "thinking_delta", thinking: "the directory structure" },
],
{ type: "signature_delta", signature: "sig_mock_abc" },
),
);
steps.push(() =>
block(res, 1, { type: "text", text: "" }, [
{ type: "text_delta", text: "我来运行一个命令查看。" },
{ type: "text_delta", text: "I'll run a command to check." },
]),
);
steps.push(() =>
+3 -1
View File
@@ -20,7 +20,9 @@ const U = "projswitcher";
const P = "password123";
const PROJ = "SwitchTarget";
test("下拉里点当前 Project:Agent 与 Session 列表不消失", async ({ page }) => {
test("clicking the current Project in the dropdown: Agent and Session lists must not disappear", async ({
page,
}) => {
await provisionAndLogin(page.request, U, P);
const initial = (await (await page.request.get(`${BASE}/api/projects`)).json()).projects[0];
+2 -2
View File
@@ -43,7 +43,7 @@ test("mobile Files sheet: open → nested dir → md rendered preview → esc cl
).json();
const sessionId = sess.session.sessionId;
const md = Buffer.from("# Sheet 标题\n\n正文段落。").toString("base64");
const md = Buffer.from("# Sheet Heading\n\nBody paragraph.").toString("base64");
const up = await page.request.put(
`${BASE}/api/sessions/${sessionId}/files/content?path=notes/demo.md`,
{ data: { dataBase64: md } },
@@ -58,7 +58,7 @@ test("mobile Files sheet: open → nested dir → md rendered preview → esc cl
// Drill into the directory → open the md → default rendered view (h1 shown, not source).
await sheet.getByText("notes").first().click();
await sheet.getByText("demo.md").first().click();
await expect(sheet.getByRole("heading", { name: "Sheet 标题" })).toBeVisible();
await expect(sheet.getByRole("heading", { name: "Sheet Heading" })).toBeVisible();
// Esc closes: unmounts entirely once the exit animation finishes.
await page.keyboard.press("Escape");
+2 -2
View File
@@ -58,7 +58,7 @@ const SKILLS = [
// and the card renders a custom icon.svg — neither spot should show this fallback path.
const BOOK_PATH_PREFIX = "M2 3h6a4";
test("skills: 技能库分组与卡片 → 管理安装 Modal → 快捷调用预填 → 下拉过滤与 slash 选中", async ({
test("skills: library groups and cards -> manage-install Modal -> quick-invoke prefill -> dropdown filter and slash selection", async ({
page,
}) => {
await provisionAndLogin(page.request, U, P);
@@ -225,7 +225,7 @@ test("skills: 技能库分组与卡片 → 管理安装 Modal → 快捷调用
await expect(skillsBtn).not.toContainText("1");
// The mock LLM's fallback reply completes a full round (allow-all auto-approves exec_command).
await expect(page.getByText("命令已执行完成,结果符合预期。")).toBeVisible();
await expect(page.getByText("Command finished; the result looks as expected.")).toBeVisible();
// —— Slash invocation: typing /agent-opt shows a skill command item; pressing Enter selects it and clears the input box (without sending) ——
await ta.fill("/agent-opt");
+5 -5
View File
@@ -3,7 +3,7 @@
* session_meta pointer for the child session; the server's readMessages expands the child Trace
* from that pointer and fills in the origin chain, and the frontend reattaches the child session
* to the tool card from that. Also verifies the child session's title is generated by the model
* from **the child session's own conversation** (the mock only replies with "child agent TODO
* from **the child session's own conversation** (the mock only replies with "Subagent TODO
* summary" when the title request sees the child's own answer), and that discovery-based
* backfill must not collapse it to NULL.
*
@@ -60,13 +60,13 @@ test("subagent card survives a reload; child session title is generated from its
await page.goto(`${BASE}/chat/${sessionId}`);
const ta = page.getByPlaceholder(/输入消息/);
await ta.waitFor();
await ta.fill("跑子agent");
await ta.fill("run a subagent");
await page.getByRole("button", { name: "发送" }).click();
// Parent's final answer (turn 2): the whole turn has ended. Nothing changes with streaming
// after this, so assertions are deterministic (turn end collapses the "Reasoning & tools"
// group, and child session end collapses the child session card).
await expect(page.getByText("命令已执行完成,结果符合预期。")).toBeVisible();
await expect(page.getByText("Command finished; the result looks as expected.")).toBeVisible();
/** Expand the "Reasoning & tools" group → expand the child session card → child session body visible. */
const openSubagent = async () => {
@@ -78,7 +78,7 @@ test("subagent card survives a reload; child session title is generated from its
const card = page.getByRole("button", { name: /子会话/ }).first();
await expect(card).toBeVisible();
await card.click();
await expect(page.getByText("子 agent 报告:3 处 TODO").first()).toBeVisible();
await expect(page.getByText("Subagent report: 3 TODOs").first()).toBeVisible();
};
// Under live streaming, the child session card is nested inside the run_subagent tool card.
@@ -95,5 +95,5 @@ test("subagent card survives a reload; child session title is generated from its
).json();
return list.sessions.find((s) => s.sessionId !== sessionId)?.title ?? null;
};
await expect.poll(childTitle, { timeout: 10000 }).toBe("子 agent 的 TODO 统计");
await expect.poll(childTitle, { timeout: 10000 }).toBe("Subagent TODO summary");
});