Files
ratatoskr/tests/test_tui.py
T
vh 9fade55901 feat(local_agents): tier-3 index + picker merge (v0.8.0)
Worldtree's GET /agents doesn't return consumer-defined (tier-3)
agents — the public list excludes them by design. Confirmed live in
v0.7.0's smoke. Without server-side knowledge, ratatoskr's picker
couldn't show tier-3 agents the operator had defined; the workflow
was "remember the agent_id, pass --agent ratatoskr:<name>
explicitly." Friction grows with every tier-3 agent.

## Fix: client-side index, merged at picker time

New module `ratatoskr.local_agents` maintains a JSON-backed index at
$XDG_CONFIG_HOME/ratatoskr/local_agents.json (override via
$RATATOSKR_LOCAL_AGENTS). `tier3` CLI define / patch / delete update
the index as side-effects. `tui._resolve_then_run` loads the index
after `list_agents(client)` and appends entries not already in the
remote list (dedup by agent_id; remote wins on conflict).

Library-level `tier3.define_agent` / `patch_agent` / `delete_agent`
stay pure — local persistence lives in the CLI layer (`_run_define`
etc.), not in the library functions. Tests of the library don't
touch the filesystem.

## Public surface

  ratatoskr.local_agents:
    LocalAgentEntry (frozen dataclass)
    load_local_agents() -> list[LocalAgentEntry]
    add_local_agent(entry)
    update_local_agent(entry)  # same semantics as add (agent_id key)
    remove_local_agent(agent_id)
    make_description(system_prompt) -> str  # synthetic picker label

Failure modes are lenient: missing file → empty index; corrupt JSON
or schema mismatch → empty index (no crash). The picker continues
to show foundational agents either way; tier-3 surface degrades to
the pre-v0.8.0 workflow.

## Picker integration

Local entries convert to ratatoskr.sessions.AgentInfo with synthetic
fields:
  name        = agent_name (from LocalAgentEntry)
  description = "(tier 3) <first non-empty line of system prompt>"
  version, capabilities, supported_models, persona_traits, ui_hints
    = None / [] / [] / {} / {}

If Worldtree later starts returning tier-3 in GET /agents, this
module's role narrows to redundant local cache; can be removed
cleanly since the dedup-by-agent-id keeps remote-wins behavior.

## Tests

286/286 GREEN (was 265, +21: 20 local_agents + 1 picker-merge
integration). Ruff clean. Tests isolate the index via
$RATATOSKR_LOCAL_AGENTS pointed at pytest's tmp_path — no pollution
of operator's real ~/.config/ratatoskr/.

## Manual smoke

Sindra-like define against personal Worldtree:
  python -m ratatoskr.tier3 define --name foo --system-prompt "..." --model X
  cat ~/.config/ratatoskr/local_agents.json
  # ratatoskr --new picker now shows ratatoskr:foo alongside mimir et al.

Cross-machine: the file is per-host. Operator can sync via dotfiles
if needed; out of scope for this commit.

Minor bump (v0.7.1 → v0.8.0) — new public module + new picker
behavior (more agents shown). No caller-side breaking changes.
2026-05-24 21:13:30 -07:00

