From a826181e118f718f9f5a77d39cc8de999871171d Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Thu, 29 Oct 2026 14:25:20 +0800 Subject: [PATCH] emrg: Esc stops the session's turn, not only this client's send --- Agent.md | 2 +- emrg/client/app.py | 35 +++++++++++-- .../renderer/src/components/Shell.test.tsx | 27 ++++++++++ tests/test_app_cancel_session.py | 51 ++++++++++++++++++- tests/test_app_widgets.py | 2 +- 5 files changed, 110 insertions(+), 7 deletions(-) diff --git a/Agent.md b/Agent.md index fe16f4d3..e6f742a4 100644 --- a/Agent.md +++ b/Agent.md @@ -55,7 +55,7 @@ One line per command: what it answers. Run the daemon in the foreground with `py - 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` (168: 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) — syntax: `node --check main.js preload.js daemon_client.js` -Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (739: 5 snapshot-store + 13 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 13 markdown + 29 transcript + 28 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 + 59 daemonBridge + 9 DaemonBridgeProvider + 49 Shell + 25 DialogHost + 26 SettingsPanel + 18 TaskFormDialog + 5 RantDialog + 4 vendorMarkdown + 6 ApprovalDialog + 16 SandboxRootsDialog) +Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (740: 5 snapshot-store + 13 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 13 markdown + 29 transcript + 28 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 + 59 daemonBridge + 9 DaemonBridgeProvider + 50 Shell + 25 DialogHost + 26 SettingsPanel + 18 TaskFormDialog + 5 RantDialog + 4 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/client/app.py b/emrg/client/app.py index 73330010..3646479e 100644 --- a/emrg/client/app.py +++ b/emrg/client/app.py @@ -507,6 +507,29 @@ def _replay_rows(messages) -> list[tuple[str, object, str]]: CANCELLED_LINE = "⏸ Interrupted — response stopped. You can continue." +def esc_stops_the_session_turn(*, busy: bool, turn_running: bool) -> bool: + """Whether Esc has a turn to stop, so the keystroke asks instead of doing nothing. + + The two facts are not the same question and neither implies the other: + + * ``busy`` is **this client's own send in flight** — it is true only for a turn + this connection started; + * ``turn_running`` is **the session's turn**, fed by the daemon's `turn_start` / + `done` frames and by the resume snapshot, so it is true for a turn a scheduled + task or another client started as well. + + Asking ``busy`` alone left the host pressing Esc at a scheduled task's turn with + nothing happening: the session was on screen, the elapsed timer was counting, and + the keystroke was silent. The daemon's cancel is session-scoped (it resolves the + turn from the named session), so a client watching that session can stop the same + turn whichever client began it — the fact to ask is therefore the wider one. + + The keystroke still only *asks*: `request_cancel` sends the frame and the daemon's + ``cancelled`` receipt remains the only statement that anything stopped. + """ + return busy or turn_running + + async def request_cancel(conn, session_id: str) -> None: """Ask the daemon to stop ``session_id``'s turn; send nothing if unknown. @@ -1272,7 +1295,8 @@ def _narrate_the_stop() -> None: # Whether the **session's** turn is running — the daemon's fact, fed by # `turn_start` / `done` / the resume snapshot, and the lifetime of the elapsed # timer (requirement 3). `busy` keeps its own meaning: this client's send is in - # flight, which is what ESC and the queue logic ask about. + # flight, which is what the queue logic asks about; ESC asks both facts through + # `esc_stops_the_session_turn`, so a turn this client did not start is stoppable. turn_running: bool = False # P1 queue-injection client side (daemon #655): messages sent while the # session is busy are queued daemon-side (task_queued). Track them here so @@ -2835,15 +2859,18 @@ async def handle_key(data: bytes) -> bool: return True return True - # ── ESC interrupt when busy ────────────────────────── + # ── ESC interrupt: this client's send, or the session's turn ── # Mimics Claude Code: Esc stops the current response mid-turn, # keeping work done so far. Dialogs (selector/autocomplete) are - # handled below — this only fires when LLM is actively responding. + # handled below — this only fires when there is a turn to stop. # Nothing is cleared or narrated here (rant 2026-09-20T12:50:13): the # daemon's `cancelled` receipt is the only statement of what stopped, so # a keypress cannot claim a stop the session never took. The typing # indicator keeps turning until that receipt arrives. - if data == b"\x1b" and busy: + # `esc_stops_the_session_turn` asks both facts: `busy` alone is this + # client's own send, so a scheduled task's turn — or a peer client's — + # was on screen and counted down while Esc did nothing. + if data == b"\x1b" and esc_stops_the_session_turn(busy=busy, turn_running=turn_running): await request_cancel(conn, session_id) return True diff --git a/emrg/gui/renderer/src/components/Shell.test.tsx b/emrg/gui/renderer/src/components/Shell.test.tsx index 72b01178..0b659aea 100644 --- a/emrg/gui/renderer/src/components/Shell.test.tsx +++ b/emrg/gui/renderer/src/components/Shell.test.tsx @@ -49,6 +49,9 @@ function mockEmrg() { const setSandboxRoots = vi.fn().mockResolvedValue({ ok: true }); const triggerTask = vi.fn().mockResolvedValue({ ok: true }); const switchSession = vi.fn().mockResolvedValue({ ok: true }); + // Rant 2026-10-29T14:21:57: the stop button must reach the daemon for a turn this + // client did not start, so the bridge it calls has to exist here. + const cancel = vi.fn().mockResolvedValue({ ok: true }); (window as unknown as { emrg?: unknown }).emrg = { onEvent, sendMessage, @@ -73,6 +76,7 @@ function mockEmrg() { setSandboxRoots, triggerTask, switchSession, + cancel, }; return { listeners, @@ -96,6 +100,7 @@ function mockEmrg() { setSandboxRoots, triggerTask, switchSession, + cancel, emit: (evt: DaemonEventFrame) => listeners.forEach((cb) => cb(evt)), }; } @@ -926,6 +931,28 @@ describe("Shell — 会话信息行 + /指令接线(rant 2026-09-01T20:16:55 / ); }); + it("停止按钮跟着会话走,而不是跟着本端发没发过请求(rant 2026-10-29T14:21:57)", async () => { + // 这一轮不是本端发起的:定时任务的一次 tick,或者另一个客户端。本端从未调用 + // `sendMessage`,只收到 daemon 的 `turn_start` 广播。 + // + // TUI 的 Esc 曾经只看「本端有请求在飞」(`busy`),于是这种轮在屏幕上跑着、 + // 计时在走,按 Esc 却什么都没发生,只能去开 GUI 或者杀掉 daemon。GUI 这边 + // 读的是 `busyBySid[activeSid]` —— daemon 广播的会话级事实 —— 所以本来是好的; + // 这条测试把「本来是好的」变成「再也不能变坏」:删掉 Shell 里那一行 + // `busy = appState.busyBySid[activeSid]`,停止按钮就不会出现,本测试变红。 + const m = mockEmrg(); + render(wrapper()); + await waitFor(() => expect(m.onEvent).toHaveBeenCalledTimes(1)); + m.emit(openSessionsFrame([{ sid: "s1", title: "Alpha" }])); + await waitFor(() => expect(screen.getByTestId("composer-input")).toBeInTheDocument()); + m.emit({ type: "turn_start", data: { started_at: Math.floor(Date.now() / 1000) }, sid: "s1" }); + + const stop = await screen.findByTestId("composer-stop"); + expect(m.sendMessage).not.toHaveBeenCalled(); // 本端确实没发起过这一轮 + fireEvent.click(stop); + await waitFor(() => expect(m.cancel).toHaveBeenCalledWith("s1")); + }); + it("快照说没有轮在跑 → 会话信息行不显示计时(不得留下上一轮的时刻)", async () => { const m = mockEmrg(); render(wrapper()); diff --git a/tests/test_app_cancel_session.py b/tests/test_app_cancel_session.py index 55c8e83c..32c5678a 100644 --- a/tests/test_app_cancel_session.py +++ b/tests/test_app_cancel_session.py @@ -36,6 +36,7 @@ from emrg.client.app import ( CANCELLED_LINE, cancelled_line, + esc_stops_the_session_turn, receipt_is_about_a_turn_this_client_shows, request_cancel, the_ending_completes_a_receipt_this_client_holds, @@ -321,7 +322,9 @@ def test_the_esc_branch_asks_and_shows_nothing(): TTY loop, so no runtime assertion can reach it, and this is the only form in which "there is no local optimistic branch" can be checked at all. """ - block = _block_after('if data == b"\\x1b" and busy:', "return True") + block = _block_after( + 'if data == b"\\x1b" and esc_stops_the_session_turn(', "return True" + ) assert "request_cancel(" in block, "Esc must ask the daemon" assert "chat.add(" not in block, "the keystroke must not narrate" @@ -330,6 +333,52 @@ def test_the_esc_branch_asks_and_shows_nothing(): assert "CANCELLED_LINE" not in block +# ── which turn Esc has to stop ─────────────────────────────── + + +def test_esc_stops_a_turn_this_client_did_not_start(): + """The session's turn counts, whoever started it. + + This is the case that was silent: a scheduled task's tick, or another client's + send, keeps the session busy and the timer counting, and this connection never + sent anything — so a judgement reading only `busy` answered false and the + keystroke did nothing. The cancel is session-scoped daemon-side, which is what + makes stopping it from here legal at all. + """ + assert esc_stops_the_session_turn(busy=False, turn_running=True) is True + + +def test_esc_stops_this_clients_own_send_too(): + """The narrower fact is not replaced by the wider one: a send in flight still counts. + + Both are true at once for a turn this client started, and either alone has to + be enough — reading only the session's fact would lose the moment between the + send and the daemon's `turn_start`. + """ + assert esc_stops_the_session_turn(busy=True, turn_running=False) is True + + +def test_esc_with_no_turn_sends_nothing(): + """Neither fact: the dialogs below own the keystroke, so no request goes out.""" + assert esc_stops_the_session_turn(busy=False, turn_running=False) is False + + +def test_the_esc_branch_asks_both_facts(): + """The wiring row: the predicate's own tests pass even if this hands it a constant. + + Same defect and same reason as `test_the_receipt_asks_the_two_fact_question` — + a call site passing `turn_running=False` leaves every predicate test above + green while restoring exactly the behaviour that was reported. + """ + block = _block_after( + 'if data == b"\\x1b" and esc_stops_the_session_turn(', "return True" + ) + + assert "busy=busy, turn_running=turn_running" in block, ( + "the branch must hand the predicate both variables, not a constant" + ) + + def test_the_narration_is_found_where_it_now_belongs(): """The control for the test above: the same extraction, on the receipt. diff --git a/tests/test_app_widgets.py b/tests/test_app_widgets.py index ba57ace5..6878a9ba 100644 --- a/tests/test_app_widgets.py +++ b/tests/test_app_widgets.py @@ -871,7 +871,7 @@ def test_the_tool_selector_closes_on_esc_before_the_turn_cancel_can_see_it(): """ src = _app_source() panel = src.index("if tool_sel.active and tool_sel.widget:") - cancel = src.index("# ── ESC interrupt when busy ──") + cancel = src.index("# ── ESC interrupt: this client's send, or the session's turn ──") assert panel < cancel block = src[panel:panel + 1200]