Compare commits

...

6 Commits

Author SHA1 Message Date
Brooklyn Nicholson
5c1a4eefd3 fix(tui): guard clarify status update + make _clear_pending final
Addresses Copilot review round 5 on #35987.

useMainApp.ts (comment #3330991262): the clarify success path set status
to 'running…' BEFORE the request-id guard, so if the agent emitted a fresh
prompt before the RPC resolved, the new prompt's prompt-specific status was
clobbered. Move the status update inside the request-id check (only set it
on a real answer). sudo/secret already set status inside their guards.

server.py (comment #3330991267): _clear_pending set the empty answer and
the event but left the entry in _pending. A user response landing before
the blocked thread ran its finally could acquire _pending_lock, find the
still-pending request, overwrite the empty answer, and return ok —
reviving a prompt that interrupt/shutdown meant to cancel. Pop _pending
(and its payload) while holding the lock so a later _respond gets 4009.
_block's own finally pop is then a harmless no-op and it still returns the
staged empty answer.

New test test_clear_pending_removes_entry_so_late_respond_cannot_revive
asserts the entry is removed and a late clarify.respond gets 4009.
Mutation-verified: reverting the pop makes the test fail.
2026-05-31 16:59:51 -05:00
Brooklyn Nicholson
8b6207db70 fix(tui): guard success-path overlay clears by request id + tighten race test
Addresses Copilot review round 4 on #35987.

useMainApp.ts (comments #3330951928, #3330951934 — real bug):
- The clarify/sudo/secret SUCCESS callbacks cleared the mounted overlay
  unconditionally. Because _respond() sets the server event (unblocking the
  agent) BEFORE the JSON-RPC response is delivered to the client, the agent
  can emit a fresh prompt.request before the resolve callback runs — so an
  unguarded clear would wipe the new prompt. Guard all three success paths
  by request id, mirroring the failure-path guard. (Copilot flagged sudo +
  secret; swept the pattern and fixed clarify too.)

tests (comment #3330951940 — coverage gap):
- The race stress test recorded prompt.expire events but never asserted
  them against the rounds where clarify.respond succeeded, so a regression
  that fires expiry AND still returns the accepted answer would pass.
  Capture each round's rid and assert respond-ok ⟹ answer delivered AND no
  prompt.expire fired for that request. Mutation-verified: forcing
  should_expire=True now fails the test ("respond succeeded but a false
  prompt.expire fired"), where the prior version passed.
2026-05-31 16:51:59 -05:00
Brooklyn Nicholson
11479a8f31 test(tui): assert prompt.expire surfaces the timeout system line
Addresses Copilot review on #35987: the prompt.expire tests only asserted
the negative (a stale/no-op expiry does NOT emit a system line), so a
regression that dropped the `sys("prompt timed out — <kind> request
cancelled")` call would still pass. Add a positive test covering all
three kinds (clarify/sudo/secret) that asserts a matching expiry which
actually clears a mounted overlay surfaces the timeout line. Verified the
test fails when the sys(...) call is removed.
2026-05-31 16:09:13 -05:00
Brooklyn Nicholson
0351215bf5 fix(tui): make prompt expiry atomic + reset status on dead-overlay dismissal
Addresses the second Copilot review round on #35987.

server.py (race correctness):
- The prior `rid not in _answers` guard was not atomic: _respond() could
  read _pending, get preempted before writing _answers, then _block's
  timeout pops _pending and emits a false prompt.expire even though the
  response is about to be accepted. Introduce a module-level _pending_lock
  and serialize the three critical sections — _respond (membership check +
  answer write + event set), _block's finally (pop + answer check +
  expire decision), and _clear_pending. _emit stays outside the lock
  (transport I/O). _respond no longer writes an answer for an rid that
  _block already popped. New stress test races a responder against the
  deadline across 40 rounds and asserts respond-ok always delivers the
  answer with no false expiry.

useMainApp.ts (client status reset):
- The null-RPC fallbacks for clarify/sudo/secret dismiss a dead overlay
  but left the status bar on the prompt-specific value ('waiting for
  input…' / 'sudo password needed' / 'secret input needed'). Nothing else
  resets it, so the bar kept claiming it was waiting after the prompt was
  gone. Reset to the real busy/ready status in all three fallbacks.

uiStore.ts (single-source the rule):
- Extract statusFromBusy() (was a private copy in createGatewayEventHandler)
  so the gateway-event handler and the useMainApp fallbacks can't drift.
  Add a focused unit test asserting it never resolves to a transient
  prompt status.
2026-05-31 15:07:26 -05:00
Brooklyn Nicholson
31341d6b1d fix(tui): address Copilot review on prompt.expire (race, sudo/secret, status)
Four findings from the PR #35987 Copilot review:

1. Timeout race (_block): _respond() can set _answers[rid] + ev.set()
   after ev.wait() returned False but before the finally block, so the
   answer is accepted yet prompt.expire still fired and cleared the
   overlay out from under it. Guard now re-checks `rid not in _answers`
   (not just the stale `answered` flag) before emitting expiry.

2. Sudo/secret dead-prompt fallback: only clarify cleared its overlay
   on a null respond RPC. sudo/secret share the same _block timeout
   path; if prompt.expire is missed and the user submits after timeout
   their overlay stuck identically. respondWith() gained an optional
   onFail; answerSudo/answerSecret now clear their overlay (matched by
   request_id) when the RPC returns null.

3. Stale status bar: after an expiry cleared a mounted overlay the
   status stayed at the prompt-specific value ("waiting for input…" /
   "sudo password needed" / "secret input needed") while the agent
   streamed. The expire handler now snaps status back via
   statusFromBusy() — but only when it actually cleared the mounted
   overlay (a stale/duplicate expire is now a no-op, including no
   spurious "timed out" system line).

4. Missing secret test: added a secret prompt.expire case, plus tests
   for the status reset and the no-op-stale-expire behavior.

Also adds a Python race-guard test that injects an answer during the
timeout window and asserts no expiry fires (fails without the guard).
2026-05-31 12:36:51 -05:00
Brooklyn Nicholson
17cf1a500f fix(tui): clear stale prompt overlay after server-side timeout
When the assistant emits a clarify/sudo/secret prompt and the user
never answers, the Python-side `_block` in `tui_gateway/server.py`
times out after 5 minutes (120s for sudo), returns an empty string,
and the agent resumes — but the TUI overlay (the `(1-N) quick pick`
choice box) stays mounted because the client was never told the
request expired. The dead overlay then captures every keystroke
while the assistant continues streaming below, so the user
literally cannot escape out or pick anything until the next message
cycle.

Fix:

- `_block` now emits a generic `prompt.expire` event (kind:
  clarify/sudo/secret, plus the original request_id) when the wait
  times out without an answer.
- The TUI handles `prompt.expire` by clearing the matching overlay
  if the request_id still matches the currently-mounted prompt.
  Match-by-id avoids clobbering a fresh prompt that opened in the
  meantime.
- Belt-and-suspenders: when `clarify.respond` fails with the
  "no pending clarify request" error (server-side already expired),
  the client now also clears the overlay locally so a user keystroke
  after timeout doesn't leave the box stuck.

Adds three Python tests (`prompt.expire` fires on timeout, doesn't
fire on legitimate answer, kind matches event prefix) and three
TUI tests (`prompt.expire` clears the matching clarify/sudo overlay
and ignores stale request_ids).
2026-05-31 12:21:15 -05:00
8 changed files with 627 additions and 30 deletions

View File

@@ -2992,6 +2992,47 @@ def test_clear_pending_without_sid_clears_all():
server._answers.pop(key, None)
def test_clear_pending_removes_entry_so_late_respond_cannot_revive():
"""_clear_pending must POP _pending, not just set the empty answer.
Otherwise a user response that lands after interrupt/shutdown cleared the
prompt can acquire _pending_lock, find the still-pending entry, overwrite
the empty answer with real input, and return ok — reviving a prompt that
was meant to be cancelled. (Copilot review on PR #35987.)
"""
ev = threading.Event()
server._pending["rid-clear"] = ("sid_clear", ev)
server._pending_prompt_payloads["rid-clear"] = ("clarify.request", {})
server._answers.pop("rid-clear", None)
try:
server._clear_pending("sid_clear")
# Entry must be gone from _pending (and its payload), with the empty
# answer staged and the blocked thread released.
assert ev.is_set()
assert server._answers.get("rid-clear") == ""
assert "rid-clear" not in server._pending
assert "rid-clear" not in server._pending_prompt_payloads
# A late response for the cleared request must now be rejected (4009),
# NOT silently accepted (which would overwrite the empty answer).
resp = server.handle_request(
{
"id": "x",
"method": "clarify.respond",
"params": {"request_id": "rid-clear", "answer": "too late"},
}
)
assert resp and resp.get("error"), f"late respond should 4009, got {resp!r}"
assert resp["error"].get("code") == 4009
# The empty answer staged by the clear must survive intact.
assert server._answers.get("rid-clear") == ""
finally:
server._pending.pop("rid-clear", None)
server._pending_prompt_payloads.pop("rid-clear", None)
server._answers.pop("rid-clear", None)
def test_respond_unpacks_sid_tuple_correctly():
"""After the (sid, Event) tuple change, _respond must still work."""
ev = threading.Event()
@@ -5158,3 +5199,213 @@ def test_notification_poller_requeues_when_busy(monkeypatch):
assert requeued["session_id"] == "proc_busy_test"
finally:
server._sessions.pop("sid_busy", None)
# ---------------------------------------------------------------------------
# _block must emit a `prompt.expire` event when it times out without an
# answer. Without it, the TUI overlay (the (1-N) choice box) sits forever
# capturing keystrokes while the assistant resumes streaming below it —
# user reports "shit just overflows and I can't escape out".
# ---------------------------------------------------------------------------
def test_block_emits_prompt_expire_on_timeout(monkeypatch):
emitted = []
monkeypatch.setattr(server, "_emit", lambda *a, **kw: emitted.append(a))
# 50ms timeout so we don't make the suite wait — _block.timeout=300 in
# real callers, but the path under test is the same.
answer = server._block(
"clarify.request",
"sid_xyz",
{"question": "ok?", "choices": ["yes", "no"]},
timeout=0.05,
)
# Agent gets empty string — proves we resumed on timeout.
assert answer == ""
kinds = [e[0] for e in emitted]
assert "clarify.request" in kinds, f"expected clarify.request, got {kinds}"
assert "prompt.expire" in kinds, (
"CRITICAL: _block timed out without emitting prompt.expire — "
"the client overlay will stay mounted and capture keystrokes "
"until the next message clears it"
)
expire = next(e for e in emitted if e[0] == "prompt.expire")
# _emit signature: (event, sid, payload)
assert expire[1] == "sid_xyz"
payload = expire[2]
assert payload["kind"] == "clarify"
assert "request_id" in payload and payload["request_id"]
def test_block_does_not_emit_prompt_expire_when_answered(monkeypatch):
emitted = []
monkeypatch.setattr(server, "_emit", lambda *a, **kw: emitted.append(a))
# Set the answer from another thread while _block is waiting.
def _answer_after_delay():
# Wait until the pending entry exists, then resolve it.
for _ in range(100):
if server._pending:
rid = next(iter(server._pending))
server._answers[rid] = "yes"
server._pending[rid][1].set()
return
time.sleep(0.005)
threading.Thread(target=_answer_after_delay, daemon=True).start()
answer = server._block(
"clarify.request",
"sid_xyz",
{"question": "ok?", "choices": ["yes", "no"]},
timeout=2,
)
assert answer == "yes"
kinds = [e[0] for e in emitted]
assert "prompt.expire" not in kinds, (
"regression: prompt.expire fired even though the user answered — "
"would race-clear the overlay AFTER the legitimate answer"
)
def test_block_emit_prompt_expire_kind_matches_event_prefix(monkeypatch):
"""sudo/secret prompts also need expire — kind is derived from the
event name (`sudo.request` → `sudo`)."""
emitted = []
monkeypatch.setattr(server, "_emit", lambda *a, **kw: emitted.append(a))
server._block("sudo.request", "sid_x", {}, timeout=0.05)
server._block("secret.request", "sid_x", {"prompt": "p", "env_var": "X"}, timeout=0.05)
expires = [e for e in emitted if e[0] == "prompt.expire"]
kinds = [e[2]["kind"] for e in expires]
assert "sudo" in kinds
assert "secret" in kinds
def test_block_no_expire_when_answer_lands_in_timeout_race(monkeypatch):
"""Race guard (Copilot review on PR #35987): _respond() can set
_answers[rid] + ev.set() AFTER ev.wait() already returned False
(timeout) but BEFORE _block reaches its finally. The answer is
genuinely accepted — _block returns it — so prompt.expire must NOT
fire, or it would clear the overlay out from under a real answer.
We simulate the exact interleaving by patching Event.wait to return
False (timeout) while injecting the answer into _answers just before
returning, mimicking a _respond() that landed in the gap.
"""
emitted = []
monkeypatch.setattr(server, "_emit", lambda *a, **kw: emitted.append(a))
real_wait = threading.Event.wait
def racing_wait(self, timeout=None):
# Find the rid this event belongs to and inject an answer, then
# report a timeout — exactly the lost-wakeup ordering.
for rid, (_sid, ev) in list(server._pending.items()):
if ev is self:
server._answers[rid] = "late-but-accepted"
break
return False # pretend we timed out
monkeypatch.setattr(threading.Event, "wait", racing_wait)
answer = server._block(
"clarify.request",
"sid_race",
{"question": "ok?", "choices": ["yes", "no"]},
timeout=0.01,
)
# The accepted answer must be returned to the agent...
assert answer == "late-but-accepted"
# ...and NO expiry should have been emitted despite the False wait.
kinds = [e[0] for e in emitted]
assert "prompt.expire" not in kinds, (
"race regression: prompt.expire fired even though an answer was "
"set during the timeout window — overlay would be cleared out "
"from under a legitimate late answer"
)
def test_block_respond_race_is_atomic_under_lock(monkeypatch):
"""Stress the _respond-vs-timeout interleaving Copilot flagged: a
response that reads _pending then gets preempted before writing
_answers must not allow _block to pop and emit a false prompt.expire.
The _pending_lock makes the two mutually exclusive. Run many short-
timeout blocks with a concurrent responder racing the deadline and
assert the full invariant per round: whenever the response succeeded
(status ok) the answer was delivered AND no prompt.expire fired for
that request; whenever it failed (4009) the answer was empty (an
expiry there is legitimate). Each round uses a unique sid+rid so
expiries can be mapped back to the round that produced them.
"""
import concurrent.futures
# Records every prompt.expire as (request_id, sid) so we can map an
# expiry back to the exact round/request it fired for.
expiries = []
def tracking_emit(event, sid, payload=None):
if event == "prompt.expire" and payload:
expiries.append((payload.get("request_id"), sid))
monkeypatch.setattr(server, "_emit", tracking_emit)
results = {"respond_ok": 0, "respond_late": 0}
def one_round(i):
sid = f"sid_race_{i}"
captured: dict[str, str | None] = {"rid": None}
# responder thread: wait until the prompt is pending, then respond
# right around the deadline to maximize interleaving.
def responder():
for _ in range(200):
with server._pending_lock:
rid = next((k for k, (s, _e) in server._pending.items() if s == sid), None)
if rid:
captured["rid"] = rid
return server.handle_request(
{"id": "x", "method": "clarify.respond",
"params": {"request_id": rid, "answer": f"ans{i}"}}
)
time.sleep(0.0005)
return None
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as ex:
fut = ex.submit(responder)
ans = server._block("clarify.request", sid, {"q": "?"}, timeout=0.02)
resp = fut.result(timeout=5)
rid = captured["rid"]
# Expiries that fired for THIS round's request (or session).
round_expiries = [e for e in expiries if e[1] == sid or (rid is not None and e[0] == rid)]
respond_succeeded = bool(resp and resp.get("result"))
if respond_succeeded:
results["respond_ok"] += 1
# CRITICAL invariant: a successful respond must deliver the answer
# AND must NOT have produced a prompt.expire for this request. This
# is the race the lock closes — a regression that fires expiry while
# still returning the answer fails HERE (the old test missed it).
assert ans == f"ans{i}", f"round {i}: respond ok but answer lost ({ans!r})"
assert not round_expiries, (
f"round {i}: respond succeeded but a false prompt.expire fired "
f"for it: {round_expiries}"
)
else:
results["respond_late"] += 1
# respond came after the pop → 4009; answer must be empty and an
# expiry for this round is legitimate (the prompt really timed out).
assert ans == "", f"round {i}: respond failed but got answer {ans!r}"
for i in range(40):
one_round(i)
assert results["respond_ok"] > 0, "test never exercised the respond-wins path"

View File

@@ -120,6 +120,11 @@ _methods: dict[str, callable] = {}
_pending: dict[str, tuple[str, threading.Event]] = {}
_pending_prompt_payloads: dict[str, tuple[str, dict]] = {}
_answers: dict[str, str] = {}
# Serializes the prompt-lifecycle critical sections so a response landing in
# the timeout window can't race the expiry decision. Guards _pending /
# _answers transitions in _respond(), _block()'s finally, and _clear_pending().
# See _block() for the race it closes (Copilot review on PR #35987).
_pending_lock = threading.Lock()
_db = None
_db_error: str | None = None
_stdout_lock = threading.Lock()
@@ -731,13 +736,44 @@ def _block(event: str, sid: str, payload: dict, timeout: int = 300) -> str:
_pending[rid] = (sid, ev)
payload["request_id"] = rid
_pending_prompt_payloads[rid] = (event, dict(payload))
answered = False
try:
_emit(event, sid, payload)
ev.wait(timeout=timeout)
answered = ev.wait(timeout=timeout)
finally:
_pending.pop(rid, None)
_pending_prompt_payloads.pop(rid, None)
return _answers.pop(rid, "")
# Decide expiry atomically with respond/clear. Without the lock there
# is a lost-wakeup race: _respond() can read _pending, get preempted
# before writing _answers, then _block pops _pending and sees
# `rid not in _answers` → emits a false prompt.expire even though the
# response is about to be accepted. Holding _pending_lock across the
# pop + answer check makes the two paths mutually exclusive: either
# _respond writes the answer first (we see it, suppress expiry) or we
# pop first (_respond then finds no _pending entry → 4009, never
# writes a stale answer). _emit is kept OUTSIDE the lock (it does
# transport I/O and must not block the lock).
with _pending_lock:
_pending.pop(rid, None)
_pending_prompt_payloads.pop(rid, None)
should_expire = not answered and rid not in _answers
# If the wait timed out without anyone calling _respond the client
# overlay (clarify/sudo/secret prompt) is still mounted and capturing
# keystrokes — the agent thread is about to resume on an empty answer,
# so we MUST tell the client to tear the prompt down or the UI is
# stuck until the next message.
if should_expire:
try:
_emit(
"prompt.expire",
sid,
{"request_id": rid, "kind": event.split(".", 1)[0]},
)
except Exception:
# _emit can theoretically throw if the transport vanished
# mid-shutdown; expiry is best-effort.
pass
with _pending_lock:
return _answers.pop(rid, "")
def _clear_pending(sid: str | None = None) -> None:
@@ -749,10 +785,21 @@ def _clear_pending(sid: str | None = None) -> None:
sessions sharing the same tui_gateway process. When *sid* is
None, every pending prompt is released (used during shutdown).
"""
for rid, (owner_sid, ev) in list(_pending.items()):
if sid is None or owner_sid == sid:
_answers[rid] = ""
ev.set()
# Same lock as _respond / _block so releasing a prompt can't race the
# timeout-expiry decision. Pop _pending (and its payload) while holding the
# lock, not just _answers/ev — otherwise a user response that lands before
# the blocked thread runs its finally can acquire the lock, find the entry
# still pending, overwrite the empty answer, and return ok, reviving a
# prompt that interrupt/shutdown meant to cancel. Removing it makes a later
# _respond get 4009 instead. _block's own finally pop is then a harmless
# no-op (pop(rid, None)), and it still returns the empty answer we staged.
with _pending_lock:
for rid, (owner_sid, ev) in list(_pending.items()):
if sid is None or owner_sid == sid:
_answers[rid] = ""
_pending.pop(rid, None)
_pending_prompt_payloads.pop(rid, None)
ev.set()
# ── Agent factory ────────────────────────────────────────────────────
@@ -4066,12 +4113,17 @@ def _(rid, params: dict) -> dict:
def _respond(rid, params, key):
r = params.get("request_id", "")
entry = _pending.get(r)
if not entry:
return _err(rid, 4009, f"no pending {key} request")
_, ev = entry
_answers[r] = params.get(key, "")
ev.set()
# Atomic with _block()'s timeout pop: take the lock so we can't write an
# answer for a request that _block has already popped + expired (which
# would leave a stale _answers entry), and so _block can't pop between our
# membership check and our write.
with _pending_lock:
entry = _pending.get(r)
if not entry:
return _err(rid, 4009, f"no pending {key} request")
_, ev = entry
_answers[r] = params.get(key, "")
ev.set()
return _ok(rid, {"status": "ok"})

View File

@@ -1113,4 +1113,138 @@ describe('createGatewayEventHandler', () => {
vi.useRealTimers()
}
})
// ─── prompt.expire ────────────────────────────────────────────────
// The TUI overlay (clarify/sudo/secret) lives in overlayStore until
// someone clears it. If the user never answers, server-side _block
// times out after ~5 min and emits prompt.expire so the client tears
// the box down — otherwise the (1-N) choice box stays anchored over
// the composer, capturing every keystroke, while the assistant turn
// streams text below it. (User-reported bug: "shit just overflows
// and i cant escape out cause the choice box doesnt go away".)
it('clears a stale clarify overlay when server emits prompt.expire', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
onEvent({
payload: { choices: ['yes', 'no'], question: 'continue?', request_id: 'rid-clarify-1' },
type: 'clarify.request'
} as any)
expect(getOverlayState().clarify).toMatchObject({ requestId: 'rid-clarify-1' })
onEvent({ payload: { kind: 'clarify', request_id: 'rid-clarify-1' }, type: 'prompt.expire' } as any)
expect(getOverlayState().clarify).toBeNull()
})
it('clears a stale sudo overlay when server emits prompt.expire', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
onEvent({ payload: { request_id: 'rid-sudo-1' }, type: 'sudo.request' } as any)
expect(getOverlayState().sudo).toMatchObject({ requestId: 'rid-sudo-1' })
onEvent({ payload: { kind: 'sudo', request_id: 'rid-sudo-1' }, type: 'prompt.expire' } as any)
expect(getOverlayState().sudo).toBeNull()
})
it('ignores prompt.expire whose request_id does not match the current overlay', () => {
// A late expiry for a stale request must NOT clobber a fresh prompt
// that opened in the meantime (rare race, but the only safe rule).
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
onEvent({
payload: { choices: ['yes', 'no'], question: 'second q?', request_id: 'rid-clarify-2' },
type: 'clarify.request'
} as any)
onEvent({ payload: { kind: 'clarify', request_id: 'rid-clarify-stale' }, type: 'prompt.expire' } as any)
expect(getOverlayState().clarify).toMatchObject({ requestId: 'rid-clarify-2' })
})
it('clears a stale secret overlay when server emits prompt.expire', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
onEvent({
payload: { env_var: 'OPENAI_API_KEY', prompt: 'enter key', request_id: 'rid-secret-1' },
type: 'secret.request'
} as any)
expect(getOverlayState().secret).toMatchObject({ requestId: 'rid-secret-1' })
onEvent({ payload: { kind: 'secret', request_id: 'rid-secret-1' }, type: 'prompt.expire' } as any)
expect(getOverlayState().secret).toBeNull()
})
it('resets the prompt-specific status when an expiry clears a mounted overlay', () => {
// The request set status to "waiting for input…"; after expiry nothing
// else resets it, so the bar would lie while the agent streams. Expiry
// must snap status back to busy/ready.
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
onEvent({
payload: { choices: ['a', 'b'], question: 'q?', request_id: 'rid-status-1' },
type: 'clarify.request'
} as any)
expect(getUiState().status).toBe('waiting for input…')
onEvent({ payload: { kind: 'clarify', request_id: 'rid-status-1' }, type: 'prompt.expire' } as any)
// busy defaults false in a fresh ui state → 'ready'
expect(getUiState().status).toBe('ready')
})
it('does not emit a system line for a no-op (stale) prompt.expire', () => {
const appended: Msg[] = []
const ctx = buildCtx(appended)
const onEvent = createGatewayEventHandler(ctx)
onEvent({
payload: { choices: ['a'], question: 'q?', request_id: 'rid-live' },
type: 'clarify.request'
} as any)
// Expiry for a DIFFERENT request id — nothing should be cleared and no
// "timed out" line should be surfaced.
onEvent({ payload: { kind: 'clarify', request_id: 'rid-gone' }, type: 'prompt.expire' } as any)
expect(ctx.system.sys).not.toHaveBeenCalledWith(expect.stringContaining('timed out'))
expect(getOverlayState().clarify).toMatchObject({ requestId: 'rid-live' })
})
it('surfaces the timeout system line when a matching expiry clears the overlay', () => {
// Positive counterpart to the no-op test above: a prompt.expire that
// actually clears a mounted overlay MUST surface the user-visible
// "prompt timed out — <kind> request cancelled" line, naming the kind.
// Without this, a regression that dropped the sys(...) call would still
// pass the negative (stale-expire) test.
const cases: Array<{ kind: string; request: any; rid: string }> = [
{
kind: 'clarify',
rid: 'rid-line-clarify',
request: { payload: { choices: ['a'], question: 'q?', request_id: 'rid-line-clarify' }, type: 'clarify.request' }
},
{ kind: 'sudo', rid: 'rid-line-sudo', request: { payload: { request_id: 'rid-line-sudo' }, type: 'sudo.request' } },
{
kind: 'secret',
rid: 'rid-line-secret',
request: {
payload: { env_var: 'OPENAI_API_KEY', prompt: 'enter key', request_id: 'rid-line-secret' },
type: 'secret.request'
}
}
]
for (const { kind, rid, request } of cases) {
const appended: Msg[] = []
const ctx = buildCtx(appended)
const onEvent = createGatewayEventHandler(ctx)
onEvent(request as any)
onEvent({ payload: { kind, request_id: rid }, type: 'prompt.expire' } as any)
expect(ctx.system.sys).toHaveBeenCalledWith(`prompt timed out — ${kind} request cancelled`)
}
})
})

View File

@@ -0,0 +1,43 @@
import { afterEach, describe, expect, it } from 'vitest'
import { patchUiState, resetUiState, statusFromBusy } from '../app/uiStore.js'
describe('statusFromBusy', () => {
afterEach(() => {
resetUiState()
})
it("returns 'running…' while the agent is mid-turn", () => {
patchUiState({ busy: true })
expect(statusFromBusy()).toBe('running…')
})
it("returns 'ready' when the agent is idle", () => {
patchUiState({ busy: false })
expect(statusFromBusy()).toBe('ready')
})
it('reflects the live busy flag at call time, not at import time', () => {
patchUiState({ busy: false })
expect(statusFromBusy()).toBe('ready')
patchUiState({ busy: true })
expect(statusFromBusy()).toBe('running…')
patchUiState({ busy: false })
expect(statusFromBusy()).toBe('ready')
})
it('never leaves the bar on a transient prompt status after a dead-overlay dismissal', () => {
// Simulate the null-RPC fallback path: a clarify/sudo/secret request set a
// prompt-specific status, the prompt died, and the overlay was dismissed.
// The fallback resets via statusFromBusy(); assert it can never resolve to
// one of the transient prompt strings.
const transient = ['waiting for input…', 'sudo password needed', 'secret input needed']
for (const busy of [true, false]) {
patchUiState({ busy, status: 'sudo password needed' })
const next = statusFromBusy()
expect(transient).not.toContain(next)
expect(next).toBe(busy ? 'running…' : 'ready')
}
})
})

View File

@@ -19,12 +19,10 @@ import { applyDelegationStatus, getDelegationState } from './delegationStore.js'
import type { GatewayEventHandlerContext } from './interfaces.js'
import { getOverlayState, patchOverlayState } from './overlayStore.js'
import { turnController } from './turnController.js'
import { getUiState, patchUiState } from './uiStore.js'
import { getUiState, patchUiState, statusFromBusy } from './uiStore.js'
const NO_PROVIDER_RE = /\bNo (?:LLM|inference) provider configured\b/i
const statusFromBusy = () => (getUiState().busy ? 'running…' : 'ready')
const applySkin = (s: GatewaySkin) =>
patchUiState({
theme: fromSkin(
@@ -682,6 +680,40 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
setStatus('secret input needed')
return
case 'prompt.expire': {
// Server-side _block timed out waiting for an answer. The Python
// agent thread has already resumed on an empty string; if we don't
// tear the overlay down here it stays mounted, swallows keystrokes,
// and looks like garbage as the next assistant turn streams in.
// Match by request_id so a late expiry can't clobber a different
// prompt that opened in the meantime.
const { kind, request_id } = ev.payload
const ov = getOverlayState()
let cleared = false
if (kind === 'clarify' && ov.clarify?.requestId === request_id) {
patchOverlayState({ clarify: null })
cleared = true
} else if (kind === 'sudo' && ov.sudo?.requestId === request_id) {
patchOverlayState({ sudo: null })
cleared = true
} else if (kind === 'secret' && ov.secret?.requestId === request_id) {
patchOverlayState({ secret: null })
cleared = true
}
if (cleared) {
// The request event set a prompt-specific status ("waiting for
// input…" / "sudo password needed" / "secret input needed").
// Nothing else resets it once the prompt is gone, so the bar would
// keep claiming we're waiting while the agent streams. Snap it back
// to the real busy/ready state.
setStatus(statusFromBusy())
sys(`prompt timed out — ${kind} request cancelled`)
}
return
}
case 'background.complete':
dropBgTask(ev.payload.task_id)

View File

@@ -38,6 +38,16 @@ export const $uiSessionId = computed($uiState, state => state.sid)
export const getUiState = () => $uiState.get()
/**
* The status to fall back to once a transient/prompt-specific status
* ('waiting for input…', 'sudo password needed', 'secret input needed') no
* longer applies — e.g. when a dead clarify/sudo/secret overlay is dismissed
* via the null-RPC fallback, or when a prompt.expire clears the overlay. If
* the agent is mid-turn it's 'running…'; otherwise 'ready'. Single-sourced so
* the gateway-event handler and the useMainApp fallbacks can't drift.
*/
export const statusFromBusy = (): string => (getUiState().busy ? 'running…' : 'ready')
export const patchUiState = (next: Partial<UiState> | ((state: UiState) => UiState)) =>
$uiState.set(typeof next === 'function' ? next($uiState.get()) : { ...$uiState.get(), ...next })

View File

@@ -33,11 +33,11 @@ import { createSlashHandler } from './createSlashHandler.js'
import { planGatewayRecovery } from './gatewayRecovery.js'
import { getInputSelection } from './inputSelectionStore.js'
import { type GatewayRpc, type TranscriptRow } from './interfaces.js'
import { $overlayState, patchOverlayState } from './overlayStore.js'
import { $overlayState, getOverlayState, patchOverlayState } from './overlayStore.js'
import { scrollWithSelectionBy } from './scroll.js'
import { turnController } from './turnController.js'
import { patchTurnState, useTurnSelector } from './turnStore.js'
import { $uiState, getUiState, patchUiState } from './uiStore.js'
import { $uiState, getUiState, patchUiState, statusFromBusy } from './uiStore.js'
import { useComposerState } from './useComposerState.js'
import { useConfigSync } from './useConfigSync.js'
import { useInputHandlers } from './useInputHandlers.js'
@@ -582,6 +582,21 @@ export function useMainApp(gw: GatewayClient) {
rpc<ClarifyRespondResponse>('clarify.respond', { answer, request_id: clarify.requestId }).then(r => {
if (!r) {
// RPC failed — most often "no pending clarify request" because the
// server-side _block already timed out. The prompt.expire event
// SHOULD have torn the overlay down already, but in case it didn't
// (transport hiccup, stale entry, etc.) we MUST clear it here or
// the user is stuck looking at a dead prompt that captures every
// keystroke. Better to lose the optional turn-trail line than
// leave a zombie overlay anchored over the composer.
if (getOverlayState().clarify?.requestId === clarify.requestId) {
patchOverlayState({ clarify: null })
// The clarify.request set status to 'waiting for input…'; with the
// overlay now dismissed nothing else resets it, so the bar would
// keep claiming we're waiting. Snap back to the real busy/ready.
patchUiState({ status: statusFromBusy() })
}
return
}
@@ -594,12 +609,23 @@ export function useMainApp(gw: GatewayClient) {
tools: [buildToolTrailLine('clarify', clarify.question)]
})
appendMessage({ role: 'user', text: answer })
patchUiState({ status: 'running…' })
} else {
sys('prompt cancelled')
}
patchOverlayState({ clarify: null })
// Guard BOTH the overlay clear AND the status update by request id:
// _respond() sets the server event (unblocking the agent) BEFORE this
// RPC response is delivered, so the agent can emit a fresh
// clarify.request before this callback runs. Clearing the overlay would
// wipe that new prompt, and setting status to 'running…' would clobber
// its prompt-specific status. Only touch UI for the request we own.
// (Same guard the failure path above uses.)
if (getOverlayState().clarify?.requestId === clarify.requestId) {
patchOverlayState({ clarify: null })
if (answer) {
patchUiState({ status: 'running…' })
}
}
})
},
[appendMessage, overlay.clarify, rpc, sys]
@@ -838,7 +864,8 @@ export function useMainApp(gw: GatewayClient) {
slashRef.current = slash
const respondWith = useCallback(
(method: string, params: Record<string, unknown>, done: () => void) => rpc(method, params).then(r => r && done()),
(method: string, params: Record<string, unknown>, done: () => void, onFail?: () => void) =>
rpc(method, params).then(r => (r ? done() : onFail?.())),
[rpc]
)
@@ -858,10 +885,33 @@ export function useMainApp(gw: GatewayClient) {
return
}
return respondWith('sudo.respond', { password: pw, request_id: overlay.sudo.requestId }, () => {
patchOverlayState({ sudo: null })
patchUiState({ status: 'running…' })
})
const requestId = overlay.sudo.requestId
return respondWith(
'sudo.respond',
{ password: pw, request_id: requestId },
() => {
// Guard by request id: the server event fires before this RPC
// resolves, so the agent may have opened a fresh sudo prompt before
// this callback runs — only clear the one we submitted.
if (getOverlayState().sudo?.requestId === requestId) {
patchOverlayState({ sudo: null })
patchUiState({ status: 'running…' })
}
},
// RPC failed — usually "no pending sudo request" because the
// server-side _block already timed out. prompt.expire SHOULD have
// cleared the overlay; clear it here too in case that event was
// missed, or the user is stuck on a dead password prompt.
() => {
if (getOverlayState().sudo?.requestId === requestId) {
patchOverlayState({ sudo: null })
// Reset the 'sudo password needed' status the request set, same
// reasoning as the clarify fallback above.
patchUiState({ status: statusFromBusy() })
}
}
)
},
[overlay.sudo, respondWith]
)
@@ -872,10 +922,30 @@ export function useMainApp(gw: GatewayClient) {
return
}
return respondWith('secret.respond', { request_id: overlay.secret.requestId, value }, () => {
patchOverlayState({ secret: null })
patchUiState({ status: 'running…' })
})
const requestId = overlay.secret.requestId
return respondWith(
'secret.respond',
{ request_id: requestId, value },
() => {
// Guard by request id — see answerSudo above (the server event fires
// before this RPC resolves, so a fresh secret prompt may already be
// mounted).
if (getOverlayState().secret?.requestId === requestId) {
patchOverlayState({ secret: null })
patchUiState({ status: 'running…' })
}
},
// Same dead-prompt guard as sudo — see answerSudo above.
() => {
if (getOverlayState().secret?.requestId === requestId) {
patchOverlayState({ secret: null })
// Reset the 'secret input needed' status the request set, same
// reasoning as the clarify/sudo fallbacks above.
patchUiState({ status: statusFromBusy() })
}
}
)
},
[overlay.secret, respondWith]
)

View File

@@ -551,6 +551,11 @@ export type GatewayEvent =
| { payload: { command: string; description: string }; session_id?: string; type: 'approval.request' }
| { payload: { request_id: string }; session_id?: string; type: 'sudo.request' }
| { payload: { env_var: string; prompt: string; request_id: string }; session_id?: string; type: 'secret.request' }
| {
payload: { kind: 'clarify' | 'secret' | 'sudo'; request_id: string }
session_id?: string
type: 'prompt.expire'
}
| { payload: { task_id: string; text: string }; session_id?: string; type: 'background.complete' }
| { payload?: { text?: string }; session_id?: string; type: 'review.summary' }
| { payload: SubagentEventPayload; session_id?: string; type: 'subagent.spawn_requested' }