be171304f5
Codifies 'give an agent a first message' (Worldtree #347): new module ratatoskr.first_message (FIRST_MESSAGE_PRESETS + seed_preset_first_message) seeds a preset agent's opening as a #347 authored turn-0 on every new session, wired into all three create paths — cli._amain (--send --new), tui._resolve_then_run (bare --new), web._create_session_endpoint (POST /api/sessions). seed_preset_first_message is strictly best-effort (INV-001): it soft-guards its inputs (return None, never assert), bounds the write with asyncio.wait_for so a stalled /history can't block create (the CLI/TUI clients disable read timeout for SSE), and swallows every exception except asyncio.CancelledError (which propagates) — so it can NEVER raise into or block the session-create path it is wired into. Per-content idempotency key → idempotent replay, no dup. Seeded with ratatoskr:sindra, whose opening greeting moved out of her card: her live system_prompt was PATCHed (non-destructive) to drop the Startup workaround the #347 first-message now replaces. Quality gate (both cross-frontier panels): heid-code-review returned zero implementation drift (2 test-only fixups applied); heid-bug-hunt caught the gap the conformance lens can't see — code matched the contract's narrow ERROR_ROUTING but INV-001's 'never raises' is broader — driving the broad-except + soft-guard + wait_for hardening above. Contract docs/contracts/first_message.contract.md (module-scoped, validated). TDD: 12 unit + 1 web wire-in; the 3 existing sindra bind tests gained a history-endpoint mock (creating a preset agent now auto-seeds). Suite 615 green, ruff+mypy clean. Auto-seed live-proven generation-free against personal :8081.
3444 lines
135 KiB
Python
3444 lines
135 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.sessions import BifrostBinding
|
||
from ratatoskr.sse_client import (
|
||
Cancelled,
|
||
Done,
|
||
SseId,
|
||
Text,
|
||
Thinking,
|
||
ToolResult,
|
||
ToolStart,
|
||
WorkerPhase,
|
||
)
|
||
from ratatoskr.tui import RatatoskrApp, _cancel_via_sse
|
||
|
||
_CANCEL_OK_RESP = {"turn_id": 42, "cancelled": True, "reason": None, "partial_message_id": None}
|
||
_CREATE_OK_RESP = {
|
||
"session_id": "s-new12345",
|
||
"agent_id": "mimir",
|
||
"message_count": 0,
|
||
"created_at": "2026-05-21T00:00:00+00:00",
|
||
"last_active": "2026-05-21T00:00:00+00:00",
|
||
"metadata": {},
|
||
}
|
||
|
||
|
||
def _args_new(**overrides) -> ParsedArgs:
|
||
base = dict(
|
||
send_content=None,
|
||
session_id=None,
|
||
new=True,
|
||
agent_id="mimir",
|
||
api_key="k",
|
||
server_url="https://w.example",
|
||
raw=False,
|
||
)
|
||
base.update(overrides)
|
||
return ParsedArgs(**base)
|
||
|
||
|
||
def _args_existing(session_id: str = "s-1existing", **overrides) -> ParsedArgs:
|
||
base = dict(
|
||
send_content=None,
|
||
session_id=session_id,
|
||
new=False,
|
||
agent_id=None,
|
||
api_key="k",
|
||
server_url="https://w.example",
|
||
raw=False,
|
||
)
|
||
base.update(overrides)
|
||
return ParsedArgs(**base)
|
||
|
||
|
||
def _spy_writes(monkeypatch) -> list:
|
||
"""Patch RichLog.write AND VerticalScroll.mount to record every renderable
|
||
or mounted-widget content into a single list (returned).
|
||
|
||
v0.9.0: transcript content is mounted into a VerticalScroll, not written
|
||
to a RichLog. The spy captures both shapes — for each mounted Static, the
|
||
Static's `renderable` (Markdown / RichText / str) lands in the list,
|
||
indistinguishably from RichLog.write entries. Integration tests assert
|
||
on substrings or types in `writes` so the merged shape is the right
|
||
abstraction.
|
||
|
||
Accepts *args/**kwargs so Textual's internal deferred-render paths still
|
||
work after a write-during-mount + Resize sequence.
|
||
"""
|
||
from textual.containers import VerticalScroll
|
||
from textual.widgets import Static
|
||
|
||
writes: list = []
|
||
|
||
original_write = RichLog.write
|
||
|
||
def spy_write(self, content, *args, **kw):
|
||
writes.append(content)
|
||
return original_write(self, content, *args, **kw)
|
||
|
||
monkeypatch.setattr(RichLog, "write", spy_write)
|
||
|
||
original_mount = VerticalScroll.mount
|
||
|
||
def spy_mount(self, *children, **kw):
|
||
for child in children:
|
||
if isinstance(child, Static):
|
||
writes.append(child.content)
|
||
else:
|
||
writes.append(child)
|
||
return original_mount(self, *children, **kw)
|
||
|
||
monkeypatch.setattr(VerticalScroll, "mount", spy_mount)
|
||
return writes
|
||
|
||
|
||
def _resolved_app(
|
||
args: ParsedArgs,
|
||
*,
|
||
session_id: str | None = None,
|
||
agent_id: str | None = None,
|
||
client: httpx.AsyncClient | None = None,
|
||
) -> RatatoskrApp:
|
||
"""Construct RatatoskrApp with pre-resolved state (issue #6 lifecycle).
|
||
|
||
Production path: `run_tui` → `_resolve_then_run` opens AsyncClient, mints
|
||
or attaches session, then constructs the App with the resolved tuple. This
|
||
helper inlines that shape so tests bypass the pre-flight without
|
||
re-implementing it. The client is opened here (and leaks at test teardown
|
||
— acceptable; respx mocks all network calls and pytest exits cleanly).
|
||
"""
|
||
sid = session_id if session_id is not None else (args.session_id or "s-default")
|
||
aid = args.agent_id if agent_id is None else agent_id
|
||
if client is None:
|
||
client = httpx.AsyncClient(
|
||
base_url=args.server_url,
|
||
headers={"Authorization": f"Bearer {args.api_key}"},
|
||
timeout=httpx.Timeout(connect=10.0, read=None, write=10.0, pool=10.0),
|
||
)
|
||
return RatatoskrApp(args, session_id=sid, agent_id=aid, client=client)
|
||
|
||
|
||
SID = SseId(42, 5)
|
||
|
||
|
||
# Issue #12 — TuiPresenterState replaces _render_event_to_log with a stateful
|
||
# per-turn presenter. (Pre-amendment TestRenderEventToLog class and
|
||
# `_render_event_to_log` function have been removed under the project's
|
||
# no-backwards-compatibility rule.)
|
||
|
||
|
||
class TestTuiPresenterState:
|
||
"""Tests for the new TuiPresenterState — per issue #12 contract."""
|
||
|
||
def test_thinking_coalesces_until_newline(self) -> None:
|
||
"""thinking_coalesces_until_newline [happy,tracer, v0.7.1]:
|
||
Per-token deltas accumulate in the buffer; flush only on `\\n`.
|
||
Three short token-shaped deltas without `\\n` → thinking_log gets
|
||
ONLY Rule(start); content stays buffered.
|
||
"""
|
||
from rich.rule import Rule
|
||
|
||
from ratatoskr.tui import TuiPresenterState
|
||
|
||
transcript = MagicMock()
|
||
thinking_log = MagicMock()
|
||
state = TuiPresenterState()
|
||
for chunk in ("Let", " me", " think"):
|
||
state.render(
|
||
Thinking(sse_id=SID, content=chunk),
|
||
transcript=transcript,
|
||
tools_log=MagicMock(),
|
||
debug_log=MagicMock(),
|
||
thinking_log=thinking_log,
|
||
raw=False,
|
||
)
|
||
writes = [c[0][0] for c in thinking_log.write.call_args_list]
|
||
# Only Rule(start) — content stays buffered (no `\n` seen).
|
||
assert len(writes) == 1
|
||
assert isinstance(writes[0], Rule)
|
||
assert state.thinking_chunk_buffer == "Let me think"
|
||
assert transcript.mount.call_count == 0
|
||
|
||
def test_thinking_flushes_on_newline(self) -> None:
|
||
"""thinking_flushes_on_newline [happy, v0.7.1]:
|
||
Delta carrying `\\n` flushes the accumulated buffer as ONE line.
|
||
"""
|
||
from ratatoskr.tui import TuiPresenterState
|
||
|
||
thinking_log = MagicMock()
|
||
state = TuiPresenterState()
|
||
for chunk in ("Hello", " world", "\n"):
|
||
state.render(
|
||
Thinking(sse_id=SID, content=chunk),
|
||
transcript=MagicMock(),
|
||
tools_log=MagicMock(),
|
||
debug_log=MagicMock(),
|
||
thinking_log=thinking_log,
|
||
raw=False,
|
||
)
|
||
writes = [c[0][0] for c in thinking_log.write.call_args_list]
|
||
# Rule(start) + "Hello world" (one coalesced line) = 2 writes
|
||
assert len(writes) == 2
|
||
assert writes[1] == "Hello world"
|
||
assert state.thinking_chunk_buffer == ""
|
||
|
||
def test_thinking_closes_to_thinking_log(self) -> None:
|
||
"""thinking_closes_to_thinking_log [happy, v0.7.1]: 2x Thinking + WorkerPhase →
|
||
v0.7.1 coalesces "a"+"b" into one buffered string; the close flushes
|
||
"ab" as a single line before Rule(end). Result: Rule(start) + "ab" +
|
||
Rule(end) = 3 writes. debug_log gets worker_phase; transcript untouched.
|
||
"""
|
||
from rich.rule import Rule
|
||
|
||
from ratatoskr.tui import TuiPresenterState
|
||
|
||
transcript = MagicMock()
|
||
debug_log = MagicMock()
|
||
thinking_log = MagicMock()
|
||
state = TuiPresenterState()
|
||
for content in ("a", "b"):
|
||
state.render(
|
||
Thinking(sse_id=SID, content=content),
|
||
transcript=transcript,
|
||
tools_log=MagicMock(),
|
||
debug_log=debug_log,
|
||
thinking_log=thinking_log,
|
||
raw=False,
|
||
)
|
||
state.render(
|
||
WorkerPhase(sse_id=SID, phase="streaming", turn_id=42),
|
||
transcript=transcript,
|
||
tools_log=MagicMock(),
|
||
debug_log=debug_log,
|
||
thinking_log=thinking_log,
|
||
raw=False,
|
||
)
|
||
thinking_writes = [c[0][0] for c in thinking_log.write.call_args_list]
|
||
# v0.7.1: 1 Rule(start) + 1 coalesced "ab" tail-flush + 1 Rule(end) = 3 writes
|
||
assert len(thinking_writes) == 3
|
||
assert isinstance(thinking_writes[0], Rule)
|
||
assert thinking_writes[1] == "ab"
|
||
assert isinstance(thinking_writes[2], Rule)
|
||
# worker_phase still goes to debug_log; transcript untouched.
|
||
assert "· worker_phase:" in _text_of(debug_log.write.call_args_list[-1][0][0])
|
||
assert not transcript.mount.called
|
||
|
||
# v0.6.5: thinking-current Static removed; test_thinking_widget_truncation
|
||
# and test_thinking_widget_visibility_lifecycle deleted (no longer apply).
|
||
|
||
def test_multiple_thinking_runs_each_get_thinking_log_section(self) -> None:
|
||
"""multiple_thinking_runs_each_get_section [scenario, v0.8.1]:
|
||
Thinking → Text → Thinking → Done → TWO start/end Rule pairs in
|
||
thinking_log (deltas coalesced into tail-flushes per run).
|
||
Text deltas now stream into the transcript via coalesce-on-newline
|
||
(no current-text Static); "hi" with no `\\n` stays buffered until
|
||
Done's tail-flush.
|
||
"""
|
||
from rich.rule import Rule
|
||
|
||
from ratatoskr.tui import TuiPresenterState
|
||
|
||
transcript = MagicMock()
|
||
thinking_log = MagicMock()
|
||
state = TuiPresenterState()
|
||
for evt in (
|
||
Thinking(sse_id=SID, content="first"),
|
||
Text(sse_id=SID, content="hi"),
|
||
Thinking(sse_id=SID, content="second"),
|
||
):
|
||
state.render(
|
||
evt, transcript=transcript,
|
||
tools_log=MagicMock(), debug_log=MagicMock(),
|
||
thinking_log=thinking_log, raw=False,
|
||
)
|
||
state.render(
|
||
_make_tui_done(),
|
||
transcript=transcript,
|
||
tools_log=MagicMock(), debug_log=MagicMock(),
|
||
thinking_log=thinking_log, raw=False,
|
||
)
|
||
# thinking_log: 4 Rules (start+end per run) + 2 tail-flush strings.
|
||
thinking_writes = [c[0][0] for c in thinking_log.write.call_args_list]
|
||
rules = [w for w in thinking_writes if isinstance(w, Rule)]
|
||
delta_strs = [w for w in thinking_writes if isinstance(w, str)]
|
||
assert len(rules) == 4, f"expected 4 Rules (2 start + 2 end), got {len(rules)}"
|
||
assert "first" in delta_strs
|
||
assert "second" in delta_strs
|
||
# v0.8.1: Text "hi" flushes as a line in transcript on Done.
|
||
transcript_renderables = [_text_of(r) for r in _mounted_renderables(transcript)]
|
||
assert "hi" in transcript_renderables
|
||
assert any(w.startswith("[done]") for w in transcript_renderables if isinstance(w, str))
|
||
|
||
def test_render_exception_fallback(self) -> None:
|
||
"""render_exception_fallback [adversarial, v0.6.5]:
|
||
thinking_log.write raises → catch in presenter, write plain-label
|
||
fallback + render_error line via INV-009 fallback path (routing
|
||
preservation: thinking events still route to thinking_log).
|
||
"""
|
||
from ratatoskr.tui import TuiPresenterState
|
||
|
||
transcript = MagicMock()
|
||
thinking_log = MagicMock()
|
||
# First call (Rule write) raises; subsequent calls succeed for fallback.
|
||
thinking_log.write.side_effect = [
|
||
AttributeError("rule write failed (msg should NOT leak)"),
|
||
None,
|
||
None,
|
||
]
|
||
state = TuiPresenterState()
|
||
state.render(
|
||
Thinking(sse_id=SID, content="x"),
|
||
transcript=transcript,
|
||
tools_log=MagicMock(),
|
||
debug_log=MagicMock(),
|
||
thinking_log=thinking_log,
|
||
raw=False,
|
||
)
|
||
writes = [c[0][0] for c in thinking_log.write.call_args_list if isinstance(c[0][0], str)]
|
||
assert any(w.startswith("[thinking]") for w in writes), writes
|
||
assert any(w == "[render_error] AttributeError" for w in writes), writes
|
||
assert not any("rule write failed" in w for w in writes), writes
|
||
assert not transcript.mount.called
|
||
|
||
def test_state_reset_per_worker(self) -> None:
|
||
"""state_reset_per_worker [trace]: fresh TuiPresenterState() starts no thinking open."""
|
||
from ratatoskr.tui import TuiPresenterState
|
||
|
||
s1 = TuiPresenterState()
|
||
s1.render(
|
||
Thinking(sse_id=SID, content="x"),
|
||
transcript=MagicMock(),
|
||
tools_log=MagicMock(),
|
||
debug_log=MagicMock(),
|
||
thinking_log=MagicMock(),
|
||
raw=False,
|
||
)
|
||
s2 = TuiPresenterState()
|
||
assert s1.thinking_open is True
|
||
assert s2.thinking_open is False
|
||
|
||
def test_cancelled_mid_thinking_closes(self) -> None:
|
||
"""cancelled_mid_thinking_closes [scenario, v0.5.0]:
|
||
Thinking, Cancelled → ONE closed thinking entry in debug_log + a
|
||
[cancelled] entry in transcript; widget hidden.
|
||
"""
|
||
from ratatoskr.tui import TuiPresenterState
|
||
|
||
transcript = MagicMock()
|
||
thinking_log = MagicMock()
|
||
state = TuiPresenterState()
|
||
state.render(
|
||
Thinking(sse_id=SID, content="partial"),
|
||
transcript=transcript,
|
||
tools_log=MagicMock(),
|
||
debug_log=MagicMock(), thinking_log=thinking_log, raw=False,
|
||
)
|
||
state.render(
|
||
Cancelled(
|
||
sse_id=SID, phase="cancelled", turn_id=42, reason="user", partial_message_id=None
|
||
),
|
||
transcript=transcript,
|
||
tools_log=MagicMock(),
|
||
debug_log=MagicMock(), thinking_log=thinking_log, raw=False,
|
||
)
|
||
# v0.6.5: streamed thinking + Rule(end) in thinking_log; [cancelled] in transcript.
|
||
transcript_renderables = [_text_of(r) for r in _mounted_renderables(transcript)]
|
||
assert any(w.startswith("[cancelled]") for w in transcript_renderables)
|
||
# thinking_log got at least Rule(start) + "partial" delta + Rule(end)
|
||
assert thinking_log.write.call_count >= 3
|
||
|
||
def test_text_then_done_mounts_widget_and_finalizes(self) -> None:
|
||
"""text_then_done_mounts_widget_and_finalizes [happy, v0.9.0]:
|
||
First Text delta mounts a Static(Markdown(buffer)) into the transcript;
|
||
Done finalizes the widget reference and mounts a styled [done] label.
|
||
No duplicate content (v0.9.0 replaces v0.8.x's flush-on-Done with
|
||
live in-place Markdown updates).
|
||
"""
|
||
from rich.markdown import Markdown
|
||
|
||
from ratatoskr.tui import TuiPresenterState
|
||
|
||
transcript = MagicMock()
|
||
state = TuiPresenterState()
|
||
state.render(
|
||
Text(sse_id=SID, content="hi"),
|
||
transcript=transcript,
|
||
tools_log=MagicMock(),
|
||
debug_log=MagicMock(),
|
||
thinking_log=MagicMock(),
|
||
raw=False,
|
||
)
|
||
# v0.9.0: response widget mounted on first Text delta with Markdown wrapper.
|
||
assert transcript.mount.called
|
||
first_widget = transcript.mount.call_args_list[0][0][0]
|
||
assert isinstance(first_widget.content, Markdown)
|
||
assert first_widget.content.markup == "hi"
|
||
assert state.text_chunk_buffer == "hi"
|
||
# Done finalizes: text_chunk_buffer cleared, widget ref released, label mounted.
|
||
state.render(
|
||
_make_tui_done(),
|
||
transcript=transcript,
|
||
tools_log=MagicMock(),
|
||
debug_log=MagicMock(),
|
||
thinking_log=MagicMock(),
|
||
raw=False,
|
||
)
|
||
writes = _mounted_renderables(transcript)
|
||
assert any(_text_of(w).startswith("[done]") for w in writes)
|
||
# v0.9.0: response Markdown rendered live during stream — only ONE
|
||
# Markdown renderable lands in the transcript (no post-Done re-render).
|
||
markdowns = [w for w in writes if isinstance(w, Markdown)]
|
||
assert len(markdowns) == 1
|
||
assert state.text_chunk_buffer == ""
|
||
assert state.current_response_widget is None
|
||
|
||
def test_raw_flag_skips_markdown(self) -> None:
|
||
"""raw_flag_skips_markdown [v0.9.0]: raw=True → response widget holds
|
||
plain str instead of Markdown. Live in-place update still happens;
|
||
only the wrapper differs.
|
||
"""
|
||
from rich.markdown import Markdown
|
||
|
||
from ratatoskr.tui import TuiPresenterState
|
||
|
||
transcript = MagicMock()
|
||
state = TuiPresenterState()
|
||
state.render(
|
||
Text(sse_id=SID, content="hi"),
|
||
transcript=transcript,
|
||
tools_log=MagicMock(),
|
||
debug_log=MagicMock(), thinking_log=MagicMock(), raw=True,
|
||
)
|
||
state.render(
|
||
_make_tui_done(),
|
||
transcript=transcript,
|
||
tools_log=MagicMock(),
|
||
debug_log=MagicMock(), thinking_log=MagicMock(), raw=True,
|
||
)
|
||
writes = _mounted_renderables(transcript)
|
||
# Raw mode bypasses Markdown entirely — content lives as plain str.
|
||
assert not any(isinstance(w, Markdown) for w in writes)
|
||
assert "hi" in writes
|
||
|
||
def test_worker_phase_demoted_to_debug_log(self) -> None:
|
||
"""worker_phase_demoted_to_debug_log [trace, v0.5.0]: WorkerPhase → debug_log
|
||
"· worker_phase:" prefix rendered with Australis dark-60 Rich style.
|
||
Transcript receives nothing.
|
||
"""
|
||
from rich.text import Text as RichText
|
||
|
||
from ratatoskr.tui import TuiPresenterState
|
||
|
||
transcript = MagicMock()
|
||
debug_log = MagicMock()
|
||
state = TuiPresenterState()
|
||
state.render(
|
||
WorkerPhase(sse_id=SID, phase="streaming", turn_id=42),
|
||
transcript=transcript,
|
||
tools_log=MagicMock(),
|
||
debug_log=debug_log, thinking_log=MagicMock(), raw=False,
|
||
)
|
||
# v0.5.0: WorkerPhase routes to debug_log, NOT transcript.
|
||
assert not transcript.mount.called
|
||
renderable = debug_log.write.call_args[0][0]
|
||
# INV-003: must be a styled Rich Text renderable, not a plain str.
|
||
# v0.4.1 retheme: style is now Australis Sea dark-60 ("#86929d") instead
|
||
# of the terminal-dim filter "dim". Assert non-empty styling either way.
|
||
assert isinstance(renderable, RichText), type(renderable)
|
||
assert renderable.style, "demoted telemetry must carry SOME style"
|
||
text = renderable.plain
|
||
assert text.startswith("· worker_phase:")
|
||
assert "[worker_phase]" not in text
|
||
|
||
# v0.6.5: test_terminal_events_belt_and_braces_widget_cleanup deleted.
|
||
# The thinking-current Static is gone, so there's no widget to clean up
|
||
# on terminal events. The corresponding Volva F3 invariant is obsoleted
|
||
# by the streaming-into-thinking_log architecture.
|
||
|
||
def test_tool_start_routes_to_tools_log(self) -> None:
|
||
"""tool_start_routes_to_tools_log [INV-014]: ToolStart writes to tools_log, NOT transcript.
|
||
|
||
Issue #13: tool events route to the dedicated Tools pane (right column).
|
||
Pre-#13 wrote them to the main transcript with `· tool_start:` prefix.
|
||
Post-#13 the prefix is preserved but the destination shifts.
|
||
"""
|
||
from ratatoskr.tui import TuiPresenterState
|
||
|
||
transcript = MagicMock()
|
||
tools_log = MagicMock()
|
||
state = TuiPresenterState()
|
||
state.render(
|
||
ToolStart(sse_id=SID, name="read_file", arguments={"path": "/x"}),
|
||
transcript=transcript,
|
||
tools_log=tools_log,
|
||
debug_log=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||
)
|
||
# INV-014: write went to tools_log
|
||
assert tools_log.write.called
|
||
assert _text_of(tools_log.write.call_args[0][0]).startswith("· tool_start:")
|
||
# INV-014: transcript was NOT written to
|
||
assert not transcript.mount.called
|
||
|
||
def test_tool_result_routes_to_tools_log(self) -> None:
|
||
"""tool_result_routes_to_tools_log [INV-014]: ToolResult → tools_log, NOT transcript."""
|
||
from ratatoskr.tui import TuiPresenterState
|
||
|
||
transcript = MagicMock()
|
||
tools_log = MagicMock()
|
||
state = TuiPresenterState()
|
||
state.render(
|
||
ToolResult(sse_id=SID, name="read_file", result="ok", duration_ms=12),
|
||
transcript=transcript,
|
||
tools_log=tools_log,
|
||
debug_log=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||
)
|
||
assert tools_log.write.called
|
||
assert _text_of(tools_log.write.call_args[0][0]).startswith("· tool_result:")
|
||
assert not transcript.mount.called
|
||
|
||
def test_text_first_delta_mounts_response_widget(self) -> None:
|
||
"""text_first_delta_mounts_response_widget [v0.9.0]: first Text delta
|
||
mounts a Static carrying Markdown(buffer) into the transcript. The
|
||
text_chunk_buffer holds the accumulated content for the next delta's
|
||
in-place update.
|
||
"""
|
||
from rich.markdown import Markdown
|
||
|
||
from ratatoskr.tui import TuiPresenterState
|
||
|
||
transcript = MagicMock()
|
||
tools_log = MagicMock()
|
||
state = TuiPresenterState()
|
||
state.render(
|
||
Text(sse_id=SID, content="hello"),
|
||
transcript=transcript,
|
||
tools_log=tools_log,
|
||
debug_log=MagicMock(),
|
||
thinking_log=MagicMock(),
|
||
raw=False,
|
||
)
|
||
assert state.text_chunk_buffer == "hello"
|
||
assert transcript.mount.call_count == 1
|
||
widget = transcript.mount.call_args[0][0]
|
||
assert isinstance(widget.content, Markdown)
|
||
assert widget.content.markup == "hello"
|
||
assert state.current_response_widget is widget
|
||
assert not tools_log.write.called
|
||
|
||
def test_text_subsequent_deltas_update_in_place(self) -> None:
|
||
"""text_subsequent_deltas_update_in_place [v0.9.0]: deltas after the
|
||
first do NOT mount a new widget — they update the existing widget's
|
||
Markdown content in place. The text_chunk_buffer accumulates.
|
||
"""
|
||
from ratatoskr.tui import TuiPresenterState
|
||
|
||
transcript = MagicMock()
|
||
state = TuiPresenterState()
|
||
for tok in ("Hel", "lo", " ", "world"):
|
||
state.render(
|
||
Text(sse_id=SID, content=tok),
|
||
transcript=transcript,
|
||
tools_log=MagicMock(),
|
||
debug_log=MagicMock(),
|
||
thinking_log=MagicMock(),
|
||
raw=False,
|
||
)
|
||
# Exactly ONE mount (the first delta); subsequent deltas update.
|
||
assert transcript.mount.call_count == 1
|
||
assert state.text_chunk_buffer == "Hello world"
|
||
# Widget reference held; buffer is the source of truth re-rendered
|
||
# into Markdown(...) for each Static.update call.
|
||
assert state.current_response_widget is not None
|
||
|
||
def test_duration_format_seconds(self) -> None:
|
||
"""duration_format_seconds [trace]: Done(duration_ms=5467) → label has "duration=5.5s"."""
|
||
from ratatoskr.tui import TuiPresenterState
|
||
|
||
transcript = MagicMock()
|
||
state = TuiPresenterState()
|
||
state.render(
|
||
_make_tui_done(duration_ms=5467),
|
||
transcript=transcript,
|
||
tools_log=MagicMock(),
|
||
debug_log=MagicMock(), thinking_log=MagicMock(), raw=True,
|
||
)
|
||
done_line = next(
|
||
_text_of(r)
|
||
for r in _mounted_renderables(transcript)
|
||
if _text_of(r).startswith("[done]")
|
||
)
|
||
assert "duration=5.5s" in done_line
|
||
assert "duration_ms=5467" not in done_line
|
||
|
||
def test_usage_format_unicode_arrow(self) -> None:
|
||
"""usage_format_unicode_arrow [trace]: TUI Done label uses → (Unicode), not -> (ASCII)."""
|
||
from ratatoskr.tui import TuiPresenterState
|
||
|
||
transcript = MagicMock()
|
||
state = TuiPresenterState()
|
||
usage = {
|
||
"prompt_tokens": 6756,
|
||
"completion_tokens": 126,
|
||
"total_tokens": 6882,
|
||
"cached_input_tokens": 0,
|
||
}
|
||
state.render(
|
||
_make_tui_done(usage=usage),
|
||
transcript=transcript,
|
||
tools_log=MagicMock(),
|
||
debug_log=MagicMock(), thinking_log=MagicMock(), raw=True,
|
||
)
|
||
done_line = next(
|
||
_text_of(r)
|
||
for r in _mounted_renderables(transcript)
|
||
if _text_of(r).startswith("[done]")
|
||
)
|
||
assert "usage 6756 in → 126 out (6882 total, 0 cached)" in done_line
|
||
|
||
|
||
class TestPresenterAuditLogging:
|
||
"""v0.10.0 — per-event audit lines + turn-summary in the debug pane.
|
||
|
||
The presenter emits one debug-pane line per arriving event (Text and
|
||
Thinking are aggregated into the turn-summary instead of per-delta to
|
||
avoid drowning the pane at token rate).
|
||
"""
|
||
|
||
def test_worker_phase_emits_audit_line(self) -> None:
|
||
"""worker_phase_emits_audit_line: WorkerPhase arrival adds an audit
|
||
line to debug_log alongside the existing `· worker_phase:` entry.
|
||
Audit line shape: `[HH:MM:SS.fff] workerphase sse_id=N:M …`.
|
||
"""
|
||
from ratatoskr.tui import TuiPresenterState
|
||
|
||
debug_log = MagicMock()
|
||
state = TuiPresenterState()
|
||
state.render(
|
||
WorkerPhase(sse_id=SID, phase="streaming", turn_id=42),
|
||
transcript=MagicMock(),
|
||
tools_log=MagicMock(),
|
||
debug_log=debug_log,
|
||
thinking_log=MagicMock(),
|
||
raw=False,
|
||
)
|
||
# Two writes: audit line + worker_phase telemetry.
|
||
assert debug_log.write.call_count == 2
|
||
audit_line = _text_of(debug_log.write.call_args_list[0][0][0])
|
||
assert "workerphase" in audit_line
|
||
assert "sse_id=42:5" in audit_line
|
||
assert "phase=streaming" in audit_line
|
||
|
||
def test_tool_start_emits_audit_line(self) -> None:
|
||
"""tool_start_emits_audit_line: ToolStart adds one audit line to
|
||
debug_log even though the tool event itself routes to tools_log.
|
||
"""
|
||
from ratatoskr.tui import TuiPresenterState
|
||
|
||
debug_log = MagicMock()
|
||
state = TuiPresenterState()
|
||
state.render(
|
||
ToolStart(sse_id=SID, name="read_file", arguments={"path": "/x"}),
|
||
transcript=MagicMock(),
|
||
tools_log=MagicMock(),
|
||
debug_log=debug_log,
|
||
thinking_log=MagicMock(),
|
||
raw=False,
|
||
)
|
||
assert debug_log.write.call_count == 1
|
||
audit_line = _text_of(debug_log.write.call_args[0][0])
|
||
assert "toolstart" in audit_line
|
||
assert "sse_id=42:5" in audit_line
|
||
assert "name=read_file" in audit_line
|
||
|
||
def test_text_delta_counted_not_per_event_audit_line(self) -> None:
|
||
"""text_delta_counted_not_per_event_audit_line: a Text delta does
|
||
NOT emit a per-event audit line (token-rate would drown the pane);
|
||
instead it bumps text_delta_count / text_byte_count for the turn-
|
||
summary at Done.
|
||
"""
|
||
from ratatoskr.tui import TuiPresenterState
|
||
|
||
debug_log = MagicMock()
|
||
state = TuiPresenterState()
|
||
state.render(
|
||
Text(sse_id=SID, content="hello world"),
|
||
transcript=MagicMock(),
|
||
tools_log=MagicMock(),
|
||
debug_log=debug_log,
|
||
thinking_log=MagicMock(),
|
||
raw=False,
|
||
)
|
||
# No debug-pane writes — text deltas are silent at token rate.
|
||
assert not debug_log.write.called
|
||
assert state.text_delta_count == 1
|
||
assert state.text_byte_count == len("hello world")
|
||
|
||
def test_affect_update_routes_to_audit_only(self) -> None:
|
||
"""affect_update_routes_to_audit_only [v0.11.0]: AffectUpdate emits ONE
|
||
debug-pane audit line (with dominant_emotion + PAD for status=current)
|
||
and touches NO other pane (no transcript mount, no tools_log, no
|
||
thinking_log). UX shape (persona pane / sticky header) is deferred.
|
||
"""
|
||
from ratatoskr.sse_client import AffectUpdate
|
||
from ratatoskr.tui import TuiPresenterState
|
||
|
||
transcript = MagicMock()
|
||
tools_log = MagicMock()
|
||
thinking_log = MagicMock()
|
||
debug_log = MagicMock()
|
||
state = TuiPresenterState()
|
||
snapshot = {
|
||
"agent_id": "mimir",
|
||
"pad": {"pleasure": 0.5, "arousal": 0.4, "dominance": 0.5},
|
||
"dominant_emotion": "curiosity",
|
||
}
|
||
state.render(
|
||
AffectUpdate(sse_id=SID, status="current", turn_id=42, snapshot=snapshot),
|
||
transcript=transcript,
|
||
tools_log=tools_log,
|
||
debug_log=debug_log,
|
||
thinking_log=thinking_log,
|
||
raw=False,
|
||
)
|
||
assert debug_log.write.call_count == 1
|
||
audit = _text_of(debug_log.write.call_args[0][0])
|
||
assert "affectupdate" in audit
|
||
assert "status=current" in audit
|
||
assert "dominant_emotion='curiosity'" in audit
|
||
assert "pad=(0.5,0.4,0.5)" in audit
|
||
assert not transcript.mount.called
|
||
assert not tools_log.write.called
|
||
assert not thinking_log.write.called
|
||
|
||
def test_awaiting_llm_first_token_mounts_indicator(self) -> None:
|
||
"""awaiting_llm_first_token_mounts_indicator [v0.14.0]: first heartbeat
|
||
mounts a Static into the transcript and bumps heartbeat_count;
|
||
debug-pane audit line carries turn_id + elapsed in seconds.
|
||
"""
|
||
from ratatoskr.sse_client import AwaitingLlmFirstToken
|
||
from ratatoskr.tui import TuiPresenterState
|
||
|
||
transcript = MagicMock()
|
||
debug_log = MagicMock()
|
||
state = TuiPresenterState()
|
||
state.render(
|
||
AwaitingLlmFirstToken(
|
||
sse_id=SID, turn_id=42, elapsed_ms_since_building_prompt=5012.3
|
||
),
|
||
transcript=transcript,
|
||
tools_log=MagicMock(),
|
||
debug_log=debug_log,
|
||
thinking_log=MagicMock(),
|
||
raw=False,
|
||
)
|
||
assert state.heartbeat_count == 1
|
||
assert state.awaiting_widget is not None
|
||
assert transcript.mount.call_count == 1
|
||
audit = _text_of(debug_log.write.call_args[0][0])
|
||
assert "awaitingllmfirsttoken" in audit
|
||
assert "turn_id=42" in audit
|
||
assert "elapsed=5.0s" in audit
|
||
|
||
def test_awaiting_subsequent_heartbeats_update_in_place(self) -> None:
|
||
"""awaiting_subsequent_heartbeats_update_in_place [v0.14.0]: second+
|
||
heartbeats reuse the existing Static (no new mount); heartbeat_count
|
||
tracks the total.
|
||
"""
|
||
from ratatoskr.sse_client import AwaitingLlmFirstToken
|
||
from ratatoskr.tui import TuiPresenterState
|
||
|
||
transcript = MagicMock()
|
||
state = TuiPresenterState()
|
||
for elapsed in (5000.0, 10005.4, 15011.8):
|
||
state.render(
|
||
AwaitingLlmFirstToken(
|
||
sse_id=SID, turn_id=42, elapsed_ms_since_building_prompt=elapsed
|
||
),
|
||
transcript=transcript,
|
||
tools_log=MagicMock(),
|
||
debug_log=MagicMock(),
|
||
thinking_log=MagicMock(),
|
||
raw=False,
|
||
)
|
||
assert state.heartbeat_count == 3
|
||
assert transcript.mount.call_count == 1 # mounted once on first
|
||
|
||
def test_awaiting_indicator_removed_when_gap_closes(self) -> None:
|
||
"""awaiting_indicator_removed_when_gap_closes [v0.14.0]: any non-
|
||
heartbeat event after one or more heartbeats removes the indicator
|
||
and clears the awaiting_widget reference. Text event simulates the
|
||
gap closing (CallingLLM fires, text begins).
|
||
"""
|
||
from ratatoskr.sse_client import AwaitingLlmFirstToken
|
||
from ratatoskr.tui import TuiPresenterState
|
||
|
||
transcript = MagicMock()
|
||
state = TuiPresenterState()
|
||
state.render(
|
||
AwaitingLlmFirstToken(
|
||
sse_id=SID, turn_id=42, elapsed_ms_since_building_prompt=5000.0
|
||
),
|
||
transcript=transcript,
|
||
tools_log=MagicMock(),
|
||
debug_log=MagicMock(),
|
||
thinking_log=MagicMock(),
|
||
raw=False,
|
||
)
|
||
widget = state.awaiting_widget
|
||
assert widget is not None
|
||
state.render(
|
||
Text(sse_id=SID, content="hello"),
|
||
transcript=transcript,
|
||
tools_log=MagicMock(),
|
||
debug_log=MagicMock(),
|
||
thinking_log=MagicMock(),
|
||
raw=False,
|
||
)
|
||
# State reference cleared (the widget itself is a real Static whose
|
||
# .remove() schedules removal — we verify the cleanup intent via
|
||
# the state field, which is the contract callers actually observe).
|
||
assert state.awaiting_widget is None
|
||
|
||
def test_affect_update_scheduled_has_no_pad_detail(self) -> None:
|
||
"""affect_update_scheduled_has_no_pad_detail [v0.11.0]: status=scheduled
|
||
carries no snapshot — the audit line omits dominant_emotion / pad and
|
||
contains only status + turn_id.
|
||
"""
|
||
from ratatoskr.sse_client import AffectUpdate
|
||
from ratatoskr.tui import TuiPresenterState
|
||
|
||
debug_log = MagicMock()
|
||
state = TuiPresenterState()
|
||
state.render(
|
||
AffectUpdate(sse_id=SID, status="scheduled", turn_id=42, snapshot=None),
|
||
transcript=MagicMock(),
|
||
tools_log=MagicMock(),
|
||
debug_log=debug_log,
|
||
thinking_log=MagicMock(),
|
||
raw=False,
|
||
)
|
||
audit = _text_of(debug_log.write.call_args[0][0])
|
||
assert "status=scheduled" in audit
|
||
assert "turn_id=42" in audit
|
||
assert "dominant_emotion" not in audit
|
||
assert "pad=" not in audit
|
||
|
||
def test_done_emits_turn_summary_line(self) -> None:
|
||
"""done_emits_turn_summary_line: when Done arrives the presenter
|
||
emits a `turn_summary` line aggregating per-delta Text + Thinking
|
||
counters. The shape exposes the totals that per-event audit lines
|
||
elided.
|
||
"""
|
||
from ratatoskr.tui import TuiPresenterState
|
||
|
||
debug_log = MagicMock()
|
||
state = TuiPresenterState()
|
||
# 3 Text deltas + 2 Thinking deltas, then Done.
|
||
state.render(
|
||
Text(sse_id=SID, content="a"),
|
||
transcript=MagicMock(), tools_log=MagicMock(),
|
||
debug_log=debug_log, thinking_log=MagicMock(), raw=False,
|
||
)
|
||
state.render(
|
||
Text(sse_id=SID, content="bc"),
|
||
transcript=MagicMock(), tools_log=MagicMock(),
|
||
debug_log=debug_log, thinking_log=MagicMock(), raw=False,
|
||
)
|
||
state.render(
|
||
Thinking(sse_id=SID, content="thought\n"),
|
||
transcript=MagicMock(), tools_log=MagicMock(),
|
||
debug_log=debug_log, thinking_log=MagicMock(), raw=False,
|
||
)
|
||
state.render(
|
||
_make_tui_done(),
|
||
transcript=MagicMock(), tools_log=MagicMock(),
|
||
debug_log=debug_log, thinking_log=MagicMock(), raw=False,
|
||
)
|
||
writes = [_text_of(c[0][0]) for c in debug_log.write.call_args_list]
|
||
summary = next(w for w in writes if "turn_summary" in w)
|
||
assert "text_deltas=2" in summary
|
||
assert "text_bytes=3" in summary # "a" + "bc"
|
||
assert "thinking_deltas=1" in summary
|
||
assert "elapsed_ms=" in summary
|
||
|
||
|
||
def _text_of(write_arg: object) -> str:
|
||
"""Extract plain text from a RichLog.write() arg (str or rich.text.Text).
|
||
|
||
Issue #12 wraps demoted-telemetry entries in `rich.text.Text(..., style="dim")`
|
||
so the RichLog can apply dim styling; non-demoted writes stay as plain str.
|
||
Tests that want to assert against content need both shapes flattened.
|
||
|
||
v0.9.0: also extracts plain text from Markdown wrappers (the streaming-text
|
||
response path uses Markdown(buffer) now; tests assert against the source
|
||
markup, which lives in `Markdown.markup`).
|
||
"""
|
||
from rich.markdown import Markdown
|
||
from rich.text import Text as RichText
|
||
|
||
if isinstance(write_arg, RichText):
|
||
return write_arg.plain
|
||
if isinstance(write_arg, Markdown):
|
||
return write_arg.markup
|
||
if isinstance(write_arg, str):
|
||
return write_arg
|
||
return "" # Rule / etc. — not text content
|
||
|
||
|
||
def _mounted_renderables(transcript_mock: MagicMock) -> list:
|
||
"""v0.9.0: TuiPresenterState now mounts Static widgets into the transcript
|
||
VerticalScroll instead of writing renderables to a RichLog. Tests using a
|
||
MagicMock transcript inspect `transcript.mount.call_args_list`; each call's
|
||
first positional arg is the Static child whose `.content` carries the
|
||
Markdown / RichText / str that pre-v0.9.0 would have been the write arg.
|
||
Returns those renderables in mount-call order so tests can assert on them
|
||
with the same shape they used for `log.write.call_args_list` previously.
|
||
"""
|
||
out: list = []
|
||
for call in transcript_mock.mount.call_args_list:
|
||
for child in call.args:
|
||
renderable = getattr(child, "content", child)
|
||
out.append(renderable)
|
||
return out
|
||
|
||
|
||
def _make_tui_done(*, duration_ms: int = 1, usage: dict[str, int] | None = None) -> Done:
|
||
return Done(
|
||
sse_id=SID,
|
||
phase="succeeded",
|
||
response="r",
|
||
model="m",
|
||
duration_ms=duration_ms,
|
||
usage=usage
|
||
if usage is not None
|
||
else {
|
||
"prompt_tokens": 0,
|
||
"completion_tokens": 0,
|
||
"total_tokens": 0,
|
||
"cached_input_tokens": 0,
|
||
},
|
||
)
|
||
|
||
|
||
class TestCancelViaSse:
|
||
@respx.mock
|
||
async def test_happy_cancel(self) -> None:
|
||
"""happy_cancel [happy,tracer]: 200 OK → returns None; transcript has no [cancel_failed]."""
|
||
respx.post("https://w.example/sessions/s-1/turns/42/cancel").mock(
|
||
return_value=httpx.Response(200, json=_CANCEL_OK_RESP)
|
||
)
|
||
transcript = MagicMock()
|
||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||
result = await _cancel_via_sse(client, "s-1", 42, transcript=transcript)
|
||
assert result is None
|
||
transcript.mount.assert_not_called()
|
||
|
||
@respx.mock
|
||
async def test_cancel_failed_500(self) -> None:
|
||
"""cancel_failed_500 [error]: …"""
|
||
respx.post("https://w.example/sessions/s-1/turns/42/cancel").mock(
|
||
return_value=httpx.Response(500, content=b"boom")
|
||
)
|
||
transcript = MagicMock()
|
||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||
await _cancel_via_sse(client, "s-1", 42, transcript=transcript)
|
||
line = transcript.mount.call_args[0][0].content
|
||
assert "[cancel_failed]" in line
|
||
assert "CancelFailed" in line
|
||
|
||
@respx.mock
|
||
async def test_cancel_already_completed(self) -> None:
|
||
"""cancel_already_completed [scenario]: 409 → '[cancel_failed] CancelAlreadyCompleted:'."""
|
||
respx.post("https://w.example/sessions/s-1/turns/42/cancel").mock(
|
||
return_value=httpx.Response(409)
|
||
)
|
||
transcript = MagicMock()
|
||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||
await _cancel_via_sse(client, "s-1", 42, transcript=transcript)
|
||
line = transcript.mount.call_args[0][0].content
|
||
assert "[cancel_failed]" in line
|
||
assert "CancelAlreadyCompleted" in line
|
||
|
||
@respx.mock
|
||
async def test_transport_error_swallowed(self) -> None:
|
||
"""transport_error_swallowed [error]: …"""
|
||
respx.post("https://w.example/sessions/s-1/turns/42/cancel").mock(
|
||
side_effect=httpx.ConnectError("network down")
|
||
)
|
||
transcript = MagicMock()
|
||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||
await _cancel_via_sse(client, "s-1", 42, transcript=transcript)
|
||
line = transcript.mount.call_args[0][0].content
|
||
assert "[cancel_failed]" in line
|
||
assert "ConnectError" in line
|
||
|
||
@respx.mock
|
||
async def test_audit_callback_records_lifecycle(self) -> None:
|
||
"""audit_callback_records_lifecycle [v0.10.0]: when the caller passes
|
||
an `audit` callback, _cancel_via_sse emits two lines on the happy
|
||
path (`cancel_post issued …` + `cancel_post ok …`) and two lines on
|
||
the failure path (`issued` + `failed …`). Gives the debug pane a
|
||
complete cancel-POST timeline.
|
||
"""
|
||
respx.post("https://w.example/sessions/s-1/turns/42/cancel").mock(
|
||
return_value=httpx.Response(200, json=_CANCEL_OK_RESP)
|
||
)
|
||
transcript = MagicMock()
|
||
audit_lines: list = []
|
||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||
await _cancel_via_sse(
|
||
client, "s-1", 42, transcript=transcript, audit=audit_lines.append
|
||
)
|
||
assert len(audit_lines) == 2
|
||
assert audit_lines[0].startswith("cancel_post issued ")
|
||
assert "session_id=s-1" in audit_lines[0]
|
||
assert "turn_id=42" in audit_lines[0]
|
||
assert audit_lines[1] == "cancel_post ok turn_id=42"
|
||
|
||
@respx.mock
|
||
async def test_audit_callback_records_failure(self) -> None:
|
||
"""audit_callback_records_failure [v0.10.0]: failure path emits
|
||
`cancel_post issued` then `cancel_post failed …` with exception type.
|
||
"""
|
||
respx.post("https://w.example/sessions/s-1/turns/42/cancel").mock(
|
||
return_value=httpx.Response(500, content=b"boom")
|
||
)
|
||
transcript = MagicMock()
|
||
audit_lines: list = []
|
||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||
await _cancel_via_sse(
|
||
client, "s-1", 42, transcript=transcript, audit=audit_lines.append
|
||
)
|
||
assert len(audit_lines) == 2
|
||
assert audit_lines[0].startswith("cancel_post issued ")
|
||
assert audit_lines[1].startswith("cancel_post failed turn_id=42 CancelFailed")
|
||
|
||
|
||
class TestPersonaFormatters:
|
||
"""v0.13.0 — _format_persona_header / _format_persona_detail rendering."""
|
||
|
||
def test_header_compact_summary(self) -> None:
|
||
"""header_compact_summary: agent_id · dominant_emotion · pad(P,A,D) · N emotions."""
|
||
from ratatoskr.tui import _format_persona_header
|
||
|
||
snapshot = {
|
||
"agent_id": "mimir",
|
||
"dominant_emotion": "curiosity",
|
||
"pad": {"pleasure": 0.52, "arousal": 0.47, "dominance": 0.50},
|
||
"emotions_active": [
|
||
{"type": "curiosity"}, {"type": "joy"},
|
||
],
|
||
}
|
||
line = _format_persona_header(snapshot)
|
||
assert "mimir" in line
|
||
assert "curiosity" in line
|
||
assert "pad(0.52, 0.47, 0.5)" in line
|
||
assert "2 emotions active" in line
|
||
|
||
def test_header_singular_emotion(self) -> None:
|
||
"""header_singular_emotion: single emotion → '1 emotion active' (no 's')."""
|
||
from ratatoskr.tui import _format_persona_header
|
||
|
||
line = _format_persona_header(
|
||
{
|
||
"agent_id": "mimir",
|
||
"dominant_emotion": "calm",
|
||
"pad": {"pleasure": 0.5, "arousal": 0.4, "dominance": 0.5},
|
||
"emotions_active": [{"type": "calm"}],
|
||
}
|
||
)
|
||
assert "1 emotion active" in line
|
||
assert "1 emotions" not in line
|
||
|
||
def test_header_no_emotions_drops_count(self) -> None:
|
||
"""header_no_emotions_drops_count: empty emotions list → no count suffix."""
|
||
from ratatoskr.tui import _format_persona_header
|
||
|
||
line = _format_persona_header(
|
||
{
|
||
"agent_id": "mimir",
|
||
"dominant_emotion": "?",
|
||
"pad": {"pleasure": 0.5, "arousal": 0.4, "dominance": 0.5},
|
||
"emotions_active": [],
|
||
}
|
||
)
|
||
assert "emotion" not in line # neither "1 emotion" nor "N emotions"
|
||
|
||
def test_detail_renders_full_snapshot(self) -> None:
|
||
"""detail_renders_full_snapshot: PAD axes + baseline + delta + drift +
|
||
emotions + last_updated_at all surface in the multi-line render.
|
||
"""
|
||
from ratatoskr.tui import _format_persona_detail
|
||
|
||
snapshot = {
|
||
"agent_id": "mimir",
|
||
"pad": {"pleasure": 0.52, "arousal": 0.47, "dominance": 0.50},
|
||
"dominant_emotion": "curiosity",
|
||
"emotions_active": [
|
||
{"type": "curiosity", "intensity": 0.6, "decay_remaining_s": 202.7}
|
||
],
|
||
"baseline_pad": {"pleasure": 0.50, "arousal": 0.40, "dominance": 0.50},
|
||
"mood_drift": {"valence_delta": 0.02, "arousal_delta": 0.07},
|
||
"last_updated_at": "2026-05-25T22:30:18+00:00",
|
||
}
|
||
detail = _format_persona_detail(snapshot)
|
||
assert "Persona snapshot · mimir" in detail
|
||
assert "Dominant emotion: curiosity" in detail
|
||
assert "pleasure" in detail
|
||
assert "baseline 0.5" in detail
|
||
assert "+0.02" in detail or "0.02" in detail
|
||
assert "valence_delta" in detail
|
||
assert "curiosity" in detail
|
||
assert "intensity 0.6" in detail
|
||
assert "decay 3.4m" in detail
|
||
assert "2026-05-25T22:30:18+00:00" in detail
|
||
|
||
|
||
class TestPresenterPersonaCallback:
|
||
"""v0.13.0 — presenter wires AffectUpdate snapshots into a callback."""
|
||
|
||
def test_current_invokes_callback_with_snapshot(self) -> None:
|
||
"""current_invokes_callback_with_snapshot: AffectUpdate(current, snapshot)
|
||
calls on_persona_snapshot(snapshot)."""
|
||
from ratatoskr.sse_client import AffectUpdate
|
||
from ratatoskr.tui import TuiPresenterState
|
||
|
||
captured: list = []
|
||
state = TuiPresenterState()
|
||
snapshot = {"agent_id": "mimir", "pad": {"pleasure": 0.5}}
|
||
state.render(
|
||
AffectUpdate(sse_id=SID, status="current", turn_id=42, snapshot=snapshot),
|
||
transcript=MagicMock(),
|
||
tools_log=MagicMock(),
|
||
debug_log=MagicMock(),
|
||
thinking_log=MagicMock(),
|
||
raw=False,
|
||
on_persona_snapshot=captured.append,
|
||
)
|
||
assert captured == [snapshot]
|
||
|
||
def test_scheduled_does_not_invoke_callback(self) -> None:
|
||
"""scheduled_does_not_invoke_callback: status=scheduled has no snapshot,
|
||
so the callback is skipped (would be called with None otherwise)."""
|
||
from ratatoskr.sse_client import AffectUpdate
|
||
from ratatoskr.tui import TuiPresenterState
|
||
|
||
captured: list = []
|
||
state = TuiPresenterState()
|
||
state.render(
|
||
AffectUpdate(sse_id=SID, status="scheduled", turn_id=42, snapshot=None),
|
||
transcript=MagicMock(),
|
||
tools_log=MagicMock(),
|
||
debug_log=MagicMock(),
|
||
thinking_log=MagicMock(),
|
||
raw=False,
|
||
on_persona_snapshot=captured.append,
|
||
)
|
||
assert captured == []
|
||
|
||
def test_callback_exception_swallowed(self) -> None:
|
||
"""callback_exception_swallowed: a raising callback does NOT crash the
|
||
presenter — the audit line still landed (it precedes the callback).
|
||
"""
|
||
from ratatoskr.sse_client import AffectUpdate
|
||
from ratatoskr.tui import TuiPresenterState
|
||
|
||
def boom(_snap: dict) -> None:
|
||
raise RuntimeError("widget tearing down")
|
||
|
||
debug_log = MagicMock()
|
||
state = TuiPresenterState()
|
||
# Should NOT raise
|
||
state.render(
|
||
AffectUpdate(
|
||
sse_id=SID, status="current", turn_id=42, snapshot={"agent_id": "x"}
|
||
),
|
||
transcript=MagicMock(),
|
||
tools_log=MagicMock(),
|
||
debug_log=debug_log,
|
||
thinking_log=MagicMock(),
|
||
raw=False,
|
||
on_persona_snapshot=boom,
|
||
)
|
||
# Audit line still emitted (it runs before the callback)
|
||
assert debug_log.write.called
|
||
|
||
|
||
class TestAppMount:
|
||
"""on_mount narrows per issue #6: only identity-widget population.
|
||
|
||
Session resolution + AsyncClient open + error-on-resolve are exercised at
|
||
the `_resolve_then_run` layer (see TestResolveThenRun); only happy mount
|
||
paths remain here, exercised with pre-resolved state via _resolved_app.
|
||
"""
|
||
|
||
async def test_happy_new_session_mount(self) -> None:
|
||
"""happy_new_session_mount [happy,tracer]: identity populated from pre-resolved state."""
|
||
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
assert app.session_id == "s-new12345"
|
||
assert app.agent_id == "mimir"
|
||
assert app.state == "idle"
|
||
# INV-002: session identity visible — agent_id + last 8 of session_id
|
||
assert "mimir" in (app.sub_title or "")
|
||
assert app.session_id[-8:] in (app.sub_title or "")
|
||
|
||
async def test_happy_existing_session_mount(self) -> None:
|
||
"""happy_existing_session_mount: identity shows <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.9.0]: left column = transcript-scroll
|
||
VerticalScroll + prompt Input. thinking-current Static removed in
|
||
v0.6.5; transcript RichLog replaced by VerticalScroll in v0.9.0.
|
||
"""
|
||
from textual.containers import Vertical, VerticalScroll
|
||
from textual.widgets import Input
|
||
|
||
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
left = app.query_one("#left-column", Vertical)
|
||
transcript = app.query_one("#transcript-scroll", VerticalScroll)
|
||
prompt = app.query_one("#prompt", Input)
|
||
assert transcript in left.walk_children()
|
||
assert prompt in left.walk_children()
|
||
# v0.6.5: thinking-current Static removed; no longer in DOM at all.
|
||
from textual.css.query import NoMatches
|
||
try:
|
||
app.query_one("#thinking-current")
|
||
raise AssertionError("thinking-current should not exist in v0.6.5")
|
||
except NoMatches:
|
||
pass # expected
|
||
|
||
async def test_right_column_has_tabbed_content_with_tools_tab(self) -> None:
|
||
"""right_column_has_tabbed_content_with_tools_tab: #side-panes + TabPane#tools-tab."""
|
||
from textual.widgets import TabbedContent, TabPane
|
||
|
||
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
tabbed = app.query_one("#side-panes", TabbedContent)
|
||
assert tabbed is not None
|
||
tools_tab = app.query_one("#tools-tab", TabPane)
|
||
assert tools_tab is not None
|
||
|
||
async def test_tools_log_inside_tools_tab(self) -> None:
|
||
"""tools_log_inside_tools_tab: tools-transcript RichLog is a descendant of tools-tab TabPane."""
|
||
from textual.widgets import RichLog, TabPane
|
||
|
||
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
tools_tab = app.query_one("#tools-tab", TabPane)
|
||
tools_log = app.query_one("#tools-log", RichLog)
|
||
assert tools_log in tools_tab.walk_children()
|
||
|
||
async def test_pane_name_widget_renders_tools(self) -> None:
|
||
"""pane_name_widget_renders_tools [INV-pane-name]: #pane-name == 'Tools' on first frame."""
|
||
from textual.widgets import Static
|
||
|
||
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
pane_name = app.query_one("#pane-name", Static)
|
||
rendered = str(pane_name.render())
|
||
assert rendered == "Tools"
|
||
|
||
async def test_ctrl_1_activates_tools_tab(self) -> None:
|
||
"""ctrl_1_activates_tools_tab [tracer]: Ctrl+1 → TabbedContent.active == 'tools-tab'."""
|
||
from textual.widgets import TabbedContent
|
||
|
||
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
await pilot.press("ctrl+1")
|
||
await pilot.pause()
|
||
tabbed = app.query_one("#side-panes", TabbedContent)
|
||
assert tabbed.active == "tools-tab"
|
||
|
||
async def test_ctrl_1_preserves_input_focus(self) -> None:
|
||
"""ctrl_1_preserves_input_focus [INV-016]: Ctrl+1 does NOT steal focus from Input."""
|
||
from textual.widgets import Input
|
||
|
||
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
prompt = app.query_one("#prompt", Input)
|
||
prompt.focus()
|
||
await pilot.pause()
|
||
assert app.focused is prompt
|
||
await pilot.press("ctrl+1")
|
||
await pilot.pause()
|
||
assert app.focused is prompt, (
|
||
f"INV-016: Input focus must survive Ctrl+1 tab switch; got focused={app.focused}"
|
||
)
|
||
|
||
async def test_debug_tab_exists(self) -> None:
|
||
"""debug_tab_exists [v0.5.0]: right column has Debug TabPane + #debug-transcript RichLog."""
|
||
from textual.widgets import RichLog, TabPane
|
||
|
||
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
debug_tab = app.query_one("#debug-tab", TabPane)
|
||
debug_log = app.query_one("#debug-log", RichLog)
|
||
assert debug_log in debug_tab.walk_children()
|
||
|
||
async def test_ctrl_2_activates_debug_tab(self) -> None:
|
||
"""ctrl_2_activates_debug_tab [v0.5.0]: Ctrl+2 → TabbedContent.active == 'debug-tab'."""
|
||
from textual.widgets import TabbedContent
|
||
|
||
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
await pilot.press("ctrl+2")
|
||
await pilot.pause()
|
||
assert app.query_one("#side-panes", TabbedContent).active == "debug-tab"
|
||
|
||
async def test_persona_tab_exists(self) -> None:
|
||
"""persona_tab_exists [v0.13.0]: right column has Persona TabPane +
|
||
#persona-log RichLog as descendant.
|
||
"""
|
||
from textual.widgets import RichLog, TabPane
|
||
|
||
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
persona_tab = app.query_one("#persona-tab", TabPane)
|
||
persona_log = app.query_one("#persona-log", RichLog)
|
||
assert persona_log in persona_tab.walk_children()
|
||
|
||
async def test_ctrl_4_activates_persona_tab(self) -> None:
|
||
"""ctrl_4_activates_persona_tab [v0.13.0]: Ctrl+4 → active == 'persona-tab'."""
|
||
from textual.widgets import TabbedContent
|
||
|
||
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
await pilot.press("ctrl+4")
|
||
await pilot.pause()
|
||
assert app.query_one("#side-panes", TabbedContent).active == "persona-tab"
|
||
|
||
async def test_persona_header_starts_empty(self) -> None:
|
||
"""persona_header_starts_empty [v0.13.0]: sticky persona-header widget
|
||
exists, starts hidden (height collapsed via .empty class) until
|
||
hydration succeeds.
|
||
"""
|
||
from textual.widgets import Static
|
||
|
||
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
header = app.query_one("#persona-header", Static)
|
||
assert "empty" in header.classes
|
||
|
||
async def test_done_label_styled_success(self) -> None:
|
||
"""done_label_styled_success [v0.9.0]: [done] label mounts as Static
|
||
carrying a RichText with Aurora green style. Inspect the mounted
|
||
Static's `.content`.
|
||
"""
|
||
from rich.text import Text as RichText
|
||
from textual.containers import VerticalScroll
|
||
from textual.widgets import RichLog
|
||
|
||
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
from ratatoskr.tui import TuiPresenterState
|
||
transcript = app.query_one("#transcript-scroll", VerticalScroll)
|
||
state = TuiPresenterState()
|
||
mounted: list = []
|
||
orig_mount = transcript.mount
|
||
|
||
def spy_mount(*ch, **kw):
|
||
mounted.extend(ch)
|
||
return orig_mount(*ch, **kw)
|
||
|
||
transcript.mount = spy_mount # type: ignore[method-assign]
|
||
state.render(
|
||
_make_tui_done(),
|
||
transcript=transcript,
|
||
tools_log=app.query_one("#tools-log", RichLog),
|
||
debug_log=app.query_one("#debug-log", RichLog),
|
||
thinking_log=MagicMock(),
|
||
raw=True,
|
||
)
|
||
done = next(
|
||
w.content for w in mounted
|
||
if isinstance(getattr(w, "content", None), RichText)
|
||
and _text_of(w.content).startswith("[done]")
|
||
)
|
||
assert done.style == "#16B866" # Aurora green
|
||
|
||
async def test_empty_state_placeholders_present(self) -> None:
|
||
"""empty_state_placeholders_present [v0.5.1]: tools-transcript + debug-transcript show
|
||
placeholder lines before any turn fires."""
|
||
from textual.widgets import RichLog
|
||
|
||
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
# Activate Debug tab so its content actually renders.
|
||
from textual.widgets import TabbedContent
|
||
tabbed = app.query_one("#side-panes", TabbedContent)
|
||
tabbed.active = "debug-tab"
|
||
await pilot.pause()
|
||
tabbed.active = "tools-tab"
|
||
await pilot.pause()
|
||
tools_log = app.query_one("#tools-log", RichLog)
|
||
debug_log = app.query_one("#debug-log", RichLog)
|
||
tools_text = " ".join(str(line) for line in tools_log.lines)
|
||
tabbed.active = "debug-tab"
|
||
await pilot.pause()
|
||
debug_text = " ".join(str(line) for line in debug_log.lines)
|
||
assert "no tool events" in tools_text
|
||
assert "worker_phase" in debug_text
|
||
|
||
async def test_pane_name_updates_on_tab_switch(self) -> None:
|
||
"""pane_name_updates_on_tab_switch [v0.5.0]: pane-name reflects active tab.
|
||
|
||
Two tabs now (Tools / Debug); pane-name updates from "Tools" to "Debug"
|
||
and back as the operator switches via Ctrl+1 / Ctrl+2.
|
||
"""
|
||
from textual.widgets import Static
|
||
|
||
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
pane_name = app.query_one("#pane-name", Static)
|
||
await pilot.press("ctrl+2")
|
||
await pilot.pause()
|
||
assert str(pane_name.render()) == "Debug"
|
||
await pilot.press("ctrl+1")
|
||
await pilot.pause()
|
||
assert str(pane_name.render()) == "Tools"
|
||
|
||
|
||
import asyncio # noqa: E402
|
||
|
||
from textual.widgets import Input # noqa: E402
|
||
|
||
|
||
async def _noop_worker(self, content: str) -> None:
|
||
"""Fake _stream_turn_worker that never completes (lets state stay 'streaming')."""
|
||
await asyncio.Future() # await forever; cancelled when test exits
|
||
|
||
|
||
class TestOnInputSubmitted:
|
||
@respx.mock
|
||
async def test_happy_submit_echoes_and_spawns(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""happy_submit_echoes_and_spawns [happy,tracer]: …"""
|
||
monkeypatch.setattr(RatatoskrApp, "_stream_turn_worker", _noop_worker)
|
||
writes = _spy_writes(monkeypatch)
|
||
app = _resolved_app(_args_existing())
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
inp = app.query_one("#prompt", Input)
|
||
inp.value = "hello"
|
||
await inp.action_submit()
|
||
await pilot.pause()
|
||
assert any("❯ hello" in str(w) for w in writes) # noqa: RUF001
|
||
assert inp.value == ""
|
||
assert app.state == "streaming"
|
||
assert app.stream_worker is not None
|
||
|
||
@respx.mock
|
||
async def test_empty_submit_no_op(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""empty_submit_no_op [trace]: '' + Enter → no change; no worker spawned."""
|
||
monkeypatch.setattr(RatatoskrApp, "_stream_turn_worker", _noop_worker)
|
||
app = _resolved_app(_args_existing())
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
# Spy AFTER mount so identity-widget writes (if any) aren't counted.
|
||
writes = _spy_writes(monkeypatch)
|
||
inp = app.query_one("#prompt", Input)
|
||
inp.value = ""
|
||
await inp.action_submit()
|
||
await pilot.pause()
|
||
assert app.state == "idle"
|
||
assert app.stream_worker is None
|
||
# POST: no RichLog write fires on empty submit
|
||
assert writes == []
|
||
|
||
@respx.mock
|
||
async def test_submit_during_streaming_shows_busy_notice(
|
||
self, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
"""submit_during_streaming_shows_busy_notice [adversarial]: …"""
|
||
monkeypatch.setattr(RatatoskrApp, "_stream_turn_worker", _noop_worker)
|
||
writes = _spy_writes(monkeypatch)
|
||
app = _resolved_app(_args_existing())
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
inp = app.query_one("#prompt", Input)
|
||
# First submit: enters streaming
|
||
inp.value = "first"
|
||
await inp.action_submit()
|
||
await pilot.pause()
|
||
first_worker = app.stream_worker
|
||
assert app.state == "streaming"
|
||
# Second submit while streaming → busy notice; no new worker
|
||
writes.clear()
|
||
inp.value = "second"
|
||
await inp.action_submit()
|
||
await pilot.pause()
|
||
assert any("[busy] turn in flight; input ignored" in str(w) for w in writes)
|
||
assert app.stream_worker is first_worker # unchanged
|
||
assert app.state == "streaming"
|
||
assert inp.value == ""
|
||
|
||
@respx.mock
|
||
async def test_submit_during_cancelling_shows_busy_notice(
|
||
self, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
"""submit_during_cancelling_shows_busy_notice [adversarial]: …"""
|
||
monkeypatch.setattr(RatatoskrApp, "_stream_turn_worker", _noop_worker)
|
||
writes = _spy_writes(monkeypatch)
|
||
app = _resolved_app(_args_existing())
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
app.state = "cancelling" # bypass the natural transition for the test
|
||
assert app.stream_worker is None # no live worker before non-idle submit
|
||
inp = app.query_one("#prompt", Input)
|
||
inp.value = "x"
|
||
await inp.action_submit()
|
||
await pilot.pause()
|
||
assert any("[busy]" in str(w) for w in writes)
|
||
assert app.state == "cancelling"
|
||
# POST-005 (from issue #4 on_input_submitted contract):
|
||
# input cleared; NO new worker spawned during non-idle submit.
|
||
assert inp.value == ""
|
||
assert app.stream_worker is None
|
||
|
||
@respx.mock
|
||
async def test_footer_hint_flips_to_cancel(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""footer_hint_flips_to_cancel [trace]: hint widget shows 'Ctrl-C to cancel'."""
|
||
from textual.widgets import Static
|
||
|
||
monkeypatch.setattr(RatatoskrApp, "_stream_turn_worker", _noop_worker)
|
||
app = _resolved_app(_args_existing())
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
hint_widget = app.query_one("#hint", Static)
|
||
assert str(hint_widget.render()) == RatatoskrApp.HINT_IDLE
|
||
inp = app.query_one("#prompt", Input)
|
||
inp.value = "hi"
|
||
await inp.action_submit()
|
||
await pilot.pause()
|
||
assert str(hint_widget.render()) == RatatoskrApp.HINT_STREAMING
|
||
|
||
|
||
import json # noqa: E402
|
||
|
||
|
||
def _sse_chunk(sse_id: str, body: dict) -> bytes:
|
||
return f"id: {sse_id}\ndata: {json.dumps(body)}\n\n".encode()
|
||
|
||
|
||
_DONE_BODY = {
|
||
"type": "done",
|
||
"phase": "succeeded",
|
||
"response": "hello",
|
||
"model": "m",
|
||
"duration_ms": 1,
|
||
"usage": {
|
||
"prompt_tokens": 0,
|
||
"completion_tokens": 0,
|
||
"total_tokens": 0,
|
||
"cached_input_tokens": 0,
|
||
},
|
||
}
|
||
_CANCELLED_STREAM_BODY = {
|
||
"type": "cancelled",
|
||
"phase": "cancelled",
|
||
"turn_id": 42,
|
||
"reason": "user_cancel",
|
||
"partial_message_id": None,
|
||
}
|
||
|
||
|
||
def _sse_resp(body: bytes | httpx.AsyncByteStream) -> httpx.Response:
|
||
headers = {"content-type": "text/event-stream"}
|
||
if isinstance(body, bytes):
|
||
return httpx.Response(200, headers=headers, content=body)
|
||
return httpx.Response(200, headers=headers, stream=body)
|
||
|
||
|
||
async def _submit_and_wait(app: RatatoskrApp, pilot, content: str) -> None:
|
||
"""Type content into the input and submit; wait for worker to finish."""
|
||
inp = app.query_one("#prompt", Input)
|
||
inp.value = content
|
||
await inp.action_submit()
|
||
await pilot.pause() # let the Input.Submitted message dispatch
|
||
# Poll until the worker resolves (state returns to idle)
|
||
for _ in range(100):
|
||
if app.state == "idle" and app.stream_worker is not None:
|
||
return
|
||
await pilot.pause(0.02)
|
||
|
||
|
||
class TestStreamTurnWorker:
|
||
@respx.mock
|
||
async def test_happy_text_done_no_double_print(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""happy_text_done_no_double_print [happy,tracer, v0.9.0]:
|
||
Text("hello") mounts a Static(Markdown("hello")) into the transcript;
|
||
Done mounts a [done] label Static. The Markdown is rendered live (one
|
||
widget for the whole stream, updated in place), so there is NO
|
||
post-Done re-render — exactly ONE Markdown renderable lands in the
|
||
transcript for the response body. v0.9.0 supersedes v0.8.2's
|
||
drop-Markdown patch with proper live rendering.
|
||
"""
|
||
stream = _sse_chunk("42:1", {"type": "text", "content": "hello"}) + _sse_chunk(
|
||
"42:2", _DONE_BODY
|
||
)
|
||
respx.post("https://w.example/sessions/s-1existing/messages").mock(
|
||
return_value=_sse_resp(stream)
|
||
)
|
||
|
||
writes = _spy_writes(monkeypatch)
|
||
|
||
app = _resolved_app(_args_existing())
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
await _submit_and_wait(app, pilot, "hi")
|
||
assert app.state == "idle"
|
||
from rich.markdown import Markdown
|
||
|
||
# The response body lives as ONE Markdown renderable mounted into
|
||
# the transcript; live updates happen via Static.update, not via
|
||
# re-mount, so there's exactly one Markdown in the spy stream.
|
||
markdowns = [w for w in writes if isinstance(w, Markdown)]
|
||
assert len(markdowns) == 1, (
|
||
f"v0.9.0: expected exactly ONE Markdown mounted, got {len(markdowns)}"
|
||
)
|
||
assert markdowns[0].markup == "hello"
|
||
# [done] label fires too.
|
||
assert any("[done]" in _text_of(w) for w in writes)
|
||
|
||
@respx.mock
|
||
async def test_raw_flag_skips_markdown_render(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""raw_flag_skips_markdown_render [trace, v0.9.0]:
|
||
With --raw, the response widget holds plain str instead of Markdown.
|
||
Turn-header markers still appear in every pane: 3 RichLog panes
|
||
receive a Rule, the transcript-scroll receives a Static-wrapped
|
||
RichText (mounted, not written), giving 3 Rules in the captured
|
||
writes list.
|
||
"""
|
||
stream = _sse_chunk("42:1", {"type": "text", "content": "hi"}) + _sse_chunk(
|
||
"42:2", _DONE_BODY
|
||
)
|
||
respx.post("https://w.example/sessions/s-1existing/messages").mock(
|
||
return_value=_sse_resp(stream)
|
||
)
|
||
writes = _spy_writes(monkeypatch)
|
||
app = _resolved_app(_args_existing(raw=True))
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
await _submit_and_wait(app, pilot, "x")
|
||
from rich.markdown import Markdown
|
||
from rich.rule import Rule
|
||
|
||
# No Markdown in raw mode.
|
||
assert not any(isinstance(w, Markdown) for w in writes)
|
||
# 3 Rules — one per RichLog pane (tools / debug / thinking).
|
||
# Transcript-scroll uses a Static turn-header Markdown alternative.
|
||
rules = [w for w in writes if isinstance(w, Rule)]
|
||
assert len(rules) == 3, f"expected 3 turn-header Rules, got {len(rules)}"
|
||
# Accumulated text "hi" mounted as plain str into transcript.
|
||
assert "hi" in writes
|
||
|
||
@respx.mock
|
||
async def test_error_terminal_returns_to_idle(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""error_terminal_returns_to_idle [happy]: …"""
|
||
stream = _sse_chunk("42:1", {"type": "text", "content": "x"}) + _sse_chunk(
|
||
"42:2",
|
||
{
|
||
"type": "error",
|
||
"phase": "failed",
|
||
"error_code": "llm_output_invalid",
|
||
"message": "boom",
|
||
},
|
||
)
|
||
respx.post("https://w.example/sessions/s-1existing/messages").mock(
|
||
return_value=_sse_resp(stream)
|
||
)
|
||
writes = _spy_writes(monkeypatch)
|
||
app = _resolved_app(_args_existing())
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
await _submit_and_wait(app, pilot, "x")
|
||
assert app.state == "idle"
|
||
assert any("[error]" in str(w) for w in writes)
|
||
|
||
@respx.mock
|
||
async def test_cancelled_terminal_returns_to_idle(
|
||
self, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
"""cancelled_terminal_returns_to_idle [happy]: …"""
|
||
stream = _sse_chunk("42:1", {"type": "text", "content": "x"}) + _sse_chunk(
|
||
"42:2", _CANCELLED_STREAM_BODY
|
||
)
|
||
respx.post("https://w.example/sessions/s-1existing/messages").mock(
|
||
return_value=_sse_resp(stream)
|
||
)
|
||
writes = _spy_writes(monkeypatch)
|
||
app = _resolved_app(_args_existing())
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
await _submit_and_wait(app, pilot, "x")
|
||
assert app.state == "idle"
|
||
assert any("[cancelled]" in str(w) for w in writes)
|
||
|
||
@respx.mock
|
||
async def test_active_turn_id_set_on_first_event(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""active_turn_id_set_on_first_event [trace]: …"""
|
||
# Use a gated stream: yield first event, then hold, so we can inspect mid-stream
|
||
first = _sse_chunk("42:1", {"type": "text", "content": "x"})
|
||
gate = asyncio.Event()
|
||
|
||
class _GatedAfterFirst(httpx.AsyncByteStream):
|
||
async def __aiter__(self):
|
||
yield first
|
||
await gate.wait()
|
||
yield _sse_chunk("42:2", _DONE_BODY)
|
||
|
||
async def aclose(self) -> None:
|
||
return None
|
||
|
||
respx.post("https://w.example/sessions/s-1existing/messages").mock(
|
||
return_value=_sse_resp(_GatedAfterFirst())
|
||
)
|
||
|
||
app = _resolved_app(_args_existing())
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
inp = app.query_one("#prompt", Input)
|
||
inp.value = "x"
|
||
await inp.action_submit()
|
||
# Wait for first event to be processed (active_turn_id set)
|
||
for _ in range(50):
|
||
if app.active_turn_id is not None:
|
||
break
|
||
await pilot.pause(0.02)
|
||
assert app.active_turn_id == 42
|
||
# Release the gate so the worker can finish and the app can shut down cleanly
|
||
gate.set()
|
||
for _ in range(50):
|
||
if app.state == "idle":
|
||
break
|
||
await pilot.pause(0.02)
|
||
|
||
@respx.mock
|
||
async def test_sse_connect_failed_returns_to_idle(
|
||
self, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
"""sse_connect_failed_returns_to_idle [error]: …"""
|
||
respx.post("https://w.example/sessions/s-1existing/messages").mock(
|
||
return_value=httpx.Response(404, json={"error": "session_not_found"})
|
||
)
|
||
writes = _spy_writes(monkeypatch)
|
||
app = _resolved_app(_args_existing())
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
await _submit_and_wait(app, pilot, "x")
|
||
assert app.state == "idle"
|
||
assert any("[sse_connect_failed]" in str(w) for w in writes)
|
||
assert app.return_value is None # app NOT exited per INV-008
|
||
|
||
@respx.mock
|
||
async def test_connection_dropped_returns_to_idle(
|
||
self, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
"""connection_dropped_returns_to_idle [error]: …"""
|
||
|
||
class _DropAfter(httpx.AsyncByteStream):
|
||
async def __aiter__(self):
|
||
yield _sse_chunk("42:1", {"type": "text", "content": "x"})
|
||
raise httpx.RemoteProtocolError("drop")
|
||
|
||
async def aclose(self) -> None:
|
||
return None
|
||
|
||
respx.post("https://w.example/sessions/s-1existing/messages").mock(
|
||
return_value=_sse_resp(_DropAfter())
|
||
)
|
||
writes = _spy_writes(monkeypatch)
|
||
app = _resolved_app(_args_existing())
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
await _submit_and_wait(app, pilot, "x")
|
||
assert app.state == "idle"
|
||
assert any("[connection_dropped]" in str(w) for w in writes)
|
||
|
||
@respx.mock
|
||
async def test_malformed_sse_data_returns_to_idle(
|
||
self, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
"""malformed_sse_data_returns_to_idle [error]: bad-JSON → [malformed_sse_data]; idle."""
|
||
stream = (
|
||
_sse_chunk("42:1", {"type": "text", "content": "x"}) + b"id: 42:2\ndata: not-json\n\n"
|
||
)
|
||
respx.post("https://w.example/sessions/s-1existing/messages").mock(
|
||
return_value=_sse_resp(stream)
|
||
)
|
||
writes = _spy_writes(monkeypatch)
|
||
app = _resolved_app(_args_existing())
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
await _submit_and_wait(app, pilot, "x")
|
||
assert app.state == "idle"
|
||
assert any("[malformed_sse_data]" in str(w) for w in writes)
|
||
assert any("not-json" in str(w) for w in writes)
|
||
# INV-008: mid-session error does NOT exit the app
|
||
assert app.return_value is None
|
||
|
||
@respx.mock
|
||
async def test_rendered_event_per_event(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""rendered_event_per_event [trace]: …"""
|
||
chunks = (
|
||
_sse_chunk("42:1", {"type": "worker_phase", "phase": "streaming", "turn_id": 42})
|
||
+ _sse_chunk("42:2", {"type": "text", "content": "hi"})
|
||
+ _sse_chunk("42:3", _DONE_BODY)
|
||
)
|
||
respx.post("https://w.example/sessions/s-1existing/messages").mock(
|
||
return_value=_sse_resp(chunks)
|
||
)
|
||
|
||
# Per issue #12: rendering went from stateless _render_event_to_log to
|
||
# TuiPresenterState.render; the spy moves to the new method.
|
||
from ratatoskr.tui import TuiPresenterState
|
||
|
||
call_count = 0
|
||
original = TuiPresenterState.render
|
||
|
||
def spy(self, event, **kw): # type: ignore[no-untyped-def]
|
||
nonlocal call_count
|
||
call_count += 1
|
||
return original(self, event, **kw)
|
||
|
||
monkeypatch.setattr(TuiPresenterState, "render", spy)
|
||
app = _resolved_app(_args_existing())
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
await _submit_and_wait(app, pilot, "x")
|
||
assert call_count == 3
|
||
|
||
|
||
class TestActionInterrupt:
|
||
@respx.mock
|
||
async def test_idle_ctrl_c_exits_zero(self) -> None:
|
||
"""idle_ctrl_c_exits_zero [happy,tracer]: state=idle; ctrl+c → exit(0)."""
|
||
app = _resolved_app(_args_existing())
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
assert app.state == "idle"
|
||
await pilot.press("ctrl+c")
|
||
await pilot.pause()
|
||
assert app.return_value == 0
|
||
|
||
@respx.mock
|
||
async def test_streaming_first_ctrl_c_cancels(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""streaming_first_ctrl_c_cancels [scenario,tracer]: …"""
|
||
# Stream that yields one text event (sets active_turn_id) then waits forever
|
||
first_chunk = _sse_chunk("42:1", {"type": "text", "content": "x"})
|
||
gate = asyncio.Event()
|
||
|
||
class _GatedAfterFirst(httpx.AsyncByteStream):
|
||
async def __aiter__(self):
|
||
yield first_chunk
|
||
await gate.wait()
|
||
|
||
async def aclose(self) -> None:
|
||
return None
|
||
|
||
respx.post("https://w.example/sessions/s-1existing/messages").mock(
|
||
return_value=_sse_resp(_GatedAfterFirst())
|
||
)
|
||
cancel_observed = asyncio.Event()
|
||
|
||
def cancel_handler(req: httpx.Request) -> httpx.Response:
|
||
cancel_observed.set()
|
||
return httpx.Response(200, json=_CANCEL_OK_RESP)
|
||
|
||
cancel_route = respx.post("https://w.example/sessions/s-1existing/turns/42/cancel").mock(
|
||
side_effect=cancel_handler
|
||
)
|
||
|
||
app = _resolved_app(_args_existing())
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
inp = app.query_one("#prompt", Input)
|
||
inp.value = "go"
|
||
await inp.action_submit()
|
||
await pilot.pause()
|
||
# Wait for active_turn_id to be set (first event consumed)
|
||
for _ in range(50):
|
||
if app.active_turn_id == 42:
|
||
break
|
||
await pilot.pause(0.02)
|
||
assert app.active_turn_id == 42
|
||
assert app.state == "streaming"
|
||
await pilot.press("ctrl+c")
|
||
# Wait for cancel POST to land
|
||
for _ in range(50):
|
||
if cancel_observed.is_set():
|
||
break
|
||
await pilot.pause(0.02)
|
||
assert cancel_route.call_count == 1
|
||
assert app.state == "cancelling"
|
||
from textual.widgets import Static
|
||
|
||
hint_widget = app.query_one("#hint", Static)
|
||
assert str(hint_widget.render()) == RatatoskrApp.HINT_CANCELLING
|
||
# Release the gate so the stream worker can finish cleanly during teardown
|
||
gate.set()
|
||
|
||
@respx.mock
|
||
async def test_streaming_no_turn_id_force_exits(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""streaming_no_turn_id_force_exits [scenario]: …"""
|
||
cancel_route = respx.post("https://w.example/sessions/s-1existing/turns/0/cancel").mock(
|
||
return_value=httpx.Response(200, json=_CANCEL_OK_RESP)
|
||
)
|
||
# Stream that hangs forever (no events to set active_turn_id)
|
||
gate = asyncio.Event()
|
||
|
||
class _NeverYields(httpx.AsyncByteStream):
|
||
async def __aiter__(self):
|
||
await gate.wait()
|
||
if False:
|
||
yield b""
|
||
|
||
async def aclose(self) -> None:
|
||
return None
|
||
|
||
respx.post("https://w.example/sessions/s-1existing/messages").mock(
|
||
return_value=_sse_resp(_NeverYields())
|
||
)
|
||
|
||
app = _resolved_app(_args_existing())
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
inp = app.query_one("#prompt", Input)
|
||
inp.value = "go"
|
||
await inp.action_submit()
|
||
await pilot.pause()
|
||
assert app.state == "streaming"
|
||
assert app.active_turn_id is None
|
||
# Capture worker reference + spy on its .cancel() before ctrl+c
|
||
worker_ref = app.stream_worker
|
||
assert worker_ref is not None
|
||
cancel_calls: list = []
|
||
original_cancel = type(worker_ref).cancel
|
||
monkeypatch.setattr(
|
||
type(worker_ref),
|
||
"cancel",
|
||
lambda self: (cancel_calls.append(self), original_cancel(self))[-1],
|
||
)
|
||
await pilot.press("ctrl+c")
|
||
await pilot.pause()
|
||
gate.set() # let the gated stream resolve so teardown is clean
|
||
assert app.return_value == 3
|
||
assert cancel_route.call_count == 0
|
||
# action_interrupt MUST cancel the stream worker on the no-active_turn_id force-exit path
|
||
assert worker_ref in cancel_calls
|
||
|
||
@respx.mock
|
||
async def test_cancelling_second_ctrl_c_force_exits(
|
||
self, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
"""cancelling_second_ctrl_c_force_exits [scenario]: …"""
|
||
# Set up a real live stream worker (gated, hangs forever) so we can
|
||
# observe action_interrupt's cancel() call on the second-Ctrl-C path.
|
||
monkeypatch.setattr(RatatoskrApp, "_stream_turn_worker", _noop_worker)
|
||
app = _resolved_app(_args_existing())
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
inp = app.query_one("#prompt", Input)
|
||
inp.value = "go"
|
||
await inp.action_submit()
|
||
await pilot.pause()
|
||
assert app.stream_worker is not None
|
||
app.state = "cancelling" # bypass the natural transition for the test
|
||
worker_ref = app.stream_worker
|
||
cancel_calls: list = []
|
||
original_cancel = type(worker_ref).cancel
|
||
monkeypatch.setattr(
|
||
type(worker_ref),
|
||
"cancel",
|
||
lambda self: (cancel_calls.append(self), original_cancel(self))[-1],
|
||
)
|
||
await pilot.press("ctrl+c")
|
||
await pilot.pause()
|
||
assert app.return_value == 3
|
||
# Second-Ctrl-C in cancelling state MUST cancel the in-flight worker
|
||
assert worker_ref in cancel_calls
|
||
|
||
@respx.mock
|
||
async def test_cancel_failed_swallowed(self) -> None:
|
||
"""cancel_failed_swallowed [scenario]: …"""
|
||
first_chunk = _sse_chunk("42:1", {"type": "text", "content": "x"})
|
||
stream_gate = asyncio.Event()
|
||
|
||
class _GatedAfterFirst(httpx.AsyncByteStream):
|
||
async def __aiter__(self):
|
||
yield first_chunk
|
||
await stream_gate.wait()
|
||
|
||
async def aclose(self) -> None:
|
||
return None
|
||
|
||
respx.post("https://w.example/sessions/s-1existing/messages").mock(
|
||
return_value=_sse_resp(_GatedAfterFirst())
|
||
)
|
||
cancel_observed = asyncio.Event()
|
||
|
||
def cancel_handler(req: httpx.Request) -> httpx.Response:
|
||
cancel_observed.set()
|
||
return httpx.Response(500, content=b"boom")
|
||
|
||
respx.post("https://w.example/sessions/s-1existing/turns/42/cancel").mock(
|
||
side_effect=cancel_handler
|
||
)
|
||
|
||
app = _resolved_app(_args_existing())
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
inp = app.query_one("#prompt", Input)
|
||
inp.value = "go"
|
||
await inp.action_submit()
|
||
await pilot.pause()
|
||
for _ in range(50):
|
||
if app.active_turn_id == 42:
|
||
break
|
||
await pilot.pause(0.02)
|
||
await pilot.press("ctrl+c")
|
||
for _ in range(50):
|
||
if cancel_observed.is_set():
|
||
break
|
||
await pilot.pause(0.02)
|
||
# Give _cancel_via_sse time to write the [cancel_failed] line
|
||
await pilot.pause(0.05)
|
||
from textual.containers import VerticalScroll
|
||
from textual.widgets import Static
|
||
transcript = app.query_one("#transcript-scroll", VerticalScroll)
|
||
rendered = "\n".join(
|
||
str(child.content)
|
||
for child in transcript.children
|
||
if isinstance(child, Static)
|
||
)
|
||
assert "[cancel_failed]" in rendered
|
||
assert app.state == "cancelling"
|
||
stream_gate.set() # let stream finish for teardown
|
||
|
||
|
||
class TestActionQuit:
|
||
@respx.mock
|
||
async def test_idle_ctrl_d_exits_zero(self) -> None:
|
||
"""idle_ctrl_d_exits_zero [happy,tracer]: state=idle; ctrl+d → exit(0)."""
|
||
app = _resolved_app(_args_existing())
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
await pilot.press("ctrl+d")
|
||
await pilot.pause()
|
||
assert app.return_value == 0
|
||
|
||
@respx.mock
|
||
async def test_streaming_ctrl_d_force_exits(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""streaming_ctrl_d_force_exits [scenario]: …"""
|
||
cancel_route = respx.post("https://w.example/sessions/s-1existing/turns/42/cancel").mock(
|
||
return_value=httpx.Response(200, json=_CANCEL_OK_RESP)
|
||
)
|
||
first_chunk = _sse_chunk("42:1", {"type": "text", "content": "x"})
|
||
gate = asyncio.Event()
|
||
|
||
class _GatedAfterFirst(httpx.AsyncByteStream):
|
||
async def __aiter__(self):
|
||
yield first_chunk
|
||
await gate.wait()
|
||
|
||
async def aclose(self) -> None:
|
||
return None
|
||
|
||
respx.post("https://w.example/sessions/s-1existing/messages").mock(
|
||
return_value=_sse_resp(_GatedAfterFirst())
|
||
)
|
||
app = _resolved_app(_args_existing())
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
inp = app.query_one("#prompt", Input)
|
||
inp.value = "go"
|
||
await inp.action_submit()
|
||
await pilot.pause()
|
||
for _ in range(50):
|
||
if app.active_turn_id == 42:
|
||
break
|
||
await pilot.pause(0.02)
|
||
# Capture worker + spy on cancel before ctrl+d
|
||
worker_ref = app.stream_worker
|
||
assert worker_ref is not None
|
||
cancel_calls: list = []
|
||
original_cancel = type(worker_ref).cancel
|
||
monkeypatch.setattr(
|
||
type(worker_ref),
|
||
"cancel",
|
||
lambda self: (cancel_calls.append(self), original_cancel(self))[-1],
|
||
)
|
||
await pilot.press("ctrl+d")
|
||
await pilot.pause()
|
||
gate.set()
|
||
assert app.return_value == 0
|
||
assert cancel_route.call_count == 0
|
||
# POST-002: Ctrl-D MUST cancel the in-flight stream worker (abandon-and-exit)
|
||
assert worker_ref in cancel_calls
|
||
|
||
|
||
from ratatoskr.tui import run_tui # noqa: E402
|
||
|
||
|
||
class TestResolveThenRun:
|
||
"""Tests at the `_resolve_then_run` layer — pre-`App.run()` session
|
||
resolution + AsyncClient ownership + stderr error routing per issue #6.
|
||
"""
|
||
|
||
@respx.mock
|
||
def test_happy_new_session_resolve(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""happy_new_session_resolve [happy]: --new path through _resolve_then_run.
|
||
|
||
Verifies POST /sessions count + SessionInfo propagation to RatatoskrApp's
|
||
pre-resolved state (session_id / agent_id / client). The corresponding
|
||
TestAppMount.test_happy_new_session_mount uses _resolved_app and bypasses
|
||
_resolve_then_run entirely; this test exercises the production resolve
|
||
path with a real POST /sessions mock.
|
||
"""
|
||
sessions_route = respx.post("https://w.example/sessions").mock(
|
||
return_value=httpx.Response(201, json=_CREATE_OK_RESP)
|
||
)
|
||
snapshot: dict = {}
|
||
|
||
async def capture_run_async(self, *a, **kw):
|
||
snapshot["session_id"] = self.session_id
|
||
snapshot["agent_id"] = self.agent_id
|
||
snapshot["client"] = self.client
|
||
snapshot["client_open"] = not self.client.is_closed
|
||
return 0
|
||
|
||
monkeypatch.setattr(RatatoskrApp, "run_async", capture_run_async)
|
||
rc = run_tui(_args_new())
|
||
assert rc == 0
|
||
# Exactly one POST /sessions invocation by _resolve_then_run
|
||
assert sessions_route.call_count == 1
|
||
# SessionInfo fields propagated into the constructed App
|
||
assert snapshot["session_id"] == "s-new12345"
|
||
assert snapshot["agent_id"] == "mimir"
|
||
assert snapshot["client"] is not None
|
||
assert snapshot["client_open"] is True
|
||
|
||
@respx.mock
|
||
def test_happy_new_with_end_user_id_resolve(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""happy_new_with_end_user_id_resolve [happy]: args.end_user_id threads into POST body.
|
||
|
||
Issue #5 amends #4: _resolve_then_run's create_session call now forwards
|
||
args.end_user_id (renamed from the contract's _mount target, since #6
|
||
moved session resolution out of on_mount into _resolve_then_run).
|
||
"""
|
||
import json as _json
|
||
|
||
sessions_route = respx.post("https://w.example/sessions").mock(
|
||
return_value=httpx.Response(201, json=_CREATE_OK_RESP)
|
||
)
|
||
|
||
async def fake_run_async(self, *a, **kw):
|
||
return 0
|
||
|
||
monkeypatch.setattr(RatatoskrApp, "run_async", fake_run_async)
|
||
rc = run_tui(_args_new(end_user_id="alice"))
|
||
assert rc == 0
|
||
assert sessions_route.call_count == 1
|
||
body = _json.loads(sessions_route.calls[0].request.content)
|
||
assert body == {"agent_id": "mimir", "end_user_id": "alice"}
|
||
|
||
@respx.mock
|
||
def test_user_agent_header_sent(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""user_agent_header_sent [trace]: outbound requests carry the ratatoskr User-Agent.
|
||
|
||
Worldtree-dev (althing 2026-05-23) requested consumers send `User-Agent:
|
||
ratatoskr/<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],
|
||
tmp_path: Path,
|
||
) -> None:
|
||
"""list_agents returns [] AND no local tier-3 entries → stderr
|
||
[no_agents]; exit 13; picker NOT opened. Isolate
|
||
$RATATOSKR_LOCAL_AGENTS so the operator's real local index
|
||
doesn't merge in and turn this into a non-empty list."""
|
||
# v0.8.0 isolation: point local agents at an empty tmp file.
|
||
monkeypatch.setenv(
|
||
"RATATOSKR_LOCAL_AGENTS", str(tmp_path / "empty_local_agents.json")
|
||
)
|
||
respx.get("https://w.example/agents").mock(return_value=httpx.Response(200, json=[]))
|
||
|
||
from ratatoskr.tui import AgentPickerApp
|
||
|
||
picker_called = False
|
||
|
||
async def sentinel(self, *a, **kw):
|
||
nonlocal picker_called
|
||
picker_called = True
|
||
return None
|
||
|
||
monkeypatch.setattr(AgentPickerApp, "run_async", sentinel)
|
||
from ratatoskr.tui import run_tui
|
||
|
||
rc = run_tui(_args_new_no_agent())
|
||
assert rc == 13
|
||
err = capsys.readouterr().err
|
||
assert "[no_agents]" in err
|
||
assert picker_called is False
|
||
|
||
|
||
class TestTuiBifrostBind:
|
||
"""Issue #17 slice 3b — TUI bind trigger: bind failures route to the real
|
||
stderr BEFORE the alt-screen opens (INV-002, mirrors issue #6; same exit
|
||
codes/vocabulary as cli._amain per INV-006)."""
|
||
|
||
@respx.mock
|
||
async def test_handshake_failure_routes_pre_altscreen(
|
||
self, capsys: pytest.CaptureFixture[str]
|
||
) -> None:
|
||
from ratatoskr.tui import _resolve_then_run
|
||
|
||
respx.post("https://w.example/sessions").mock(
|
||
return_value=httpx.Response(
|
||
502,
|
||
json={
|
||
"error_code": "bifrost_handshake_failed",
|
||
"detail": {"bifrost_error": "bifrost.auth_rejected"},
|
||
},
|
||
)
|
||
)
|
||
args = _args_new(
|
||
agent_id="ratatoskr:sindra",
|
||
bifrost=BifrostBinding(endpoint_url="http://10.100.10.50:8391"),
|
||
bifrost_plane="memory",
|
||
consumer_key="ck",
|
||
)
|
||
rc = await _resolve_then_run(args)
|
||
assert rc == 23
|
||
err = capsys.readouterr().err
|
||
assert "bifrost.auth_rejected" in err
|
||
assert "consumer key" in err # the 401-scoping hint
|
||
|
||
@respx.mock
|
||
async def test_consumer_key_missing_routes_pre_altscreen(
|
||
self, capsys: pytest.CaptureFixture[str]
|
||
) -> None:
|
||
from ratatoskr.tui import _resolve_then_run
|
||
|
||
args = _args_new(
|
||
agent_id="a",
|
||
bifrost=BifrostBinding(endpoint_url="http://x:8391"),
|
||
consumer_key=None,
|
||
)
|
||
rc = await _resolve_then_run(args)
|
||
assert rc == 22
|
||
assert "bifrost_consumer_key_missing" in capsys.readouterr().err
|
||
|
||
@respx.mock
|
||
async def test_bound_create_carries_binding_and_consumer_key(
|
||
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||
) -> None:
|
||
"""A successful bound create sends the bifrost body + the consumer-key
|
||
bearer and prints the bound-state indicator (run_async stubbed so no
|
||
alt-screen opens)."""
|
||
from ratatoskr import tui as tui_mod
|
||
from ratatoskr.tui import _resolve_then_run
|
||
|
||
route = respx.post("https://w.example/sessions").mock(
|
||
return_value=httpx.Response(
|
||
201,
|
||
json={
|
||
"session_id": "s-bound",
|
||
"agent_id": "ratatoskr:sindra",
|
||
"message_count": 0,
|
||
"created_at": "2026-06-18T12:00:00+00:00",
|
||
"last_active": "2026-06-18T12:00:00+00:00",
|
||
"metadata": {},
|
||
},
|
||
)
|
||
)
|
||
# sindra is a preset agent → the TUI create path now auto-seeds a #347 first-message.
|
||
respx.post("https://w.example/sessions/s-bound/history").mock(
|
||
return_value=httpx.Response(201, json={})
|
||
)
|
||
|
||
async def fake_run_async(self) -> int:
|
||
return 0
|
||
|
||
monkeypatch.setattr(tui_mod.RatatoskrApp, "run_async", fake_run_async)
|
||
args = _args_new(
|
||
agent_id="ratatoskr:sindra",
|
||
bifrost=BifrostBinding(endpoint_url="http://10.100.10.50:8391"),
|
||
bifrost_plane="memory",
|
||
consumer_key="ck",
|
||
)
|
||
rc = await _resolve_then_run(args)
|
||
assert rc == 0
|
||
import json as _json
|
||
|
||
body = _json.loads(route.calls[0].request.content)
|
||
assert body["bifrost"] == {
|
||
"endpoint_url": "http://10.100.10.50:8391", "scope": None
|
||
}
|
||
assert route.calls[0].request.headers["Authorization"] == "Bearer ck"
|
||
err = capsys.readouterr().err
|
||
assert "bifrost: status=bound" in err
|
||
assert "plane=memory" in err
|
||
|
||
|
||
class TestSessionPickerApp:
|
||
"""docs/contracts/issues/6.contract.md FN SessionPickerApp (amendment slice b2)."""
|
||
|
||
@staticmethod
|
||
def _two():
|
||
from ratatoskr.sessions import SessionInfo
|
||
|
||
return [
|
||
SessionInfo(
|
||
session_id="s-first-0001", agent_id="mimir", created_at="t0",
|
||
last_active="t1", metadata={}, message_count=3, name=None,
|
||
archived=False, tags=[],
|
||
),
|
||
SessionInfo(
|
||
session_id="s-second-002", agent_id="echo", created_at="t0",
|
||
last_active="t2", metadata={}, message_count=None, name="probe",
|
||
archived=False, tags=[],
|
||
),
|
||
]
|
||
|
||
def test_pick_returns_session_id(self) -> None:
|
||
"""pick_returns_session_id [happy,tracer]: idx 1 + Enter → exit value == that session_id."""
|
||
from ratatoskr.tui import SessionPickerApp
|
||
|
||
app = SessionPickerApp(self._two())
|
||
|
||
async def drive() -> str | None:
|
||
async with app.run_test() as pilot:
|
||
from textual.widgets import ListView
|
||
|
||
lv = app.query_one("#session-list", ListView)
|
||
lv.index = 1
|
||
await pilot.pause()
|
||
await pilot.press("enter")
|
||
await pilot.pause()
|
||
return app.return_value
|
||
|
||
import asyncio
|
||
|
||
assert asyncio.run(drive()) == "s-second-002"
|
||
|
||
def test_esc_returns_none(self) -> None:
|
||
"""esc_returns_none [happy]: Esc → exit value is None (dismiss, resume nothing)."""
|
||
from ratatoskr.tui import SessionPickerApp
|
||
|
||
app = SessionPickerApp(self._two())
|
||
|
||
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
|
||
|
||
assert asyncio.run(drive()) is None
|
||
|
||
def test_ctrl_d_returns_none(self) -> None:
|
||
"""ctrl_d_returns_none [adversarial]: Ctrl-D → None."""
|
||
from ratatoskr.tui import SessionPickerApp
|
||
|
||
app = SessionPickerApp(self._two())
|
||
|
||
async def drive() -> str | None:
|
||
async with app.run_test() as pilot:
|
||
await pilot.press("ctrl+d")
|
||
await pilot.pause()
|
||
return app.return_value
|
||
|
||
import asyncio
|
||
|
||
assert asyncio.run(drive()) is None
|
||
|
||
|
||
class TestBareSessionPicker:
|
||
"""docs/contracts/issues/6.contract.md amendment (slice b2): _resolve_then_run bare mode."""
|
||
|
||
@staticmethod
|
||
def _bare_args() -> ParsedArgs:
|
||
return ParsedArgs(
|
||
send_content=None, session_id=None, new=False, agent_id=None,
|
||
api_key="k", server_url="https://w.example", raw=False,
|
||
end_user_id=None, bifrost=None, bifrost_plane=None, consumer_key=None,
|
||
)
|
||
|
||
@staticmethod
|
||
def _sess(sid: str, agent: str = "mimir"):
|
||
from ratatoskr.sessions import SessionInfo
|
||
|
||
return SessionInfo(
|
||
session_id=sid, agent_id=agent, created_at="t0", last_active="t1",
|
||
metadata={}, message_count=1, name=None, archived=False, tags=[],
|
||
)
|
||
|
||
def test_bare_zero_sessions_errors(
|
||
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||
) -> None:
|
||
"""bare_zero_sessions_errors [error]: 0 sessions → exit 14 [no_sessions]; App not opened."""
|
||
import ratatoskr.tui as tui_mod
|
||
from ratatoskr.sessions import SessionPage
|
||
|
||
async def fake_list(client, **kw):
|
||
return SessionPage(items=[], next_cursor=None)
|
||
|
||
monkeypatch.setattr(tui_mod, "list_sessions", fake_list)
|
||
opened: list[int] = []
|
||
|
||
async def spy(self, *a, **k):
|
||
opened.append(1)
|
||
return 0
|
||
|
||
monkeypatch.setattr(RatatoskrApp, "run_async", spy)
|
||
rc = run_tui(self._bare_args())
|
||
assert rc == 14
|
||
assert "[no_sessions]" in capsys.readouterr().err
|
||
assert not opened
|
||
|
||
def test_bare_one_session_auto_resumes(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""bare_one_session_auto_resumes: exactly 1 → auto-resume, no picker (§4 >1 rule)."""
|
||
import ratatoskr.tui as tui_mod
|
||
from ratatoskr.sessions import SessionPage
|
||
from ratatoskr.tui import SessionPickerApp
|
||
|
||
async def fake_list(client, **kw):
|
||
return SessionPage(items=[self._sess("s-solo")], next_cursor=None)
|
||
|
||
monkeypatch.setattr(tui_mod, "list_sessions", fake_list)
|
||
picker_used: list[int] = []
|
||
|
||
async def spy_picker(self, *a, **k):
|
||
picker_used.append(1)
|
||
return None
|
||
|
||
monkeypatch.setattr(SessionPickerApp, "run_async", spy_picker)
|
||
snap: dict = {}
|
||
|
||
async def cap(self, *a, **k):
|
||
snap["sid"] = self.session_id
|
||
return 0
|
||
|
||
monkeypatch.setattr(RatatoskrApp, "run_async", cap)
|
||
rc = run_tui(self._bare_args())
|
||
assert rc == 0
|
||
assert snap["sid"] == "s-solo"
|
||
assert not picker_used
|
||
|
||
def test_bare_multi_opens_picker(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""bare_multi_opens_picker [scenario,tracer]: >1 → picker; its choice resumes."""
|
||
import ratatoskr.tui as tui_mod
|
||
from ratatoskr.sessions import SessionPage
|
||
from ratatoskr.tui import SessionPickerApp
|
||
|
||
async def fake_list(client, **kw):
|
||
return SessionPage(items=[self._sess("s-a"), self._sess("s-b")], next_cursor=None)
|
||
|
||
monkeypatch.setattr(tui_mod, "list_sessions", fake_list)
|
||
|
||
async def pick_b(self, *a, **k):
|
||
return "s-b"
|
||
|
||
monkeypatch.setattr(SessionPickerApp, "run_async", pick_b)
|
||
snap: dict = {}
|
||
|
||
async def cap(self, *a, **k):
|
||
snap["sid"] = self.session_id
|
||
return 0
|
||
|
||
monkeypatch.setattr(RatatoskrApp, "run_async", cap)
|
||
rc = run_tui(self._bare_args())
|
||
assert rc == 0
|
||
assert snap["sid"] == "s-b"
|
||
|
||
def test_bare_picker_dismiss_exits_zero(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""bare_picker_dismiss_exits_zero [scenario]: picker None → exit 0; App not opened."""
|
||
import ratatoskr.tui as tui_mod
|
||
from ratatoskr.sessions import SessionPage
|
||
from ratatoskr.tui import SessionPickerApp
|
||
|
||
async def fake_list(client, **kw):
|
||
return SessionPage(items=[self._sess("s-a"), self._sess("s-b")], next_cursor=None)
|
||
|
||
monkeypatch.setattr(tui_mod, "list_sessions", fake_list)
|
||
|
||
async def pick_none(self, *a, **k):
|
||
return None
|
||
|
||
monkeypatch.setattr(SessionPickerApp, "run_async", pick_none)
|
||
opened: list[int] = []
|
||
|
||
async def spy(self, *a, **k):
|
||
opened.append(1)
|
||
return 0
|
||
|
||
monkeypatch.setattr(RatatoskrApp, "run_async", spy)
|
||
rc = run_tui(self._bare_args())
|
||
assert rc == 0
|
||
assert not opened
|
||
|
||
def test_bare_list_sessions_api_failure(
|
||
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||
) -> None:
|
||
"""bare_list_sessions_api_failure [error]: list_sessions 500 → exit 20; App not opened."""
|
||
import ratatoskr.tui as tui_mod
|
||
from ratatoskr.sessions import SessionApiFailed
|
||
|
||
async def fake_list(client, **kw):
|
||
raise SessionApiFailed(status=500, body=b"boom")
|
||
|
||
monkeypatch.setattr(tui_mod, "list_sessions", fake_list)
|
||
opened: list[int] = []
|
||
|
||
async def spy(self, *a, **k):
|
||
opened.append(1)
|
||
return 0
|
||
|
||
monkeypatch.setattr(RatatoskrApp, "run_async", spy)
|
||
rc = run_tui(self._bare_args())
|
||
assert rc == 20
|
||
assert "[session_api_failed]" in capsys.readouterr().err
|
||
assert not opened
|
||
|
||
|
||
class TestSessionToolsHydration:
|
||
"""get_session_tools + the #183 Tools-pane inventory hydrate (GET /sessions/{id}/tools)."""
|
||
|
||
def test_format_tool_inventory(self) -> None:
|
||
"""format_tool_inventory [unit]: header + builtin + bifrost lines."""
|
||
from ratatoskr.tui import _format_tool_inventory
|
||
|
||
lines = _format_tool_inventory(
|
||
{
|
||
"agent_id": "alice:wizard",
|
||
"builtin_tools": [],
|
||
"bifrost_tools": [{"name": "bifrost.x"}, {"name": "bifrost.y"}],
|
||
}
|
||
)
|
||
joined = "\n".join(lines)
|
||
assert "agent=alice:wizard" in joined
|
||
assert "builtin=0 bifrost=2" in joined
|
||
assert "builtin: (none)" in joined
|
||
assert "bifrost.x, bifrost.y" in joined
|
||
|
||
async def test_hydrate_writes_inventory_and_audits(
|
||
self, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
"""hydrate_writes_inventory [scenario,tracer]: 200 → inventory in Tools pane + audit."""
|
||
import ratatoskr.tui as tui_mod
|
||
|
||
writes = _spy_writes(monkeypatch)
|
||
|
||
async def fake_tools(client, session_id):
|
||
return {
|
||
"agent_id": "alice:wizard",
|
||
"builtin_tools": [],
|
||
"bifrost_tools": [{"name": "bifrost.set_field"}],
|
||
}
|
||
|
||
monkeypatch.setattr(tui_mod, "get_session_tools", fake_tools)
|
||
app = _resolved_app(_args_existing(session_id="s-tools-01"))
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
await app._hydrate_session_tools()
|
||
await pilot.pause()
|
||
joined = " ".join(_text_of(w) for w in writes)
|
||
assert "session tool inventory" in joined
|
||
assert "bifrost.set_field" in joined
|
||
assert "session_tools_hydrated" in joined # audit line landed
|
||
|
||
async def test_hydrate_failure_audits_no_crash(
|
||
self, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
"""hydrate_failure [error]: get_session_tools raises → failure audit; no crash."""
|
||
import ratatoskr.tui as tui_mod
|
||
from ratatoskr.sessions import SessionApiFailed
|
||
|
||
writes = _spy_writes(monkeypatch)
|
||
|
||
async def boom(client, session_id):
|
||
raise SessionApiFailed(status=404, body=b"session_not_found")
|
||
|
||
monkeypatch.setattr(tui_mod, "get_session_tools", boom)
|
||
app = _resolved_app(_args_existing(session_id="s-tools-02"))
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
await app._hydrate_session_tools()
|
||
await pilot.pause()
|
||
joined = " ".join(_text_of(w) for w in writes)
|
||
assert "session_tools_hydration_failed" in joined
|
||
|
||
|
||
class TestBifrostStateHydration:
|
||
"""get_session_bifrost + the #176 BifrostState pane (GET /admin/sessions/{id}/bifrost)."""
|
||
|
||
@staticmethod
|
||
def _mute_tools(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Neutralize the on_mount Tools-pane worker so it makes no real call."""
|
||
import ratatoskr.tui as tui_mod
|
||
|
||
async def noop(client, session_id):
|
||
return {"agent_id": "x", "builtin_tools": [], "bifrost_tools": []}
|
||
|
||
monkeypatch.setattr(tui_mod, "get_session_tools", noop)
|
||
|
||
def test_format_bifrost_state(self) -> None:
|
||
"""format_bifrost_state [unit]: connected / endpoint / caps / tools lines."""
|
||
from ratatoskr.tui import _format_bifrost_state
|
||
|
||
lines = _format_bifrost_state(
|
||
{
|
||
"endpoint_url": "https://b/mcp",
|
||
"consumer_id": "alice",
|
||
"connected": True,
|
||
"capabilities_granted": ["tools:call", "tools:read"],
|
||
"tools": [{"name": "bifrost.echo"}],
|
||
}
|
||
)
|
||
joined = "\n".join(lines)
|
||
assert "connected=True" in joined
|
||
assert "consumer=alice" in joined
|
||
assert "https://b/mcp" in joined
|
||
assert "tools:call, tools:read" in joined
|
||
assert "bifrost.echo" in joined
|
||
|
||
async def test_hydrate_no_admin_key(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""hydrate_no_admin_key [scenario]: admin_key None → 'not configured' + skip audit."""
|
||
self._mute_tools(monkeypatch)
|
||
writes = _spy_writes(monkeypatch)
|
||
app = _resolved_app(_args_existing(session_id="s-bf-1")) # admin_key defaults None
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
await app._hydrate_bifrost_state()
|
||
await pilot.pause()
|
||
joined = " ".join(_text_of(w) for w in writes)
|
||
assert "admin key not configured" in joined
|
||
assert "bifrost_state_skipped" in joined
|
||
|
||
async def test_hydrate_success(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""hydrate_success [scenario,tracer]: 200 → binding in BifrostState pane + audit."""
|
||
import ratatoskr.tui as tui_mod
|
||
|
||
self._mute_tools(monkeypatch)
|
||
writes = _spy_writes(monkeypatch)
|
||
|
||
async def fake_bifrost(client, session_id, *, admin_key):
|
||
return {
|
||
"endpoint_url": "https://b/mcp",
|
||
"consumer_id": "alice",
|
||
"connected": True,
|
||
"capabilities_granted": ["tools:call"],
|
||
"tools": [{"name": "bifrost.echo"}],
|
||
}
|
||
|
||
monkeypatch.setattr(tui_mod, "get_session_bifrost", fake_bifrost)
|
||
app = _resolved_app(_args_existing(session_id="s-bf-2", admin_key="ak"))
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
await app._hydrate_bifrost_state()
|
||
await pilot.pause()
|
||
joined = " ".join(_text_of(w) for w in writes)
|
||
assert "bifrost binding" in joined
|
||
assert "bifrost.echo" in joined
|
||
assert "bifrost_state_hydrated" in joined
|
||
|
||
async def test_hydrate_404_not_bound(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""hydrate_404_not_bound [error]: 404 → 'not bound to Bifrost' + audit; no crash."""
|
||
import ratatoskr.tui as tui_mod
|
||
from ratatoskr.sessions import SessionApiFailed
|
||
|
||
self._mute_tools(monkeypatch)
|
||
writes = _spy_writes(monkeypatch)
|
||
|
||
async def not_bound(client, session_id, *, admin_key):
|
||
raise SessionApiFailed(status=404, body=b"session_not_bifrost_bound")
|
||
|
||
monkeypatch.setattr(tui_mod, "get_session_bifrost", not_bound)
|
||
app = _resolved_app(_args_existing(session_id="s-bf-3", admin_key="ak"))
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
await app._hydrate_bifrost_state()
|
||
await pilot.pause()
|
||
joined = " ".join(_text_of(w) for w in writes)
|
||
assert "not bound to Bifrost" in joined
|
||
assert "bifrost_state_unavailable" in joined
|
||
|
||
|
||
class TestAdminEventsStream:
|
||
"""stream_admin_events + the #11 AdminEvents pane (GET /admin/events, session-filtered)."""
|
||
|
||
@staticmethod
|
||
def _mute_hydrates(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Neutralize the other on_mount workers (tools + bifrost) — no real calls."""
|
||
import ratatoskr.tui as tui_mod
|
||
from ratatoskr.sessions import SessionApiFailed
|
||
|
||
async def noop_tools(client, session_id):
|
||
return {"agent_id": "x", "builtin_tools": [], "bifrost_tools": []}
|
||
|
||
async def noop_bifrost(client, session_id, *, admin_key):
|
||
raise SessionApiFailed(status=404, body=b"nb")
|
||
|
||
monkeypatch.setattr(tui_mod, "get_session_tools", noop_tools)
|
||
monkeypatch.setattr(tui_mod, "get_session_bifrost", noop_bifrost)
|
||
|
||
def test_format_admin_event(self) -> None:
|
||
"""format_admin_event [unit]: HH:MM:SS + type + fields; session_id dropped."""
|
||
from ratatoskr.sse_client import AdminEvent
|
||
from ratatoskr.tui import _format_admin_event
|
||
|
||
line = _format_admin_event(
|
||
AdminEvent(
|
||
42, "turn.completed", "2026-05-06T10:00:05.000Z",
|
||
{"session_id": "s1", "turn_id": 7, "duration_ms": 1200, "phase": "succeeded"},
|
||
)
|
||
)
|
||
assert "turn.completed" in line
|
||
assert "[10:00:05]" in line
|
||
assert "turn_id=7" in line
|
||
assert "session_id" not in line # dropped — pane is already session-scoped
|
||
|
||
def test_admin_event_matches_filter(self) -> None:
|
||
"""admin_event_matches [unit]: active-session + non-heartbeat system.* pass (§6)."""
|
||
from ratatoskr.sse_client import AdminEvent
|
||
|
||
E = AdminEvent
|
||
app = _resolved_app(_args_existing(session_id="s-match"))
|
||
assert app._admin_event_matches(E(1, "session.created", "t", {"session_id": "s-match"}))
|
||
assert not app._admin_event_matches(E(2, "turn.started", "t", {"session_id": "other"}))
|
||
assert not app._admin_event_matches(E(0, "system.heartbeat", "t", {}))
|
||
assert app._admin_event_matches(E(3, "system.events_dropped", "t", {"count": 5}))
|
||
|
||
async def test_stream_writes_filtered_events(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""stream_filtered [scenario,tracer]: only active-session + non-heartbeat lines land."""
|
||
import ratatoskr.tui as tui_mod
|
||
from ratatoskr.sse_client import AdminEvent
|
||
|
||
self._mute_hydrates(monkeypatch)
|
||
writes = _spy_writes(monkeypatch)
|
||
|
||
async def fake_stream(client, *, admin_key, last_event_id=None):
|
||
yield AdminEvent(41, "session.created", "t", {"session_id": "s-ae-2"})
|
||
yield AdminEvent(0, "system.heartbeat", "t", {}) # filtered (noise)
|
||
yield AdminEvent(42, "turn.started", "t", {"session_id": "other"}) # diff session
|
||
yield AdminEvent(43, "session.deleted", "t", {"session_id": "s-ae-2"})
|
||
|
||
monkeypatch.setattr(tui_mod, "stream_admin_events", fake_stream)
|
||
app = _resolved_app(_args_existing(session_id="s-ae-2", admin_key="ak"))
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
await app._stream_admin_events()
|
||
await pilot.pause()
|
||
joined = " ".join(_text_of(w) for w in writes)
|
||
assert "session.created" in joined
|
||
assert "session.deleted" in joined
|
||
assert "system.heartbeat" not in joined
|
||
assert "turn.started" not in joined # different session → filtered
|
||
|
||
async def test_stream_no_admin_key(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""stream_no_admin_key [scenario]: admin_key None → 'not configured' + skip audit."""
|
||
self._mute_hydrates(monkeypatch)
|
||
writes = _spy_writes(monkeypatch)
|
||
app = _resolved_app(_args_existing(session_id="s-ae-3")) # admin_key None
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
await app._stream_admin_events()
|
||
await pilot.pause()
|
||
joined = " ".join(_text_of(w) for w in writes)
|
||
assert "admin key not configured" in joined
|
||
assert "admin_events_skipped" in joined
|
||
|
||
async def test_stream_403_unavailable(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""stream_403 [error]: 403 scope-denied → 'unavailable' + audit; no crash."""
|
||
import ratatoskr.tui as tui_mod
|
||
from ratatoskr.sse_client import SseConnectFailed
|
||
|
||
self._mute_hydrates(monkeypatch)
|
||
writes = _spy_writes(monkeypatch)
|
||
|
||
async def denied(client, *, admin_key, last_event_id=None):
|
||
raise SseConnectFailed(status=403, body=b"auth_scope_denied")
|
||
yield # unreachable — makes this an async generator
|
||
|
||
monkeypatch.setattr(tui_mod, "stream_admin_events", denied)
|
||
app = _resolved_app(_args_existing(session_id="s-ae-4", admin_key="ak"))
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
await app._stream_admin_events()
|
||
await pilot.pause()
|
||
joined = " ".join(_text_of(w) for w in writes)
|
||
assert "admin events unavailable: HTTP 403" in joined
|
||
assert "admin_events_unavailable" in joined
|