Skip to content
Open
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
139 changes: 139 additions & 0 deletions backend/tests/test_cli_answer_reconcile.py
Original file line number Diff line number Diff line change
@@ -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()
80 changes: 80 additions & 0 deletions backend/tmux_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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):
Expand All @@ -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:
Expand Down
32 changes: 31 additions & 1 deletion frontend/components/VoiceAgent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)\/[^/]+/, "~");
Expand Down Expand Up @@ -505,6 +511,10 @@ export default function VoiceAgent() {
const connectedRef = useRef(false);
const pollTimers = useRef<Map<string, ReturnType<typeof setInterval>>>(new Map());
const txPollRef = useRef<ReturnType<typeof setInterval> | 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<{
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down