Files
ratatoskr/tests/test_tui.py
T
vh 24e4371ec7 feat(tui): issue #13 — §5 layout reshape + Tools pane (v0.4.0)
Reshape the TUI from vertical-stack single-pane to Horizontal
two-column with TabbedContent on the right; v1 has a single Tools
tab that consumes ToolStart/ToolResult SSE events previously
rendered inline in the transcript. Foundation for the rest of
design-brief §5; subsequent panes (Persona/AdminEvents/BifrostState/
ServerLog) plug in as sibling TabPanes when their substrate
blockers resolve.

Three coupled pieces, all in-place amendments to issues #4 + #12:

- **Layout**: compose() yields Horizontal#main-row containing
  Vertical#left-column (transcript + thinking-current + prompt) and
  Vertical#right-column (TabbedContent#side-panes with
  TabPane#tools-tab → RichLog#tools-log). Width split 2fr:1fr. CSS
  dock rules narrow to per-container scope so thinking-current
  toggling doesn't reflow the right column.
- **Tools pane**: TuiPresenterState.render() signature widens with
  tools_log: RichLog. ToolStart/ToolResult route there per INV-014;
  every other event keeps its issue-#12 routing. Plain-label
  fallback under render-exception preserves routing (INV-009).
- **Ctrl+1 binding + pane-name widget**: BINDINGS gains
  Binding("ctrl+1", "focus_tools") which programmatically sets
  TabbedContent.active; Textual's default preserves Input focus per
  INV-016 (test asserts; regression path documented).
  Static#pane-name in the footer renders "Tools" v1 (static — no
  tab-switch handler wiring lands in #13 per amendment-2 from
  Volva paraphrase review).

CLI mode (--send) is unaffected by design per INV-018 — non-
interactive, no tabs concept; CLI keeps inline tool-event rendering.

Contract: docs/contracts/issues/13.contract.md (drift-check clean,
two amendments applied from Volva contract-paraphrase pass).

Tests: +9 net (TestLayoutShape × 7 + TestTuiPresenterState routing
× 3, minus 1 deprecated test_tool_start_demoted superseded by
test_tool_start_routes_to_tools_log). 236 total GREEN; ruff clean.

Live smoke against personal Worldtree's mimir: tool-using turn
(KB search) populated tools_log with tool_start + tool_result for
search_library + read_note; transcript stayed chat-only with
worker_phase + thinking. Routing-not-duplication confirmed
end-to-end.
2026-05-23 21:41:16 -07:00

