tui_gateway: blocking prompts wait for the human (drop _block timeouts)

This commit is contained in:
alt-glitch 2026-06-10 20:23:25 +05:30
parent 394f45a3d5
commit 43b096eedb
2 changed files with 90 additions and 2 deletions

View file

@ -3580,6 +3580,90 @@ def test_respond_unpacks_sid_tuple_correctly():
server._answers.pop("rid-x", None)
# ---------------------------------------------------------------------------
# Blocking prompts wait for the human (v6 north-star #5): _block with
# timeout=None must never expire — interrupt/shutdown (_clear_pending)
# are the only releases.
# ---------------------------------------------------------------------------
def _run_block_in_thread(monkeypatch, sid):
"""Start _block(timeout=None) on a background thread; return
(thread, results, get_rid) where get_rid polls for the pending rid."""
monkeypatch.setattr(server, "_emit", lambda *a, **kw: None)
results: list[str] = []
def runner():
results.append(server._block("clarify.request", sid, {"question": "q"}))
t = threading.Thread(target=runner, daemon=True)
t.start()
def get_rid():
deadline = time.time() + 5
while time.time() < deadline:
with server._prompt_lock:
for rid, (owner, _ev) in server._pending.items():
if owner == sid:
return rid
time.sleep(0.01)
raise AssertionError("pending rid never appeared for sid=%s" % sid)
return t, results, get_rid
def test_block_no_timeout_waits_for_delayed_answer(monkeypatch):
"""_block(timeout=None) must keep blocking until the answer arrives —
no premature empty return."""
t, results, get_rid = _run_block_in_thread(monkeypatch, "sid_block_wait")
rid = get_rid()
# Answer after a short delay; _block must still be waiting.
time.sleep(0.3)
assert t.is_alive(), "_block returned before any answer was provided"
with server._prompt_lock:
server._answers[rid] = "green"
server._pending[rid][1].set()
t.join(timeout=5)
assert not t.is_alive()
assert results == ["green"]
def test_clear_pending_releases_no_timeout_block(monkeypatch):
"""_clear_pending(sid) must release a timeout=None _block with ''."""
t, results, get_rid = _run_block_in_thread(monkeypatch, "sid_block_clear")
get_rid()
server._clear_pending("sid_block_clear")
t.join(timeout=5)
assert not t.is_alive()
assert results == [""]
def test_clear_pending_other_sid_does_not_release_block(monkeypatch):
"""_clear_pending on an unrelated session must NOT release a pending
timeout=None _block (session scoping)."""
t, results, get_rid = _run_block_in_thread(monkeypatch, "sid_block_scoped")
rid = get_rid()
server._clear_pending("sid_some_other_session")
time.sleep(0.2)
assert t.is_alive(), (
"_clear_pending on another sid released a prompt owned by "
"sid_block_scoped — session scoping is broken"
)
assert not results
# Clean up: release properly so the thread joins.
with server._prompt_lock:
server._answers[rid] = "done"
server._pending[rid][1].set()
t.join(timeout=5)
assert not t.is_alive()
assert results == ["done"]
# ---------------------------------------------------------------------------
# /model switch and other agent-mutating commands must reject while the
# session is running. agent.switch_model() mutates self.model, self.provider,

View file

@ -1066,7 +1066,11 @@ def _enable_gateway_prompts() -> None:
# ── Blocking prompt factory ──────────────────────────────────────────
def _block(event: str, sid: str, payload: dict, timeout: int = 300) -> str:
def _block(event: str, sid: str, payload: dict, timeout: int | None = None) -> str:
# Blocking prompts wait for the human; interrupt/shutdown (via
# _clear_pending) are the only releases — v6 north-star #5. A
# timeout would orphan the TUI prompt and silently feed the agent
# an empty answer, so callers default to waiting forever.
rid = uuid.uuid4().hex[:8]
ev = threading.Event()
with _prompt_lock:
@ -2328,7 +2332,7 @@ def _wire_callbacks(sid: str):
from tools.terminal_tool import set_sudo_password_callback
from tools.skills_tool import set_secret_capture_callback
set_sudo_password_callback(lambda: _block("sudo.request", sid, {}, timeout=120))
set_sudo_password_callback(lambda: _block("sudo.request", sid, {}))
def secret_cb(env_var, prompt, metadata=None):
pl = {"prompt": prompt, "env_var": env_var}