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 a2b5938..01a6df9 100644 --- a/backend/tmux_runner.py +++ b/backend/tmux_runner.py @@ -176,6 +176,15 @@ def __init__(self, handle: str, cwd: str, model: str): # stale allow run later could approve a LATER prompt the user never heard. self.prompt_seq = 0 # bumps each time a prompt parks self.answer_claimed = -1 # prompt_seq an in-flight answer has claimed + # 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. @@ -860,6 +869,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) @@ -1311,6 +1382,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): @@ -1325,6 +1398,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 e611d1f..0c59d30 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