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:
+84
-8
@@ -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]")
|
||||
|
||||
Reference in New Issue
Block a user