Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <branch>`, 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 <N>` (did the merge land a tree its reviews named)
Expand Down
35 changes: 31 additions & 4 deletions emrg/client/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
27 changes: 27 additions & 0 deletions emrg/gui/renderer/src/components/Shell.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -73,6 +76,7 @@ function mockEmrg() {
setSandboxRoots,
triggerTask,
switchSession,
cancel,
};
return {
listeners,
Expand All @@ -96,6 +100,7 @@ function mockEmrg() {
setSandboxRoots,
triggerTask,
switchSession,
cancel,
emit: (evt: DaemonEventFrame) => listeners.forEach((cb) => cb(evt)),
};
}
Expand Down Expand Up @@ -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(<Shell />));
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(<Shell />));
Expand Down
51 changes: 50 additions & 1 deletion tests/test_app_cancel_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"
Expand All @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion tests/test_app_widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Loading