2108 lines
80 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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,
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 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
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_coalesce_single_widget_update(self) -> None:
"""thinking_coalesce_single_widget_update [happy,tracer]:
3 Thinking events → thinking_widget.update called 3 times with cumulative content;
RichLog has 0 thinking entries (closure hasn't fired yet).
"""
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
widget = MagicMock()
state = TuiPresenterState()
state.render(
Thinking(sse_id=SID, content="a"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
raw=False,
)
state.render(
Thinking(sse_id=SID, content="b"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
raw=False,
)
state.render(
Thinking(sse_id=SID, content="c"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
raw=False,
)
# Widget updated 3 times — once per delta — with cumulative content
assert widget.update.call_count == 3
# Latest call shows the full accumulated content (under 200 chars so no truncation)
assert widget.update.call_args_list[-1][0][0] == "abc"
# Widget became visible at first delta
assert widget.display is True
# No RichLog write yet — closure hasn't fired
assert log.write.call_count == 0
def test_thinking_closes_one_richlog_entry(self) -> None:
"""thinking_closes_one_richlog_entry [happy]: 2x Thinking + WorkerPhase →
RichLog has ONE closed thinking entry + one worker_phase entry; widget cleared+hidden.
"""
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
widget = MagicMock()
state = TuiPresenterState()
state.render(
Thinking(sse_id=SID, content="a"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
raw=False,
)
state.render(
Thinking(sse_id=SID, content="b"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
raw=False,
)
state.render(
WorkerPhase(sse_id=SID, phase="streaming", turn_id=42),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
raw=False,
)
# Closure wrote "· thinking: ab"; then worker_phase wrote "· worker_phase: ..."
assert log.write.call_count == 2
# First write = closed thinking entry containing the full accumulated text
assert "· thinking: ab" in log.write.call_args_list[0][0][0]
# Second write = worker_phase with demotion prefix
assert "· worker_phase:" in log.write.call_args_list[1][0][0]
# Widget cleared + hidden
widget.update.assert_called_with("")
assert widget.display is False
def test_thinking_widget_truncation(self) -> None:
"""thinking_widget_truncation [trace]: buffer 500 chars → widget shows "" + last 200."""
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
widget = MagicMock()
state = TuiPresenterState()
# Push 500 chars across multiple deltas.
long = "x" * 500
state.render(
Thinking(sse_id=SID, content=long),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
raw=False,
)
last_update = widget.update.call_args_list[-1][0][0]
# …-prefix + last-200 = 201 chars
assert last_update.startswith("")
assert len(last_update) == 201
def test_thinking_widget_visibility_lifecycle(self) -> None:
"""thinking_widget_visibility_lifecycle [trace]: hidden at start; visible during thinking;
hidden after closing event.
"""
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
widget = MagicMock()
widget.display = False # initial state (composed hidden)
state = TuiPresenterState()
# First thinking delta → widget visible
state.render(
Thinking(sse_id=SID, content="x"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
raw=False,
)
assert widget.display is True
# Closure (WorkerPhase) → widget hidden
state.render(
WorkerPhase(sse_id=SID, phase="streaming", turn_id=42),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
raw=False,
)
assert widget.display is False
def test_multiple_thinking_runs_each_get_richlog_entry(self) -> None:
"""multiple_thinking_runs_each_get_richlog_entry [scenario]:
Thinking → Text → Thinking → Done → TWO closed thinking RichLog entries.
"""
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
widget = MagicMock()
state = TuiPresenterState()
state.render(
Thinking(sse_id=SID, content="first"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
raw=False,
)
state.render(
Text(sse_id=SID, content="hi"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
raw=False,
)
state.render(
Thinking(sse_id=SID, content="second"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
raw=False,
)
# Close the second run with a Done.
state.render(
_make_tui_done(), log=log, thinking_widget=widget, tools_log=MagicMock(), raw=True
)
# Count closed thinking entries — now dim RichText; plain text starts with "· thinking:".
thinking_entries = [_text_of(call[0][0]) for call in log.write.call_args_list]
thinking_entries = [t for t in thinking_entries if t.startswith("· thinking:")]
assert len(thinking_entries) == 2
assert "first" in thinking_entries[0]
assert "second" in thinking_entries[1]
def test_render_exception_fallback(self) -> None:
"""render_exception_fallback [adversarial]:
widget.update raises → RichLog gets BOTH a plain-labeled fallback line for
the original event AND a `[render_error] <ExceptionClassName>` line
(NO exception message per INV-009 security clause); state does NOT propagate.
"""
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
widget = MagicMock()
widget.update.side_effect = AttributeError("widget gone (msg should NOT leak)")
state = TuiPresenterState()
# Should not raise; should write a fallback labeled line + a [render_error] line.
state.render(
Thinking(sse_id=SID, content="x"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
raw=False,
)
writes = [call[0][0] for call in log.write.call_args_list if isinstance(call[0][0], str)]
# POST-007: plain-label fallback for the original Thinking event (pre-amendment shape).
assert any(w.startswith("[thinking]") for w in writes), writes
# POST-007: render_error line with class name ONLY.
assert any(w == "[render_error] AttributeError" for w in writes), writes
# Critical: exception message MUST NOT appear in any write (INV-009 security).
assert not any("widget gone" in w for w in writes), writes
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"),
log=MagicMock(),
thinking_widget=MagicMock(),
tools_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]:
Thinking, Cancelled → ONE closed thinking entry + a [cancelled] entry; widget hidden.
"""
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
widget = MagicMock()
state = TuiPresenterState()
state.render(
Thinking(sse_id=SID, content="partial"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
raw=False,
)
state.render(
Cancelled(
sse_id=SID, phase="cancelled", turn_id=42, reason="user", partial_message_id=None
),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
raw=False,
)
# Closed thinking entries are now dim RichText; terminal labels are plain str.
writes = [_text_of(c[0][0]) for c in log.write.call_args_list]
assert any(w.startswith("· thinking: partial") for w in writes)
assert any(w.startswith("[cancelled]") for w in writes)
assert widget.display is False
def test_done_renders_markdown_after_label(self) -> None:
"""done_renders_markdown_after_label [happy]:
Text("hi"), Done(response="hi") with raw=False → [done] label, Rule, Markdown in RichLog.
"""
from rich.markdown import Markdown
from rich.rule import Rule
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
widget = MagicMock()
state = TuiPresenterState()
state.render(
Text(sse_id=SID, content="hi"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
raw=False,
)
state.render(
_make_tui_done(), log=log, thinking_widget=widget, tools_log=MagicMock(), raw=False
)
writes = [c[0][0] for c in log.write.call_args_list]
# Text stream wrote "hi" with no prefix.
assert "hi" in writes
# [done] label wrote.
assert any(isinstance(w, str) and w.startswith("[done]") for w in writes)
# Rule + Markdown render present (post-Done body re-render per issue #4 INV-005).
assert any(isinstance(w, Rule) for w in writes)
assert any(isinstance(w, Markdown) for w in writes)
def test_raw_flag_skips_markdown(self) -> None:
"""raw_flag_skips_markdown [trace]: raw=True → no Rule, no Markdown."""
from rich.markdown import Markdown
from rich.rule import Rule
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
widget = MagicMock()
state = TuiPresenterState()
state.render(
Text(sse_id=SID, content="hi"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
raw=True,
)
state.render(
_make_tui_done(), log=log, thinking_widget=widget, tools_log=MagicMock(), raw=True
)
writes = [c[0][0] for c in log.write.call_args_list]
assert not any(isinstance(w, Rule) for w in writes)
assert not any(isinstance(w, Markdown) for w in writes)
def test_worker_phase_demoted(self) -> None:
"""worker_phase_demoted [trace]: WorkerPhase → RichLog "· worker_phase:" prefix
rendered with dim Rich style (INV-003: dim style + `· ` prefix in TUI).
"""
from rich.text import Text as RichText
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
state = TuiPresenterState()
state.render(
WorkerPhase(sse_id=SID, phase="streaming", turn_id=42),
log=log,
thinking_widget=MagicMock(),
tools_log=MagicMock(),
raw=False,
)
renderable = log.write.call_args[0][0]
# INV-003: must be a dim-styled Rich Text renderable, not a plain str.
assert isinstance(renderable, RichText), type(renderable)
assert renderable.style == "dim"
text = renderable.plain
assert text.startswith("· worker_phase:")
assert "[worker_phase]" not in text
def test_terminal_events_belt_and_braces_widget_cleanup(self) -> None:
"""terminal_events_belt_and_braces_widget_cleanup [trace]:
Done / Error / Cancelled MUST clear+hide the thinking widget even when
thinking_open is False (Volva F3 fix; POST-005 + STEPS 5-6).
"""
from ratatoskr.tui import TuiPresenterState
for terminal in (
_make_tui_done(),
Error(sse_id=SID, phase="failed", message="boom", error_code="x"),
Cancelled(
sse_id=SID, phase="cancelled", turn_id=42, reason="r", partial_message_id=None
),
):
log = MagicMock()
widget = MagicMock()
widget.display = True # pre-set to non-default to detect the clear
state = TuiPresenterState()
# thinking_open is False (state just constructed).
state.render(terminal, log=log, thinking_widget=widget, tools_log=MagicMock(), raw=True)
# Belt-and-braces: widget cleared + hidden on EVERY terminal event.
widget.update.assert_called_with("")
assert widget.display is False, type(terminal).__name__
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
log = MagicMock()
tools_log = MagicMock()
state = TuiPresenterState()
state.render(
ToolStart(sse_id=SID, name="read_file", arguments={"path": "/x"}),
log=log,
thinking_widget=MagicMock(),
tools_log=tools_log,
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 log.write.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
log = MagicMock()
tools_log = MagicMock()
state = TuiPresenterState()
state.render(
ToolResult(sse_id=SID, name="read_file", result="ok", duration_ms=12),
log=log,
thinking_widget=MagicMock(),
tools_log=tools_log,
raw=False,
)
assert tools_log.write.called
assert _text_of(tools_log.write.call_args[0][0]).startswith("· tool_result:")
assert not log.write.called
def test_text_event_does_not_route_to_tools_log(self) -> None:
"""text_event_does_not_route_to_tools_log [INV-015]: Text → transcript, NOT tools_log."""
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
tools_log = MagicMock()
state = TuiPresenterState()
state.render(
Text(sse_id=SID, content="hello"),
log=log,
thinking_widget=MagicMock(),
tools_log=tools_log,
raw=False,
)
assert log.write.called
assert log.write.call_args[0][0] == "hello"
# INV-015: tools_log was NOT written to
assert not tools_log.write.called
def test_text_no_prefix(self) -> None:
"""text_no_prefix [trace]: Text → RichLog line has no `·` prefix, no demotion."""
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
state = TuiPresenterState()
state.render(
Text(sse_id=SID, content="hello"),
log=log,
thinking_widget=MagicMock(),
tools_log=MagicMock(),
raw=False,
)
line = log.write.call_args[0][0]
# Pure content, no demotion prefix.
assert line == "hello"
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
log = MagicMock()
state = TuiPresenterState()
state.render(
_make_tui_done(duration_ms=5467),
log=log,
thinking_widget=MagicMock(),
tools_log=MagicMock(),
raw=True,
)
done_line = next(
c[0][0]
for c in log.write.call_args_list
if isinstance(c[0][0], str) and c[0][0].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
log = MagicMock()
state = TuiPresenterState()
usage = {
"prompt_tokens": 6756,
"completion_tokens": 126,
"total_tokens": 6882,
"cached_input_tokens": 0,
}
state.render(
_make_tui_done(usage=usage),
log=log,
thinking_widget=MagicMock(),
tools_log=MagicMock(),
raw=True,
)
done_line = next(
c[0][0]
for c in log.write.call_args_list
if isinstance(c[0][0], str) and c[0][0].startswith("[done]")
)
assert "usage 6756 in → 126 out (6882 total, 0 cached)" in done_line
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.
"""
from rich.text import Text as RichText
if isinstance(write_arg, RichText):
return write_arg.plain
if isinstance(write_arg, str):
return write_arg
return "" # Markdown / Rule / etc. — not text content
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; 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:
"""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 <unknown> 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 → <unknown> · …<tail>
assert "<unknown>" 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_has_transcript_and_prompt(self) -> None:
"""left_column_has_transcript_and_prompt: left column = transcript + prompt + thinking."""
from textual.containers import Vertical
from textual.widgets import Input, RichLog, Static
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)
assert left is not None
transcript = app.query_one("#transcript", RichLog)
prompt = app.query_one("#prompt", Input)
thinking = app.query_one("#thinking-current", Static)
# Widgets are inside the left column (descendant check)
assert transcript in left.walk_children()
assert prompt in left.walk_children()
assert thinking in left.walk_children()
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-log 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}"
)
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_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 = _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"
# 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.
# INV-005: BOTH separator (Rule) AND markdown render must be present in non-raw.
from rich.markdown import Markdown
from rich.rule import Rule
assert any(isinstance(w, Markdown) for w in writes)
assert any(isinstance(w, Rule) 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 = _resolved_app(_args_existing(raw=True))
async with app.run_test() as pilot:
await pilot.pause()
await _submit_and_wait(app, pilot, "x")
# INV-005: with --raw, NEITHER Rule separator NOR Markdown render appears.
from rich.markdown import Markdown
from rich.rule import Rule
assert not any(isinstance(w, Markdown) for w in writes)
assert not any(isinstance(w, Rule) 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 = _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)
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 = _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/<version> (<contact>)` 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_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],
) -> None:
"""list_agents returns [] → stderr [no_agents]; exit 13; picker NOT opened."""
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