From b7395608e48972c99c887b9aa8f105b5566ec59e Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Mon, 9 Nov 2026 20:17:53 +0800 Subject: [PATCH] emrg: the switch path in both directions --- Agent.md | 4 +- .../renderer/src/components/Shell.test.tsx | 26 +++++ emrg/gui/test/switch-session.test.js | 108 +++++++++++++++++- 3 files changed, 135 insertions(+), 3 deletions(-) diff --git a/Agent.md b/Agent.md index 01972795..173c5e5a 100644 --- a/Agent.md +++ b/Agent.md @@ -54,8 +54,8 @@ What EMRG does today — capability statements, not a changelog. One line per command: what it answers. Run the daemon in the foreground with `python -m emrg`. - Python: `uv run pytest tests/ -v` — the number is **measured, never stored**: `uv run --no-sync python3 scripts/check-doc-count.py --measure`. Import check: `uv run python -c "from emrg.client.app import run_client"` -GUI: `cd emrg/gui && npm test` (172: 100 daemon_client + 20 conn-manager + 7 integration + 7 nav-policy + 7 gui-state + 9 build-config + 11 boot-contract + 3 preload-api + 2 theme-guard + 2 image-hash + 4 switch-session) — syntax: `node --check main.js preload.js daemon_client.js` -Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (764: 5 snapshot-store + 13 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 21 markdown + 34 transcript + 32 TranscriptView + 22 history + 25 historyReplay + 40 composer + 75 Composer + 6 LinkDialog + 16 sidebar + 18 Sidebar + 9 fileTree + 9 FileTree + 17 resultPanel + 8 ResultPanel + 27 workspaceView + 13 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 12 dialogLists + 3 HelpDialog + 9 MemoryDialog + 8 SkillsDialog + 8 openSession + 6 WelcomeDialog + 9 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 60 daemonBridge + 9 DaemonBridgeProvider + 52 Shell + 25 DialogHost + 26 SettingsPanel + 18 TaskFormDialog + 5 RantDialog + 8 vendorMarkdown + 6 ApprovalDialog + 16 SandboxRootsDialog) +GUI: `cd emrg/gui && npm test` (175: 100 daemon_client + 20 conn-manager + 7 integration + 7 nav-policy + 7 gui-state + 9 build-config + 11 boot-contract + 3 preload-api + 2 theme-guard + 2 image-hash + 7 switch-session) — syntax: `node --check main.js preload.js daemon_client.js` +Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (765: 5 snapshot-store + 13 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 21 markdown + 34 transcript + 32 TranscriptView + 22 history + 25 historyReplay + 40 composer + 75 Composer + 6 LinkDialog + 16 sidebar + 18 Sidebar + 9 fileTree + 9 FileTree + 17 resultPanel + 8 ResultPanel + 27 workspaceView + 13 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 12 dialogLists + 3 HelpDialog + 9 MemoryDialog + 8 SkillsDialog + 8 openSession + 6 WelcomeDialog + 9 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 60 daemonBridge + 9 DaemonBridgeProvider + 53 Shell + 25 DialogHost + 26 SettingsPanel + 18 TaskFormDialog + 5 RantDialog + 8 vendorMarkdown + 6 ApprovalDialog + 16 SandboxRootsDialog) - CI: pytest on ubuntu **and windows-2025**, the GUI suites, and an actionlint gate over `.github/workflows/`; re-trigger a dropped push with `scripts/re-trigger-ci.sh `, never an empty commit - Merge gates, run before merging — each asks what no other gate asks: `uv run --no-sync python3 scripts/check-vote-count.py` (approvals still valid) · `scripts/check-pr-base.py` (base reaches master) · `scripts/check-merge-freshness.py` (that green CI is about the landing tree) · `scripts/check-merge-order.py` (dirt and what it lands) · `scripts/check-merge-landing-diff.py` (what the landing changes) · `scripts/check-merge-pairs.py` / `scripts/check-merge-sequence.py` (a pair, or a whole order) · `scripts/check-merge-tree-health.py` (the merged tree passes one guard) · `scripts/check-merge-plan-suite.py` (the plan's final tree passes the suite) — all under `uv run --no-sync python3` - Post-merge audit: `uv run --no-sync python3 scripts/check-merge-landed.py ` (did the merge land a tree its reviews named) diff --git a/emrg/gui/renderer/src/components/Shell.test.tsx b/emrg/gui/renderer/src/components/Shell.test.tsx index 991f17b7..3da2d038 100644 --- a/emrg/gui/renderer/src/components/Shell.test.tsx +++ b/emrg/gui/renderer/src/components/Shell.test.tsx @@ -762,6 +762,32 @@ describe("Shell (Batch 5 slice 3 chat wiring)", () => { expect(screen.getByTestId("agent-session-row")).toHaveTextContent("会话一"); }); + it("项目会话切换被拒(issue #2771):拒绝出现在人操作的项目面板上,人没有被搬走", async () => { + // 侧栏之外的第二条切换路径:项目面板的会话子视图(agent-session-row → onSelectAgentSession + // → selectProjectSession)。此前那条 catch 没有任何测试走过 —— 删掉它整套仍然 52 passed, + // 而「切换被拒时把拒绝显示给点的人」这句话就只剩另一半被证明。 + const m = mockEmrg(); + m.listAgents.mockResolvedValue([{ name: "p1", path: "/p/p1" }]); + (window as unknown as { emrg: { listAgentSessions?: unknown } }).emrg.listAgentSessions = + vi.fn().mockResolvedValue({ sessions: [{ session_id: "s1", title: "会话一" }] }); + m.switchSession.mockRejectedValueOnce(new Error("boom-project")); + render(wrapper()); + await waitFor(() => expect(screen.getByTestId("composer")).toBeInTheDocument()); + fireEvent.click(screen.getByTestId("nav-agents")); + await waitFor(() => expect(screen.getByTestId("agent-row")).toBeInTheDocument()); + const row = screen.getByTestId("agent-row"); + fireEvent.click(row.querySelector('button[title="Sessions"]') as HTMLButtonElement); + await waitFor(() => expect(screen.getByTestId("agent-session-row")).toBeInTheDocument()); + // 点那一行 —— 这一击此前在整套测试里从未发生 + fireEvent.click(screen.getByTestId("agent-session-row")); + // 拒绝可见:横幅带 daemon 交来的消息 + const banner = await screen.findByTestId("switch-error"); + expect(banner).toHaveTextContent("boom-project"); + // 人没有被搬走:仍在项目面板,会话视图没有被切过去 + expect(screen.getByTestId("panel-agents")).toBeInTheDocument(); + expect(screen.queryByTestId("composer")).not.toBeInTheDocument(); + }); + // ── Batch 5 slice 8:任务表单 + Rant 对话框接线 ── it("任务面板激活时每 5s 轮询 listTasks(vanilla startTaskPoll 语义)", async () => { diff --git a/emrg/gui/test/switch-session.test.js b/emrg/gui/test/switch-session.test.js index e87408a7..8636442d 100644 --- a/emrg/gui/test/switch-session.test.js +++ b/emrg/gui/test/switch-session.test.js @@ -39,11 +39,48 @@ function handlerRegion(src, name) { return next === -1 ? rest : rest.slice(0, next); } +/** + * 把 `//` 与块注释从一段源码里剥掉(issue #2771)。 + * + * 守卫问的是「这段处理块里还有没有**代码**读这个名字」,而子串匹配回答的是「这一段正文里还 + * 有没有这串字符」。两者差在注释上:只点名这个名字、一行代码都没读它的注释会被读成代码 + * ⇒ 「写一句解释为什么删掉那条拒绝」这种正当改动变红,而红字指向的代码没坏。注释不是代码, + * 所以先剥再匹配。 + * + * 两处细节是被测出来的,不是挑好看的: + * - **先剥块注释**:块注释里的 `//` 不是行注释的开头。 + * - **行注释的 `//` 不能紧跟在 `:` 后面**:`"http://…"` 里的 `//` 是字符串的一部分,从这里 + * 一直剥到行尾会把**同一行后面的代码**一起吃掉 —— 那会让真读字段的代码读成没读。假绿比 + * 假红贵,所以这一步有自己的判别臂(见文件末尾那条测)。 + */ +function stripComments(src) { + const withoutBlocks = src.replace(/\/\*[\s\S]*?\*\//g, ""); + return withoutBlocks.replace(/(^|[^:])\/\/[^\n]*/g, "$1"); +} + +/** + * 这个字段名本身,按**整词**匹配。 + * + * 子串匹配分不清 `ownStream` 与以它开头的更长的名字:`ownStreamRequestId` 是一次请求的 id, + * 读它与读流标记是两件事(issue #2771)。整词匹配把这两件事分开,而一条真拒绝不可能存在 + * 却不出现这个名字的整词 —— 匹配变窄的代价因此为零。 + */ +const OWN_STREAM_FIELD = /\bownStream\b/; + +/** + * 一条源码是否**在代码里**读了那个字段:先剥注释,再按整词匹配。 + * + * 谓词有名字是有理由的:数测试的仪器按行首形状认「这一行是不是在定义一条测试」, + * `OWN_STREAM_FIELD.test(...)` 单独起一行会被它读成一条定义(issue #2771 实测)。 + * 一个名字把「读一个字段」写成一句话,顺带让它不再长得像一条测试。 + */ +const readsOwnStream = (text) => OWN_STREAM_FIELD.test(stripComments(text)); + test("emrg:switchSession 的处理块不再读 ownStream(issue #2637)", () => { const region = handlerRegion(source, "switchSession"); assert.ok(region.length > 500, `the switch region must be the real handler, got ${region.length} chars`); assert.ok( - !region.includes("ownStream"), + !readsOwnStream(region), "the switch handler must not refuse on ownStream any more: switching leaves the previous " + "connection open, so a running turn is unaffected by moving the window to another session", ); @@ -79,3 +116,72 @@ test("区域取法本身会切分(换一个处理块取到的是另一块)", assert.ok(closeRegion.includes("closeSession("), "the closeSession region must carry its own body"); assert.ok(!switchRegion.includes("closeSession("), "the switch region must not run into the next handler"); }); + +// ── issue #2771:上面那条断言问的对象是「代码」,所以它得在注释与更长的名字上都读对 ── + +test("只点名 ownStream 的注释读作「没有代码读它」,真读它的一行仍然红", () => { + // 两向各一句,都在**字符串**上,不动 main.js:改真文件只能证明这一次,改不得下一次。 + const commented = [ + "async (_e, { sessionId } = {}) => {", + " // the refusal removed in issue #2637 read connManager.get(prevSid).ownStream before", + " // opening any connection: these lines name that field and read nothing from it.", + " const prevSid = currentSessionId;", + " return {};", + "}", + ].join("\n"); + assert.ok( + !readsOwnStream(commented), + "a comment explaining why the refusal was removed must not be read as the refusal itself", + ); + + const refused = [ + "async (_e, { sessionId } = {}) => {", + " if (connManager.get(currentSessionId).ownStream) {", + ' throw new Error("session busy");', + " }", + "}", + ].join("\n"); + assert.ok( + readsOwnStream(refused), + "a handler that really refuses on the field must still be caught — this is the arm the guard exists for", + ); + + // 块注释同样是注释:`/* … */` 里的点名不算读。 + const blockCommented = " /* this used to read conn.ownStream — issue #2637 */\n return {};"; + assert.ok(!readsOwnStream(blockCommented), "a block comment is not code either"); +}); + +test("以 ownStream 开头的更长的名字不是读这个字段", () => { + // `ownStreamRequestId` 是一次请求的 id,`_releaseOwnStream()` 是释放锁;两者都与「本处理块 + // 是否因流标记拒绝」无关,而子串匹配会把前者读成后者(issue #2771 实测 `includes` 为 true)。 + const longer = [ + " conn._releaseOwnStream();", + " this.ownStreamRequestId = rid;", + " if (frame.request_id === this.ownStreamRequestId) this._releaseOwnStream();", + ].join("\n"); + assert.ok( + !readsOwnStream(longer), + "names that merely begin with the field are a different subject, and the guard must not redden them", + ); + // 对照:这一类名字确实存在于本程序里,所以上一条不是在与一个假想的形状搏斗。 + assert.ok( + source.includes("_releaseOwnStream"), + "control: the release helper this test excludes does exist in main.js", + ); +}); + +test("字符串里的 // 不是注释起点(剥除不能吃掉同一行后面的代码)", () => { + // 这一条是剥除本身的判别臂,方向是**假绿**:若行注释的 `//` 不排除前面是 `:` 的那种, + // 一次 URL 字面量之后的所有代码都会被剥掉,于是同行的真拒绝读成「没读」。 + const withUrl = ' const u = "http://example.test/x"; if (conn.ownStream) throw new Error("busy");'; + assert.ok( + readsOwnStream(withUrl), + "a `//` inside a string literal must not swallow the code that follows it on the same line", + ); + // 对照:同一行里真的是注释时,行尾的 `//` 之后不再算代码。 + const withComment = ' const u = "http://example.test/x"; return {}; // ownStream lives elsewhere'; + assert.ok( + !readsOwnStream(withComment), + "a trailing line comment is still a comment", + ); +});