From c1ddb70f813a4f2b573a4da3c76e8d1683513963 Mon Sep 17 00:00:00 2001 From: Nithiin Kathiresan Date: Mon, 15 Jun 2026 19:31:27 +0530 Subject: [PATCH] Fix stale UI prompt when a permission/question is answered in the CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AskUserQuestion menus and the ExitPlanMode dialog render in the live TUI, so the user can answer them with the keyboard. When they do, no voice answer() fires, so the runner's `s.pending` was never cleared — list() and poll_status kept reporting a prompt the user had already dealt with, and the UI's prompt card stuck around. Backend: the per-session event tail now polls the pane (throttled) while a CLI-answerable prompt is pending. Once that prompt's menu/dialog has been seen on screen and then leaves it, the prompt was answered in the CLI, so `s.pending` is retired (two-strike confirm guards against a capture caught mid-redraw; identity-keying to the Prompt avoids clearing one whose menu hasn't rendered yet). Risky-tool permission prompts park the hook with no on-screen menu and are deliberately never reconciled this way. Frontend: the 2s session-list poll now reconciles the prompt card against the backend — if the card's session no longer reports a pending prompt, the card is dismissed. A timestamp guard prevents a list() response already in flight before the card went up from clearing a freshly-raised prompt. Adds backend regression tests for _reconcile_pending. Co-Authored-By: Claude Opus 4.8 --- backend/tests/test_cli_answer_reconcile.py | 139 +++++++++++++++++++++ backend/tmux_runner.py | 80 ++++++++++++ frontend/components/VoiceAgent.tsx | 32 ++++- 3 files changed, 250 insertions(+), 1 deletion(-) create mode 100644 backend/tests/test_cli_answer_reconcile.py diff --git a/backend/tests/test_cli_answer_reconcile.py b/backend/tests/test_cli_answer_reconcile.py new file mode 100644 index 0000000..62fccc1 --- /dev/null +++ b/backend/tests/test_cli_answer_reconcile.py @@ -0,0 +1,139 @@ +"""Regression tests for retiring a prompt the user answered directly in the CLI. + +AskUserQuestion menus and the ExitPlanMode dialog render in the live TUI, so the +user can answer them with the keyboard — and then no voice answer() fires to +clear `s.pending`, leaving list()/poll_status reporting a prompt the user already +dealt with (a stale card in the UI). TmuxClaudeRunner._reconcile_pending watches +the pane and drops such a prompt once its menu/dialog leaves the screen. + +Risky-tool permission prompts must NOT be reconciled this way: their PreToolUse +hook parks with no on-screen menu, so an empty pane is not an external answer. + +Requires the backend deps (claude_agent_sdk) — run with the project venv: + backend/.venv/bin/python -m unittest discover -s backend/tests +Skips cleanly if the SDK isn't importable. +""" +import os +import sys +import unittest +from unittest import mock + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +try: + import tmux_runner + from claude_runner import Prompt +except ModuleNotFoundError as e: # pragma: no cover - env-dependent + print(f"SKIP test_cli_answer_reconcile: {e} (run with the backend venv)") + sys.exit(0) + + +MENU_PANE = "Pick one\n 1. yes\n 2. no\n Enter to select · ↑/↓ to navigate" +PLAN_PANE = "Plan ready to execute. Would you like to proceed?\n 1. Yes\n 2. No" +BLANK_PANE = "❯ \n(idle prompt box)" + + +def _runner_with_session(prompt: Prompt, status: str): + runner = tmux_runner.TmuxClaudeRunner() + s = tmux_runner._TmuxSession("h1", cwd="/tmp", model="opus") + s.pending = prompt + s.pending_tool_use_id = "tu-1" + s.status = status + runner._sessions[s.handle] = s + return runner, s + + +def _capture_returns(runner, *panes): + """Make _capture return `panes` in order (last value repeats).""" + seq = list(panes) + + async def fake_capture(_s): + return seq.pop(0) if len(seq) > 1 else seq[0] + + runner._capture = fake_capture + + +class CliAnswerReconcile(unittest.IsolatedAsyncioTestCase): + async def test_question_cleared_after_menu_leaves_screen(self): + runner, s = _runner_with_session( + Prompt(kind="choice", text="pick one", options=["yes", "no"], + tool_name="AskUserQuestion"), + "needs_choice") + _capture_returns(runner, MENU_PANE, BLANK_PANE, BLANK_PANE) + await runner._reconcile_pending(s) # menu on screen -> seen + self.assertIsNotNone(s.pending) + await runner._reconcile_pending(s) # gone (strike 1) -> still held + self.assertIsNotNone(s.pending) + await runner._reconcile_pending(s) # gone (strike 2) -> cleared + self.assertIsNone(s.pending) + self.assertIsNone(s.pending_tool_use_id) + self.assertEqual(s.status, "running") + + async def test_not_cleared_before_menu_renders(self): + # The hook fired needs_choice but the TUI hasn't drawn the menu yet; an + # empty pane must not be mistaken for an answer. + runner, s = _runner_with_session( + Prompt(kind="choice", text="pick one", options=["yes"], + tool_name="AskUserQuestion"), + "needs_choice") + _capture_returns(runner, BLANK_PANE) + for _ in range(5): + await runner._reconcile_pending(s) + self.assertIsNotNone(s.pending) + + async def test_single_offscreen_poll_does_not_clear(self): + # One stray capture caught mid-redraw must not retire the prompt. + runner, s = _runner_with_session( + Prompt(kind="choice", text="pick one", options=["yes"], + tool_name="AskUserQuestion"), + "needs_choice") + _capture_returns(runner, MENU_PANE, BLANK_PANE, MENU_PANE) + await runner._reconcile_pending(s) # seen + await runner._reconcile_pending(s) # gone once (strike 1) + await runner._reconcile_pending(s) # back on screen -> strikes reset + self.assertIsNotNone(s.pending) + self.assertEqual(s._pending_gone_strikes, 0) + + async def test_risky_permission_never_reconciled(self): + # A parked-hook permission has no on-screen menu; the empty pane must + # never clear it (only voice/mode/interrupt resolve it). + runner, s = _runner_with_session( + Prompt(kind="permission", text="run Bash", options=["allow", "deny"], + tool_name="Bash"), + "needs_permission") + _capture_returns(runner, BLANK_PANE) + for _ in range(5): + await runner._reconcile_pending(s) + self.assertIsNotNone(s.pending) + self.assertEqual(s.status, "needs_permission") + + async def test_exit_plan_dialog_cleared(self): + runner, s = _runner_with_session( + Prompt(kind="permission", text="exit plan mode", options=["allow", "deny"], + tool_name="ExitPlanMode"), + "needs_permission") + _capture_returns(runner, PLAN_PANE, BLANK_PANE, BLANK_PANE) + await runner._reconcile_pending(s) + await runner._reconcile_pending(s) + await runner._reconcile_pending(s) + self.assertIsNone(s.pending) + + async def test_skipped_while_turn_lock_held(self): + # A voice answer() holds _turn_lock while driving the menu; its transient + # disappearance must not be read as a CLI answer. + runner, s = _runner_with_session( + Prompt(kind="choice", text="pick one", options=["yes"], + tool_name="AskUserQuestion"), + "needs_choice") + _capture_returns(runner, MENU_PANE, BLANK_PANE, BLANK_PANE) + await s._turn_lock.acquire() + try: + for _ in range(5): + await runner._reconcile_pending(s) + finally: + s._turn_lock.release() + self.assertIsNotNone(s.pending) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tmux_runner.py b/backend/tmux_runner.py index 20a894d..d1e32da 100644 --- a/backend/tmux_runner.py +++ b/backend/tmux_runner.py @@ -164,6 +164,15 @@ def __init__(self, handle: str, cwd: str, model: str): self.tools_used: list[str] = [] self.pending: Prompt | None = None self.pending_tool_use_id: str | None = None + # CLI-answerable prompts (AskUserQuestion menus, the ExitPlanMode dialog) + # render in the live TUI, so the user can answer them with the keyboard — + # in which case no voice answer() fires to clear `pending`. We watch the + # pane: once we've seen THIS prompt's menu on screen (_pending_seen is the + # very Prompt object), its disappearance means it was answered in the CLI. + # Identity-keying to the Prompt avoids clearing a freshly-raised prompt + # whose menu hasn't rendered yet. + self._pending_seen: Prompt | None = None + self._pending_gone_strikes = 0 # An AskUserQuestion can carry several questions answered in sequence on # one form; track the full list and which one we're on so the menu is # driven through every question, not just the first. @@ -837,6 +846,68 @@ async def _wait_for_menu(self, s: _TmuxSession, timeout: float = 4.0) -> None: return await asyncio.sleep(0.15) + def _is_cli_answerable(self, p: Prompt | None) -> bool: + """True for prompts the user can also answer with the keyboard in the live + TUI. AskUserQuestion menus and the ExitPlanMode dialog both render on + screen (the PreToolUse hook emits 'allow' for them and the runner drives + the menu). Risky-tool permission prompts instead PARK the hook with no + on-screen menu, so they can only be resolved by voice/mode/interrupt — the + pane never shows them and must never be reconciled against it.""" + if p is None: + return False + if p.kind == "choice": + return True + return p.kind == "permission" and p.tool_name == "ExitPlanMode" + + def _prompt_on_screen(self, p: Prompt, pane: str) -> bool: + """Whether the CLI-answerable prompt `p` is still rendered in `pane`.""" + if p.kind == "permission": # ExitPlanMode plan-approval dialog + return "Would you like to proceed?" in pane or "ready to execute" in pane + return "to navigate" in pane or "Enter to select" in pane # question menu + + async def _reconcile_pending(self, s: _TmuxSession) -> None: + """Drop a CLI-answerable prompt the user answered directly in the terminal. + + The voice answer() path clears `pending` itself; but a keyboard answer in + the live TUI leaves `pending` set, so list()/poll_status keep reporting a + prompt the user already dealt with and the UI shows a stale card. We watch + the pane: once this prompt's menu/dialog has been seen on screen and then + leaves it, the user answered (or dismissed) it in the CLI — so clear it. + + Skipped while a turn holds _turn_lock: the voice answer() is itself driving + the menu then, and its transient disappearance is not an external answer. + Requires two consecutive off-screen polls so a single capture caught + mid-redraw can't trigger a false clear.""" + p = s.pending + if not self._is_cli_answerable(p) or s._turn_lock.locked(): + return + pane = await self._capture(s) + if self._prompt_on_screen(p, pane): + s._pending_seen = p + s._pending_gone_strikes = 0 + return + if s._pending_seen is not p: + return # this prompt's menu hasn't rendered yet — don't clear early + s._pending_gone_strikes += 1 + if s._pending_gone_strikes < 2: + return + if s.pending is not p: # changed underneath us between awaits + return + log_event("claude", "backend", "decision", + "prompt answered in the CLI; clearing stale UI prompt", + session=_slabel(s), + detail={"handle": s.handle, "kind": p.kind, "tool_name": p.tool_name}) + s.pending = None + s.pending_tool_use_id = None + s._pending_seen = None + s._pending_gone_strikes = 0 + s.questions = [] + s.q_index = 0 + if s.status in ("needs_permission", "needs_choice"): + # The CLI answer let Claude proceed; a later turn_complete will flip + # this to "completed". Until then it's no longer waiting on the user. + s.status = "running" + async def interrupt(self, handle: str) -> None: s = self._get(handle) bg = self._bg.pop(handle, None) @@ -1273,6 +1344,8 @@ def poll_status(self, handle: str) -> dict: async def _tail_events(self, s: _TmuxSession) -> None: path = os.path.join(s.ctrl, "events.jsonl") + loop = asyncio.get_event_loop() + next_reconcile = 0.0 while not s._closed: try: if os.path.exists(path): @@ -1287,6 +1360,13 @@ async def _tail_events(self, s: _TmuxSession) -> None: for line in parts: if line.strip(): await self._handle_event(s, line) + # A keyboard answer in the live TUI fires no event, so poll the + # pane (throttled — a capture is a subprocess) to retire prompts + # answered directly in the CLI. + now = loop.time() + if s.pending is not None and now >= next_reconcile: + next_reconcile = now + 0.4 + await self._reconcile_pending(s) except asyncio.CancelledError: raise except Exception: diff --git a/frontend/components/VoiceAgent.tsx b/frontend/components/VoiceAgent.tsx index d9570da..5b5d3b7 100644 --- a/frontend/components/VoiceAgent.tsx +++ b/frontend/components/VoiceAgent.tsx @@ -277,6 +277,12 @@ type Sess = { prompt?: { kind: string; text: string; options?: string[] }; }; +// Monotonic-ish millisecond clock for ordering in-flight fetches against UI +// state changes (performance.now() where available, wall clock as a fallback). +function nowMs(): number { + return typeof performance !== "undefined" ? performance.now() : Date.now(); +} + // Abbreviate the user's home dir to ~ for a compact path display. function abbrevHome(path: string): string { return path.replace(/^\/(Users|home)\/[^/]+/, "~"); @@ -505,6 +511,10 @@ export default function VoiceAgent() { const connectedRef = useRef(false); const pollTimers = useRef>>(new Map()); const txPollRef = useRef | null>(null); + // When the prompt card was last raised. The session-list poll uses this to + // clear a card the backend no longer reports without racing a list() response + // that was already in flight before the card went up (see refreshSessions). + const promptCardSetAtRef = useRef(0); // Cost-log connection identity & snapshot timer. connectionId persists for one // voice connect()/disconnect() cycle so snapshots can be grouped later. const costLogRef = useRef<{ @@ -681,6 +691,7 @@ export default function VoiceAgent() { const req = reqRaw.length > 90 ? `${reqRaw.slice(0, 90)}…` : reqRaw; const forReq = req ? ` for your request “${req}”` : ""; if ((res.status === "needs_permission" || res.status === "needs_choice") && res.prompt) { + promptCardSetAtRef.current = nowMs(); setPending({ sessionId: sid, kind: res.prompt.kind, @@ -841,13 +852,32 @@ export default function VoiceAgent() { }; const refreshSessions = async () => { + const startedAt = nowMs(); try { - setSessions(await fetchSessions()); + const list = await fetchSessions(); + setSessions(list); + reconcilePendingCard(list, startedAt); } catch { /* ignore */ } }; + // Dismiss the prompt card when the backend no longer reports a pending prompt + // for its session — e.g. the user approved/answered it directly in the CLI, + // where no voice answer fires to clear the card. Guard with `startedAt`: a + // list() response that began before the card was raised reflects pre-prompt + // state, so it must not clear a card that just went up. + const reconcilePendingCard = (list: Sess[], startedAt: number) => { + setPending((p) => { + if (!p || startedAt < promptCardSetAtRef.current) return p; + const sess = list.find((s) => s.handle === p.sessionId); + if (!sess) return p; // session not in this read — leave the card as-is + if (sess.prompt) return p; // still pending on the backend + if (sess.status === "needs_permission" || sess.status === "needs_choice") return p; + return null; // resolved elsewhere (answered in the CLI) — retire the card + }); + }; + // Point-in-time context appended to the static instructions at connect(): // Claude sessions outlive voice connections, so a fresh model otherwise wakes // up blind to what's running and re-asks (or worse, re-creates). Built ONCE