"""Tests for ratatoskr.tui per docs/contracts/issues/4.contract.md.""" from pathlib import Path 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, SseId, Text, Thinking, ToolResult, ToolStart, WorkerPhase, ) from ratatoskr.tui import RatatoskrApp, _cancel_via_sse _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 AND VerticalScroll.mount to record every renderable or mounted-widget content into a single list (returned). v0.9.0: transcript content is mounted into a VerticalScroll, not written to a RichLog. The spy captures both shapes — for each mounted Static, the Static's `renderable` (Markdown / RichText / str) lands in the list, indistinguishably from RichLog.write entries. Integration tests assert on substrings or types in `writes` so the merged shape is the right abstraction. Accepts *args/**kwargs so Textual's internal deferred-render paths still work after a write-during-mount + Resize sequence. """ from textual.containers import VerticalScroll from textual.widgets import Static writes: list = [] original_write = RichLog.write def spy_write(self, content, *args, **kw): writes.append(content) return original_write(self, content, *args, **kw) monkeypatch.setattr(RichLog, "write", spy_write) original_mount = VerticalScroll.mount def spy_mount(self, *children, **kw): for child in children: if isinstance(child, Static): writes.append(child.content) else: writes.append(child) return original_mount(self, *children, **kw) monkeypatch.setattr(VerticalScroll, "mount", spy_mount) return writes def _resolved_app( args: ParsedArgs, *, session_id: str | None = None, agent_id: str | None = None, client: httpx.AsyncClient | None = None, ) -> RatatoskrApp: """Construct RatatoskrApp with pre-resolved state (issue #6 lifecycle). Production path: `run_tui` → `_resolve_then_run` opens AsyncClient, mints or attaches session, then constructs the App with the resolved tuple. This helper inlines that shape so tests bypass the pre-flight without re-implementing it. The client is opened here (and leaks at test teardown — acceptable; respx mocks all network calls and pytest exits cleanly). """ sid = session_id if session_id is not None else (args.session_id or "s-default") aid = args.agent_id if agent_id is None else agent_id if client is None: client = httpx.AsyncClient( base_url=args.server_url, headers={"Authorization": f"Bearer {args.api_key}"}, timeout=httpx.Timeout(connect=10.0, read=None, write=10.0, pool=10.0), ) return RatatoskrApp(args, session_id=sid, agent_id=aid, client=client) SID = SseId(42, 5) # Issue #12 — TuiPresenterState replaces _render_event_to_log with a stateful # per-turn presenter. (Pre-amendment TestRenderEventToLog class and # `_render_event_to_log` function have been removed under the project's # no-backwards-compatibility rule.) class TestTuiPresenterState: """Tests for the new TuiPresenterState — per issue #12 contract.""" def test_thinking_coalesces_until_newline(self) -> None: """thinking_coalesces_until_newline [happy,tracer, v0.7.1]: Per-token deltas accumulate in the buffer; flush only on `\\n`. Three short token-shaped deltas without `\\n` → thinking_log gets ONLY Rule(start); content stays buffered. """ from rich.rule import Rule from ratatoskr.tui import TuiPresenterState transcript = MagicMock() thinking_log = MagicMock() state = TuiPresenterState() for chunk in ("Let", " me", " think"): state.render( Thinking(sse_id=SID, content=chunk), transcript=transcript, tools_log=MagicMock(), debug_log=MagicMock(), thinking_log=thinking_log, raw=False, ) writes = [c[0][0] for c in thinking_log.write.call_args_list] # Only Rule(start) — content stays buffered (no `\n` seen). assert len(writes) == 1 assert isinstance(writes[0], Rule) assert state.thinking_chunk_buffer == "Let me think" assert transcript.mount.call_count == 0 def test_thinking_flushes_on_newline(self) -> None: """thinking_flushes_on_newline [happy, v0.7.1]: Delta carrying `\\n` flushes the accumulated buffer as ONE line. """ from ratatoskr.tui import TuiPresenterState thinking_log = MagicMock() state = TuiPresenterState() for chunk in ("Hello", " world", "\n"): state.render( Thinking(sse_id=SID, content=chunk), transcript=MagicMock(), tools_log=MagicMock(), debug_log=MagicMock(), thinking_log=thinking_log, raw=False, ) writes = [c[0][0] for c in thinking_log.write.call_args_list] # Rule(start) + "Hello world" (one coalesced line) = 2 writes assert len(writes) == 2 assert writes[1] == "Hello world" assert state.thinking_chunk_buffer == "" def test_thinking_closes_to_thinking_log(self) -> None: """thinking_closes_to_thinking_log [happy, v0.7.1]: 2x Thinking + WorkerPhase → v0.7.1 coalesces "a"+"b" into one buffered string; the close flushes "ab" as a single line before Rule(end). Result: Rule(start) + "ab" + Rule(end) = 3 writes. debug_log gets worker_phase; transcript untouched. """ from rich.rule import Rule from ratatoskr.tui import TuiPresenterState transcript = MagicMock() debug_log = MagicMock() thinking_log = MagicMock() state = TuiPresenterState() for content in ("a", "b"): state.render( Thinking(sse_id=SID, content=content), transcript=transcript, tools_log=MagicMock(), debug_log=debug_log, thinking_log=thinking_log, raw=False, ) state.render( WorkerPhase(sse_id=SID, phase="streaming", turn_id=42), transcript=transcript, tools_log=MagicMock(), debug_log=debug_log, thinking_log=thinking_log, raw=False, ) thinking_writes = [c[0][0] for c in thinking_log.write.call_args_list] # v0.7.1: 1 Rule(start) + 1 coalesced "ab" tail-flush + 1 Rule(end) = 3 writes assert len(thinking_writes) == 3 assert isinstance(thinking_writes[0], Rule) assert thinking_writes[1] == "ab" assert isinstance(thinking_writes[2], Rule) # worker_phase still goes to debug_log; transcript untouched. assert "· worker_phase:" in _text_of(debug_log.write.call_args_list[-1][0][0]) assert not transcript.mount.called # v0.6.5: thinking-current Static removed; test_thinking_widget_truncation # and test_thinking_widget_visibility_lifecycle deleted (no longer apply). def test_multiple_thinking_runs_each_get_thinking_log_section(self) -> None: """multiple_thinking_runs_each_get_section [scenario, v0.8.1]: Thinking → Text → Thinking → Done → TWO start/end Rule pairs in thinking_log (deltas coalesced into tail-flushes per run). Text deltas now stream into the transcript via coalesce-on-newline (no current-text Static); "hi" with no `\\n` stays buffered until Done's tail-flush. """ from rich.rule import Rule from ratatoskr.tui import TuiPresenterState transcript = MagicMock() thinking_log = MagicMock() state = TuiPresenterState() for evt in ( Thinking(sse_id=SID, content="first"), Text(sse_id=SID, content="hi"), Thinking(sse_id=SID, content="second"), ): state.render( evt, transcript=transcript, tools_log=MagicMock(), debug_log=MagicMock(), thinking_log=thinking_log, raw=False, ) state.render( _make_tui_done(), transcript=transcript, tools_log=MagicMock(), debug_log=MagicMock(), thinking_log=thinking_log, raw=False, ) # thinking_log: 4 Rules (start+end per run) + 2 tail-flush strings. thinking_writes = [c[0][0] for c in thinking_log.write.call_args_list] rules = [w for w in thinking_writes if isinstance(w, Rule)] delta_strs = [w for w in thinking_writes if isinstance(w, str)] assert len(rules) == 4, f"expected 4 Rules (2 start + 2 end), got {len(rules)}" assert "first" in delta_strs assert "second" in delta_strs # v0.8.1: Text "hi" flushes as a line in transcript on Done. transcript_renderables = [_text_of(r) for r in _mounted_renderables(transcript)] assert "hi" in transcript_renderables assert any(w.startswith("[done]") for w in transcript_renderables if isinstance(w, str)) def test_render_exception_fallback(self) -> None: """render_exception_fallback [adversarial, v0.6.5]: thinking_log.write raises → catch in presenter, write plain-label fallback + render_error line via INV-009 fallback path (routing preservation: thinking events still route to thinking_log). """ from ratatoskr.tui import TuiPresenterState transcript = MagicMock() thinking_log = MagicMock() # First call (Rule write) raises; subsequent calls succeed for fallback. thinking_log.write.side_effect = [ AttributeError("rule write failed (msg should NOT leak)"), None, None, ] state = TuiPresenterState() state.render( Thinking(sse_id=SID, content="x"), transcript=transcript, tools_log=MagicMock(), debug_log=MagicMock(), thinking_log=thinking_log, raw=False, ) writes = [c[0][0] for c in thinking_log.write.call_args_list if isinstance(c[0][0], str)] assert any(w.startswith("[thinking]") for w in writes), writes assert any(w == "[render_error] AttributeError" for w in writes), writes assert not any("rule write failed" in w for w in writes), writes assert not transcript.mount.called def test_state_reset_per_worker(self) -> None: """state_reset_per_worker [trace]: fresh TuiPresenterState() starts no thinking open.""" from ratatoskr.tui import TuiPresenterState s1 = TuiPresenterState() s1.render( Thinking(sse_id=SID, content="x"), transcript=MagicMock(), tools_log=MagicMock(), debug_log=MagicMock(), thinking_log=MagicMock(), raw=False, ) s2 = TuiPresenterState() assert s1.thinking_open is True assert s2.thinking_open is False def test_cancelled_mid_thinking_closes(self) -> None: """cancelled_mid_thinking_closes [scenario, v0.5.0]: Thinking, Cancelled → ONE closed thinking entry in debug_log + a [cancelled] entry in transcript; widget hidden. """ from ratatoskr.tui import TuiPresenterState transcript = MagicMock() thinking_log = MagicMock() state = TuiPresenterState() state.render( Thinking(sse_id=SID, content="partial"), transcript=transcript, tools_log=MagicMock(), debug_log=MagicMock(), thinking_log=thinking_log, raw=False, ) state.render( Cancelled( sse_id=SID, phase="cancelled", turn_id=42, reason="user", partial_message_id=None ), transcript=transcript, tools_log=MagicMock(), debug_log=MagicMock(), thinking_log=thinking_log, raw=False, ) # v0.6.5: streamed thinking + Rule(end) in thinking_log; [cancelled] in transcript. transcript_renderables = [_text_of(r) for r in _mounted_renderables(transcript)] assert any(w.startswith("[cancelled]") for w in transcript_renderables) # thinking_log got at least Rule(start) + "partial" delta + Rule(end) assert thinking_log.write.call_count >= 3 def test_text_then_done_mounts_widget_and_finalizes(self) -> None: """text_then_done_mounts_widget_and_finalizes [happy, v0.9.0]: First Text delta mounts a Static(Markdown(buffer)) into the transcript; Done finalizes the widget reference and mounts a styled [done] label. No duplicate content (v0.9.0 replaces v0.8.x's flush-on-Done with live in-place Markdown updates). """ from rich.markdown import Markdown from ratatoskr.tui import TuiPresenterState transcript = MagicMock() state = TuiPresenterState() state.render( Text(sse_id=SID, content="hi"), transcript=transcript, tools_log=MagicMock(), debug_log=MagicMock(), thinking_log=MagicMock(), raw=False, ) # v0.9.0: response widget mounted on first Text delta with Markdown wrapper. assert transcript.mount.called first_widget = transcript.mount.call_args_list[0][0][0] assert isinstance(first_widget.content, Markdown) assert first_widget.content.markup == "hi" assert state.text_chunk_buffer == "hi" # Done finalizes: text_chunk_buffer cleared, widget ref released, label mounted. state.render( _make_tui_done(), transcript=transcript, tools_log=MagicMock(), debug_log=MagicMock(), thinking_log=MagicMock(), raw=False, ) writes = _mounted_renderables(transcript) assert any(_text_of(w).startswith("[done]") for w in writes) # v0.9.0: response Markdown rendered live during stream — only ONE # Markdown renderable lands in the transcript (no post-Done re-render). markdowns = [w for w in writes if isinstance(w, Markdown)] assert len(markdowns) == 1 assert state.text_chunk_buffer == "" assert state.current_response_widget is None def test_raw_flag_skips_markdown(self) -> None: """raw_flag_skips_markdown [v0.9.0]: raw=True → response widget holds plain str instead of Markdown. Live in-place update still happens; only the wrapper differs. """ from rich.markdown import Markdown from ratatoskr.tui import TuiPresenterState transcript = MagicMock() state = TuiPresenterState() state.render( Text(sse_id=SID, content="hi"), transcript=transcript, tools_log=MagicMock(), debug_log=MagicMock(), thinking_log=MagicMock(), raw=True, ) state.render( _make_tui_done(), transcript=transcript, tools_log=MagicMock(), debug_log=MagicMock(), thinking_log=MagicMock(), raw=True, ) writes = _mounted_renderables(transcript) # Raw mode bypasses Markdown entirely — content lives as plain str. assert not any(isinstance(w, Markdown) for w in writes) assert "hi" in writes def test_worker_phase_demoted_to_debug_log(self) -> None: """worker_phase_demoted_to_debug_log [trace, v0.5.0]: WorkerPhase → debug_log "· worker_phase:" prefix rendered with Australis dark-60 Rich style. Transcript receives nothing. """ from rich.text import Text as RichText from ratatoskr.tui import TuiPresenterState transcript = MagicMock() debug_log = MagicMock() state = TuiPresenterState() state.render( WorkerPhase(sse_id=SID, phase="streaming", turn_id=42), transcript=transcript, tools_log=MagicMock(), debug_log=debug_log, thinking_log=MagicMock(), raw=False, ) # v0.5.0: WorkerPhase routes to debug_log, NOT transcript. assert not transcript.mount.called renderable = debug_log.write.call_args[0][0] # INV-003: must be a styled Rich Text renderable, not a plain str. # v0.4.1 retheme: style is now Australis Sea dark-60 ("#86929d") instead # of the terminal-dim filter "dim". Assert non-empty styling either way. assert isinstance(renderable, RichText), type(renderable) assert renderable.style, "demoted telemetry must carry SOME style" text = renderable.plain assert text.startswith("· worker_phase:") assert "[worker_phase]" not in text # v0.6.5: test_terminal_events_belt_and_braces_widget_cleanup deleted. # The thinking-current Static is gone, so there's no widget to clean up # on terminal events. The corresponding Volva F3 invariant is obsoleted # by the streaming-into-thinking_log architecture. def test_tool_start_routes_to_tools_log(self) -> None: """tool_start_routes_to_tools_log [INV-014]: ToolStart writes to tools_log, NOT transcript. Issue #13: tool events route to the dedicated Tools pane (right column). Pre-#13 wrote them to the main transcript with `· tool_start:` prefix. Post-#13 the prefix is preserved but the destination shifts. """ from ratatoskr.tui import TuiPresenterState transcript = MagicMock() tools_log = MagicMock() state = TuiPresenterState() state.render( ToolStart(sse_id=SID, name="read_file", arguments={"path": "/x"}), transcript=transcript, tools_log=tools_log, debug_log=MagicMock(), thinking_log=MagicMock(), raw=False, ) # INV-014: write went to tools_log assert tools_log.write.called assert _text_of(tools_log.write.call_args[0][0]).startswith("· tool_start:") # INV-014: transcript was NOT written to assert not transcript.mount.called def test_tool_result_routes_to_tools_log(self) -> None: """tool_result_routes_to_tools_log [INV-014]: ToolResult → tools_log, NOT transcript.""" from ratatoskr.tui import TuiPresenterState transcript = MagicMock() tools_log = MagicMock() state = TuiPresenterState() state.render( ToolResult(sse_id=SID, name="read_file", result="ok", duration_ms=12), transcript=transcript, tools_log=tools_log, debug_log=MagicMock(), thinking_log=MagicMock(), raw=False, ) assert tools_log.write.called assert _text_of(tools_log.write.call_args[0][0]).startswith("· tool_result:") assert not transcript.mount.called def test_text_first_delta_mounts_response_widget(self) -> None: """text_first_delta_mounts_response_widget [v0.9.0]: first Text delta mounts a Static carrying Markdown(buffer) into the transcript. The text_chunk_buffer holds the accumulated content for the next delta's in-place update. """ from rich.markdown import Markdown from ratatoskr.tui import TuiPresenterState transcript = MagicMock() tools_log = MagicMock() state = TuiPresenterState() state.render( Text(sse_id=SID, content="hello"), transcript=transcript, tools_log=tools_log, debug_log=MagicMock(), thinking_log=MagicMock(), raw=False, ) assert state.text_chunk_buffer == "hello" assert transcript.mount.call_count == 1 widget = transcript.mount.call_args[0][0] assert isinstance(widget.content, Markdown) assert widget.content.markup == "hello" assert state.current_response_widget is widget assert not tools_log.write.called def test_text_subsequent_deltas_update_in_place(self) -> None: """text_subsequent_deltas_update_in_place [v0.9.0]: deltas after the first do NOT mount a new widget — they update the existing widget's Markdown content in place. The text_chunk_buffer accumulates. """ from ratatoskr.tui import TuiPresenterState transcript = MagicMock() state = TuiPresenterState() for tok in ("Hel", "lo", " ", "world"): state.render( Text(sse_id=SID, content=tok), transcript=transcript, tools_log=MagicMock(), debug_log=MagicMock(), thinking_log=MagicMock(), raw=False, ) # Exactly ONE mount (the first delta); subsequent deltas update. assert transcript.mount.call_count == 1 assert state.text_chunk_buffer == "Hello world" # Widget reference held; buffer is the source of truth re-rendered # into Markdown(...) for each Static.update call. assert state.current_response_widget is not None def test_duration_format_seconds(self) -> None: """duration_format_seconds [trace]: Done(duration_ms=5467) → label has "duration=5.5s".""" from ratatoskr.tui import TuiPresenterState transcript = MagicMock() state = TuiPresenterState() state.render( _make_tui_done(duration_ms=5467), transcript=transcript, tools_log=MagicMock(), debug_log=MagicMock(), thinking_log=MagicMock(), raw=True, ) done_line = next( _text_of(r) for r in _mounted_renderables(transcript) if _text_of(r).startswith("[done]") ) assert "duration=5.5s" in done_line assert "duration_ms=5467" not in done_line def test_usage_format_unicode_arrow(self) -> None: """usage_format_unicode_arrow [trace]: TUI Done label uses → (Unicode), not -> (ASCII).""" from ratatoskr.tui import TuiPresenterState transcript = MagicMock() state = TuiPresenterState() usage = { "prompt_tokens": 6756, "completion_tokens": 126, "total_tokens": 6882, "cached_input_tokens": 0, } state.render( _make_tui_done(usage=usage), transcript=transcript, tools_log=MagicMock(), debug_log=MagicMock(), thinking_log=MagicMock(), raw=True, ) done_line = next( _text_of(r) for r in _mounted_renderables(transcript) if _text_of(r).startswith("[done]") ) assert "usage 6756 in → 126 out (6882 total, 0 cached)" in done_line class TestPresenterAuditLogging: """v0.10.0 — per-event audit lines + turn-summary in the debug pane. The presenter emits one debug-pane line per arriving event (Text and Thinking are aggregated into the turn-summary instead of per-delta to avoid drowning the pane at token rate). """ def test_worker_phase_emits_audit_line(self) -> None: """worker_phase_emits_audit_line: WorkerPhase arrival adds an audit line to debug_log alongside the existing `· worker_phase:` entry. Audit line shape: `[HH:MM:SS.fff] workerphase sse_id=N:M …`. """ from ratatoskr.tui import TuiPresenterState debug_log = MagicMock() state = TuiPresenterState() state.render( WorkerPhase(sse_id=SID, phase="streaming", turn_id=42), transcript=MagicMock(), tools_log=MagicMock(), debug_log=debug_log, thinking_log=MagicMock(), raw=False, ) # Two writes: audit line + worker_phase telemetry. assert debug_log.write.call_count == 2 audit_line = _text_of(debug_log.write.call_args_list[0][0][0]) assert "workerphase" in audit_line assert "sse_id=42:5" in audit_line assert "phase=streaming" in audit_line def test_tool_start_emits_audit_line(self) -> None: """tool_start_emits_audit_line: ToolStart adds one audit line to debug_log even though the tool event itself routes to tools_log. """ from ratatoskr.tui import TuiPresenterState debug_log = MagicMock() state = TuiPresenterState() state.render( ToolStart(sse_id=SID, name="read_file", arguments={"path": "/x"}), transcript=MagicMock(), tools_log=MagicMock(), debug_log=debug_log, thinking_log=MagicMock(), raw=False, ) assert debug_log.write.call_count == 1 audit_line = _text_of(debug_log.write.call_args[0][0]) assert "toolstart" in audit_line assert "sse_id=42:5" in audit_line assert "name=read_file" in audit_line def test_text_delta_counted_not_per_event_audit_line(self) -> None: """text_delta_counted_not_per_event_audit_line: a Text delta does NOT emit a per-event audit line (token-rate would drown the pane); instead it bumps text_delta_count / text_byte_count for the turn- summary at Done. """ from ratatoskr.tui import TuiPresenterState debug_log = MagicMock() state = TuiPresenterState() state.render( Text(sse_id=SID, content="hello world"), transcript=MagicMock(), tools_log=MagicMock(), debug_log=debug_log, thinking_log=MagicMock(), raw=False, ) # No debug-pane writes — text deltas are silent at token rate. assert not debug_log.write.called assert state.text_delta_count == 1 assert state.text_byte_count == len("hello world") def test_affect_update_routes_to_audit_only(self) -> None: """affect_update_routes_to_audit_only [v0.11.0]: AffectUpdate emits ONE debug-pane audit line (with dominant_emotion + PAD for status=current) and touches NO other pane (no transcript mount, no tools_log, no thinking_log). UX shape (persona pane / sticky header) is deferred. """ from ratatoskr.sse_client import AffectUpdate from ratatoskr.tui import TuiPresenterState transcript = MagicMock() tools_log = MagicMock() thinking_log = MagicMock() debug_log = MagicMock() state = TuiPresenterState() snapshot = { "agent_id": "mimir", "pad": {"pleasure": 0.5, "arousal": 0.4, "dominance": 0.5}, "dominant_emotion": "curiosity", } state.render( AffectUpdate(sse_id=SID, status="current", turn_id=42, snapshot=snapshot), transcript=transcript, tools_log=tools_log, debug_log=debug_log, thinking_log=thinking_log, raw=False, ) assert debug_log.write.call_count == 1 audit = _text_of(debug_log.write.call_args[0][0]) assert "affectupdate" in audit assert "status=current" in audit assert "dominant_emotion='curiosity'" in audit assert "pad=(0.5,0.4,0.5)" in audit assert not transcript.mount.called assert not tools_log.write.called assert not thinking_log.write.called def test_awaiting_llm_first_token_mounts_indicator(self) -> None: """awaiting_llm_first_token_mounts_indicator [v0.14.0]: first heartbeat mounts a Static into the transcript and bumps heartbeat_count; debug-pane audit line carries turn_id + elapsed in seconds. """ from ratatoskr.sse_client import AwaitingLlmFirstToken from ratatoskr.tui import TuiPresenterState transcript = MagicMock() debug_log = MagicMock() state = TuiPresenterState() state.render( AwaitingLlmFirstToken( sse_id=SID, turn_id=42, elapsed_ms_since_building_prompt=5012.3 ), transcript=transcript, tools_log=MagicMock(), debug_log=debug_log, thinking_log=MagicMock(), raw=False, ) assert state.heartbeat_count == 1 assert state.awaiting_widget is not None assert transcript.mount.call_count == 1 audit = _text_of(debug_log.write.call_args[0][0]) assert "awaitingllmfirsttoken" in audit assert "turn_id=42" in audit assert "elapsed=5.0s" in audit def test_awaiting_subsequent_heartbeats_update_in_place(self) -> None: """awaiting_subsequent_heartbeats_update_in_place [v0.14.0]: second+ heartbeats reuse the existing Static (no new mount); heartbeat_count tracks the total. """ from ratatoskr.sse_client import AwaitingLlmFirstToken from ratatoskr.tui import TuiPresenterState transcript = MagicMock() state = TuiPresenterState() for elapsed in (5000.0, 10005.4, 15011.8): state.render( AwaitingLlmFirstToken( sse_id=SID, turn_id=42, elapsed_ms_since_building_prompt=elapsed ), transcript=transcript, tools_log=MagicMock(), debug_log=MagicMock(), thinking_log=MagicMock(), raw=False, ) assert state.heartbeat_count == 3 assert transcript.mount.call_count == 1 # mounted once on first def test_awaiting_indicator_removed_when_gap_closes(self) -> None: """awaiting_indicator_removed_when_gap_closes [v0.14.0]: any non- heartbeat event after one or more heartbeats removes the indicator and clears the awaiting_widget reference. Text event simulates the gap closing (CallingLLM fires, text begins). """ from ratatoskr.sse_client import AwaitingLlmFirstToken from ratatoskr.tui import TuiPresenterState transcript = MagicMock() state = TuiPresenterState() state.render( AwaitingLlmFirstToken( sse_id=SID, turn_id=42, elapsed_ms_since_building_prompt=5000.0 ), transcript=transcript, tools_log=MagicMock(), debug_log=MagicMock(), thinking_log=MagicMock(), raw=False, ) widget = state.awaiting_widget assert widget is not None state.render( Text(sse_id=SID, content="hello"), transcript=transcript, tools_log=MagicMock(), debug_log=MagicMock(), thinking_log=MagicMock(), raw=False, ) # State reference cleared (the widget itself is a real Static whose # .remove() schedules removal — we verify the cleanup intent via # the state field, which is the contract callers actually observe). assert state.awaiting_widget is None def test_affect_update_scheduled_has_no_pad_detail(self) -> None: """affect_update_scheduled_has_no_pad_detail [v0.11.0]: status=scheduled carries no snapshot — the audit line omits dominant_emotion / pad and contains only status + turn_id. """ from ratatoskr.sse_client import AffectUpdate from ratatoskr.tui import TuiPresenterState debug_log = MagicMock() state = TuiPresenterState() state.render( AffectUpdate(sse_id=SID, status="scheduled", turn_id=42, snapshot=None), transcript=MagicMock(), tools_log=MagicMock(), debug_log=debug_log, thinking_log=MagicMock(), raw=False, ) audit = _text_of(debug_log.write.call_args[0][0]) assert "status=scheduled" in audit assert "turn_id=42" in audit assert "dominant_emotion" not in audit assert "pad=" not in audit def test_done_emits_turn_summary_line(self) -> None: """done_emits_turn_summary_line: when Done arrives the presenter emits a `turn_summary` line aggregating per-delta Text + Thinking counters. The shape exposes the totals that per-event audit lines elided. """ from ratatoskr.tui import TuiPresenterState debug_log = MagicMock() state = TuiPresenterState() # 3 Text deltas + 2 Thinking deltas, then Done. state.render( Text(sse_id=SID, content="a"), transcript=MagicMock(), tools_log=MagicMock(), debug_log=debug_log, thinking_log=MagicMock(), raw=False, ) state.render( Text(sse_id=SID, content="bc"), transcript=MagicMock(), tools_log=MagicMock(), debug_log=debug_log, thinking_log=MagicMock(), raw=False, ) state.render( Thinking(sse_id=SID, content="thought\n"), transcript=MagicMock(), tools_log=MagicMock(), debug_log=debug_log, thinking_log=MagicMock(), raw=False, ) state.render( _make_tui_done(), transcript=MagicMock(), tools_log=MagicMock(), debug_log=debug_log, thinking_log=MagicMock(), raw=False, ) writes = [_text_of(c[0][0]) for c in debug_log.write.call_args_list] summary = next(w for w in writes if "turn_summary" in w) assert "text_deltas=2" in summary assert "text_bytes=3" in summary # "a" + "bc" assert "thinking_deltas=1" in summary assert "elapsed_ms=" in summary def _text_of(write_arg: object) -> str: """Extract plain text from a RichLog.write() arg (str or rich.text.Text). Issue #12 wraps demoted-telemetry entries in `rich.text.Text(..., style="dim")` so the RichLog can apply dim styling; non-demoted writes stay as plain str. Tests that want to assert against content need both shapes flattened. v0.9.0: also extracts plain text from Markdown wrappers (the streaming-text response path uses Markdown(buffer) now; tests assert against the source markup, which lives in `Markdown.markup`). """ from rich.markdown import Markdown from rich.text import Text as RichText if isinstance(write_arg, RichText): return write_arg.plain if isinstance(write_arg, Markdown): return write_arg.markup if isinstance(write_arg, str): return write_arg return "" # Rule / etc. — not text content def _mounted_renderables(transcript_mock: MagicMock) -> list: """v0.9.0: TuiPresenterState now mounts Static widgets into the transcript VerticalScroll instead of writing renderables to a RichLog. Tests using a MagicMock transcript inspect `transcript.mount.call_args_list`; each call's first positional arg is the Static child whose `.content` carries the Markdown / RichText / str that pre-v0.9.0 would have been the write arg. Returns those renderables in mount-call order so tests can assert on them with the same shape they used for `log.write.call_args_list` previously. """ out: list = [] for call in transcript_mock.mount.call_args_list: for child in call.args: renderable = getattr(child, "content", child) out.append(renderable) return out def _make_tui_done(*, duration_ms: int = 1, usage: dict[str, int] | None = None) -> Done: return Done( sse_id=SID, phase="succeeded", response="r", model="m", duration_ms=duration_ms, usage=usage if usage is not None else { "prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0, "cached_input_tokens": 0, }, ) class TestCancelViaSse: @respx.mock async def test_happy_cancel(self) -> None: """happy_cancel [happy,tracer]: 200 OK → returns None; transcript 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) ) transcript = MagicMock() async with httpx.AsyncClient(base_url="https://w.example") as client: result = await _cancel_via_sse(client, "s-1", 42, transcript=transcript) assert result is None transcript.mount.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") ) transcript = MagicMock() async with httpx.AsyncClient(base_url="https://w.example") as client: await _cancel_via_sse(client, "s-1", 42, transcript=transcript) line = transcript.mount.call_args[0][0].content 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) ) transcript = MagicMock() async with httpx.AsyncClient(base_url="https://w.example") as client: await _cancel_via_sse(client, "s-1", 42, transcript=transcript) line = transcript.mount.call_args[0][0].content 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") ) transcript = MagicMock() async with httpx.AsyncClient(base_url="https://w.example") as client: await _cancel_via_sse(client, "s-1", 42, transcript=transcript) line = transcript.mount.call_args[0][0].content assert "[cancel_failed]" in line assert "ConnectError" in line @respx.mock async def test_audit_callback_records_lifecycle(self) -> None: """audit_callback_records_lifecycle [v0.10.0]: when the caller passes an `audit` callback, _cancel_via_sse emits two lines on the happy path (`cancel_post issued …` + `cancel_post ok …`) and two lines on the failure path (`issued` + `failed …`). Gives the debug pane a complete cancel-POST timeline. """ respx.post("https://w.example/sessions/s-1/turns/42/cancel").mock( return_value=httpx.Response(200, json=_CANCEL_OK_RESP) ) transcript = MagicMock() audit_lines: list = [] async with httpx.AsyncClient(base_url="https://w.example") as client: await _cancel_via_sse( client, "s-1", 42, transcript=transcript, audit=audit_lines.append ) assert len(audit_lines) == 2 assert audit_lines[0].startswith("cancel_post issued ") assert "session_id=s-1" in audit_lines[0] assert "turn_id=42" in audit_lines[0] assert audit_lines[1] == "cancel_post ok turn_id=42" @respx.mock async def test_audit_callback_records_failure(self) -> None: """audit_callback_records_failure [v0.10.0]: failure path emits `cancel_post issued` then `cancel_post failed …` with exception type. """ respx.post("https://w.example/sessions/s-1/turns/42/cancel").mock( return_value=httpx.Response(500, content=b"boom") ) transcript = MagicMock() audit_lines: list = [] async with httpx.AsyncClient(base_url="https://w.example") as client: await _cancel_via_sse( client, "s-1", 42, transcript=transcript, audit=audit_lines.append ) assert len(audit_lines) == 2 assert audit_lines[0].startswith("cancel_post issued ") assert audit_lines[1].startswith("cancel_post failed turn_id=42 CancelFailed") class TestPersonaFormatters: """v0.13.0 — _format_persona_header / _format_persona_detail rendering.""" def test_header_compact_summary(self) -> None: """header_compact_summary: agent_id · dominant_emotion · pad(P,A,D) · N emotions.""" from ratatoskr.tui import _format_persona_header snapshot = { "agent_id": "mimir", "dominant_emotion": "curiosity", "pad": {"pleasure": 0.52, "arousal": 0.47, "dominance": 0.50}, "emotions_active": [ {"type": "curiosity"}, {"type": "joy"}, ], } line = _format_persona_header(snapshot) assert "mimir" in line assert "curiosity" in line assert "pad(0.52, 0.47, 0.5)" in line assert "2 emotions active" in line def test_header_singular_emotion(self) -> None: """header_singular_emotion: single emotion → '1 emotion active' (no 's').""" from ratatoskr.tui import _format_persona_header line = _format_persona_header( { "agent_id": "mimir", "dominant_emotion": "calm", "pad": {"pleasure": 0.5, "arousal": 0.4, "dominance": 0.5}, "emotions_active": [{"type": "calm"}], } ) assert "1 emotion active" in line assert "1 emotions" not in line def test_header_no_emotions_drops_count(self) -> None: """header_no_emotions_drops_count: empty emotions list → no count suffix.""" from ratatoskr.tui import _format_persona_header line = _format_persona_header( { "agent_id": "mimir", "dominant_emotion": "?", "pad": {"pleasure": 0.5, "arousal": 0.4, "dominance": 0.5}, "emotions_active": [], } ) assert "emotion" not in line # neither "1 emotion" nor "N emotions" def test_detail_renders_full_snapshot(self) -> None: """detail_renders_full_snapshot: PAD axes + baseline + delta + drift + emotions + last_updated_at all surface in the multi-line render. """ from ratatoskr.tui import _format_persona_detail snapshot = { "agent_id": "mimir", "pad": {"pleasure": 0.52, "arousal": 0.47, "dominance": 0.50}, "dominant_emotion": "curiosity", "emotions_active": [ {"type": "curiosity", "intensity": 0.6, "decay_remaining_s": 202.7} ], "baseline_pad": {"pleasure": 0.50, "arousal": 0.40, "dominance": 0.50}, "mood_drift": {"valence_delta": 0.02, "arousal_delta": 0.07}, "last_updated_at": "2026-05-25T22:30:18+00:00", } detail = _format_persona_detail(snapshot) assert "Persona snapshot · mimir" in detail assert "Dominant emotion: curiosity" in detail assert "pleasure" in detail assert "baseline 0.5" in detail assert "+0.02" in detail or "0.02" in detail assert "valence_delta" in detail assert "curiosity" in detail assert "intensity 0.6" in detail assert "decay 3.4m" in detail assert "2026-05-25T22:30:18+00:00" in detail class TestPresenterPersonaCallback: """v0.13.0 — presenter wires AffectUpdate snapshots into a callback.""" def test_current_invokes_callback_with_snapshot(self) -> None: """current_invokes_callback_with_snapshot: AffectUpdate(current, snapshot) calls on_persona_snapshot(snapshot).""" from ratatoskr.sse_client import AffectUpdate from ratatoskr.tui import TuiPresenterState captured: list = [] state = TuiPresenterState() snapshot = {"agent_id": "mimir", "pad": {"pleasure": 0.5}} state.render( AffectUpdate(sse_id=SID, status="current", turn_id=42, snapshot=snapshot), transcript=MagicMock(), tools_log=MagicMock(), debug_log=MagicMock(), thinking_log=MagicMock(), raw=False, on_persona_snapshot=captured.append, ) assert captured == [snapshot] def test_scheduled_does_not_invoke_callback(self) -> None: """scheduled_does_not_invoke_callback: status=scheduled has no snapshot, so the callback is skipped (would be called with None otherwise).""" from ratatoskr.sse_client import AffectUpdate from ratatoskr.tui import TuiPresenterState captured: list = [] state = TuiPresenterState() state.render( AffectUpdate(sse_id=SID, status="scheduled", turn_id=42, snapshot=None), transcript=MagicMock(), tools_log=MagicMock(), debug_log=MagicMock(), thinking_log=MagicMock(), raw=False, on_persona_snapshot=captured.append, ) assert captured == [] def test_callback_exception_swallowed(self) -> None: """callback_exception_swallowed: a raising callback does NOT crash the presenter — the audit line still landed (it precedes the callback). """ from ratatoskr.sse_client import AffectUpdate from ratatoskr.tui import TuiPresenterState def boom(_snap: dict) -> None: raise RuntimeError("widget tearing down") debug_log = MagicMock() state = TuiPresenterState() # Should NOT raise state.render( AffectUpdate( sse_id=SID, status="current", turn_id=42, snapshot={"agent_id": "x"} ), transcript=MagicMock(), tools_log=MagicMock(), debug_log=debug_log, thinking_log=MagicMock(), raw=False, on_persona_snapshot=boom, ) # Audit line still emitted (it runs before the callback) assert debug_log.write.called class TestAppMount: """on_mount narrows per issue #6: only identity-widget population. Session resolution + AsyncClient open + error-on-resolve are exercised at the `_resolve_then_run` layer (see TestResolveThenRun); only happy mount paths remain here, exercised with pre-resolved state via _resolved_app. """ async def test_happy_new_session_mount(self) -> None: """happy_new_session_mount [happy,tracer]: identity populated from pre-resolved state.""" app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir") 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 "") async def test_happy_existing_session_mount(self) -> None: """happy_existing_session_mount: identity shows when agent_id is None.""" app = _resolved_app(_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" # INV-002 carve-out: agent unknown → · … assert "" in (app.sub_title or "") assert app.session_id[-8:] in (app.sub_title or "") async def test_footer_identity_visible_first_frame(self) -> None: """footer_identity_visible_first_frame [trace]: identity widget rendered first frame.""" from textual.widgets import Static app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir") async with app.run_test() as pilot: await pilot.pause() identity_widget = app.query_one("#identity", Static) rendered = str(identity_widget.render()) assert "mimir" in rendered assert "·" in rendered assert app.session_id[-8:] in rendered # Issue #13 — TUI layout reshape + Tools pane (§5 v1 entry point) class TestLayoutShape: """INV-013 + INV-014 + INV-017: Horizontal two-column layout with Tools tab.""" async def test_main_row_is_horizontal(self) -> None: """main_row_is_horizontal [tracer]: compose() yields Horizontal#main-row.""" from textual.containers import Horizontal app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir") async with app.run_test() as pilot: await pilot.pause() row = app.query_one("#main-row", Horizontal) assert row is not None async def test_left_column_content_only(self) -> None: """left_column_content_only [v0.9.0]: left column = transcript-scroll VerticalScroll + prompt Input. thinking-current Static removed in v0.6.5; transcript RichLog replaced by VerticalScroll in v0.9.0. """ from textual.containers import Vertical, VerticalScroll from textual.widgets import Input app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir") async with app.run_test() as pilot: await pilot.pause() left = app.query_one("#left-column", Vertical) transcript = app.query_one("#transcript-scroll", VerticalScroll) prompt = app.query_one("#prompt", Input) assert transcript in left.walk_children() assert prompt in left.walk_children() # v0.6.5: thinking-current Static removed; no longer in DOM at all. from textual.css.query import NoMatches try: app.query_one("#thinking-current") raise AssertionError("thinking-current should not exist in v0.6.5") except NoMatches: pass # expected async def test_right_column_has_tabbed_content_with_tools_tab(self) -> None: """right_column_has_tabbed_content_with_tools_tab: #side-panes + TabPane#tools-tab.""" from textual.widgets import TabbedContent, TabPane app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir") async with app.run_test() as pilot: await pilot.pause() tabbed = app.query_one("#side-panes", TabbedContent) assert tabbed is not None tools_tab = app.query_one("#tools-tab", TabPane) assert tools_tab is not None async def test_tools_log_inside_tools_tab(self) -> None: """tools_log_inside_tools_tab: tools-transcript RichLog is a descendant of tools-tab TabPane.""" from textual.widgets import RichLog, TabPane app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir") async with app.run_test() as pilot: await pilot.pause() tools_tab = app.query_one("#tools-tab", TabPane) tools_log = app.query_one("#tools-log", RichLog) assert tools_log in tools_tab.walk_children() async def test_pane_name_widget_renders_tools(self) -> None: """pane_name_widget_renders_tools [INV-pane-name]: #pane-name == 'Tools' on first frame.""" from textual.widgets import Static app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir") async with app.run_test() as pilot: await pilot.pause() pane_name = app.query_one("#pane-name", Static) rendered = str(pane_name.render()) assert rendered == "Tools" async def test_ctrl_1_activates_tools_tab(self) -> None: """ctrl_1_activates_tools_tab [tracer]: Ctrl+1 → TabbedContent.active == 'tools-tab'.""" from textual.widgets import TabbedContent app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir") async with app.run_test() as pilot: await pilot.pause() await pilot.press("ctrl+1") await pilot.pause() tabbed = app.query_one("#side-panes", TabbedContent) assert tabbed.active == "tools-tab" async def test_ctrl_1_preserves_input_focus(self) -> None: """ctrl_1_preserves_input_focus [INV-016]: Ctrl+1 does NOT steal focus from Input.""" from textual.widgets import Input app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir") async with app.run_test() as pilot: await pilot.pause() prompt = app.query_one("#prompt", Input) prompt.focus() await pilot.pause() assert app.focused is prompt await pilot.press("ctrl+1") await pilot.pause() assert app.focused is prompt, ( f"INV-016: Input focus must survive Ctrl+1 tab switch; got focused={app.focused}" ) async def test_debug_tab_exists(self) -> None: """debug_tab_exists [v0.5.0]: right column has Debug TabPane + #debug-transcript RichLog.""" from textual.widgets import RichLog, TabPane app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir") async with app.run_test() as pilot: await pilot.pause() debug_tab = app.query_one("#debug-tab", TabPane) debug_log = app.query_one("#debug-log", RichLog) assert debug_log in debug_tab.walk_children() async def test_ctrl_2_activates_debug_tab(self) -> None: """ctrl_2_activates_debug_tab [v0.5.0]: Ctrl+2 → TabbedContent.active == 'debug-tab'.""" from textual.widgets import TabbedContent app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir") async with app.run_test() as pilot: await pilot.pause() await pilot.press("ctrl+2") await pilot.pause() assert app.query_one("#side-panes", TabbedContent).active == "debug-tab" async def test_persona_tab_exists(self) -> None: """persona_tab_exists [v0.13.0]: right column has Persona TabPane + #persona-log RichLog as descendant. """ from textual.widgets import RichLog, TabPane app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir") async with app.run_test() as pilot: await pilot.pause() persona_tab = app.query_one("#persona-tab", TabPane) persona_log = app.query_one("#persona-log", RichLog) assert persona_log in persona_tab.walk_children() async def test_ctrl_4_activates_persona_tab(self) -> None: """ctrl_4_activates_persona_tab [v0.13.0]: Ctrl+4 → active == 'persona-tab'.""" from textual.widgets import TabbedContent app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir") async with app.run_test() as pilot: await pilot.pause() await pilot.press("ctrl+4") await pilot.pause() assert app.query_one("#side-panes", TabbedContent).active == "persona-tab" async def test_persona_header_starts_empty(self) -> None: """persona_header_starts_empty [v0.13.0]: sticky persona-header widget exists, starts hidden (height collapsed via .empty class) until hydration succeeds. """ from textual.widgets import Static app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir") async with app.run_test() as pilot: await pilot.pause() header = app.query_one("#persona-header", Static) assert "empty" in header.classes async def test_done_label_styled_success(self) -> None: """done_label_styled_success [v0.9.0]: [done] label mounts as Static carrying a RichText with Aurora green style. Inspect the mounted Static's `.content`. """ from rich.text import Text as RichText from textual.containers import VerticalScroll from textual.widgets import RichLog app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir") async with app.run_test() as pilot: await pilot.pause() from ratatoskr.tui import TuiPresenterState transcript = app.query_one("#transcript-scroll", VerticalScroll) state = TuiPresenterState() mounted: list = [] orig_mount = transcript.mount def spy_mount(*ch, **kw): mounted.extend(ch) return orig_mount(*ch, **kw) transcript.mount = spy_mount # type: ignore[method-assign] state.render( _make_tui_done(), transcript=transcript, tools_log=app.query_one("#tools-log", RichLog), debug_log=app.query_one("#debug-log", RichLog), thinking_log=MagicMock(), raw=True, ) done = next( w.content for w in mounted if isinstance(getattr(w, "content", None), RichText) and _text_of(w.content).startswith("[done]") ) assert done.style == "#16B866" # Aurora green async def test_empty_state_placeholders_present(self) -> None: """empty_state_placeholders_present [v0.5.1]: tools-transcript + debug-transcript show placeholder lines before any turn fires.""" from textual.widgets import RichLog app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir") async with app.run_test() as pilot: await pilot.pause() # Activate Debug tab so its content actually renders. from textual.widgets import TabbedContent tabbed = app.query_one("#side-panes", TabbedContent) tabbed.active = "debug-tab" await pilot.pause() tabbed.active = "tools-tab" await pilot.pause() tools_log = app.query_one("#tools-log", RichLog) debug_log = app.query_one("#debug-log", RichLog) tools_text = " ".join(str(line) for line in tools_log.lines) tabbed.active = "debug-tab" await pilot.pause() debug_text = " ".join(str(line) for line in debug_log.lines) assert "no tool events" in tools_text assert "worker_phase" in debug_text async def test_pane_name_updates_on_tab_switch(self) -> None: """pane_name_updates_on_tab_switch [v0.5.0]: pane-name reflects active tab. Two tabs now (Tools / Debug); pane-name updates from "Tools" to "Debug" and back as the operator switches via Ctrl+1 / Ctrl+2. """ from textual.widgets import Static app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir") async with app.run_test() as pilot: await pilot.pause() pane_name = app.query_one("#pane-name", Static) await pilot.press("ctrl+2") await pilot.pause() assert str(pane_name.render()) == "Debug" await pilot.press("ctrl+1") await pilot.pause() assert str(pane_name.render()) == "Tools" import asyncio # noqa: E402 from textual.widgets import Input # noqa: E402 async def _noop_worker(self, content: str) -> 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 = _resolved_app(_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 = _resolved_app(_args_existing()) async with app.run_test() as pilot: await pilot.pause() # Spy AFTER mount so identity-widget writes (if any) aren't counted. writes = _spy_writes(monkeypatch) 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 # POST: no RichLog write fires on empty submit assert writes == [] @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 = _resolved_app(_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 = _resolved_app(_args_existing()) async with app.run_test() as pilot: await pilot.pause() app.state = "cancelling" # bypass the natural transition for the test assert app.stream_worker is None # no live worker before non-idle submit 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" # POST-005 (from issue #4 on_input_submitted contract): # input cleared; NO new worker spawned during non-idle submit. assert inp.value == "" assert app.stream_worker is None @respx.mock async def test_footer_hint_flips_to_cancel(self, monkeypatch: pytest.MonkeyPatch) -> None: """footer_hint_flips_to_cancel [trace]: hint widget shows 'Ctrl-C to cancel'.""" from textual.widgets import Static monkeypatch.setattr(RatatoskrApp, "_stream_turn_worker", _noop_worker) app = _resolved_app(_args_existing()) async with app.run_test() as pilot: await pilot.pause() hint_widget = app.query_one("#hint", Static) assert str(hint_widget.render()) == RatatoskrApp.HINT_IDLE inp = app.query_one("#prompt", Input) inp.value = "hi" await inp.action_submit() await pilot.pause() assert str(hint_widget.render()) == 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_no_double_print(self, monkeypatch: pytest.MonkeyPatch) -> None: """happy_text_done_no_double_print [happy,tracer, v0.9.0]: Text("hello") mounts a Static(Markdown("hello")) into the transcript; Done mounts a [done] label Static. The Markdown is rendered live (one widget for the whole stream, updated in place), so there is NO post-Done re-render — exactly ONE Markdown renderable lands in the transcript for the response body. v0.9.0 supersedes v0.8.2's drop-Markdown patch with proper live rendering. """ 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 = _resolved_app(_args_existing()) async with app.run_test() as pilot: await pilot.pause() await _submit_and_wait(app, pilot, "hi") assert app.state == "idle" from rich.markdown import Markdown # The response body lives as ONE Markdown renderable mounted into # the transcript; live updates happen via Static.update, not via # re-mount, so there's exactly one Markdown in the spy stream. markdowns = [w for w in writes if isinstance(w, Markdown)] assert len(markdowns) == 1, ( f"v0.9.0: expected exactly ONE Markdown mounted, got {len(markdowns)}" ) assert markdowns[0].markup == "hello" # [done] label fires too. assert any("[done]" in _text_of(w) 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, v0.9.0]: With --raw, the response widget holds plain str instead of Markdown. Turn-header markers still appear in every pane: 3 RichLog panes receive a Rule, the transcript-scroll receives a Static-wrapped RichText (mounted, not written), giving 3 Rules in the captured writes list. """ 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 = _resolved_app(_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 from rich.rule import Rule # No Markdown in raw mode. assert not any(isinstance(w, Markdown) for w in writes) # 3 Rules — one per RichLog pane (tools / debug / thinking). # Transcript-scroll uses a Static turn-header Markdown alternative. rules = [w for w in writes if isinstance(w, Rule)] assert len(rules) == 3, f"expected 3 turn-header Rules, got {len(rules)}" # Accumulated text "hi" mounted as plain str into transcript. assert "hi" 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 = _resolved_app(_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 = _resolved_app(_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 = _resolved_app(_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 = _resolved_app(_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 = _resolved_app(_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_malformed_sse_data_returns_to_idle( self, monkeypatch: pytest.MonkeyPatch ) -> None: """malformed_sse_data_returns_to_idle [error]: bad-JSON → [malformed_sse_data]; idle.""" stream = ( _sse_chunk("42:1", {"type": "text", "content": "x"}) + b"id: 42:2\ndata: not-json\n\n" ) respx.post("https://w.example/sessions/s-1existing/messages").mock( return_value=_sse_resp(stream) ) writes = _spy_writes(monkeypatch) app = _resolved_app(_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("[malformed_sse_data]" in str(w) for w in writes) assert any("not-json" in str(w) for w in writes) # INV-008: mid-session error does NOT exit the app assert app.return_value is None @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) ) # Per issue #12: rendering went from stateless _render_event_to_log to # TuiPresenterState.render; the spy moves to the new method. from ratatoskr.tui import TuiPresenterState call_count = 0 original = TuiPresenterState.render def spy(self, event, **kw): # type: ignore[no-untyped-def] nonlocal call_count call_count += 1 return original(self, event, **kw) monkeypatch.setattr(TuiPresenterState, "render", spy) app = _resolved_app(_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 = _resolved_app(_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 = _resolved_app(_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" from textual.widgets import Static hint_widget = app.query_one("#hint", Static) assert str(hint_widget.render()) == 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 = _resolved_app(_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 # Capture worker reference + spy on its .cancel() before ctrl+c worker_ref = app.stream_worker assert worker_ref is not None cancel_calls: list = [] original_cancel = type(worker_ref).cancel monkeypatch.setattr( type(worker_ref), "cancel", lambda self: (cancel_calls.append(self), original_cancel(self))[-1], ) 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 # action_interrupt MUST cancel the stream worker on the no-active_turn_id force-exit path assert worker_ref in cancel_calls @respx.mock async def test_cancelling_second_ctrl_c_force_exits( self, monkeypatch: pytest.MonkeyPatch ) -> None: """cancelling_second_ctrl_c_force_exits [scenario]: …""" # Set up a real live stream worker (gated, hangs forever) so we can # observe action_interrupt's cancel() call on the second-Ctrl-C path. monkeypatch.setattr(RatatoskrApp, "_stream_turn_worker", _noop_worker) app = _resolved_app(_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.stream_worker is not None app.state = "cancelling" # bypass the natural transition for the test worker_ref = app.stream_worker cancel_calls: list = [] original_cancel = type(worker_ref).cancel monkeypatch.setattr( type(worker_ref), "cancel", lambda self: (cancel_calls.append(self), original_cancel(self))[-1], ) await pilot.press("ctrl+c") await pilot.pause() assert app.return_value == 3 # Second-Ctrl-C in cancelling state MUST cancel the in-flight worker assert worker_ref in cancel_calls @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 = _resolved_app(_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) from textual.containers import VerticalScroll from textual.widgets import Static transcript = app.query_one("#transcript-scroll", VerticalScroll) rendered = "\n".join( str(child.content) for child in transcript.children if isinstance(child, Static) ) 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 = _resolved_app(_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 = _resolved_app(_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) # Capture worker + spy on cancel before ctrl+d worker_ref = app.stream_worker assert worker_ref is not None cancel_calls: list = [] original_cancel = type(worker_ref).cancel monkeypatch.setattr( type(worker_ref), "cancel", lambda self: (cancel_calls.append(self), original_cancel(self))[-1], ) await pilot.press("ctrl+d") await pilot.pause() gate.set() assert app.return_value == 0 assert cancel_route.call_count == 0 # POST-002: Ctrl-D MUST cancel the in-flight stream worker (abandon-and-exit) assert worker_ref in cancel_calls from ratatoskr.tui import run_tui # noqa: E402 class TestResolveThenRun: """Tests at the `_resolve_then_run` layer — pre-`App.run()` session resolution + AsyncClient ownership + stderr error routing per issue #6. """ @respx.mock def test_happy_new_session_resolve(self, monkeypatch: pytest.MonkeyPatch) -> None: """happy_new_session_resolve [happy]: --new path through _resolve_then_run. Verifies POST /sessions count + SessionInfo propagation to RatatoskrApp's pre-resolved state (session_id / agent_id / client). The corresponding TestAppMount.test_happy_new_session_mount uses _resolved_app and bypasses _resolve_then_run entirely; this test exercises the production resolve path with a real POST /sessions mock. """ sessions_route = respx.post("https://w.example/sessions").mock( return_value=httpx.Response(201, json=_CREATE_OK_RESP) ) snapshot: dict = {} async def capture_run_async(self, *a, **kw): snapshot["session_id"] = self.session_id snapshot["agent_id"] = self.agent_id snapshot["client"] = self.client snapshot["client_open"] = not self.client.is_closed return 0 monkeypatch.setattr(RatatoskrApp, "run_async", capture_run_async) rc = run_tui(_args_new()) assert rc == 0 # Exactly one POST /sessions invocation by _resolve_then_run assert sessions_route.call_count == 1 # SessionInfo fields propagated into the constructed App assert snapshot["session_id"] == "s-new12345" assert snapshot["agent_id"] == "mimir" assert snapshot["client"] is not None assert snapshot["client_open"] is True @respx.mock def test_happy_new_with_end_user_id_resolve(self, monkeypatch: pytest.MonkeyPatch) -> None: """happy_new_with_end_user_id_resolve [happy]: args.end_user_id threads into POST body. Issue #5 amends #4: _resolve_then_run's create_session call now forwards args.end_user_id (renamed from the contract's _mount target, since #6 moved session resolution out of on_mount into _resolve_then_run). """ import json as _json sessions_route = respx.post("https://w.example/sessions").mock( return_value=httpx.Response(201, json=_CREATE_OK_RESP) ) async def fake_run_async(self, *a, **kw): return 0 monkeypatch.setattr(RatatoskrApp, "run_async", fake_run_async) rc = run_tui(_args_new(end_user_id="alice")) assert rc == 0 assert sessions_route.call_count == 1 body = _json.loads(sessions_route.calls[0].request.content) assert body == {"agent_id": "mimir", "end_user_id": "alice"} @respx.mock def test_user_agent_header_sent(self, monkeypatch: pytest.MonkeyPatch) -> None: """user_agent_header_sent [trace]: outbound requests carry the ratatoskr User-Agent. Worldtree-dev (althing 2026-05-23) requested consumers send `User-Agent: ratatoskr/ ()` so server logs can distinguish ratatoskr traffic from other consumers. """ sessions_route = respx.post("https://w.example/sessions").mock( return_value=httpx.Response(201, json=_CREATE_OK_RESP) ) async def fake_run_async(self, *a, **kw): return 0 monkeypatch.setattr(RatatoskrApp, "run_async", fake_run_async) rc = run_tui(_args_new()) assert rc == 0 ua = sessions_route.calls[0].request.headers["User-Agent"] assert ua.startswith("ratatoskr/") assert "vh@phasefinal.com" in ua @respx.mock def test_alt_screen_never_opens_on_resolve_error(self, monkeypatch: pytest.MonkeyPatch) -> None: """alt_screen_never_opens_on_resolve_error [trace]: 404 → run_tui=12; run_async unhit. Directly probes INV-001: session resolution failures MUST short-circuit BEFORE the alt-screen opens. """ respx.post("https://w.example/sessions").mock( return_value=httpx.Response(404, json={"error": "unknown_agent_id"}) ) sentinel_called = False async def sentinel(self, *a, **kw): nonlocal sentinel_called sentinel_called = True return 0 monkeypatch.setattr(RatatoskrApp, "run_async", sentinel) rc = run_tui(_args_new()) assert rc == 12 assert not sentinel_called @respx.mock def test_agent_not_found_on_resolve(self, capsys: pytest.CaptureFixture[str]) -> None: """agent_not_found_on_resolve [error]: --new + 404 → stderr [agent_not_found]; exit 12.""" respx.post("https://w.example/sessions").mock( return_value=httpx.Response(404, json={"error": "unknown_agent_id"}) ) rc = run_tui(_args_new()) err = capsys.readouterr().err assert rc == 12 assert "[agent_not_found]" in err assert "agent_id=mimir" in err @respx.mock def test_session_api_failed_on_resolve(self, capsys: pytest.CaptureFixture[str]) -> None: """session_api_failed_on_resolve [error]: --new + 500 → [session_api_failed] stderr.""" respx.post("https://w.example/sessions").mock( return_value=httpx.Response(500, content=b"server error") ) rc = run_tui(_args_new()) err = capsys.readouterr().err assert rc == 20 assert "[session_api_failed]" in err assert "status=500" in err @respx.mock def test_network_error_on_resolve(self, capsys: pytest.CaptureFixture[str]) -> None: """network_error_on_resolve [error]: --new + ConnectError → [network_error] stderr.""" respx.post("https://w.example/sessions").mock(side_effect=httpx.ConnectError("down")) rc = run_tui(_args_new()) err = capsys.readouterr().err assert rc == 21 assert "[network_error]" in err assert "ConnectError" in err @respx.mock def test_stderr_label_format_matches_cli(self, capsys: pytest.CaptureFixture[str]) -> None: """stderr_label_format_matches_cli [trace]: cli._amain and _resolve_then_run produce identical stderr lines for AgentNotFound (INV-006). """ # Re-fetch cli's ParsedArgs from the current module state — test_cli's # `importlib.reload(ratatoskr.cli)` rebinds the class, so the top-of-file # `from ratatoskr.cli import ParsedArgs` may now refer to a stale class. from ratatoskr import cli as cli_mod respx.post("https://w.example/sessions").mock( return_value=httpx.Response(404, json={"error": "unknown_agent_id"}) ) cli_args = cli_mod.ParsedArgs( send_content="x", session_id=None, new=True, agent_id="mimir", api_key="k", server_url="https://w.example", raw=False, ) # Drive cli._amain's error path (--send mode) cli_rc = asyncio.run(cli_mod._amain(cli_args)) cli_err = capsys.readouterr().err # Drive _resolve_then_run's error path (TUI mode); _args_new() uses the # pre-reload ParsedArgs which still matches tui.run_tui's isinstance check. tui_rc = run_tui(_args_new()) tui_err = capsys.readouterr().err # Same exit code, same verbatim stderr line. assert cli_rc == 12 assert tui_rc == 12 assert cli_err == tui_err assert cli_err == "[agent_not_found] agent_id=mimir\n" def test_client_open_after_resolve(self, monkeypatch: pytest.MonkeyPatch) -> None: """client_open_after_resolve [trace]: app.client is open at the time run_async runs.""" snapshot: dict = {} async def capture_run_async(self, *a, **kw): snapshot["client_is"] = self.client snapshot["closed_during_run"] = self.client.is_closed return 0 monkeypatch.setattr(RatatoskrApp, "run_async", capture_run_async) rc = run_tui(_args_existing()) assert rc == 0 assert snapshot["client_is"] is not None assert snapshot["closed_during_run"] is False def test_client_lifetime_owned_by_run_tui(self, monkeypatch: pytest.MonkeyPatch) -> None: """client_lifetime_owned_by_run_tui [trace]: open during run_async, closed after run_tui. Probes INV-002: the App is a consumer of an externally-owned client; the async-with in run_tui closes it, not on_unmount. """ snapshot: dict = {} async def capture_run_async(self, *a, **kw): # During run_async (the alt-screen lifetime) the client is open. snapshot["client"] = self.client snapshot["closed_during_run"] = self.client.is_closed return 0 monkeypatch.setattr(RatatoskrApp, "run_async", capture_run_async) rc = run_tui(_args_existing()) assert rc == 0 client = snapshot["client"] assert client is not None # Open while the app was running; closed by run_tui's async-with after. assert snapshot["closed_during_run"] is False assert client.is_closed is True def test_run_tui_closes_client_on_app_exit(self, monkeypatch: pytest.MonkeyPatch) -> None: """run_tui_closes_client_on_app_exit: async-with closes client after app.run_async ret.""" seen_clients: list[httpx.AsyncClient] = [] async def fake_run_async(self, *a, **kw): seen_clients.append(self.client) return 0 monkeypatch.setattr(RatatoskrApp, "run_async", fake_run_async) rc = run_tui(_args_existing()) assert rc == 0 assert len(seen_clients) == 1 # After run_tui returns, the client should be closed by the async-with assert seen_clients[0].is_closed async def test_on_unmount_does_not_close_client(self) -> None: """on_unmount narrowed [trace]: probes INV-002 from the on_unmount side. The complementary check to test_client_lifetime_owned_by_run_tui (which patches run_async and so never exercises on_unmount). Here we DO run the real on_unmount via Pilot ctrl+d → app teardown, and assert the client is still open afterward (close site is run_tui's async-with, which is NOT entered in this Pilot-driven test). """ client = httpx.AsyncClient( base_url="https://w.example", headers={"Authorization": "Bearer k"}, timeout=httpx.Timeout(connect=10.0, read=None, write=10.0, pool=10.0), ) app = RatatoskrApp( _args_existing(), session_id="s-1existing", agent_id=None, client=client, ) async with app.run_test() as pilot: await pilot.pause() assert client.is_closed is False await pilot.press("ctrl+d") await pilot.pause() # After app.run_test() teardown, on_unmount has fired. Per INV-002 the # client MUST still be open — only run_tui's async-with closes it. assert client.is_closed is False await client.aclose() # test-side cleanup 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_async exit code.""" captured: list[ParsedArgs] = [] async def fake_run_async(self, *a, **kw): captured.append(self.args) return 0 monkeypatch.setattr(RatatoskrApp, "run_async", fake_run_async) 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) # ---- Issue #8: startup agent picker ---------------------------------------- def _args_new_no_agent(**overrides) -> ParsedArgs: """ParsedArgs for bare --new (no --agent) — TUI-mode picker entry.""" base = dict( send_content=None, session_id=None, new=True, agent_id=None, # Issue #8: bare --new, picker drives the choice api_key="k", server_url="https://w.example", raw=False, ) base.update(overrides) return ParsedArgs(**base) _AGENTS_RESP = [ { "agent_id": "mimir", "name": "Mimir", "description": "Keeper of the Well of Knowledge.", }, { "agent_id": "lofn", "name": "Lofn", "description": "Mediator of secret affairs.", }, ] class TestAgentPickerApp: def test_picker_renders_rows(self) -> None: """picker_renders_rows: AgentPickerApp composes one ListItem per agent.""" from textual.widgets import ListView from ratatoskr.sessions import AgentInfo from ratatoskr.tui import AgentPickerApp agents = [ AgentInfo( agent_id="a", name="A", description="x", version=None, capabilities=[], supported_models=[], persona_traits={}, ui_hints={}, ), AgentInfo( agent_id="b", name="B", description="y", version=None, capabilities=[], supported_models=[], persona_traits={}, ui_hints={}, ), ] app = AgentPickerApp(agents) async def probe() -> None: async with app.run_test() as pilot: lv = app.query_one("#agent-list", ListView) assert len(lv.children) == 2 await pilot.pause() app.exit(None) import asyncio asyncio.run(probe()) def test_picker_pick_returns_agent_id(self) -> None: """picker_pick_returns_agent_id: highlight idx 1 + Enter → exit value == 'b'.""" from ratatoskr.sessions import AgentInfo from ratatoskr.tui import AgentPickerApp agents = [ AgentInfo( agent_id="a", name="A", description="x", version=None, capabilities=[], supported_models=[], persona_traits={}, ui_hints={}, ), AgentInfo( agent_id="b", name="B", description="y", version=None, capabilities=[], supported_models=[], persona_traits={}, ui_hints={}, ), ] app = AgentPickerApp(agents) async def drive() -> str | None: async with app.run_test() as pilot: from textual.widgets import ListView lv = app.query_one("#agent-list", ListView) lv.index = 1 await pilot.pause() await pilot.press("enter") await pilot.pause() return app.return_value import asyncio chosen = asyncio.run(drive()) assert chosen == "b" def test_picker_esc_returns_none(self) -> None: """picker_esc_returns_none: Esc → exit value is None.""" from ratatoskr.sessions import AgentInfo from ratatoskr.tui import AgentPickerApp agents = [ AgentInfo( agent_id="a", name="A", description="x", version=None, capabilities=[], supported_models=[], persona_traits={}, ui_hints={}, ), ] app = AgentPickerApp(agents) async def drive() -> str | None: async with app.run_test() as pilot: await pilot.press("escape") await pilot.pause() return app.return_value import asyncio chosen = asyncio.run(drive()) assert chosen is None class TestResolveThenRunWithPicker: """Issue #8: picker integration in _resolve_then_run.""" @respx.mock def test_picker_happy_path(self, monkeypatch: pytest.MonkeyPatch) -> None: """picker_happy_path [happy,tracer]: agents fetched → picker exits with id → create_session. Patches AgentPickerApp.run_async to return 'lofn' (simulating user pick); asserts list_agents fired once, POST /sessions body carries agent_id=lofn, and RatatoskrApp opens with the chosen identity. """ agents_route = respx.get("https://w.example/agents").mock( return_value=httpx.Response(200, json=_AGENTS_RESP) ) sessions_route = respx.post("https://w.example/sessions").mock( return_value=httpx.Response( 201, json={ **_CREATE_OK_RESP, "agent_id": "lofn", }, ) ) from ratatoskr.tui import AgentPickerApp async def picker_returns_lofn(self, *a, **kw): return "lofn" monkeypatch.setattr(AgentPickerApp, "run_async", picker_returns_lofn) snapshot: dict = {} async def capture_main(self, *a, **kw): snapshot["session_id"] = self.session_id snapshot["agent_id"] = self.agent_id return 0 monkeypatch.setattr(RatatoskrApp, "run_async", capture_main) from ratatoskr.tui import run_tui rc = run_tui(_args_new_no_agent()) assert rc == 0 assert agents_route.call_count == 1 assert sessions_route.call_count == 1 import json as _json body = _json.loads(sessions_route.calls[0].request.content) assert body == {"agent_id": "lofn"} assert snapshot["agent_id"] == "lofn" @respx.mock def test_picker_esc_clean_exit(self, monkeypatch: pytest.MonkeyPatch) -> None: """picker_esc_clean_exit: picker returns None → exit 0; no create_session; no main App.""" agents_route = respx.get("https://w.example/agents").mock( return_value=httpx.Response(200, json=_AGENTS_RESP) ) sessions_route = respx.post("https://w.example/sessions").mock( return_value=httpx.Response(201, json=_CREATE_OK_RESP) ) from ratatoskr.tui import AgentPickerApp async def picker_dismissed(self, *a, **kw): return None monkeypatch.setattr(AgentPickerApp, "run_async", picker_dismissed) main_called = False async def sentinel(self, *a, **kw): nonlocal main_called main_called = True return 0 monkeypatch.setattr(RatatoskrApp, "run_async", sentinel) from ratatoskr.tui import run_tui rc = run_tui(_args_new_no_agent()) assert rc == 0 assert agents_route.call_count == 1 assert sessions_route.call_count == 0 assert main_called is False @respx.mock def test_picker_skipped_when_agent_id_provided(self, monkeypatch: pytest.MonkeyPatch) -> None: """picker_skipped_when_agent_id_provided: --new --agent mimir → list_agents NOT called.""" agents_route = respx.get("https://w.example/agents").mock( return_value=httpx.Response(200, json=_AGENTS_RESP) ) sessions_route = respx.post("https://w.example/sessions").mock( return_value=httpx.Response(201, json=_CREATE_OK_RESP) ) async def fake_main(self, *a, **kw): return 0 monkeypatch.setattr(RatatoskrApp, "run_async", fake_main) from ratatoskr.tui import run_tui rc = run_tui(_args_new()) # agent_id="mimir" assert rc == 0 assert agents_route.call_count == 0 assert sessions_route.call_count == 1 @respx.mock def test_picker_merges_local_tier3_agents( self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: """picker_merges_local_tier3_agents [v0.8.0]: local index entries appear in the picker's agent list alongside remote agents.""" from ratatoskr.local_agents import LocalAgentEntry, add_local_agent # Isolate the local index in a tmp file. monkeypatch.setenv( "RATATOSKR_LOCAL_AGENTS", str(tmp_path / "local_agents.json") ) add_local_agent(LocalAgentEntry( agent_id="ratatoskr:wizard", agent_name="wizard", model="qwen3.6-35-a3b", description="(tier 3) test wizard", defined_at="2026-05-25T00:00:00+00:00", )) respx.get("https://w.example/agents").mock( return_value=httpx.Response(200, json=_AGENTS_RESP) ) respx.post("https://w.example/sessions").mock( return_value=httpx.Response(201, json=_CREATE_OK_RESP) ) from ratatoskr.tui import AgentPickerApp captured: list = [] async def capture_picker_init(self, *a, **kw): captured.append(list(self.agents)) return "mimir" # auto-pick something so the rest succeeds # Patch __init__ to capture the agent list passed to the picker. orig_init = AgentPickerApp.__init__ def init_spy(self, agents): captured.append(list(agents)) orig_init(self, agents) monkeypatch.setattr(AgentPickerApp, "__init__", init_spy) async def picker_returns_mimir(self): return "mimir" monkeypatch.setattr(AgentPickerApp, "run_async", picker_returns_mimir) async def fake_main(self, *a, **kw): return 0 monkeypatch.setattr(RatatoskrApp, "run_async", fake_main) from ratatoskr.tui import run_tui rc = run_tui(_args_new_no_agent()) assert rc == 0 # The local tier-3 agent should appear in the picker's agents list. assert captured, "AgentPickerApp.__init__ was never called" agent_ids = {a.agent_id for a in captured[0]} assert "ratatoskr:wizard" in agent_ids # Plus the remote agents. assert "mimir" in agent_ids assert "lofn" in agent_ids @respx.mock def test_picker_skipped_when_session_mode(self, monkeypatch: pytest.MonkeyPatch) -> None: """picker_skipped_when_session_mode: --session s-1 → no list_agents, no create_session.""" agents_route = respx.get("https://w.example/agents").mock( return_value=httpx.Response(200, json=_AGENTS_RESP) ) sessions_route = respx.post("https://w.example/sessions").mock( return_value=httpx.Response(201, json=_CREATE_OK_RESP) ) async def fake_main(self, *a, **kw): return 0 monkeypatch.setattr(RatatoskrApp, "run_async", fake_main) from ratatoskr.tui import run_tui rc = run_tui(_args_existing()) assert rc == 0 assert agents_route.call_count == 0 assert sessions_route.call_count == 0 @respx.mock def test_picker_list_agents_session_api_failed( self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: """list_agents 500 → stderr [session_api_failed]; exit 20; picker NOT opened.""" respx.get("https://w.example/agents").mock( return_value=httpx.Response(500, content=b"oops") ) from ratatoskr.tui import AgentPickerApp picker_called = False async def sentinel(self, *a, **kw): nonlocal picker_called picker_called = True return None monkeypatch.setattr(AgentPickerApp, "run_async", sentinel) main_called = False async def main_sentinel(self, *a, **kw): nonlocal main_called main_called = True return 0 monkeypatch.setattr(RatatoskrApp, "run_async", main_sentinel) from ratatoskr.tui import run_tui rc = run_tui(_args_new_no_agent()) assert rc == 20 err = capsys.readouterr().err assert "[session_api_failed]" in err assert "status=500" in err assert picker_called is False assert main_called is False @respx.mock def test_picker_empty_list( self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path, ) -> None: """list_agents returns [] AND no local tier-3 entries → stderr [no_agents]; exit 13; picker NOT opened. Isolate $RATATOSKR_LOCAL_AGENTS so the operator's real local index doesn't merge in and turn this into a non-empty list.""" # v0.8.0 isolation: point local agents at an empty tmp file. monkeypatch.setenv( "RATATOSKR_LOCAL_AGENTS", str(tmp_path / "empty_local_agents.json") ) respx.get("https://w.example/agents").mock(return_value=httpx.Response(200, json=[])) from ratatoskr.tui import AgentPickerApp picker_called = False async def sentinel(self, *a, **kw): nonlocal picker_called picker_called = True return None monkeypatch.setattr(AgentPickerApp, "run_async", sentinel) from ratatoskr.tui import run_tui rc = run_tui(_args_new_no_agent()) assert rc == 13 err = capsys.readouterr().err assert "[no_agents]" in err assert picker_called is False