feat(tui): implement issue #4 contract via TDD; amend cli for TUI dispatch
47 contract-listed tests authored + GREEN (43 tui + 4 issue-#3 amendments). 164/164 tests GREEN suite-wide; ruff clean. Vertical-slice ordering: _render_event_to_log → _cancel_via_sse → CLI amendments → RatatoskrApp class + on_mount + on_unmount → on_input_submitted → _stream_turn_worker → action_interrupt + action_quit → run_tui. Two in-flight contract amendments caught during TDD: - PRE-002 of run_tui was `(args.session_id is None) != args.new` — backwards (fails when --session is set + new=False). Corrected to `bool(args.session_id) != bool(args.new)`. - RichLog created with markup=False (contract drafted markup=True). Rich interprets `[xxx]` as style markup and strips it, which would break every labeled stderr-style line ([cancel_failed], [done], [error], etc.). The post-Done Markdown rendering still works because rich.markdown.Markdown is a Renderable and doesn't need widget-level markup. Implementation notes: - _stream_turn_worker takes the log widget as a parameter passed from on_input_submitted. Querying #transcript from inside a Textual worker context fails with NoMatches; capturing the reference once at handler-time and threading it through the worker sidesteps the issue. - _spy_writes(monkeypatch) test helper records every RichLog.write call. RichLog's `.lines` Strip buffer isn't populated synchronously after .write() returns, which makes post-app-shutdown inspection unreliable; a write-spy gives deterministic verification. - SIGINT-mid-stream tests use custom httpx.AsyncByteStream subclasses with asyncio.Event gates to make timing deterministic without sleep-based polling — the cancel-respx-mock sets the gate event when its endpoint is observed, releasing the next SSE chunk. - _submit_and_wait test helper needs `await pilot.pause()` BEFORE the polling loop so the Input.Submitted message has a chance to dispatch. Discovered via debug-print trace; tracked in the test helper. CLI amendments (per issue #4 in-place amendment of #3 contract): - ParsedArgs.send_content: str | None (was str) - ParsedArgs.raw: bool added - _parse_args: --send default=None; empty-string still rejected; --raw added - main: branches on args.send_content — None → lazy `from ratatoskr.tui import run_tui` + run_tui(args); else asyncio.run(_amain(args)). Lazy import preserves issue #3 INV-001. Persistent-memory updated per the commit-along rule: tui module landed, recent-decisions entries for #4 (contract + Volva + TDD), next natural moves rotated to Volva code-review + manual smoke against the personal Worldtree (key landed in env.sh per infra-ops's earlier delivery).
This commit is contained in:
@@ -0,0 +1,988 @@
|
||||
"""Tests for ratatoskr.tui per docs/contracts/issues/4.contract.md."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
from textual.widgets import RichLog
|
||||
|
||||
from ratatoskr.cli import ParsedArgs
|
||||
from ratatoskr.sse_client import (
|
||||
Cancelled,
|
||||
Done,
|
||||
Error,
|
||||
SseId,
|
||||
Text,
|
||||
TextBoundary,
|
||||
Thinking,
|
||||
ToolResult,
|
||||
ToolStart,
|
||||
WorkerPhase,
|
||||
)
|
||||
from ratatoskr.tui import RatatoskrApp, _cancel_via_sse, _render_event_to_log
|
||||
|
||||
_CANCEL_OK_RESP = {"turn_id": 42, "cancelled": True, "reason": None, "partial_message_id": None}
|
||||
_CREATE_OK_RESP = {
|
||||
"session_id": "s-new12345",
|
||||
"agent_id": "mimir",
|
||||
"message_count": 0,
|
||||
"created_at": "2026-05-21T00:00:00+00:00",
|
||||
"last_active": "2026-05-21T00:00:00+00:00",
|
||||
"metadata": {},
|
||||
}
|
||||
|
||||
|
||||
def _args_new(**overrides) -> ParsedArgs:
|
||||
base = dict(
|
||||
send_content=None,
|
||||
session_id=None,
|
||||
new=True,
|
||||
agent_id="mimir",
|
||||
api_key="k",
|
||||
server_url="https://w.example",
|
||||
raw=False,
|
||||
)
|
||||
base.update(overrides)
|
||||
return ParsedArgs(**base)
|
||||
|
||||
|
||||
def _args_existing(session_id: str = "s-1existing", **overrides) -> ParsedArgs:
|
||||
base = dict(
|
||||
send_content=None,
|
||||
session_id=session_id,
|
||||
new=False,
|
||||
agent_id=None,
|
||||
api_key="k",
|
||||
server_url="https://w.example",
|
||||
raw=False,
|
||||
)
|
||||
base.update(overrides)
|
||||
return ParsedArgs(**base)
|
||||
|
||||
|
||||
def _spy_writes(monkeypatch) -> list:
|
||||
"""Patch RichLog.write to record every arg into a list (returned)."""
|
||||
writes: list = []
|
||||
original = RichLog.write
|
||||
|
||||
def spy(self, content, **kw):
|
||||
writes.append(content)
|
||||
return original(self, content, **kw)
|
||||
|
||||
monkeypatch.setattr(RichLog, "write", spy)
|
||||
return writes
|
||||
|
||||
|
||||
SID = SseId(42, 5)
|
||||
|
||||
|
||||
class TestRenderEventToLog:
|
||||
def test_text_renders_raw_delta(self) -> None:
|
||||
"""text_renders_raw_delta [happy,tracer]: Text → log.write('hello')."""
|
||||
log = MagicMock()
|
||||
_render_event_to_log(Text(sse_id=SID, content="hello"), log=log, raw=False)
|
||||
log.write.assert_called_once_with("hello")
|
||||
|
||||
def test_done_renders_label_only(self) -> None:
|
||||
"""done_renders_label_only: …"""
|
||||
log = MagicMock()
|
||||
evt = Done(
|
||||
sse_id=SID,
|
||||
phase="completed",
|
||||
response="hi there",
|
||||
model="glm5-turbo",
|
||||
duration_ms=1234,
|
||||
usage={"prompt": 1, "completion": 2},
|
||||
)
|
||||
_render_event_to_log(evt, log=log, raw=False)
|
||||
log.write.assert_called_once()
|
||||
line = log.write.call_args[0][0]
|
||||
assert line.startswith("[done]")
|
||||
assert "turn_id=42" in line
|
||||
assert "model=glm5-turbo" in line
|
||||
# POST-003: the Done line is labels only; markdown render is the caller's job
|
||||
assert "hi there" not in line
|
||||
|
||||
def test_error_renders_label(self) -> None:
|
||||
"""error_renders_label: Error → log line starts with [error]."""
|
||||
log = MagicMock()
|
||||
evt = Error(sse_id=SID, phase="failed", message="boom", error_code="llm_output_invalid")
|
||||
_render_event_to_log(evt, log=log, raw=False)
|
||||
line = log.write.call_args[0][0]
|
||||
assert line.startswith("[error]")
|
||||
assert "turn_id=42" in line
|
||||
assert "code=llm_output_invalid" in line
|
||||
|
||||
def test_cancelled_renders_label(self) -> None:
|
||||
"""cancelled_renders_label: Cancelled → log line starts with [cancelled]."""
|
||||
log = MagicMock()
|
||||
evt = Cancelled(
|
||||
sse_id=SID, phase="cancelled", turn_id=42, reason="user", partial_message_id=7
|
||||
)
|
||||
_render_event_to_log(evt, log=log, raw=False)
|
||||
line = log.write.call_args[0][0]
|
||||
assert line.startswith("[cancelled]")
|
||||
assert "reason='user'" in line
|
||||
assert "partial_message_id=7" in line
|
||||
|
||||
def test_worker_phase_renders_label(self) -> None:
|
||||
"""worker_phase_renders_label: WorkerPhase → log line starts with [worker_phase]."""
|
||||
log = MagicMock()
|
||||
evt = WorkerPhase(sse_id=SID, phase="streaming", turn_id=42)
|
||||
_render_event_to_log(evt, log=log, raw=False)
|
||||
line = log.write.call_args[0][0]
|
||||
assert line.startswith("[worker_phase]")
|
||||
assert "phase=streaming" in line
|
||||
|
||||
def test_thinking_truncated(self) -> None:
|
||||
"""thinking_truncated [trace]: …"""
|
||||
log = MagicMock()
|
||||
_render_event_to_log(Thinking(sse_id=SID, content="a" * 500), log=log, raw=False)
|
||||
line = log.write.call_args[0][0]
|
||||
assert line.startswith("[thinking]")
|
||||
assert "a" * 500 not in line
|
||||
assert "a" * 200 in line
|
||||
|
||||
def test_tool_start_renders_label(self) -> None:
|
||||
"""tool_start_renders_label: ToolStart → [tool_start] name=... args=..."""
|
||||
log = MagicMock()
|
||||
evt = ToolStart(sse_id=SID, name="read_file", arguments={"path": "/x"})
|
||||
_render_event_to_log(evt, log=log, raw=False)
|
||||
line = log.write.call_args[0][0]
|
||||
assert line.startswith("[tool_start] name=read_file args=")
|
||||
|
||||
def test_tool_result_truncated(self) -> None:
|
||||
"""tool_result_truncated [trace]: …"""
|
||||
log = MagicMock()
|
||||
evt = ToolResult(sse_id=SID, name="x", result="b" * 500, duration_ms=42)
|
||||
_render_event_to_log(evt, log=log, raw=False)
|
||||
line = log.write.call_args[0][0]
|
||||
assert line.startswith("[tool_result]")
|
||||
# The whole repr-portion of the result is truncated to 200; the full 500-b
|
||||
# string can never fit in line whole.
|
||||
assert "b" * 500 not in line
|
||||
|
||||
def test_text_boundary_renders_label(self) -> None:
|
||||
"""text_boundary_renders_label: TextBoundary → [text_boundary] kind=... char_offset=..."""
|
||||
log = MagicMock()
|
||||
evt = TextBoundary(sse_id=SID, kind="sentence", char_offset=128, ts="2026-05-21T00:00:00Z")
|
||||
_render_event_to_log(evt, log=log, raw=False)
|
||||
line = log.write.call_args[0][0]
|
||||
assert line.startswith("[text_boundary]")
|
||||
assert "kind=sentence" in line
|
||||
assert "char_offset=128" in line
|
||||
|
||||
|
||||
class TestCancelViaSse:
|
||||
@respx.mock
|
||||
async def test_happy_cancel(self) -> None:
|
||||
"""happy_cancel [happy,tracer]: 200 OK → returns None; log has no [cancel_failed]."""
|
||||
respx.post("https://w.example/sessions/s-1/turns/42/cancel").mock(
|
||||
return_value=httpx.Response(200, json=_CANCEL_OK_RESP)
|
||||
)
|
||||
log = MagicMock()
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
result = await _cancel_via_sse(client, "s-1", 42, log=log)
|
||||
assert result is None
|
||||
log.write.assert_not_called()
|
||||
|
||||
@respx.mock
|
||||
async def test_cancel_failed_500(self) -> None:
|
||||
"""cancel_failed_500 [error]: …"""
|
||||
respx.post("https://w.example/sessions/s-1/turns/42/cancel").mock(
|
||||
return_value=httpx.Response(500, content=b"boom")
|
||||
)
|
||||
log = MagicMock()
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
await _cancel_via_sse(client, "s-1", 42, log=log)
|
||||
line = log.write.call_args[0][0]
|
||||
assert "[cancel_failed]" in line
|
||||
assert "CancelFailed" in line
|
||||
|
||||
@respx.mock
|
||||
async def test_cancel_already_completed(self) -> None:
|
||||
"""cancel_already_completed [scenario]: 409 → '[cancel_failed] CancelAlreadyCompleted:'."""
|
||||
respx.post("https://w.example/sessions/s-1/turns/42/cancel").mock(
|
||||
return_value=httpx.Response(409)
|
||||
)
|
||||
log = MagicMock()
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
await _cancel_via_sse(client, "s-1", 42, log=log)
|
||||
line = log.write.call_args[0][0]
|
||||
assert "[cancel_failed]" in line
|
||||
assert "CancelAlreadyCompleted" in line
|
||||
|
||||
@respx.mock
|
||||
async def test_transport_error_swallowed(self) -> None:
|
||||
"""transport_error_swallowed [error]: …"""
|
||||
respx.post("https://w.example/sessions/s-1/turns/42/cancel").mock(
|
||||
side_effect=httpx.ConnectError("network down")
|
||||
)
|
||||
log = MagicMock()
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
await _cancel_via_sse(client, "s-1", 42, log=log)
|
||||
line = log.write.call_args[0][0]
|
||||
assert "[cancel_failed]" in line
|
||||
assert "ConnectError" in line
|
||||
|
||||
|
||||
class TestAppMount:
|
||||
@respx.mock
|
||||
async def test_happy_new_session_mount(self) -> None:
|
||||
"""happy_new_session_mount [happy,tracer]: …"""
|
||||
respx.post("https://w.example/sessions").mock(
|
||||
return_value=httpx.Response(201, json=_CREATE_OK_RESP)
|
||||
)
|
||||
app = RatatoskrApp(_args_new())
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
assert app.session_id == "s-new12345"
|
||||
assert app.agent_id == "mimir"
|
||||
assert app.state == "idle"
|
||||
# INV-002: session identity visible — agent_id + last 8 of session_id
|
||||
assert "mimir" in (app.sub_title or "")
|
||||
assert app.session_id[-8:] in (app.sub_title or "")
|
||||
|
||||
@respx.mock
|
||||
async def test_happy_existing_session_mount(self) -> None:
|
||||
"""happy_existing_session_mount: …"""
|
||||
sessions_route = respx.post("https://w.example/sessions").mock(
|
||||
return_value=httpx.Response(201, json=_CREATE_OK_RESP)
|
||||
)
|
||||
app = RatatoskrApp(_args_existing(session_id="s-existing-tail8x"))
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
assert app.session_id == "s-existing-tail8x"
|
||||
assert app.state == "idle"
|
||||
assert sessions_route.call_count == 0
|
||||
# INV-002 carve-out: agent unknown → <unknown> · …<tail>
|
||||
assert "<unknown>" in (app.sub_title or "")
|
||||
assert app.session_id[-8:] in (app.sub_title or "")
|
||||
|
||||
@respx.mock
|
||||
async def test_agent_not_found_on_mount(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""agent_not_found_on_mount [error]: …"""
|
||||
respx.post("https://w.example/sessions").mock(
|
||||
return_value=httpx.Response(404, json={"error": "unknown_agent_id"})
|
||||
)
|
||||
writes = _spy_writes(monkeypatch)
|
||||
app = RatatoskrApp(_args_new())
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
assert app.return_value == 12
|
||||
assert any("[agent_not_found]" in str(w) for w in writes)
|
||||
|
||||
@respx.mock
|
||||
async def test_session_api_failed_on_mount(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""session_api_failed_on_mount [error]: …"""
|
||||
respx.post("https://w.example/sessions").mock(
|
||||
return_value=httpx.Response(500, content=b"server error")
|
||||
)
|
||||
writes = _spy_writes(monkeypatch)
|
||||
app = RatatoskrApp(_args_new())
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
assert app.return_value == 20
|
||||
assert any("[session_api_failed]" in str(w) and "status=500" in str(w) for w in writes)
|
||||
|
||||
@respx.mock
|
||||
async def test_network_error_on_mount(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""network_error_on_mount [error]: …"""
|
||||
respx.post("https://w.example/sessions").mock(side_effect=httpx.ConnectError("down"))
|
||||
writes = _spy_writes(monkeypatch)
|
||||
app = RatatoskrApp(_args_new())
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
assert app.return_value == 21
|
||||
assert any("[network_error]" in str(w) for w in writes)
|
||||
|
||||
@respx.mock
|
||||
async def test_footer_identity_visible_first_frame(self) -> None:
|
||||
"""footer_identity_visible_first_frame [trace]: …"""
|
||||
respx.post("https://w.example/sessions").mock(
|
||||
return_value=httpx.Response(201, json=_CREATE_OK_RESP)
|
||||
)
|
||||
app = RatatoskrApp(_args_new())
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
# Both halves of the identity present
|
||||
assert "mimir" in (app.sub_title or "")
|
||||
assert "·" in (app.sub_title or "")
|
||||
assert app.session_id[-8:] in (app.sub_title or "")
|
||||
|
||||
@respx.mock
|
||||
async def test_client_open_after_mount(self) -> None:
|
||||
"""client_open_after_mount [trace]: post-mount self.client is open."""
|
||||
respx.post("https://w.example/sessions").mock(
|
||||
return_value=httpx.Response(201, json=_CREATE_OK_RESP)
|
||||
)
|
||||
app = RatatoskrApp(_args_new())
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
assert app.client is not None
|
||||
assert app.client.is_closed is False
|
||||
|
||||
|
||||
class TestAppUnmount:
|
||||
@respx.mock
|
||||
async def test_unmount_closes_client(self) -> None:
|
||||
"""unmount_closes_client [happy,tracer]: …"""
|
||||
app = RatatoskrApp(_args_existing())
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
client_ref = app.client
|
||||
assert client_ref is not None and not client_ref.is_closed
|
||||
await pilot.press("ctrl+d")
|
||||
await pilot.pause()
|
||||
assert client_ref.is_closed
|
||||
|
||||
|
||||
import asyncio # noqa: E402
|
||||
|
||||
from textual.widgets import Input # noqa: E402
|
||||
|
||||
|
||||
async def _noop_worker(self, content: str, log) -> None:
|
||||
"""Fake _stream_turn_worker that never completes (lets state stay 'streaming')."""
|
||||
await asyncio.Future() # await forever; cancelled when test exits
|
||||
|
||||
|
||||
class TestOnInputSubmitted:
|
||||
@respx.mock
|
||||
async def test_happy_submit_echoes_and_spawns(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""happy_submit_echoes_and_spawns [happy,tracer]: …"""
|
||||
monkeypatch.setattr(RatatoskrApp, "_stream_turn_worker", _noop_worker)
|
||||
writes = _spy_writes(monkeypatch)
|
||||
app = RatatoskrApp(_args_existing())
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
inp = app.query_one("#prompt", Input)
|
||||
inp.value = "hello"
|
||||
await inp.action_submit()
|
||||
await pilot.pause()
|
||||
assert any("❯ hello" in str(w) for w in writes) # noqa: RUF001
|
||||
assert inp.value == ""
|
||||
assert app.state == "streaming"
|
||||
assert app.stream_worker is not None
|
||||
|
||||
@respx.mock
|
||||
async def test_empty_submit_no_op(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""empty_submit_no_op [trace]: '' + Enter → no change; no worker spawned."""
|
||||
monkeypatch.setattr(RatatoskrApp, "_stream_turn_worker", _noop_worker)
|
||||
app = RatatoskrApp(_args_existing())
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
inp = app.query_one("#prompt", Input)
|
||||
inp.value = ""
|
||||
await inp.action_submit()
|
||||
await pilot.pause()
|
||||
assert app.state == "idle"
|
||||
assert app.stream_worker is None
|
||||
|
||||
@respx.mock
|
||||
async def test_submit_during_streaming_shows_busy_notice(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""submit_during_streaming_shows_busy_notice [adversarial]: …"""
|
||||
monkeypatch.setattr(RatatoskrApp, "_stream_turn_worker", _noop_worker)
|
||||
writes = _spy_writes(monkeypatch)
|
||||
app = RatatoskrApp(_args_existing())
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
inp = app.query_one("#prompt", Input)
|
||||
# First submit: enters streaming
|
||||
inp.value = "first"
|
||||
await inp.action_submit()
|
||||
await pilot.pause()
|
||||
first_worker = app.stream_worker
|
||||
assert app.state == "streaming"
|
||||
# Second submit while streaming → busy notice; no new worker
|
||||
writes.clear()
|
||||
inp.value = "second"
|
||||
await inp.action_submit()
|
||||
await pilot.pause()
|
||||
assert any("[busy] turn in flight; input ignored" in str(w) for w in writes)
|
||||
assert app.stream_worker is first_worker # unchanged
|
||||
assert app.state == "streaming"
|
||||
assert inp.value == ""
|
||||
|
||||
@respx.mock
|
||||
async def test_submit_during_cancelling_shows_busy_notice(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""submit_during_cancelling_shows_busy_notice [adversarial]: …"""
|
||||
monkeypatch.setattr(RatatoskrApp, "_stream_turn_worker", _noop_worker)
|
||||
writes = _spy_writes(monkeypatch)
|
||||
app = RatatoskrApp(_args_existing())
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
app.state = "cancelling" # bypass the natural transition for the test
|
||||
inp = app.query_one("#prompt", Input)
|
||||
inp.value = "x"
|
||||
await inp.action_submit()
|
||||
await pilot.pause()
|
||||
assert any("[busy]" in str(w) for w in writes)
|
||||
assert app.state == "cancelling"
|
||||
|
||||
@respx.mock
|
||||
async def test_footer_hint_flips_to_cancel(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""footer_hint_flips_to_cancel [trace]: after submit, hint shows 'Ctrl-C to cancel'."""
|
||||
monkeypatch.setattr(RatatoskrApp, "_stream_turn_worker", _noop_worker)
|
||||
app = RatatoskrApp(_args_existing())
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
assert app.hint == RatatoskrApp.HINT_IDLE
|
||||
inp = app.query_one("#prompt", Input)
|
||||
inp.value = "hi"
|
||||
await inp.action_submit()
|
||||
await pilot.pause()
|
||||
assert app.hint == RatatoskrApp.HINT_STREAMING
|
||||
|
||||
|
||||
import json # noqa: E402
|
||||
|
||||
|
||||
def _sse_chunk(sse_id: str, body: dict) -> bytes:
|
||||
return f"id: {sse_id}\ndata: {json.dumps(body)}\n\n".encode()
|
||||
|
||||
|
||||
_DONE_BODY = {
|
||||
"type": "done",
|
||||
"phase": "succeeded",
|
||||
"response": "hello",
|
||||
"model": "m",
|
||||
"duration_ms": 1,
|
||||
"usage": {
|
||||
"prompt_tokens": 0,
|
||||
"completion_tokens": 0,
|
||||
"total_tokens": 0,
|
||||
"cached_input_tokens": 0,
|
||||
},
|
||||
}
|
||||
_CANCELLED_STREAM_BODY = {
|
||||
"type": "cancelled",
|
||||
"phase": "cancelled",
|
||||
"turn_id": 42,
|
||||
"reason": "user_cancel",
|
||||
"partial_message_id": None,
|
||||
}
|
||||
|
||||
|
||||
def _sse_resp(body: bytes | httpx.AsyncByteStream) -> httpx.Response:
|
||||
headers = {"content-type": "text/event-stream"}
|
||||
if isinstance(body, bytes):
|
||||
return httpx.Response(200, headers=headers, content=body)
|
||||
return httpx.Response(200, headers=headers, stream=body)
|
||||
|
||||
|
||||
async def _submit_and_wait(app: RatatoskrApp, pilot, content: str) -> None:
|
||||
"""Type content into the input and submit; wait for worker to finish."""
|
||||
inp = app.query_one("#prompt", Input)
|
||||
inp.value = content
|
||||
await inp.action_submit()
|
||||
await pilot.pause() # let the Input.Submitted message dispatch
|
||||
# Poll until the worker resolves (state returns to idle)
|
||||
for _ in range(100):
|
||||
if app.state == "idle" and app.stream_worker is not None:
|
||||
return
|
||||
await pilot.pause(0.02)
|
||||
|
||||
|
||||
class TestStreamTurnWorker:
|
||||
@respx.mock
|
||||
async def test_happy_text_done_renders_markdown(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""happy_text_done_renders_markdown [happy,tracer]: …"""
|
||||
stream = (
|
||||
|
||||
_sse_chunk("42:1", {"type": "text", "content": "hello"})
|
||||
|
||||
+ _sse_chunk("42:2", _DONE_BODY)
|
||||
|
||||
)
|
||||
respx.post(
|
||||
|
||||
"https://w.example/sessions/s-1existing/messages"
|
||||
|
||||
).mock(return_value=_sse_resp(stream))
|
||||
|
||||
writes = _spy_writes(monkeypatch)
|
||||
|
||||
app = RatatoskrApp(_args_existing())
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
await _submit_and_wait(app, pilot, "hi")
|
||||
assert app.state == "idle"
|
||||
# Streamed delta + done label + rule + markdown render
|
||||
assert any(w == "hello" for w in writes)
|
||||
assert any("[done]" in str(w) for w in writes)
|
||||
# The post-Done markdown render uses rich Rule + Markdown — non-string writes
|
||||
from rich.markdown import Markdown
|
||||
assert any(isinstance(w, Markdown) for w in writes)
|
||||
|
||||
@respx.mock
|
||||
async def test_raw_flag_skips_markdown_render(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""raw_flag_skips_markdown_render [trace]: …"""
|
||||
stream = (
|
||||
|
||||
_sse_chunk("42:1", {"type": "text", "content": "hi"})
|
||||
|
||||
+ _sse_chunk("42:2", _DONE_BODY)
|
||||
|
||||
)
|
||||
respx.post(
|
||||
|
||||
"https://w.example/sessions/s-1existing/messages"
|
||||
|
||||
).mock(return_value=_sse_resp(stream))
|
||||
writes = _spy_writes(monkeypatch)
|
||||
app = RatatoskrApp(_args_existing(raw=True))
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
await _submit_and_wait(app, pilot, "x")
|
||||
from rich.markdown import Markdown
|
||||
assert not any(isinstance(w, Markdown) for w in writes)
|
||||
|
||||
@respx.mock
|
||||
async def test_error_terminal_returns_to_idle(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""error_terminal_returns_to_idle [happy]: …"""
|
||||
stream = (
|
||||
|
||||
_sse_chunk("42:1", {"type": "text", "content": "x"})
|
||||
|
||||
+ _sse_chunk(
|
||||
"42:2",
|
||||
{
|
||||
"type": "error",
|
||||
"phase": "failed",
|
||||
"error_code": "llm_output_invalid",
|
||||
"message": "boom",
|
||||
},
|
||||
)
|
||||
|
||||
)
|
||||
respx.post(
|
||||
|
||||
"https://w.example/sessions/s-1existing/messages"
|
||||
|
||||
).mock(return_value=_sse_resp(stream))
|
||||
writes = _spy_writes(monkeypatch)
|
||||
app = RatatoskrApp(_args_existing())
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
await _submit_and_wait(app, pilot, "x")
|
||||
assert app.state == "idle"
|
||||
assert any("[error]" in str(w) for w in writes)
|
||||
|
||||
@respx.mock
|
||||
async def test_cancelled_terminal_returns_to_idle(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""cancelled_terminal_returns_to_idle [happy]: …"""
|
||||
stream = (
|
||||
|
||||
_sse_chunk("42:1", {"type": "text", "content": "x"})
|
||||
|
||||
+ _sse_chunk("42:2", _CANCELLED_STREAM_BODY)
|
||||
|
||||
)
|
||||
respx.post(
|
||||
|
||||
"https://w.example/sessions/s-1existing/messages"
|
||||
|
||||
).mock(return_value=_sse_resp(stream))
|
||||
writes = _spy_writes(monkeypatch)
|
||||
app = RatatoskrApp(_args_existing())
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
await _submit_and_wait(app, pilot, "x")
|
||||
assert app.state == "idle"
|
||||
assert any("[cancelled]" in str(w) for w in writes)
|
||||
|
||||
@respx.mock
|
||||
async def test_active_turn_id_set_on_first_event(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""active_turn_id_set_on_first_event [trace]: …"""
|
||||
# Use a gated stream: yield first event, then hold, so we can inspect mid-stream
|
||||
first = _sse_chunk("42:1", {"type": "text", "content": "x"})
|
||||
gate = asyncio.Event()
|
||||
|
||||
class _GatedAfterFirst(httpx.AsyncByteStream):
|
||||
async def __aiter__(self):
|
||||
yield first
|
||||
await gate.wait()
|
||||
yield _sse_chunk("42:2", _DONE_BODY)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
return None
|
||||
|
||||
respx.post("https://w.example/sessions/s-1existing/messages").mock(
|
||||
return_value=_sse_resp(_GatedAfterFirst())
|
||||
)
|
||||
|
||||
app = RatatoskrApp(_args_existing())
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
inp = app.query_one("#prompt", Input)
|
||||
inp.value = "x"
|
||||
await inp.action_submit()
|
||||
# Wait for first event to be processed (active_turn_id set)
|
||||
for _ in range(50):
|
||||
if app.active_turn_id is not None:
|
||||
break
|
||||
await pilot.pause(0.02)
|
||||
assert app.active_turn_id == 42
|
||||
# Release the gate so the worker can finish and the app can shut down cleanly
|
||||
gate.set()
|
||||
for _ in range(50):
|
||||
if app.state == "idle":
|
||||
break
|
||||
await pilot.pause(0.02)
|
||||
|
||||
@respx.mock
|
||||
async def test_sse_connect_failed_returns_to_idle(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""sse_connect_failed_returns_to_idle [error]: …"""
|
||||
respx.post("https://w.example/sessions/s-1existing/messages").mock(
|
||||
return_value=httpx.Response(404, json={"error": "session_not_found"})
|
||||
)
|
||||
writes = _spy_writes(monkeypatch)
|
||||
app = RatatoskrApp(_args_existing())
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
await _submit_and_wait(app, pilot, "x")
|
||||
assert app.state == "idle"
|
||||
assert any("[sse_connect_failed]" in str(w) for w in writes)
|
||||
assert app.return_value is None # app NOT exited per INV-008
|
||||
|
||||
@respx.mock
|
||||
async def test_connection_dropped_returns_to_idle(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""connection_dropped_returns_to_idle [error]: …"""
|
||||
|
||||
class _DropAfter(httpx.AsyncByteStream):
|
||||
async def __aiter__(self):
|
||||
yield _sse_chunk("42:1", {"type": "text", "content": "x"})
|
||||
raise httpx.RemoteProtocolError("drop")
|
||||
|
||||
async def aclose(self) -> None:
|
||||
return None
|
||||
|
||||
respx.post("https://w.example/sessions/s-1existing/messages").mock(
|
||||
return_value=_sse_resp(_DropAfter())
|
||||
)
|
||||
writes = _spy_writes(monkeypatch)
|
||||
app = RatatoskrApp(_args_existing())
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
await _submit_and_wait(app, pilot, "x")
|
||||
assert app.state == "idle"
|
||||
assert any("[connection_dropped]" in str(w) for w in writes)
|
||||
|
||||
@respx.mock
|
||||
async def test_rendered_event_per_event(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""rendered_event_per_event [trace]: …"""
|
||||
chunks = (
|
||||
_sse_chunk("42:1", {"type": "worker_phase", "phase": "streaming", "turn_id": 42})
|
||||
+ _sse_chunk("42:2", {"type": "text", "content": "hi"})
|
||||
+ _sse_chunk("42:3", _DONE_BODY)
|
||||
)
|
||||
respx.post(
|
||||
|
||||
"https://w.example/sessions/s-1existing/messages"
|
||||
|
||||
).mock(return_value=_sse_resp(chunks))
|
||||
|
||||
from ratatoskr import tui as tui_mod
|
||||
|
||||
call_count = 0
|
||||
original = tui_mod._render_event_to_log
|
||||
|
||||
def spy(event, *, log, raw):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return original(event, log=log, raw=raw)
|
||||
|
||||
monkeypatch.setattr(tui_mod, "_render_event_to_log", spy)
|
||||
app = RatatoskrApp(_args_existing())
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
await _submit_and_wait(app, pilot, "x")
|
||||
assert call_count == 3
|
||||
|
||||
|
||||
class TestActionInterrupt:
|
||||
@respx.mock
|
||||
async def test_idle_ctrl_c_exits_zero(self) -> None:
|
||||
"""idle_ctrl_c_exits_zero [happy,tracer]: state=idle; ctrl+c → exit(0)."""
|
||||
app = RatatoskrApp(_args_existing())
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
assert app.state == "idle"
|
||||
await pilot.press("ctrl+c")
|
||||
await pilot.pause()
|
||||
assert app.return_value == 0
|
||||
|
||||
@respx.mock
|
||||
async def test_streaming_first_ctrl_c_cancels(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""streaming_first_ctrl_c_cancels [scenario,tracer]: …"""
|
||||
# Stream that yields one text event (sets active_turn_id) then waits forever
|
||||
first_chunk = _sse_chunk("42:1", {"type": "text", "content": "x"})
|
||||
gate = asyncio.Event()
|
||||
|
||||
class _GatedAfterFirst(httpx.AsyncByteStream):
|
||||
async def __aiter__(self):
|
||||
yield first_chunk
|
||||
await gate.wait()
|
||||
|
||||
async def aclose(self) -> None:
|
||||
return None
|
||||
|
||||
respx.post("https://w.example/sessions/s-1existing/messages").mock(
|
||||
return_value=_sse_resp(_GatedAfterFirst())
|
||||
)
|
||||
cancel_observed = asyncio.Event()
|
||||
|
||||
def cancel_handler(req: httpx.Request) -> httpx.Response:
|
||||
cancel_observed.set()
|
||||
return httpx.Response(200, json=_CANCEL_OK_RESP)
|
||||
|
||||
cancel_route = respx.post("https://w.example/sessions/s-1existing/turns/42/cancel").mock(
|
||||
side_effect=cancel_handler
|
||||
)
|
||||
|
||||
app = RatatoskrApp(_args_existing())
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
inp = app.query_one("#prompt", Input)
|
||||
inp.value = "go"
|
||||
await inp.action_submit()
|
||||
await pilot.pause()
|
||||
# Wait for active_turn_id to be set (first event consumed)
|
||||
for _ in range(50):
|
||||
if app.active_turn_id == 42:
|
||||
break
|
||||
await pilot.pause(0.02)
|
||||
assert app.active_turn_id == 42
|
||||
assert app.state == "streaming"
|
||||
await pilot.press("ctrl+c")
|
||||
# Wait for cancel POST to land
|
||||
for _ in range(50):
|
||||
if cancel_observed.is_set():
|
||||
break
|
||||
await pilot.pause(0.02)
|
||||
assert cancel_route.call_count == 1
|
||||
assert app.state == "cancelling"
|
||||
assert app.hint == RatatoskrApp.HINT_CANCELLING
|
||||
# Release the gate so the stream worker can finish cleanly during teardown
|
||||
gate.set()
|
||||
|
||||
@respx.mock
|
||||
async def test_streaming_no_turn_id_force_exits(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""streaming_no_turn_id_force_exits [scenario]: …"""
|
||||
cancel_route = respx.post("https://w.example/sessions/s-1existing/turns/0/cancel").mock(
|
||||
return_value=httpx.Response(200, json=_CANCEL_OK_RESP)
|
||||
)
|
||||
# Stream that hangs forever (no events to set active_turn_id)
|
||||
gate = asyncio.Event()
|
||||
|
||||
class _NeverYields(httpx.AsyncByteStream):
|
||||
async def __aiter__(self):
|
||||
await gate.wait()
|
||||
if False:
|
||||
yield b""
|
||||
|
||||
async def aclose(self) -> None:
|
||||
return None
|
||||
|
||||
respx.post("https://w.example/sessions/s-1existing/messages").mock(
|
||||
return_value=_sse_resp(_NeverYields())
|
||||
)
|
||||
|
||||
app = RatatoskrApp(_args_existing())
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
inp = app.query_one("#prompt", Input)
|
||||
inp.value = "go"
|
||||
await inp.action_submit()
|
||||
await pilot.pause()
|
||||
assert app.state == "streaming"
|
||||
assert app.active_turn_id is None
|
||||
await pilot.press("ctrl+c")
|
||||
await pilot.pause()
|
||||
gate.set() # let the gated stream resolve so teardown is clean
|
||||
assert app.return_value == 3
|
||||
assert cancel_route.call_count == 0
|
||||
|
||||
@respx.mock
|
||||
async def test_cancelling_second_ctrl_c_force_exits(self) -> None:
|
||||
"""cancelling_second_ctrl_c_force_exits [scenario]: …"""
|
||||
app = RatatoskrApp(_args_existing())
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
app.state = "cancelling" # bypass the natural transition for the test
|
||||
await pilot.press("ctrl+c")
|
||||
await pilot.pause()
|
||||
assert app.return_value == 3
|
||||
|
||||
@respx.mock
|
||||
async def test_cancel_failed_swallowed(self) -> None:
|
||||
"""cancel_failed_swallowed [scenario]: …"""
|
||||
first_chunk = _sse_chunk("42:1", {"type": "text", "content": "x"})
|
||||
stream_gate = asyncio.Event()
|
||||
|
||||
class _GatedAfterFirst(httpx.AsyncByteStream):
|
||||
async def __aiter__(self):
|
||||
yield first_chunk
|
||||
await stream_gate.wait()
|
||||
|
||||
async def aclose(self) -> None:
|
||||
return None
|
||||
|
||||
respx.post("https://w.example/sessions/s-1existing/messages").mock(
|
||||
return_value=_sse_resp(_GatedAfterFirst())
|
||||
)
|
||||
cancel_observed = asyncio.Event()
|
||||
|
||||
def cancel_handler(req: httpx.Request) -> httpx.Response:
|
||||
cancel_observed.set()
|
||||
return httpx.Response(500, content=b"boom")
|
||||
|
||||
respx.post("https://w.example/sessions/s-1existing/turns/42/cancel").mock(
|
||||
side_effect=cancel_handler
|
||||
)
|
||||
|
||||
app = RatatoskrApp(_args_existing())
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
inp = app.query_one("#prompt", Input)
|
||||
inp.value = "go"
|
||||
await inp.action_submit()
|
||||
await pilot.pause()
|
||||
for _ in range(50):
|
||||
if app.active_turn_id == 42:
|
||||
break
|
||||
await pilot.pause(0.02)
|
||||
await pilot.press("ctrl+c")
|
||||
for _ in range(50):
|
||||
if cancel_observed.is_set():
|
||||
break
|
||||
await pilot.pause(0.02)
|
||||
# Give _cancel_via_sse time to write the [cancel_failed] line
|
||||
await pilot.pause(0.05)
|
||||
log = app.query_one("#transcript", RichLog)
|
||||
rendered = "\n".join(str(strip.text) for strip in log.lines)
|
||||
assert "[cancel_failed]" in rendered
|
||||
assert app.state == "cancelling"
|
||||
stream_gate.set() # let stream finish for teardown
|
||||
|
||||
|
||||
class TestActionQuit:
|
||||
@respx.mock
|
||||
async def test_idle_ctrl_d_exits_zero(self) -> None:
|
||||
"""idle_ctrl_d_exits_zero [happy,tracer]: state=idle; ctrl+d → exit(0)."""
|
||||
app = RatatoskrApp(_args_existing())
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
await pilot.press("ctrl+d")
|
||||
await pilot.pause()
|
||||
assert app.return_value == 0
|
||||
|
||||
@respx.mock
|
||||
async def test_streaming_ctrl_d_force_exits(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""streaming_ctrl_d_force_exits [scenario]: …"""
|
||||
cancel_route = respx.post("https://w.example/sessions/s-1existing/turns/42/cancel").mock(
|
||||
return_value=httpx.Response(200, json=_CANCEL_OK_RESP)
|
||||
)
|
||||
first_chunk = _sse_chunk("42:1", {"type": "text", "content": "x"})
|
||||
gate = asyncio.Event()
|
||||
|
||||
class _GatedAfterFirst(httpx.AsyncByteStream):
|
||||
async def __aiter__(self):
|
||||
yield first_chunk
|
||||
await gate.wait()
|
||||
|
||||
async def aclose(self) -> None:
|
||||
return None
|
||||
|
||||
respx.post("https://w.example/sessions/s-1existing/messages").mock(
|
||||
return_value=_sse_resp(_GatedAfterFirst())
|
||||
)
|
||||
app = RatatoskrApp(_args_existing())
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
inp = app.query_one("#prompt", Input)
|
||||
inp.value = "go"
|
||||
await inp.action_submit()
|
||||
await pilot.pause()
|
||||
for _ in range(50):
|
||||
if app.active_turn_id == 42:
|
||||
break
|
||||
await pilot.pause(0.02)
|
||||
await pilot.press("ctrl+d")
|
||||
await pilot.pause()
|
||||
gate.set()
|
||||
assert app.return_value == 0
|
||||
assert cancel_route.call_count == 0
|
||||
|
||||
|
||||
from ratatoskr.tui import run_tui # noqa: E402
|
||||
|
||||
|
||||
class TestRunTui:
|
||||
def test_happy_returns_zero_on_quit(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""happy_returns_zero_on_quit [happy,tracer]: run_tui propagates App.run() exit code."""
|
||||
captured: list[ParsedArgs] = []
|
||||
|
||||
def fake_run(self) -> int:
|
||||
captured.append(self.args)
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(RatatoskrApp, "run", fake_run)
|
||||
rc = run_tui(_args_existing())
|
||||
assert rc == 0
|
||||
assert len(captured) == 1
|
||||
assert captured[0].send_content is None
|
||||
|
||||
def test_precondition_send_content_none(self) -> None:
|
||||
"""precondition_send_content_none [adversarial]: …"""
|
||||
bad_args = ParsedArgs(
|
||||
send_content="x", # PRE-001 violation
|
||||
session_id="s-1",
|
||||
new=False,
|
||||
agent_id=None,
|
||||
api_key="k",
|
||||
server_url="https://w.example",
|
||||
raw=False,
|
||||
)
|
||||
with pytest.raises(AssertionError):
|
||||
run_tui(bad_args)
|
||||
Reference in New Issue
Block a user