9fade55901
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.
2278 lines
88 KiB
Python
2278 lines
88 KiB
Python
"""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
|