mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-10 04:08:28 +08:00
Compare commits
6 Commits
salvage/40
...
bb/clarify
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c1a4eefd3 | ||
|
|
8b6207db70 | ||
|
|
11479a8f31 | ||
|
|
0351215bf5 | ||
|
|
31341d6b1d | ||
|
|
17cf1a500f |
@@ -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"
|
||||
|
||||
@@ -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"})
|
||||
|
||||
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
43
ui-tui/src/__tests__/statusFromBusy.test.ts
Normal file
43
ui-tui/src/__tests__/statusFromBusy.test.ts
Normal 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')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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 })
|
||||
|
||||
|
||||
@@ -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]
|
||||
)
|
||||
|
||||
@@ -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' }
|
||||
|
||||
Reference in New Issue
Block a user