2278 lines
88 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 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 to record every arg into a list (returned).
Accepts *args/**kwargs so Textual's internal deferred-render path
(which calls write positionally with width/expand/shrink/scroll_end)
still works after a write-during-mount + Resize sequence.
"""
writes: list = []
original = RichLog.write
def spy(self, content, *args, **kw):
writes.append(content)
return original(self, content, *args, **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_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
log = MagicMock()
thinking_log = MagicMock()
state = TuiPresenterState()
for chunk in ("Let", " me", " think"):
state.render(
Thinking(sse_id=SID, content=chunk),
log=log,
tools_log=MagicMock(),
debug_log=MagicMock(),
current_text=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 log.write.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),
log=MagicMock(),
tools_log=MagicMock(),
debug_log=MagicMock(),
current_text=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
log = MagicMock()
debug_log = MagicMock()
thinking_log = MagicMock()
state = TuiPresenterState()
for content in ("a", "b"):
state.render(
Thinking(sse_id=SID, content=content),
log=log,
tools_log=MagicMock(),
debug_log=debug_log,
current_text=MagicMock(),
thinking_log=thinking_log,
raw=False,
)
state.render(
WorkerPhase(sse_id=SID, phase="streaming", turn_id=42),
log=log,
tools_log=MagicMock(),
debug_log=debug_log,
current_text=MagicMock(),
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 log.write.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.6.5]:
Thinking → Text → Thinking → Done → TWO start/end Rule pairs in
thinking_log, each wrapping their delta lines. Text goes to
current_text (buffered). Transcript: [done] + Markdown body.
"""
from rich.rule import Rule
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
thinking_log = MagicMock()
current_text = 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, log=log,
tools_log=MagicMock(), debug_log=MagicMock(),
current_text=current_text, thinking_log=thinking_log, raw=False,
)
state.render(
_make_tui_done(),
log=log,
tools_log=MagicMock(), debug_log=MagicMock(),
current_text=current_text, thinking_log=thinking_log, raw=False,
)
# v0.6.5: thinking_log holds 4 Rules (start + end per run) + 2 delta lines.
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
# Text "hi" went to current_text (buffered), not the transcript directly.
current_text.update.assert_any_call("hi")
# Transcript: [done] label + Markdown(response) (raw=False).
log_writes = [_text_of(c[0][0]) for c in log.write.call_args_list]
assert any(w.startswith("[done]") for w in log_writes 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
log = 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"),
log=log,
tools_log=MagicMock(),
debug_log=MagicMock(),
current_text=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 log.write.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"),
log=MagicMock(),
tools_log=MagicMock(),
debug_log=MagicMock(),
current_text=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
log = MagicMock()
thinking_log = MagicMock()
state = TuiPresenterState()
state.render(
Thinking(sse_id=SID, content="partial"),
log=log,
tools_log=MagicMock(),
debug_log=MagicMock(),
current_text=MagicMock(), thinking_log=thinking_log, raw=False,
)
state.render(
Cancelled(
sse_id=SID, phase="cancelled", turn_id=42, reason="user", partial_message_id=None
),
log=log,
tools_log=MagicMock(),
debug_log=MagicMock(),
current_text=MagicMock(), thinking_log=thinking_log, raw=False,
)
# v0.6.5: streamed thinking + Rule(end) in thinking_log; [cancelled] in transcript.
log_writes = [_text_of(c[0][0]) for c in log.write.call_args_list]
assert any(w.startswith("[cancelled]") for w in log_writes)
# thinking_log got at least Rule(start) + "partial" delta + Rule(end)
assert thinking_log.write.call_count >= 3
def test_done_renders_markdown_after_label(self) -> None:
"""done_renders_markdown_after_label [happy, v0.6.0]:
Text("hi") accumulates into current_text Static (buffered streaming);
Done(response="hi") with raw=False → [done] label + Rule + Markdown
in transcript. current_text cleared on terminal.
"""
from rich.markdown import Markdown
from rich.rule import Rule
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
current_text = MagicMock()
state = TuiPresenterState()
state.render(
Text(sse_id=SID, content="hi"),
log=log,
tools_log=MagicMock(),
debug_log=MagicMock(),
current_text=current_text,
thinking_log=MagicMock(),
raw=False,
)
# Text accumulated to current_text, NOT written to log.
current_text.update.assert_any_call("hi")
state.render(
_make_tui_done(),
log=log,
tools_log=MagicMock(),
debug_log=MagicMock(),
current_text=current_text,
thinking_log=MagicMock(),
raw=False,
)
# Done cleared current_text and wrote [done] label + Rule + Markdown.
current_text.update.assert_any_call("")
writes = [c[0][0] for c in log.write.call_args_list]
assert any(_text_of(w).startswith("[done]") for w in writes)
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()
state = TuiPresenterState()
state.render(
Text(sse_id=SID, content="hi"),
log=log,
tools_log=MagicMock(),
debug_log=MagicMock(),
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,
)
state.render(
_make_tui_done(),
log=log,
tools_log=MagicMock(),
debug_log=MagicMock(),
current_text=MagicMock(), thinking_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_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
log = MagicMock()
debug_log = MagicMock()
state = TuiPresenterState()
state.render(
WorkerPhase(sse_id=SID, phase="streaming", turn_id=42),
log=log,
tools_log=MagicMock(),
debug_log=debug_log,
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
)
# v0.5.0: WorkerPhase routes to debug_log, NOT transcript.
assert not log.write.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
log = MagicMock()
tools_log = MagicMock()
state = TuiPresenterState()
state.render(
ToolStart(sse_id=SID, name="read_file", arguments={"path": "/x"}),
log=log,
tools_log=tools_log,
debug_log=MagicMock(),
current_text=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 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,
tools_log=tools_log,
debug_log=MagicMock(),
current_text=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 log.write.called
def test_text_event_buffers_into_current_text(self) -> None:
"""text_event_buffers_into_current_text [v0.6.0]: Text → current_text Static
(accumulated), NOT log or tools_log. Streaming UX fix — no per-token spam.
"""
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
tools_log = MagicMock()
current_text = MagicMock()
state = TuiPresenterState()
state.render(
Text(sse_id=SID, content="hello"),
log=log,
tools_log=tools_log,
debug_log=MagicMock(),
current_text=current_text,
thinking_log=MagicMock(),
raw=False,
)
current_text.update.assert_called_once_with("hello")
assert not log.write.called
assert not tools_log.write.called
def test_text_deltas_accumulate(self) -> None:
"""text_deltas_accumulate [v0.6.0]: multiple Text deltas → current_text shows
concatenated content, NOT separate per-delta lines.
"""
from ratatoskr.tui import TuiPresenterState
current_text = MagicMock()
state = TuiPresenterState()
for tok in ("Hel", "lo", " ", "world"):
state.render(
Text(sse_id=SID, content=tok),
log=MagicMock(),
tools_log=MagicMock(),
debug_log=MagicMock(),
current_text=current_text,
thinking_log=MagicMock(),
raw=False,
)
# Final update reflects the full concatenation.
assert current_text.update.call_args_list[-1][0][0] == "Hello world"
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,
tools_log=MagicMock(),
debug_log=MagicMock(),
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,
)
done_line = next(
_text_of(c[0][0])
for c in log.write.call_args_list
if _text_of(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,
tools_log=MagicMock(),
debug_log=MagicMock(),
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,
)
done_line = next(
_text_of(c[0][0])
for c in log.write.call_args_list
if _text_of(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_content_only(self) -> None:
"""left_column_content_only [v0.6.5]: left column = transcript + prompt
+ current-text (streaming text Static). thinking-current Static
removed entirely as of v0.6.5.
"""
from textual.containers import Vertical
from textual.widgets import Input, RichLog
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", RichLog)
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-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}"
)
async def test_debug_tab_exists(self) -> None:
"""debug_tab_exists [v0.5.0]: right column has Debug TabPane + #debug-log 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_done_label_styled_success(self) -> None:
"""done_label_styled_success [v0.5.1]: [done] label renders in Aurora green."""
from rich.text import Text as RichText
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()
# Probe the presenter directly — write a Done via state.render.
from ratatoskr.tui import TuiPresenterState
log = app.query_one("#transcript", RichLog)
state = TuiPresenterState()
seen: list = []
orig = log.write
log.write = lambda c, *a, **kw: (seen.append(c), orig(c, *a, **kw))[1]
state.render(
_make_tui_done(),
log=log,
tools_log=app.query_one("#tools-log", RichLog),
debug_log=app.query_one("#debug-log", RichLog),
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,
)
done = next(
c for c in seen
if isinstance(c, RichText) and _text_of(c).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-log + debug-log 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_renders_markdown(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""happy_text_done_renders_markdown [happy,tracer, v0.6.0]:
Text deltas go to current_text (not transcript); on Done, transcript
gets turn-header Rule, [done] label, post-Done Rule + Markdown body.
"""
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"
# v0.6.0: Text("hello") goes to current_text Static, NOT log.
# writes spy captures RichLog.write only, so "hello" SHOULD NOT appear.
from rich.markdown import Markdown
from rich.rule import Rule
assert not any(w == "hello" for w in writes)
assert any("[done]" in str(w) for w in writes)
# Post-Done: Markdown body + Rule + turn-header Rule all present.
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, v0.6.0]:
With --raw, no Markdown render. A turn-header Rule IS still written
(v0.6.0 INV — turn correlation lives in every pane). The post-Done
Rule(separator) is suppressed; accumulated streamed text is written
as a plain string instead.
"""
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)
# Only turn-header Rules — one per pane (transcript + tools +
# debug + thinking = 4). No post-Done separator Rule.
rules = [w for w in writes if isinstance(w, Rule)]
assert len(rules) == 4, f"expected 4 turn-header Rules, got {len(rules)}"
# Accumulated text "hi" written as plain string post-Done.
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)
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_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],
) -> 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