fix(tui): cancel silent streams promptly

Move chat streaming onto a cancellable async task so Ctrl+C interrupts a pending socket read on macOS instead of waiting for the 120-second read timeout. Add a headless silent-stream regression that verifies prompt recovery and a successful next message.
This commit is contained in:
Athena Kaminsky
2026-08-26 08:17:31 -05:00
committed by Athena
parent da3509eb04
commit 9ca37057eb
2 changed files with 193 additions and 93 deletions
+84 -8
View File
@@ -154,15 +154,15 @@ def test_inflight_tool_denial_uses_original_conversation_id(monkeypatch):
class _StreamResponse:
status_code = 200
def __enter__(self):
async def __aenter__(self):
return self
def __exit__(self, *args):
async def __aexit__(self, *args):
return None
def iter_lines(self):
async def aiter_lines(self):
stream_started.set()
release_stream.wait(timeout=2)
await asyncio.to_thread(release_stream.wait, 2)
yield "event: tool_request"
yield 'data: {"token":"secret","actions":[{"name":"run_snippet"}]}'
yield ""
@@ -173,17 +173,20 @@ def test_inflight_tool_denial_uses_original_conversation_id(monkeypatch):
def __init__(self, **kwargs):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *args):
return None
def stream(self, *args, **kwargs):
return _StreamResponse()
def close(self):
return None
def _capture_denial(*, conversation_id, **kwargs):
denied_for.append(conversation_id)
return ["run_snippet"]
monkeypatch.setattr(tui_app.httpx, "Client", _StreamClient)
monkeypatch.setattr(tui_app.httpx, "AsyncClient", _StreamClient)
monkeypatch.setattr(tui_app, "_deny_tool_request", _capture_denial)
app = tui_app.NexusTUI.build_app(api_url="http://127.0.0.1:9")
@@ -205,5 +208,78 @@ def test_inflight_tool_denial_uses_original_conversation_id(monkeypatch):
asyncio.run(_run())
def test_interrupt_cancels_silent_stream_and_accepts_next_message(monkeypatch):
pytest.importorskip("textual")
import nexusos_cli.tui_app as tui_app
first_stream_started = threading.Event()
class _StreamResponse:
status_code = 200
def __init__(self, call_number):
self.call_number = call_number
async def __aenter__(self):
return self
async def __aexit__(self, *args):
return None
async def aiter_lines(self):
if self.call_number == 1:
first_stream_started.set()
await asyncio.Event().wait()
yield "data: \"READY\""
yield ""
yield "event: done"
yield "data: {}"
class _StreamClient:
calls = 0
def __init__(self, **kwargs):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *args):
return None
def stream(self, *args, **kwargs):
type(self).calls += 1
return _StreamResponse(type(self).calls)
monkeypatch.setattr(tui_app.httpx, "AsyncClient", _StreamClient)
app = tui_app.NexusTUI.build_app(api_url="http://127.0.0.1:9")
async def _wait_until_idle():
for _ in range(100):
if not app._busy:
return
await asyncio.sleep(0.01)
pytest.fail("stream did not become idle within one second")
async def _run():
async with app.run_test():
app._start_chat("first")
assert await asyncio.to_thread(first_stream_started.wait, 2)
app.action_interrupt()
await _wait_until_idle()
log = app.query_one("#log")
assert not any("ReadTimeout" in line.text for line in log.lines)
app._start_chat("second")
await _wait_until_idle()
assert app.history[-1] == {
"role": "assistant",
"content": "READY",
}
asyncio.run(_run())
def test_escape_round_trip_helper():
assert "[" in _escape("x[y]") or "\\[" in _escape("x[y]")