cfee89ac1c
Operator-driven big-batch polish + restructure:
## 1. Streaming text — no more per-token RichLog spam
Pre-v0.6.0, every Text SSE delta wrote its own RichLog line, so
"Let me read the..." became 4+ separate lines (a Worldtree-style
sentence-by-sentence reveal that read as broken). v0.6.0 adds a
`#current-text` Static docked above the prompt; TuiPresenterState
buffers Text deltas in `text_buffer` and updates the Static in
place. On terminal event the Static clears and the transcript
gets:
- raw=False: post-Done Markdown body + Rule separator
- raw=True: accumulated plain text
The Static collapses to height=0 when empty so the prompt sits at
the column bottom unchanged.
## 2. Turn-ID headers across every pane
`_stream_turn_worker` writes a `Rule(title="turn N")` to all four
log panes (transcript, tools, debug, thinking) on the first event
of each new turn. Operators can now visually correlate "what
happened in Tools during turn 42" by section markers in matching
positions across panes.
## 3. New Thinking TabPane (Ctrl+3)
Closed thinking runs now route to `#thinking-log` (a dedicated
TabPane) instead of `#debug-log`. Each closed run writes three
entries:
- Rule(title="turn N · thinking #K start")
- Markdown(thinking_content)
- Rule(title="turn N · thinking #K end")
Model reasoning often has lists/code/structure — rendering as
Markdown (instead of the previous "· thinking: ..." prefix line)
makes it scannable. The `thinking_run_index` counter scopes per
turn so multi-thinking-run turns get distinct markers.
`thinking-current` Static (live per-delta preview) stays in the
right column above TabbedContent (unchanged from v0.5.0) — live
visibility persists across tab switches.
## 4. Agent picker — multi-line items, full description visible
Pre-v0.6.0 the picker rendered each agent as a single Label with
"{id} · {name} — {description}", which truncated descriptions
visually. v0.6.0 uses two Static children per ListItem:
- bold Aurora bright-blue line: "{agent_id} · {name}"
- wrapped Sea dark-60 line(s): full description
ListItems are auto-height so long descriptions wrap as needed.
Highlighted (--highlight) row uses Sea dark-30 background instead
of Aurora blue (which the operator flagged as ugly).
## 5. Kill residual blue chrome
The user's "background is still blue" report traced to the prompt
Input's focused border, which I'd set to $primary (Aurora blue).
Switched to $au-bright-cyan (#42dcd1) — focus highlight is now
cyan, consistent with the operator's-voice accent throughout the
TUI. Also added explicit overrides for ContentTabs strip
background + active-tab underline color → Australis cyan.
## 6. Surfaced emotion-appraisal request to worldtree-dev
User asked for emotion-appraisal telemetry, but no SSE event for
this exists in the spec — persona/Vili affect lives in persona.log
(file-tail, blocked on remote-Worldtree topology) and per-character
state (poll endpoint, not per-turn). Posted an althing thread
proposing two shapes (worker_phase payload extension OR new
affect_update event type) and routing the decision to their team.
A 4th `Emotion` TabPane plugs in trivially when a wire event lands.
Low-priority / quality-of-life framing — not blocking ship.
## Contract amendment
docs/contracts/issues/13.contract.md amended in-place: INV-019
extended to 3 TabPanes; new INV-021 (Text → current_text Static),
INV-022 (thinking closed runs → thinking_log with Markdown +
start/end Rules), INV-023 (turn-ID headers across all panes),
INV-024 (thinking-current Static stays in right column with
"thinking… " prefix per v0.5.1 polish). INV-020 (render-exception
fallback routing) updated for Thinking → thinking_log. Drift-check
clean.
## Tests
241 GREEN (down from 244 in test count — 5 routing tests rewritten
for the new shape, replacing the v0.5.0 thinking-in-debug-log
assertions with the v0.6.0 thinking-log-as-Markdown shape; net
test coverage equivalent). ruff clean.
Live smoke against personal Worldtree's mimir confirmed:
- transcript: 27 lines (turn header + user echo + done +
markdown body, NO per-token spam)
- thinking_log: 19 lines (turn header + 2x thinking start/end
Rule sections with Markdown bodies)
- current_text cleared post-Done
Minor bump (v0.5.1 → v0.6.0) per SemVer etiquette: visible routing
+ new pane = operator-observable surface change.
2298 lines
89 KiB
Python
2298 lines
89 KiB
Python
"""Tests for ratatoskr.tui per docs/contracts/issues/4.contract.md."""
|
||
|
||
from unittest.mock import MagicMock
|
||
|
||
import httpx
|
||
import pytest
|
||
import respx
|
||
from textual.widgets import RichLog
|
||
|
||
from ratatoskr.cli import ParsedArgs
|
||
from ratatoskr.sse_client import (
|
||
Cancelled,
|
||
Done,
|
||
Error,
|
||
SseId,
|
||
Text,
|
||
Thinking,
|
||
ToolResult,
|
||
ToolStart,
|
||
WorkerPhase,
|
||
)
|
||
from ratatoskr.tui import RatatoskrApp, _cancel_via_sse
|
||
|
||
_CANCEL_OK_RESP = {"turn_id": 42, "cancelled": True, "reason": None, "partial_message_id": None}
|
||
_CREATE_OK_RESP = {
|
||
"session_id": "s-new12345",
|
||
"agent_id": "mimir",
|
||
"message_count": 0,
|
||
"created_at": "2026-05-21T00:00:00+00:00",
|
||
"last_active": "2026-05-21T00:00:00+00:00",
|
||
"metadata": {},
|
||
}
|
||
|
||
|
||
def _args_new(**overrides) -> ParsedArgs:
|
||
base = dict(
|
||
send_content=None,
|
||
session_id=None,
|
||
new=True,
|
||
agent_id="mimir",
|
||
api_key="k",
|
||
server_url="https://w.example",
|
||
raw=False,
|
||
)
|
||
base.update(overrides)
|
||
return ParsedArgs(**base)
|
||
|
||
|
||
def _args_existing(session_id: str = "s-1existing", **overrides) -> ParsedArgs:
|
||
base = dict(
|
||
send_content=None,
|
||
session_id=session_id,
|
||
new=False,
|
||
agent_id=None,
|
||
api_key="k",
|
||
server_url="https://w.example",
|
||
raw=False,
|
||
)
|
||
base.update(overrides)
|
||
return ParsedArgs(**base)
|
||
|
||
|
||
def _spy_writes(monkeypatch) -> list:
|
||
"""Patch RichLog.write to record every arg into a list (returned).
|
||
|
||
Accepts *args/**kwargs so Textual's internal deferred-render path
|
||
(which calls write positionally with width/expand/shrink/scroll_end)
|
||
still works after a write-during-mount + Resize sequence.
|
||
"""
|
||
writes: list = []
|
||
original = RichLog.write
|
||
|
||
def spy(self, content, *args, **kw):
|
||
writes.append(content)
|
||
return original(self, content, *args, **kw)
|
||
|
||
monkeypatch.setattr(RichLog, "write", spy)
|
||
return writes
|
||
|
||
|
||
def _resolved_app(
|
||
args: ParsedArgs,
|
||
*,
|
||
session_id: str | None = None,
|
||
agent_id: str | None = None,
|
||
client: httpx.AsyncClient | None = None,
|
||
) -> RatatoskrApp:
|
||
"""Construct RatatoskrApp with pre-resolved state (issue #6 lifecycle).
|
||
|
||
Production path: `run_tui` → `_resolve_then_run` opens AsyncClient, mints
|
||
or attaches session, then constructs the App with the resolved tuple. This
|
||
helper inlines that shape so tests bypass the pre-flight without
|
||
re-implementing it. The client is opened here (and leaks at test teardown
|
||
— acceptable; respx mocks all network calls and pytest exits cleanly).
|
||
"""
|
||
sid = session_id if session_id is not None else (args.session_id or "s-default")
|
||
aid = args.agent_id if agent_id is None else agent_id
|
||
if client is None:
|
||
client = httpx.AsyncClient(
|
||
base_url=args.server_url,
|
||
headers={"Authorization": f"Bearer {args.api_key}"},
|
||
timeout=httpx.Timeout(connect=10.0, read=None, write=10.0, pool=10.0),
|
||
)
|
||
return RatatoskrApp(args, session_id=sid, agent_id=aid, client=client)
|
||
|
||
|
||
SID = SseId(42, 5)
|
||
|
||
|
||
# Issue #12 — TuiPresenterState replaces _render_event_to_log with a stateful
|
||
# per-turn presenter. (Pre-amendment TestRenderEventToLog class and
|
||
# `_render_event_to_log` function have been removed under the project's
|
||
# no-backwards-compatibility rule.)
|
||
|
||
|
||
class TestTuiPresenterState:
|
||
"""Tests for the new TuiPresenterState — per issue #12 contract."""
|
||
|
||
def test_thinking_coalesce_single_widget_update(self) -> None:
|
||
"""thinking_coalesce_single_widget_update [happy,tracer]:
|
||
3 Thinking events → thinking_widget.update called 3 times with cumulative content;
|
||
RichLog has 0 thinking entries (closure hasn't fired yet).
|
||
"""
|
||
from ratatoskr.tui import TuiPresenterState
|
||
|
||
log = MagicMock()
|
||
widget = MagicMock()
|
||
state = TuiPresenterState()
|
||
state.render(
|
||
Thinking(sse_id=SID, content="a"),
|
||
log=log,
|
||
thinking_widget=widget,
|
||
tools_log=MagicMock(),
|
||
debug_log=MagicMock(),
|
||
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||
)
|
||
state.render(
|
||
Thinking(sse_id=SID, content="b"),
|
||
log=log,
|
||
thinking_widget=widget,
|
||
tools_log=MagicMock(),
|
||
debug_log=MagicMock(),
|
||
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||
)
|
||
state.render(
|
||
Thinking(sse_id=SID, content="c"),
|
||
log=log,
|
||
thinking_widget=widget,
|
||
tools_log=MagicMock(),
|
||
debug_log=MagicMock(),
|
||
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||
)
|
||
# Widget updated 3 times — once per delta — with cumulative content
|
||
assert widget.update.call_count == 3
|
||
# Latest call shows the full accumulated content (under 200 chars so no truncation).
|
||
# v0.5.1 polish: widget text is prefixed with "thinking… " for self-explanation.
|
||
assert widget.update.call_args_list[-1][0][0] == "thinking… abc"
|
||
# Widget became visible at first delta
|
||
assert widget.display is True
|
||
# No RichLog write yet — closure hasn't fired
|
||
assert log.write.call_count == 0
|
||
|
||
def test_thinking_closes_to_thinking_log(self) -> None:
|
||
"""thinking_closes_to_thinking_log [happy, v0.6.0]: 2x Thinking + WorkerPhase →
|
||
thinking_log gets Rule(start) + Markdown + Rule(end); debug_log gets worker_phase;
|
||
transcript and tools_log untouched. Widget cleared+hidden.
|
||
"""
|
||
from rich.markdown import Markdown
|
||
from rich.rule import Rule
|
||
|
||
from ratatoskr.tui import TuiPresenterState
|
||
|
||
log = MagicMock()
|
||
debug_log = MagicMock()
|
||
thinking_log = MagicMock()
|
||
widget = MagicMock()
|
||
state = TuiPresenterState()
|
||
for content in ("a", "b"):
|
||
state.render(
|
||
Thinking(sse_id=SID, content=content),
|
||
log=log,
|
||
thinking_widget=widget,
|
||
tools_log=MagicMock(),
|
||
debug_log=debug_log,
|
||
current_text=MagicMock(),
|
||
thinking_log=thinking_log,
|
||
raw=False,
|
||
)
|
||
state.render(
|
||
WorkerPhase(sse_id=SID, phase="streaming", turn_id=42),
|
||
log=log,
|
||
thinking_widget=widget,
|
||
tools_log=MagicMock(),
|
||
debug_log=debug_log,
|
||
current_text=MagicMock(),
|
||
thinking_log=thinking_log,
|
||
raw=False,
|
||
)
|
||
# v0.6.0: closure writes Rule(start) + Markdown + Rule(end) to thinking_log.
|
||
thinking_writes = [c[0][0] for c in thinking_log.write.call_args_list]
|
||
assert any(isinstance(w, Rule) for w in thinking_writes), thinking_writes
|
||
assert any(isinstance(w, Markdown) for w in thinking_writes), thinking_writes
|
||
# worker_phase still goes to debug_log; transcript still untouched.
|
||
assert debug_log.write.called
|
||
assert "· worker_phase:" in _text_of(debug_log.write.call_args_list[-1][0][0])
|
||
assert not log.write.called
|
||
# Widget cleared + hidden
|
||
widget.update.assert_called_with("")
|
||
assert widget.display is False
|
||
|
||
def test_thinking_widget_truncation(self) -> None:
|
||
"""thinking_widget_truncation [trace]: buffer 500 chars → widget shows "…" + last 200."""
|
||
from ratatoskr.tui import TuiPresenterState
|
||
|
||
log = MagicMock()
|
||
widget = MagicMock()
|
||
state = TuiPresenterState()
|
||
# Push 500 chars across multiple deltas.
|
||
long = "x" * 500
|
||
state.render(
|
||
Thinking(sse_id=SID, content=long),
|
||
log=log,
|
||
thinking_widget=widget,
|
||
tools_log=MagicMock(),
|
||
debug_log=MagicMock(),
|
||
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||
)
|
||
last_update = widget.update.call_args_list[-1][0][0]
|
||
# v0.5.1 polish: widget gets a "thinking… " prefix + ellipsis-truncated tail.
|
||
assert last_update.startswith("thinking… ")
|
||
# tail is "…" + last-200 = 201 chars; prefix is 10 chars ("thinking… ")
|
||
assert len(last_update) == len("thinking… ") + 201
|
||
assert "…" in last_update
|
||
|
||
def test_thinking_widget_visibility_lifecycle(self) -> None:
|
||
"""thinking_widget_visibility_lifecycle [trace]: hidden at start; visible during thinking;
|
||
hidden after closing event.
|
||
"""
|
||
from ratatoskr.tui import TuiPresenterState
|
||
|
||
log = MagicMock()
|
||
widget = MagicMock()
|
||
widget.display = False # initial state (composed hidden)
|
||
state = TuiPresenterState()
|
||
# First thinking delta → widget visible
|
||
state.render(
|
||
Thinking(sse_id=SID, content="x"),
|
||
log=log,
|
||
thinking_widget=widget,
|
||
tools_log=MagicMock(),
|
||
debug_log=MagicMock(),
|
||
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||
)
|
||
assert widget.display is True
|
||
# Closure (WorkerPhase) → widget hidden
|
||
state.render(
|
||
WorkerPhase(sse_id=SID, phase="streaming", turn_id=42),
|
||
log=log,
|
||
thinking_widget=widget,
|
||
tools_log=MagicMock(),
|
||
debug_log=MagicMock(),
|
||
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||
)
|
||
assert widget.display is False
|
||
|
||
def test_multiple_thinking_runs_each_get_thinking_log_section(self) -> None:
|
||
"""multiple_thinking_runs_each_get_thinking_log_section [scenario, v0.6.0]:
|
||
Thinking → Text → Thinking → Done → TWO start/end Rule + Markdown sections
|
||
in thinking_log. Text goes to current_text Static (buffered). Transcript
|
||
receives [done] label + Markdown body only.
|
||
"""
|
||
from rich.markdown import Markdown
|
||
from rich.rule import Rule
|
||
|
||
from ratatoskr.tui import TuiPresenterState
|
||
|
||
log = MagicMock()
|
||
thinking_log = MagicMock()
|
||
current_text = MagicMock()
|
||
widget = MagicMock()
|
||
state = TuiPresenterState()
|
||
for evt in (
|
||
Thinking(sse_id=SID, content="first"),
|
||
Text(sse_id=SID, content="hi"),
|
||
Thinking(sse_id=SID, content="second"),
|
||
):
|
||
state.render(
|
||
evt, log=log, thinking_widget=widget,
|
||
tools_log=MagicMock(), debug_log=MagicMock(),
|
||
current_text=current_text, thinking_log=thinking_log, raw=False,
|
||
)
|
||
state.render(
|
||
_make_tui_done(),
|
||
log=log, thinking_widget=widget,
|
||
tools_log=MagicMock(), debug_log=MagicMock(),
|
||
current_text=current_text, thinking_log=thinking_log, raw=False,
|
||
)
|
||
# v0.6.0: thinking_log holds (Rule(start) + Markdown + Rule(end)) x2.
|
||
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)]
|
||
markdowns = [w for w in thinking_writes if isinstance(w, Markdown)]
|
||
assert len(rules) == 4, f"expected 4 Rules (2 start + 2 end), got {len(rules)}"
|
||
assert len(markdowns) == 2, f"expected 2 Markdown sections, got {len(markdowns)}"
|
||
# Text "hi" went to current_text (buffered), not the transcript directly.
|
||
current_text.update.assert_any_call("hi")
|
||
# Transcript: [done] label + Markdown(response) (raw=False).
|
||
log_writes = [_text_of(c[0][0]) for c in log.write.call_args_list]
|
||
assert any(w.startswith("[done]") for w in log_writes if isinstance(w, str))
|
||
|
||
def test_render_exception_fallback(self) -> None:
|
||
"""render_exception_fallback [adversarial, v0.6.0]:
|
||
widget.update raises → thinking_log gets the plain-label fallback for
|
||
Thinking (per v0.6.0 routing — Thinking now routes to thinking_log,
|
||
not debug_log). render_error line follows. transcript untouched.
|
||
"""
|
||
from ratatoskr.tui import TuiPresenterState
|
||
|
||
log = MagicMock()
|
||
thinking_log = MagicMock()
|
||
widget = MagicMock()
|
||
widget.update.side_effect = AttributeError("widget gone (msg should NOT leak)")
|
||
state = TuiPresenterState()
|
||
state.render(
|
||
Thinking(sse_id=SID, content="x"),
|
||
log=log,
|
||
thinking_widget=widget,
|
||
tools_log=MagicMock(),
|
||
debug_log=MagicMock(),
|
||
current_text=MagicMock(),
|
||
thinking_log=thinking_log,
|
||
raw=False,
|
||
)
|
||
writes = [c[0][0] for c in thinking_log.write.call_args_list if isinstance(c[0][0], str)]
|
||
assert any(w.startswith("[thinking]") for w in writes), writes
|
||
assert any(w == "[render_error] AttributeError" for w in writes), writes
|
||
assert not any("widget gone" in w for w in writes), writes
|
||
assert not log.write.called
|
||
|
||
def test_state_reset_per_worker(self) -> None:
|
||
"""state_reset_per_worker [trace]: fresh TuiPresenterState() starts no thinking open."""
|
||
from ratatoskr.tui import TuiPresenterState
|
||
|
||
s1 = TuiPresenterState()
|
||
s1.render(
|
||
Thinking(sse_id=SID, content="x"),
|
||
log=MagicMock(),
|
||
thinking_widget=MagicMock(),
|
||
tools_log=MagicMock(),
|
||
debug_log=MagicMock(),
|
||
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||
)
|
||
s2 = TuiPresenterState()
|
||
assert s1.thinking_open is True
|
||
assert s2.thinking_open is False
|
||
|
||
def test_cancelled_mid_thinking_closes(self) -> None:
|
||
"""cancelled_mid_thinking_closes [scenario, v0.5.0]:
|
||
Thinking, Cancelled → ONE closed thinking entry in debug_log + a
|
||
[cancelled] entry in transcript; widget hidden.
|
||
"""
|
||
from ratatoskr.tui import TuiPresenterState
|
||
|
||
log = MagicMock()
|
||
debug_log = MagicMock()
|
||
widget = MagicMock()
|
||
state = TuiPresenterState()
|
||
state.render(
|
||
Thinking(sse_id=SID, content="partial"),
|
||
log=log,
|
||
thinking_widget=widget,
|
||
tools_log=MagicMock(),
|
||
debug_log=debug_log,
|
||
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||
)
|
||
state.render(
|
||
Cancelled(
|
||
sse_id=SID, phase="cancelled", turn_id=42, reason="user", partial_message_id=None
|
||
),
|
||
log=log,
|
||
thinking_widget=widget,
|
||
tools_log=MagicMock(),
|
||
debug_log=debug_log,
|
||
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||
)
|
||
# v0.6.0: closed thinking lands in thinking_log (Markdown body wrapped in
|
||
# Rule start/end). terminal [cancelled] still in transcript.
|
||
log_writes = [_text_of(c[0][0]) for c in log.write.call_args_list]
|
||
assert any(w.startswith("[cancelled]") for w in log_writes)
|
||
assert widget.display is False
|
||
|
||
def test_done_renders_markdown_after_label(self) -> None:
|
||
"""done_renders_markdown_after_label [happy, v0.6.0]:
|
||
Text("hi") accumulates into current_text Static (buffered streaming);
|
||
Done(response="hi") with raw=False → [done] label + Rule + Markdown
|
||
in transcript. current_text cleared on terminal.
|
||
"""
|
||
from rich.markdown import Markdown
|
||
from rich.rule import Rule
|
||
|
||
from ratatoskr.tui import TuiPresenterState
|
||
|
||
log = MagicMock()
|
||
current_text = MagicMock()
|
||
widget = MagicMock()
|
||
state = TuiPresenterState()
|
||
state.render(
|
||
Text(sse_id=SID, content="hi"),
|
||
log=log,
|
||
thinking_widget=widget,
|
||
tools_log=MagicMock(),
|
||
debug_log=MagicMock(),
|
||
current_text=current_text,
|
||
thinking_log=MagicMock(),
|
||
raw=False,
|
||
)
|
||
# Text accumulated to current_text, NOT written to log.
|
||
current_text.update.assert_any_call("hi")
|
||
state.render(
|
||
_make_tui_done(),
|
||
log=log,
|
||
thinking_widget=widget,
|
||
tools_log=MagicMock(),
|
||
debug_log=MagicMock(),
|
||
current_text=current_text,
|
||
thinking_log=MagicMock(),
|
||
raw=False,
|
||
)
|
||
# Done cleared current_text and wrote [done] label + Rule + Markdown.
|
||
current_text.update.assert_any_call("")
|
||
writes = [c[0][0] for c in log.write.call_args_list]
|
||
assert any(_text_of(w).startswith("[done]") for w in writes)
|
||
assert any(isinstance(w, Rule) for w in writes)
|
||
assert any(isinstance(w, Markdown) for w in writes)
|
||
|
||
def test_raw_flag_skips_markdown(self) -> None:
|
||
"""raw_flag_skips_markdown [trace]: raw=True → no Rule, no Markdown."""
|
||
from rich.markdown import Markdown
|
||
from rich.rule import Rule
|
||
|
||
from ratatoskr.tui import TuiPresenterState
|
||
|
||
log = MagicMock()
|
||
widget = MagicMock()
|
||
state = TuiPresenterState()
|
||
state.render(
|
||
Text(sse_id=SID, content="hi"),
|
||
log=log,
|
||
thinking_widget=widget,
|
||
tools_log=MagicMock(),
|
||
debug_log=MagicMock(),
|
||
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,
|
||
)
|
||
state.render(
|
||
_make_tui_done(),
|
||
log=log,
|
||
thinking_widget=widget,
|
||
tools_log=MagicMock(),
|
||
debug_log=MagicMock(),
|
||
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,
|
||
)
|
||
writes = [c[0][0] for c in log.write.call_args_list]
|
||
assert not any(isinstance(w, Rule) for w in writes)
|
||
assert not any(isinstance(w, Markdown) for w in writes)
|
||
|
||
def test_worker_phase_demoted_to_debug_log(self) -> None:
|
||
"""worker_phase_demoted_to_debug_log [trace, v0.5.0]: WorkerPhase → debug_log
|
||
"· worker_phase:" prefix rendered with Australis dark-60 Rich style.
|
||
Transcript receives nothing.
|
||
"""
|
||
from rich.text import Text as RichText
|
||
|
||
from ratatoskr.tui import TuiPresenterState
|
||
|
||
log = MagicMock()
|
||
debug_log = MagicMock()
|
||
state = TuiPresenterState()
|
||
state.render(
|
||
WorkerPhase(sse_id=SID, phase="streaming", turn_id=42),
|
||
log=log,
|
||
thinking_widget=MagicMock(),
|
||
tools_log=MagicMock(),
|
||
debug_log=debug_log,
|
||
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||
)
|
||
# v0.5.0: WorkerPhase routes to debug_log, NOT transcript.
|
||
assert not log.write.called
|
||
renderable = debug_log.write.call_args[0][0]
|
||
# INV-003: must be a styled Rich Text renderable, not a plain str.
|
||
# v0.4.1 retheme: style is now Australis Sea dark-60 ("#86929d") instead
|
||
# of the terminal-dim filter "dim". Assert non-empty styling either way.
|
||
assert isinstance(renderable, RichText), type(renderable)
|
||
assert renderable.style, "demoted telemetry must carry SOME style"
|
||
text = renderable.plain
|
||
assert text.startswith("· worker_phase:")
|
||
assert "[worker_phase]" not in text
|
||
|
||
def test_terminal_events_belt_and_braces_widget_cleanup(self) -> None:
|
||
"""terminal_events_belt_and_braces_widget_cleanup [trace]:
|
||
Done / Error / Cancelled MUST clear+hide the thinking widget even when
|
||
thinking_open is False (Volva F3 fix; POST-005 + STEPS 5-6).
|
||
"""
|
||
from ratatoskr.tui import TuiPresenterState
|
||
|
||
for terminal in (
|
||
_make_tui_done(),
|
||
Error(sse_id=SID, phase="failed", message="boom", error_code="x"),
|
||
Cancelled(
|
||
sse_id=SID, phase="cancelled", turn_id=42, reason="r", partial_message_id=None
|
||
),
|
||
):
|
||
log = MagicMock()
|
||
widget = MagicMock()
|
||
widget.display = True # pre-set to non-default to detect the clear
|
||
state = TuiPresenterState()
|
||
# thinking_open is False (state just constructed).
|
||
state.render(
|
||
terminal,
|
||
log=log,
|
||
thinking_widget=widget,
|
||
tools_log=MagicMock(),
|
||
debug_log=MagicMock(),
|
||
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,
|
||
)
|
||
# Belt-and-braces: widget cleared + hidden on EVERY terminal event.
|
||
widget.update.assert_called_with("")
|
||
assert widget.display is False, type(terminal).__name__
|
||
|
||
def test_tool_start_routes_to_tools_log(self) -> None:
|
||
"""tool_start_routes_to_tools_log [INV-014]: ToolStart writes to tools_log, NOT transcript.
|
||
|
||
Issue #13: tool events route to the dedicated Tools pane (right column).
|
||
Pre-#13 wrote them to the main transcript with `· tool_start:` prefix.
|
||
Post-#13 the prefix is preserved but the destination shifts.
|
||
"""
|
||
from ratatoskr.tui import TuiPresenterState
|
||
|
||
log = MagicMock()
|
||
tools_log = MagicMock()
|
||
state = TuiPresenterState()
|
||
state.render(
|
||
ToolStart(sse_id=SID, name="read_file", arguments={"path": "/x"}),
|
||
log=log,
|
||
thinking_widget=MagicMock(),
|
||
tools_log=tools_log,
|
||
debug_log=MagicMock(),
|
||
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||
)
|
||
# INV-014: write went to tools_log
|
||
assert tools_log.write.called
|
||
assert _text_of(tools_log.write.call_args[0][0]).startswith("· tool_start:")
|
||
# INV-014: transcript was NOT written to
|
||
assert not log.write.called
|
||
|
||
def test_tool_result_routes_to_tools_log(self) -> None:
|
||
"""tool_result_routes_to_tools_log [INV-014]: ToolResult → tools_log, NOT transcript."""
|
||
from ratatoskr.tui import TuiPresenterState
|
||
|
||
log = MagicMock()
|
||
tools_log = MagicMock()
|
||
state = TuiPresenterState()
|
||
state.render(
|
||
ToolResult(sse_id=SID, name="read_file", result="ok", duration_ms=12),
|
||
log=log,
|
||
thinking_widget=MagicMock(),
|
||
tools_log=tools_log,
|
||
debug_log=MagicMock(),
|
||
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||
)
|
||
assert tools_log.write.called
|
||
assert _text_of(tools_log.write.call_args[0][0]).startswith("· tool_result:")
|
||
assert not log.write.called
|
||
|
||
def test_text_event_buffers_into_current_text(self) -> None:
|
||
"""text_event_buffers_into_current_text [v0.6.0]: Text → current_text Static
|
||
(accumulated), NOT log or tools_log. Streaming UX fix — no per-token spam.
|
||
"""
|
||
from ratatoskr.tui import TuiPresenterState
|
||
|
||
log = MagicMock()
|
||
tools_log = MagicMock()
|
||
current_text = MagicMock()
|
||
state = TuiPresenterState()
|
||
state.render(
|
||
Text(sse_id=SID, content="hello"),
|
||
log=log,
|
||
thinking_widget=MagicMock(),
|
||
tools_log=tools_log,
|
||
debug_log=MagicMock(),
|
||
current_text=current_text,
|
||
thinking_log=MagicMock(),
|
||
raw=False,
|
||
)
|
||
current_text.update.assert_called_once_with("hello")
|
||
assert not log.write.called
|
||
assert not tools_log.write.called
|
||
|
||
def test_text_deltas_accumulate(self) -> None:
|
||
"""text_deltas_accumulate [v0.6.0]: multiple Text deltas → current_text shows
|
||
concatenated content, NOT separate per-delta lines.
|
||
"""
|
||
from ratatoskr.tui import TuiPresenterState
|
||
|
||
current_text = MagicMock()
|
||
state = TuiPresenterState()
|
||
for tok in ("Hel", "lo", " ", "world"):
|
||
state.render(
|
||
Text(sse_id=SID, content=tok),
|
||
log=MagicMock(),
|
||
thinking_widget=MagicMock(),
|
||
tools_log=MagicMock(),
|
||
debug_log=MagicMock(),
|
||
current_text=current_text,
|
||
thinking_log=MagicMock(),
|
||
raw=False,
|
||
)
|
||
# Final update reflects the full concatenation.
|
||
assert current_text.update.call_args_list[-1][0][0] == "Hello world"
|
||
|
||
def test_duration_format_seconds(self) -> None:
|
||
"""duration_format_seconds [trace]: Done(duration_ms=5467) → label has "duration=5.5s"."""
|
||
from ratatoskr.tui import TuiPresenterState
|
||
|
||
log = MagicMock()
|
||
state = TuiPresenterState()
|
||
state.render(
|
||
_make_tui_done(duration_ms=5467),
|
||
log=log,
|
||
thinking_widget=MagicMock(),
|
||
tools_log=MagicMock(),
|
||
debug_log=MagicMock(),
|
||
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,
|
||
)
|
||
done_line = next(
|
||
_text_of(c[0][0])
|
||
for c in log.write.call_args_list
|
||
if _text_of(c[0][0]).startswith("[done]")
|
||
)
|
||
assert "duration=5.5s" in done_line
|
||
assert "duration_ms=5467" not in done_line
|
||
|
||
def test_usage_format_unicode_arrow(self) -> None:
|
||
"""usage_format_unicode_arrow [trace]: TUI Done label uses → (Unicode), not -> (ASCII)."""
|
||
from ratatoskr.tui import TuiPresenterState
|
||
|
||
log = MagicMock()
|
||
state = TuiPresenterState()
|
||
usage = {
|
||
"prompt_tokens": 6756,
|
||
"completion_tokens": 126,
|
||
"total_tokens": 6882,
|
||
"cached_input_tokens": 0,
|
||
}
|
||
state.render(
|
||
_make_tui_done(usage=usage),
|
||
log=log,
|
||
thinking_widget=MagicMock(),
|
||
tools_log=MagicMock(),
|
||
debug_log=MagicMock(),
|
||
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,
|
||
)
|
||
done_line = next(
|
||
_text_of(c[0][0])
|
||
for c in log.write.call_args_list
|
||
if _text_of(c[0][0]).startswith("[done]")
|
||
)
|
||
assert "usage 6756 in → 126 out (6882 total, 0 cached)" in done_line
|
||
|
||
|
||
def _text_of(write_arg: object) -> str:
|
||
"""Extract plain text from a RichLog.write() arg (str or rich.text.Text).
|
||
|
||
Issue #12 wraps demoted-telemetry entries in `rich.text.Text(..., style="dim")`
|
||
so the RichLog can apply dim styling; non-demoted writes stay as plain str.
|
||
Tests that want to assert against content need both shapes flattened.
|
||
"""
|
||
from rich.text import Text as RichText
|
||
|
||
if isinstance(write_arg, RichText):
|
||
return write_arg.plain
|
||
if isinstance(write_arg, str):
|
||
return write_arg
|
||
return "" # Markdown / Rule / etc. — not text content
|
||
|
||
|
||
def _make_tui_done(*, duration_ms: int = 1, usage: dict[str, int] | None = None) -> Done:
|
||
return Done(
|
||
sse_id=SID,
|
||
phase="succeeded",
|
||
response="r",
|
||
model="m",
|
||
duration_ms=duration_ms,
|
||
usage=usage
|
||
if usage is not None
|
||
else {
|
||
"prompt_tokens": 0,
|
||
"completion_tokens": 0,
|
||
"total_tokens": 0,
|
||
"cached_input_tokens": 0,
|
||
},
|
||
)
|
||
|
||
|
||
class TestCancelViaSse:
|
||
@respx.mock
|
||
async def test_happy_cancel(self) -> None:
|
||
"""happy_cancel [happy,tracer]: 200 OK → returns None; log has no [cancel_failed]."""
|
||
respx.post("https://w.example/sessions/s-1/turns/42/cancel").mock(
|
||
return_value=httpx.Response(200, json=_CANCEL_OK_RESP)
|
||
)
|
||
log = MagicMock()
|
||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||
result = await _cancel_via_sse(client, "s-1", 42, log=log)
|
||
assert result is None
|
||
log.write.assert_not_called()
|
||
|
||
@respx.mock
|
||
async def test_cancel_failed_500(self) -> None:
|
||
"""cancel_failed_500 [error]: …"""
|
||
respx.post("https://w.example/sessions/s-1/turns/42/cancel").mock(
|
||
return_value=httpx.Response(500, content=b"boom")
|
||
)
|
||
log = MagicMock()
|
||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||
await _cancel_via_sse(client, "s-1", 42, log=log)
|
||
line = log.write.call_args[0][0]
|
||
assert "[cancel_failed]" in line
|
||
assert "CancelFailed" in line
|
||
|
||
@respx.mock
|
||
async def test_cancel_already_completed(self) -> None:
|
||
"""cancel_already_completed [scenario]: 409 → '[cancel_failed] CancelAlreadyCompleted:'."""
|
||
respx.post("https://w.example/sessions/s-1/turns/42/cancel").mock(
|
||
return_value=httpx.Response(409)
|
||
)
|
||
log = MagicMock()
|
||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||
await _cancel_via_sse(client, "s-1", 42, log=log)
|
||
line = log.write.call_args[0][0]
|
||
assert "[cancel_failed]" in line
|
||
assert "CancelAlreadyCompleted" in line
|
||
|
||
@respx.mock
|
||
async def test_transport_error_swallowed(self) -> None:
|
||
"""transport_error_swallowed [error]: …"""
|
||
respx.post("https://w.example/sessions/s-1/turns/42/cancel").mock(
|
||
side_effect=httpx.ConnectError("network down")
|
||
)
|
||
log = MagicMock()
|
||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||
await _cancel_via_sse(client, "s-1", 42, log=log)
|
||
line = log.write.call_args[0][0]
|
||
assert "[cancel_failed]" in line
|
||
assert "ConnectError" in line
|
||
|
||
|
||
class TestAppMount:
|
||
"""on_mount narrows per issue #6: only identity-widget population.
|
||
|
||
Session resolution + AsyncClient open + error-on-resolve are exercised at
|
||
the `_resolve_then_run` layer (see TestResolveThenRun); only happy mount
|
||
paths remain here, exercised with pre-resolved state via _resolved_app.
|
||
"""
|
||
|
||
async def test_happy_new_session_mount(self) -> None:
|
||
"""happy_new_session_mount [happy,tracer]: identity populated from pre-resolved state."""
|
||
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
assert app.session_id == "s-new12345"
|
||
assert app.agent_id == "mimir"
|
||
assert app.state == "idle"
|
||
# INV-002: session identity visible — agent_id + last 8 of session_id
|
||
assert "mimir" in (app.sub_title or "")
|
||
assert app.session_id[-8:] in (app.sub_title or "")
|
||
|
||
async def test_happy_existing_session_mount(self) -> None:
|
||
"""happy_existing_session_mount: identity shows <unknown> when agent_id is None."""
|
||
app = _resolved_app(_args_existing(session_id="s-existing-tail8x"))
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
assert app.session_id == "s-existing-tail8x"
|
||
assert app.state == "idle"
|
||
# INV-002 carve-out: agent unknown → <unknown> · …<tail>
|
||
assert "<unknown>" in (app.sub_title or "")
|
||
assert app.session_id[-8:] in (app.sub_title or "")
|
||
|
||
async def test_footer_identity_visible_first_frame(self) -> None:
|
||
"""footer_identity_visible_first_frame [trace]: identity widget rendered first frame."""
|
||
from textual.widgets import Static
|
||
|
||
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
identity_widget = app.query_one("#identity", Static)
|
||
rendered = str(identity_widget.render())
|
||
assert "mimir" in rendered
|
||
assert "·" in rendered
|
||
assert app.session_id[-8:] in rendered
|
||
|
||
|
||
# Issue #13 — TUI layout reshape + Tools pane (§5 v1 entry point)
|
||
|
||
|
||
class TestLayoutShape:
|
||
"""INV-013 + INV-014 + INV-017: Horizontal two-column layout with Tools tab."""
|
||
|
||
async def test_main_row_is_horizontal(self) -> None:
|
||
"""main_row_is_horizontal [tracer]: compose() yields Horizontal#main-row."""
|
||
from textual.containers import Horizontal
|
||
|
||
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
row = app.query_one("#main-row", Horizontal)
|
||
assert row is not None
|
||
|
||
async def test_left_column_content_only(self) -> None:
|
||
"""left_column_content_only [v0.5.0]: left column = transcript + prompt ONLY.
|
||
|
||
thinking-current Static moved to right column so the left column is
|
||
genuinely content-only (transcript + prompt input).
|
||
"""
|
||
from textual.containers import Vertical
|
||
from textual.widgets import Input, RichLog, Static
|
||
|
||
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
left = app.query_one("#left-column", Vertical)
|
||
right = app.query_one("#right-column", Vertical)
|
||
transcript = app.query_one("#transcript", RichLog)
|
||
prompt = app.query_one("#prompt", Input)
|
||
thinking = app.query_one("#thinking-current", Static)
|
||
assert transcript in left.walk_children()
|
||
assert prompt in left.walk_children()
|
||
# v0.5.0: thinking-current is now under the right column, NOT left.
|
||
assert thinking not in left.walk_children()
|
||
assert thinking in right.walk_children()
|
||
|
||
async def test_right_column_has_tabbed_content_with_tools_tab(self) -> None:
|
||
"""right_column_has_tabbed_content_with_tools_tab: #side-panes + TabPane#tools-tab."""
|
||
from textual.widgets import TabbedContent, TabPane
|
||
|
||
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
tabbed = app.query_one("#side-panes", TabbedContent)
|
||
assert tabbed is not None
|
||
tools_tab = app.query_one("#tools-tab", TabPane)
|
||
assert tools_tab is not None
|
||
|
||
async def test_tools_log_inside_tools_tab(self) -> None:
|
||
"""tools_log_inside_tools_tab: tools-log RichLog is a descendant of tools-tab TabPane."""
|
||
from textual.widgets import RichLog, TabPane
|
||
|
||
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
tools_tab = app.query_one("#tools-tab", TabPane)
|
||
tools_log = app.query_one("#tools-log", RichLog)
|
||
assert tools_log in tools_tab.walk_children()
|
||
|
||
async def test_pane_name_widget_renders_tools(self) -> None:
|
||
"""pane_name_widget_renders_tools [INV-pane-name]: #pane-name == 'Tools' on first frame."""
|
||
from textual.widgets import Static
|
||
|
||
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
pane_name = app.query_one("#pane-name", Static)
|
||
rendered = str(pane_name.render())
|
||
assert rendered == "Tools"
|
||
|
||
async def test_ctrl_1_activates_tools_tab(self) -> None:
|
||
"""ctrl_1_activates_tools_tab [tracer]: Ctrl+1 → TabbedContent.active == 'tools-tab'."""
|
||
from textual.widgets import TabbedContent
|
||
|
||
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
await pilot.press("ctrl+1")
|
||
await pilot.pause()
|
||
tabbed = app.query_one("#side-panes", TabbedContent)
|
||
assert tabbed.active == "tools-tab"
|
||
|
||
async def test_ctrl_1_preserves_input_focus(self) -> None:
|
||
"""ctrl_1_preserves_input_focus [INV-016]: Ctrl+1 does NOT steal focus from Input."""
|
||
from textual.widgets import Input
|
||
|
||
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
prompt = app.query_one("#prompt", Input)
|
||
prompt.focus()
|
||
await pilot.pause()
|
||
assert app.focused is prompt
|
||
await pilot.press("ctrl+1")
|
||
await pilot.pause()
|
||
assert app.focused is prompt, (
|
||
f"INV-016: Input focus must survive Ctrl+1 tab switch; got focused={app.focused}"
|
||
)
|
||
|
||
async def test_debug_tab_exists(self) -> None:
|
||
"""debug_tab_exists [v0.5.0]: right column has Debug TabPane + #debug-log RichLog."""
|
||
from textual.widgets import RichLog, TabPane
|
||
|
||
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
debug_tab = app.query_one("#debug-tab", TabPane)
|
||
debug_log = app.query_one("#debug-log", RichLog)
|
||
assert debug_log in debug_tab.walk_children()
|
||
|
||
async def test_ctrl_2_activates_debug_tab(self) -> None:
|
||
"""ctrl_2_activates_debug_tab [v0.5.0]: Ctrl+2 → TabbedContent.active == 'debug-tab'."""
|
||
from textual.widgets import TabbedContent
|
||
|
||
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
await pilot.press("ctrl+2")
|
||
await pilot.pause()
|
||
assert app.query_one("#side-panes", TabbedContent).active == "debug-tab"
|
||
|
||
async def test_done_label_styled_success(self) -> None:
|
||
"""done_label_styled_success [v0.5.1]: [done] label renders in Aurora green."""
|
||
from rich.text import Text as RichText
|
||
from textual.widgets import RichLog
|
||
|
||
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
# Probe the presenter directly — write a Done via state.render.
|
||
from ratatoskr.tui import TuiPresenterState
|
||
log = app.query_one("#transcript", RichLog)
|
||
state = TuiPresenterState()
|
||
seen: list = []
|
||
orig = log.write
|
||
log.write = lambda c, *a, **kw: (seen.append(c), orig(c, *a, **kw))[1]
|
||
state.render(
|
||
_make_tui_done(),
|
||
log=log,
|
||
thinking_widget=app.query_one("#thinking-current"),
|
||
tools_log=app.query_one("#tools-log", RichLog),
|
||
debug_log=app.query_one("#debug-log", RichLog),
|
||
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,
|
||
)
|
||
done = next(
|
||
c for c in seen
|
||
if isinstance(c, RichText) and _text_of(c).startswith("[done]")
|
||
)
|
||
assert done.style == "#16B866" # Aurora green
|
||
|
||
async def test_empty_state_placeholders_present(self) -> None:
|
||
"""empty_state_placeholders_present [v0.5.1]: tools-log + debug-log show
|
||
placeholder lines before any turn fires."""
|
||
from textual.widgets import RichLog
|
||
|
||
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
# Activate Debug tab so its content actually renders.
|
||
from textual.widgets import TabbedContent
|
||
tabbed = app.query_one("#side-panes", TabbedContent)
|
||
tabbed.active = "debug-tab"
|
||
await pilot.pause()
|
||
tabbed.active = "tools-tab"
|
||
await pilot.pause()
|
||
tools_log = app.query_one("#tools-log", RichLog)
|
||
debug_log = app.query_one("#debug-log", RichLog)
|
||
tools_text = " ".join(str(line) for line in tools_log.lines)
|
||
tabbed.active = "debug-tab"
|
||
await pilot.pause()
|
||
debug_text = " ".join(str(line) for line in debug_log.lines)
|
||
assert "no tool events" in tools_text
|
||
assert "worker_phase" in debug_text
|
||
|
||
async def test_pane_name_updates_on_tab_switch(self) -> None:
|
||
"""pane_name_updates_on_tab_switch [v0.5.0]: pane-name reflects active tab.
|
||
|
||
Two tabs now (Tools / Debug); pane-name updates from "Tools" to "Debug"
|
||
and back as the operator switches via Ctrl+1 / Ctrl+2.
|
||
"""
|
||
from textual.widgets import Static
|
||
|
||
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
pane_name = app.query_one("#pane-name", Static)
|
||
await pilot.press("ctrl+2")
|
||
await pilot.pause()
|
||
assert str(pane_name.render()) == "Debug"
|
||
await pilot.press("ctrl+1")
|
||
await pilot.pause()
|
||
assert str(pane_name.render()) == "Tools"
|
||
|
||
|
||
import asyncio # noqa: E402
|
||
|
||
from textual.widgets import Input # noqa: E402
|
||
|
||
|
||
async def _noop_worker(self, content: str) -> None:
|
||
"""Fake _stream_turn_worker that never completes (lets state stay 'streaming')."""
|
||
await asyncio.Future() # await forever; cancelled when test exits
|
||
|
||
|
||
class TestOnInputSubmitted:
|
||
@respx.mock
|
||
async def test_happy_submit_echoes_and_spawns(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""happy_submit_echoes_and_spawns [happy,tracer]: …"""
|
||
monkeypatch.setattr(RatatoskrApp, "_stream_turn_worker", _noop_worker)
|
||
writes = _spy_writes(monkeypatch)
|
||
app = _resolved_app(_args_existing())
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
inp = app.query_one("#prompt", Input)
|
||
inp.value = "hello"
|
||
await inp.action_submit()
|
||
await pilot.pause()
|
||
assert any("❯ hello" in str(w) for w in writes) # noqa: RUF001
|
||
assert inp.value == ""
|
||
assert app.state == "streaming"
|
||
assert app.stream_worker is not None
|
||
|
||
@respx.mock
|
||
async def test_empty_submit_no_op(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""empty_submit_no_op [trace]: '' + Enter → no change; no worker spawned."""
|
||
monkeypatch.setattr(RatatoskrApp, "_stream_turn_worker", _noop_worker)
|
||
app = _resolved_app(_args_existing())
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
# Spy AFTER mount so identity-widget writes (if any) aren't counted.
|
||
writes = _spy_writes(monkeypatch)
|
||
inp = app.query_one("#prompt", Input)
|
||
inp.value = ""
|
||
await inp.action_submit()
|
||
await pilot.pause()
|
||
assert app.state == "idle"
|
||
assert app.stream_worker is None
|
||
# POST: no RichLog write fires on empty submit
|
||
assert writes == []
|
||
|
||
@respx.mock
|
||
async def test_submit_during_streaming_shows_busy_notice(
|
||
self, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
"""submit_during_streaming_shows_busy_notice [adversarial]: …"""
|
||
monkeypatch.setattr(RatatoskrApp, "_stream_turn_worker", _noop_worker)
|
||
writes = _spy_writes(monkeypatch)
|
||
app = _resolved_app(_args_existing())
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
inp = app.query_one("#prompt", Input)
|
||
# First submit: enters streaming
|
||
inp.value = "first"
|
||
await inp.action_submit()
|
||
await pilot.pause()
|
||
first_worker = app.stream_worker
|
||
assert app.state == "streaming"
|
||
# Second submit while streaming → busy notice; no new worker
|
||
writes.clear()
|
||
inp.value = "second"
|
||
await inp.action_submit()
|
||
await pilot.pause()
|
||
assert any("[busy] turn in flight; input ignored" in str(w) for w in writes)
|
||
assert app.stream_worker is first_worker # unchanged
|
||
assert app.state == "streaming"
|
||
assert inp.value == ""
|
||
|
||
@respx.mock
|
||
async def test_submit_during_cancelling_shows_busy_notice(
|
||
self, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
"""submit_during_cancelling_shows_busy_notice [adversarial]: …"""
|
||
monkeypatch.setattr(RatatoskrApp, "_stream_turn_worker", _noop_worker)
|
||
writes = _spy_writes(monkeypatch)
|
||
app = _resolved_app(_args_existing())
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
app.state = "cancelling" # bypass the natural transition for the test
|
||
assert app.stream_worker is None # no live worker before non-idle submit
|
||
inp = app.query_one("#prompt", Input)
|
||
inp.value = "x"
|
||
await inp.action_submit()
|
||
await pilot.pause()
|
||
assert any("[busy]" in str(w) for w in writes)
|
||
assert app.state == "cancelling"
|
||
# POST-005 (from issue #4 on_input_submitted contract):
|
||
# input cleared; NO new worker spawned during non-idle submit.
|
||
assert inp.value == ""
|
||
assert app.stream_worker is None
|
||
|
||
@respx.mock
|
||
async def test_footer_hint_flips_to_cancel(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""footer_hint_flips_to_cancel [trace]: hint widget shows 'Ctrl-C to cancel'."""
|
||
from textual.widgets import Static
|
||
|
||
monkeypatch.setattr(RatatoskrApp, "_stream_turn_worker", _noop_worker)
|
||
app = _resolved_app(_args_existing())
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
hint_widget = app.query_one("#hint", Static)
|
||
assert str(hint_widget.render()) == RatatoskrApp.HINT_IDLE
|
||
inp = app.query_one("#prompt", Input)
|
||
inp.value = "hi"
|
||
await inp.action_submit()
|
||
await pilot.pause()
|
||
assert str(hint_widget.render()) == RatatoskrApp.HINT_STREAMING
|
||
|
||
|
||
import json # noqa: E402
|
||
|
||
|
||
def _sse_chunk(sse_id: str, body: dict) -> bytes:
|
||
return f"id: {sse_id}\ndata: {json.dumps(body)}\n\n".encode()
|
||
|
||
|
||
_DONE_BODY = {
|
||
"type": "done",
|
||
"phase": "succeeded",
|
||
"response": "hello",
|
||
"model": "m",
|
||
"duration_ms": 1,
|
||
"usage": {
|
||
"prompt_tokens": 0,
|
||
"completion_tokens": 0,
|
||
"total_tokens": 0,
|
||
"cached_input_tokens": 0,
|
||
},
|
||
}
|
||
_CANCELLED_STREAM_BODY = {
|
||
"type": "cancelled",
|
||
"phase": "cancelled",
|
||
"turn_id": 42,
|
||
"reason": "user_cancel",
|
||
"partial_message_id": None,
|
||
}
|
||
|
||
|
||
def _sse_resp(body: bytes | httpx.AsyncByteStream) -> httpx.Response:
|
||
headers = {"content-type": "text/event-stream"}
|
||
if isinstance(body, bytes):
|
||
return httpx.Response(200, headers=headers, content=body)
|
||
return httpx.Response(200, headers=headers, stream=body)
|
||
|
||
|
||
async def _submit_and_wait(app: RatatoskrApp, pilot, content: str) -> None:
|
||
"""Type content into the input and submit; wait for worker to finish."""
|
||
inp = app.query_one("#prompt", Input)
|
||
inp.value = content
|
||
await inp.action_submit()
|
||
await pilot.pause() # let the Input.Submitted message dispatch
|
||
# Poll until the worker resolves (state returns to idle)
|
||
for _ in range(100):
|
||
if app.state == "idle" and app.stream_worker is not None:
|
||
return
|
||
await pilot.pause(0.02)
|
||
|
||
|
||
class TestStreamTurnWorker:
|
||
@respx.mock
|
||
async def test_happy_text_done_renders_markdown(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""happy_text_done_renders_markdown [happy,tracer, v0.6.0]:
|
||
Text deltas go to current_text (not transcript); on Done, transcript
|
||
gets turn-header Rule, [done] label, post-Done Rule + Markdown body.
|
||
"""
|
||
stream = _sse_chunk("42:1", {"type": "text", "content": "hello"}) + _sse_chunk(
|
||
"42:2", _DONE_BODY
|
||
)
|
||
respx.post("https://w.example/sessions/s-1existing/messages").mock(
|
||
return_value=_sse_resp(stream)
|
||
)
|
||
|
||
writes = _spy_writes(monkeypatch)
|
||
|
||
app = _resolved_app(_args_existing())
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
await _submit_and_wait(app, pilot, "hi")
|
||
assert app.state == "idle"
|
||
# v0.6.0: Text("hello") goes to current_text Static, NOT log.
|
||
# writes spy captures RichLog.write only, so "hello" SHOULD NOT appear.
|
||
from rich.markdown import Markdown
|
||
from rich.rule import Rule
|
||
|
||
assert not any(w == "hello" for w in writes)
|
||
assert any("[done]" in str(w) for w in writes)
|
||
# Post-Done: Markdown body + Rule + turn-header Rule all present.
|
||
assert any(isinstance(w, Markdown) for w in writes)
|
||
assert any(isinstance(w, Rule) for w in writes)
|
||
|
||
@respx.mock
|
||
async def test_raw_flag_skips_markdown_render(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""raw_flag_skips_markdown_render [trace, v0.6.0]:
|
||
With --raw, no Markdown render. A turn-header Rule IS still written
|
||
(v0.6.0 INV — turn correlation lives in every pane). The post-Done
|
||
Rule(separator) is suppressed; accumulated streamed text is written
|
||
as a plain string instead.
|
||
"""
|
||
stream = _sse_chunk("42:1", {"type": "text", "content": "hi"}) + _sse_chunk(
|
||
"42:2", _DONE_BODY
|
||
)
|
||
respx.post("https://w.example/sessions/s-1existing/messages").mock(
|
||
return_value=_sse_resp(stream)
|
||
)
|
||
writes = _spy_writes(monkeypatch)
|
||
app = _resolved_app(_args_existing(raw=True))
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
await _submit_and_wait(app, pilot, "x")
|
||
from rich.markdown import Markdown
|
||
from rich.rule import Rule
|
||
|
||
# No Markdown in raw mode.
|
||
assert not any(isinstance(w, Markdown) for w in writes)
|
||
# Only turn-header Rules — one per pane (transcript + tools +
|
||
# debug + thinking = 4). No post-Done separator Rule.
|
||
rules = [w for w in writes if isinstance(w, Rule)]
|
||
assert len(rules) == 4, f"expected 4 turn-header Rules, got {len(rules)}"
|
||
# Accumulated text "hi" written as plain string post-Done.
|
||
assert "hi" in writes
|
||
|
||
@respx.mock
|
||
async def test_error_terminal_returns_to_idle(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""error_terminal_returns_to_idle [happy]: …"""
|
||
stream = _sse_chunk("42:1", {"type": "text", "content": "x"}) + _sse_chunk(
|
||
"42:2",
|
||
{
|
||
"type": "error",
|
||
"phase": "failed",
|
||
"error_code": "llm_output_invalid",
|
||
"message": "boom",
|
||
},
|
||
)
|
||
respx.post("https://w.example/sessions/s-1existing/messages").mock(
|
||
return_value=_sse_resp(stream)
|
||
)
|
||
writes = _spy_writes(monkeypatch)
|
||
app = _resolved_app(_args_existing())
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
await _submit_and_wait(app, pilot, "x")
|
||
assert app.state == "idle"
|
||
assert any("[error]" in str(w) for w in writes)
|
||
|
||
@respx.mock
|
||
async def test_cancelled_terminal_returns_to_idle(
|
||
self, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
"""cancelled_terminal_returns_to_idle [happy]: …"""
|
||
stream = _sse_chunk("42:1", {"type": "text", "content": "x"}) + _sse_chunk(
|
||
"42:2", _CANCELLED_STREAM_BODY
|
||
)
|
||
respx.post("https://w.example/sessions/s-1existing/messages").mock(
|
||
return_value=_sse_resp(stream)
|
||
)
|
||
writes = _spy_writes(monkeypatch)
|
||
app = _resolved_app(_args_existing())
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
await _submit_and_wait(app, pilot, "x")
|
||
assert app.state == "idle"
|
||
assert any("[cancelled]" in str(w) for w in writes)
|
||
|
||
@respx.mock
|
||
async def test_active_turn_id_set_on_first_event(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""active_turn_id_set_on_first_event [trace]: …"""
|
||
# Use a gated stream: yield first event, then hold, so we can inspect mid-stream
|
||
first = _sse_chunk("42:1", {"type": "text", "content": "x"})
|
||
gate = asyncio.Event()
|
||
|
||
class _GatedAfterFirst(httpx.AsyncByteStream):
|
||
async def __aiter__(self):
|
||
yield first
|
||
await gate.wait()
|
||
yield _sse_chunk("42:2", _DONE_BODY)
|
||
|
||
async def aclose(self) -> None:
|
||
return None
|
||
|
||
respx.post("https://w.example/sessions/s-1existing/messages").mock(
|
||
return_value=_sse_resp(_GatedAfterFirst())
|
||
)
|
||
|
||
app = _resolved_app(_args_existing())
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
inp = app.query_one("#prompt", Input)
|
||
inp.value = "x"
|
||
await inp.action_submit()
|
||
# Wait for first event to be processed (active_turn_id set)
|
||
for _ in range(50):
|
||
if app.active_turn_id is not None:
|
||
break
|
||
await pilot.pause(0.02)
|
||
assert app.active_turn_id == 42
|
||
# Release the gate so the worker can finish and the app can shut down cleanly
|
||
gate.set()
|
||
for _ in range(50):
|
||
if app.state == "idle":
|
||
break
|
||
await pilot.pause(0.02)
|
||
|
||
@respx.mock
|
||
async def test_sse_connect_failed_returns_to_idle(
|
||
self, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
"""sse_connect_failed_returns_to_idle [error]: …"""
|
||
respx.post("https://w.example/sessions/s-1existing/messages").mock(
|
||
return_value=httpx.Response(404, json={"error": "session_not_found"})
|
||
)
|
||
writes = _spy_writes(monkeypatch)
|
||
app = _resolved_app(_args_existing())
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
await _submit_and_wait(app, pilot, "x")
|
||
assert app.state == "idle"
|
||
assert any("[sse_connect_failed]" in str(w) for w in writes)
|
||
assert app.return_value is None # app NOT exited per INV-008
|
||
|
||
@respx.mock
|
||
async def test_connection_dropped_returns_to_idle(
|
||
self, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
"""connection_dropped_returns_to_idle [error]: …"""
|
||
|
||
class _DropAfter(httpx.AsyncByteStream):
|
||
async def __aiter__(self):
|
||
yield _sse_chunk("42:1", {"type": "text", "content": "x"})
|
||
raise httpx.RemoteProtocolError("drop")
|
||
|
||
async def aclose(self) -> None:
|
||
return None
|
||
|
||
respx.post("https://w.example/sessions/s-1existing/messages").mock(
|
||
return_value=_sse_resp(_DropAfter())
|
||
)
|
||
writes = _spy_writes(monkeypatch)
|
||
app = _resolved_app(_args_existing())
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
await _submit_and_wait(app, pilot, "x")
|
||
assert app.state == "idle"
|
||
assert any("[connection_dropped]" in str(w) for w in writes)
|
||
|
||
@respx.mock
|
||
async def test_malformed_sse_data_returns_to_idle(
|
||
self, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
"""malformed_sse_data_returns_to_idle [error]: bad-JSON → [malformed_sse_data]; idle."""
|
||
stream = (
|
||
_sse_chunk("42:1", {"type": "text", "content": "x"}) + b"id: 42:2\ndata: not-json\n\n"
|
||
)
|
||
respx.post("https://w.example/sessions/s-1existing/messages").mock(
|
||
return_value=_sse_resp(stream)
|
||
)
|
||
writes = _spy_writes(monkeypatch)
|
||
app = _resolved_app(_args_existing())
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
await _submit_and_wait(app, pilot, "x")
|
||
assert app.state == "idle"
|
||
assert any("[malformed_sse_data]" in str(w) for w in writes)
|
||
assert any("not-json" in str(w) for w in writes)
|
||
# INV-008: mid-session error does NOT exit the app
|
||
assert app.return_value is None
|
||
|
||
@respx.mock
|
||
async def test_rendered_event_per_event(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""rendered_event_per_event [trace]: …"""
|
||
chunks = (
|
||
_sse_chunk("42:1", {"type": "worker_phase", "phase": "streaming", "turn_id": 42})
|
||
+ _sse_chunk("42:2", {"type": "text", "content": "hi"})
|
||
+ _sse_chunk("42:3", _DONE_BODY)
|
||
)
|
||
respx.post("https://w.example/sessions/s-1existing/messages").mock(
|
||
return_value=_sse_resp(chunks)
|
||
)
|
||
|
||
# Per issue #12: rendering went from stateless _render_event_to_log to
|
||
# TuiPresenterState.render; the spy moves to the new method.
|
||
from ratatoskr.tui import TuiPresenterState
|
||
|
||
call_count = 0
|
||
original = TuiPresenterState.render
|
||
|
||
def spy(self, event, **kw): # type: ignore[no-untyped-def]
|
||
nonlocal call_count
|
||
call_count += 1
|
||
return original(self, event, **kw)
|
||
|
||
monkeypatch.setattr(TuiPresenterState, "render", spy)
|
||
app = _resolved_app(_args_existing())
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
await _submit_and_wait(app, pilot, "x")
|
||
assert call_count == 3
|
||
|
||
|
||
class TestActionInterrupt:
|
||
@respx.mock
|
||
async def test_idle_ctrl_c_exits_zero(self) -> None:
|
||
"""idle_ctrl_c_exits_zero [happy,tracer]: state=idle; ctrl+c → exit(0)."""
|
||
app = _resolved_app(_args_existing())
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
assert app.state == "idle"
|
||
await pilot.press("ctrl+c")
|
||
await pilot.pause()
|
||
assert app.return_value == 0
|
||
|
||
@respx.mock
|
||
async def test_streaming_first_ctrl_c_cancels(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""streaming_first_ctrl_c_cancels [scenario,tracer]: …"""
|
||
# Stream that yields one text event (sets active_turn_id) then waits forever
|
||
first_chunk = _sse_chunk("42:1", {"type": "text", "content": "x"})
|
||
gate = asyncio.Event()
|
||
|
||
class _GatedAfterFirst(httpx.AsyncByteStream):
|
||
async def __aiter__(self):
|
||
yield first_chunk
|
||
await gate.wait()
|
||
|
||
async def aclose(self) -> None:
|
||
return None
|
||
|
||
respx.post("https://w.example/sessions/s-1existing/messages").mock(
|
||
return_value=_sse_resp(_GatedAfterFirst())
|
||
)
|
||
cancel_observed = asyncio.Event()
|
||
|
||
def cancel_handler(req: httpx.Request) -> httpx.Response:
|
||
cancel_observed.set()
|
||
return httpx.Response(200, json=_CANCEL_OK_RESP)
|
||
|
||
cancel_route = respx.post("https://w.example/sessions/s-1existing/turns/42/cancel").mock(
|
||
side_effect=cancel_handler
|
||
)
|
||
|
||
app = _resolved_app(_args_existing())
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
inp = app.query_one("#prompt", Input)
|
||
inp.value = "go"
|
||
await inp.action_submit()
|
||
await pilot.pause()
|
||
# Wait for active_turn_id to be set (first event consumed)
|
||
for _ in range(50):
|
||
if app.active_turn_id == 42:
|
||
break
|
||
await pilot.pause(0.02)
|
||
assert app.active_turn_id == 42
|
||
assert app.state == "streaming"
|
||
await pilot.press("ctrl+c")
|
||
# Wait for cancel POST to land
|
||
for _ in range(50):
|
||
if cancel_observed.is_set():
|
||
break
|
||
await pilot.pause(0.02)
|
||
assert cancel_route.call_count == 1
|
||
assert app.state == "cancelling"
|
||
from textual.widgets import Static
|
||
|
||
hint_widget = app.query_one("#hint", Static)
|
||
assert str(hint_widget.render()) == RatatoskrApp.HINT_CANCELLING
|
||
# Release the gate so the stream worker can finish cleanly during teardown
|
||
gate.set()
|
||
|
||
@respx.mock
|
||
async def test_streaming_no_turn_id_force_exits(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""streaming_no_turn_id_force_exits [scenario]: …"""
|
||
cancel_route = respx.post("https://w.example/sessions/s-1existing/turns/0/cancel").mock(
|
||
return_value=httpx.Response(200, json=_CANCEL_OK_RESP)
|
||
)
|
||
# Stream that hangs forever (no events to set active_turn_id)
|
||
gate = asyncio.Event()
|
||
|
||
class _NeverYields(httpx.AsyncByteStream):
|
||
async def __aiter__(self):
|
||
await gate.wait()
|
||
if False:
|
||
yield b""
|
||
|
||
async def aclose(self) -> None:
|
||
return None
|
||
|
||
respx.post("https://w.example/sessions/s-1existing/messages").mock(
|
||
return_value=_sse_resp(_NeverYields())
|
||
)
|
||
|
||
app = _resolved_app(_args_existing())
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
inp = app.query_one("#prompt", Input)
|
||
inp.value = "go"
|
||
await inp.action_submit()
|
||
await pilot.pause()
|
||
assert app.state == "streaming"
|
||
assert app.active_turn_id is None
|
||
# Capture worker reference + spy on its .cancel() before ctrl+c
|
||
worker_ref = app.stream_worker
|
||
assert worker_ref is not None
|
||
cancel_calls: list = []
|
||
original_cancel = type(worker_ref).cancel
|
||
monkeypatch.setattr(
|
||
type(worker_ref),
|
||
"cancel",
|
||
lambda self: (cancel_calls.append(self), original_cancel(self))[-1],
|
||
)
|
||
await pilot.press("ctrl+c")
|
||
await pilot.pause()
|
||
gate.set() # let the gated stream resolve so teardown is clean
|
||
assert app.return_value == 3
|
||
assert cancel_route.call_count == 0
|
||
# action_interrupt MUST cancel the stream worker on the no-active_turn_id force-exit path
|
||
assert worker_ref in cancel_calls
|
||
|
||
@respx.mock
|
||
async def test_cancelling_second_ctrl_c_force_exits(
|
||
self, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
"""cancelling_second_ctrl_c_force_exits [scenario]: …"""
|
||
# Set up a real live stream worker (gated, hangs forever) so we can
|
||
# observe action_interrupt's cancel() call on the second-Ctrl-C path.
|
||
monkeypatch.setattr(RatatoskrApp, "_stream_turn_worker", _noop_worker)
|
||
app = _resolved_app(_args_existing())
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
inp = app.query_one("#prompt", Input)
|
||
inp.value = "go"
|
||
await inp.action_submit()
|
||
await pilot.pause()
|
||
assert app.stream_worker is not None
|
||
app.state = "cancelling" # bypass the natural transition for the test
|
||
worker_ref = app.stream_worker
|
||
cancel_calls: list = []
|
||
original_cancel = type(worker_ref).cancel
|
||
monkeypatch.setattr(
|
||
type(worker_ref),
|
||
"cancel",
|
||
lambda self: (cancel_calls.append(self), original_cancel(self))[-1],
|
||
)
|
||
await pilot.press("ctrl+c")
|
||
await pilot.pause()
|
||
assert app.return_value == 3
|
||
# Second-Ctrl-C in cancelling state MUST cancel the in-flight worker
|
||
assert worker_ref in cancel_calls
|
||
|
||
@respx.mock
|
||
async def test_cancel_failed_swallowed(self) -> None:
|
||
"""cancel_failed_swallowed [scenario]: …"""
|
||
first_chunk = _sse_chunk("42:1", {"type": "text", "content": "x"})
|
||
stream_gate = asyncio.Event()
|
||
|
||
class _GatedAfterFirst(httpx.AsyncByteStream):
|
||
async def __aiter__(self):
|
||
yield first_chunk
|
||
await stream_gate.wait()
|
||
|
||
async def aclose(self) -> None:
|
||
return None
|
||
|
||
respx.post("https://w.example/sessions/s-1existing/messages").mock(
|
||
return_value=_sse_resp(_GatedAfterFirst())
|
||
)
|
||
cancel_observed = asyncio.Event()
|
||
|
||
def cancel_handler(req: httpx.Request) -> httpx.Response:
|
||
cancel_observed.set()
|
||
return httpx.Response(500, content=b"boom")
|
||
|
||
respx.post("https://w.example/sessions/s-1existing/turns/42/cancel").mock(
|
||
side_effect=cancel_handler
|
||
)
|
||
|
||
app = _resolved_app(_args_existing())
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
inp = app.query_one("#prompt", Input)
|
||
inp.value = "go"
|
||
await inp.action_submit()
|
||
await pilot.pause()
|
||
for _ in range(50):
|
||
if app.active_turn_id == 42:
|
||
break
|
||
await pilot.pause(0.02)
|
||
await pilot.press("ctrl+c")
|
||
for _ in range(50):
|
||
if cancel_observed.is_set():
|
||
break
|
||
await pilot.pause(0.02)
|
||
# Give _cancel_via_sse time to write the [cancel_failed] line
|
||
await pilot.pause(0.05)
|
||
log = app.query_one("#transcript", RichLog)
|
||
rendered = "\n".join(str(strip.text) for strip in log.lines)
|
||
assert "[cancel_failed]" in rendered
|
||
assert app.state == "cancelling"
|
||
stream_gate.set() # let stream finish for teardown
|
||
|
||
|
||
class TestActionQuit:
|
||
@respx.mock
|
||
async def test_idle_ctrl_d_exits_zero(self) -> None:
|
||
"""idle_ctrl_d_exits_zero [happy,tracer]: state=idle; ctrl+d → exit(0)."""
|
||
app = _resolved_app(_args_existing())
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
await pilot.press("ctrl+d")
|
||
await pilot.pause()
|
||
assert app.return_value == 0
|
||
|
||
@respx.mock
|
||
async def test_streaming_ctrl_d_force_exits(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""streaming_ctrl_d_force_exits [scenario]: …"""
|
||
cancel_route = respx.post("https://w.example/sessions/s-1existing/turns/42/cancel").mock(
|
||
return_value=httpx.Response(200, json=_CANCEL_OK_RESP)
|
||
)
|
||
first_chunk = _sse_chunk("42:1", {"type": "text", "content": "x"})
|
||
gate = asyncio.Event()
|
||
|
||
class _GatedAfterFirst(httpx.AsyncByteStream):
|
||
async def __aiter__(self):
|
||
yield first_chunk
|
||
await gate.wait()
|
||
|
||
async def aclose(self) -> None:
|
||
return None
|
||
|
||
respx.post("https://w.example/sessions/s-1existing/messages").mock(
|
||
return_value=_sse_resp(_GatedAfterFirst())
|
||
)
|
||
app = _resolved_app(_args_existing())
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
inp = app.query_one("#prompt", Input)
|
||
inp.value = "go"
|
||
await inp.action_submit()
|
||
await pilot.pause()
|
||
for _ in range(50):
|
||
if app.active_turn_id == 42:
|
||
break
|
||
await pilot.pause(0.02)
|
||
# Capture worker + spy on cancel before ctrl+d
|
||
worker_ref = app.stream_worker
|
||
assert worker_ref is not None
|
||
cancel_calls: list = []
|
||
original_cancel = type(worker_ref).cancel
|
||
monkeypatch.setattr(
|
||
type(worker_ref),
|
||
"cancel",
|
||
lambda self: (cancel_calls.append(self), original_cancel(self))[-1],
|
||
)
|
||
await pilot.press("ctrl+d")
|
||
await pilot.pause()
|
||
gate.set()
|
||
assert app.return_value == 0
|
||
assert cancel_route.call_count == 0
|
||
# POST-002: Ctrl-D MUST cancel the in-flight stream worker (abandon-and-exit)
|
||
assert worker_ref in cancel_calls
|
||
|
||
|
||
from ratatoskr.tui import run_tui # noqa: E402
|
||
|
||
|
||
class TestResolveThenRun:
|
||
"""Tests at the `_resolve_then_run` layer — pre-`App.run()` session
|
||
resolution + AsyncClient ownership + stderr error routing per issue #6.
|
||
"""
|
||
|
||
@respx.mock
|
||
def test_happy_new_session_resolve(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""happy_new_session_resolve [happy]: --new path through _resolve_then_run.
|
||
|
||
Verifies POST /sessions count + SessionInfo propagation to RatatoskrApp's
|
||
pre-resolved state (session_id / agent_id / client). The corresponding
|
||
TestAppMount.test_happy_new_session_mount uses _resolved_app and bypasses
|
||
_resolve_then_run entirely; this test exercises the production resolve
|
||
path with a real POST /sessions mock.
|
||
"""
|
||
sessions_route = respx.post("https://w.example/sessions").mock(
|
||
return_value=httpx.Response(201, json=_CREATE_OK_RESP)
|
||
)
|
||
snapshot: dict = {}
|
||
|
||
async def capture_run_async(self, *a, **kw):
|
||
snapshot["session_id"] = self.session_id
|
||
snapshot["agent_id"] = self.agent_id
|
||
snapshot["client"] = self.client
|
||
snapshot["client_open"] = not self.client.is_closed
|
||
return 0
|
||
|
||
monkeypatch.setattr(RatatoskrApp, "run_async", capture_run_async)
|
||
rc = run_tui(_args_new())
|
||
assert rc == 0
|
||
# Exactly one POST /sessions invocation by _resolve_then_run
|
||
assert sessions_route.call_count == 1
|
||
# SessionInfo fields propagated into the constructed App
|
||
assert snapshot["session_id"] == "s-new12345"
|
||
assert snapshot["agent_id"] == "mimir"
|
||
assert snapshot["client"] is not None
|
||
assert snapshot["client_open"] is True
|
||
|
||
@respx.mock
|
||
def test_happy_new_with_end_user_id_resolve(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""happy_new_with_end_user_id_resolve [happy]: args.end_user_id threads into POST body.
|
||
|
||
Issue #5 amends #4: _resolve_then_run's create_session call now forwards
|
||
args.end_user_id (renamed from the contract's _mount target, since #6
|
||
moved session resolution out of on_mount into _resolve_then_run).
|
||
"""
|
||
import json as _json
|
||
|
||
sessions_route = respx.post("https://w.example/sessions").mock(
|
||
return_value=httpx.Response(201, json=_CREATE_OK_RESP)
|
||
)
|
||
|
||
async def fake_run_async(self, *a, **kw):
|
||
return 0
|
||
|
||
monkeypatch.setattr(RatatoskrApp, "run_async", fake_run_async)
|
||
rc = run_tui(_args_new(end_user_id="alice"))
|
||
assert rc == 0
|
||
assert sessions_route.call_count == 1
|
||
body = _json.loads(sessions_route.calls[0].request.content)
|
||
assert body == {"agent_id": "mimir", "end_user_id": "alice"}
|
||
|
||
@respx.mock
|
||
def test_user_agent_header_sent(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""user_agent_header_sent [trace]: outbound requests carry the ratatoskr User-Agent.
|
||
|
||
Worldtree-dev (althing 2026-05-23) requested consumers send `User-Agent:
|
||
ratatoskr/<version> (<contact>)` so server logs can distinguish ratatoskr
|
||
traffic from other consumers.
|
||
"""
|
||
sessions_route = respx.post("https://w.example/sessions").mock(
|
||
return_value=httpx.Response(201, json=_CREATE_OK_RESP)
|
||
)
|
||
|
||
async def fake_run_async(self, *a, **kw):
|
||
return 0
|
||
|
||
monkeypatch.setattr(RatatoskrApp, "run_async", fake_run_async)
|
||
rc = run_tui(_args_new())
|
||
assert rc == 0
|
||
ua = sessions_route.calls[0].request.headers["User-Agent"]
|
||
assert ua.startswith("ratatoskr/")
|
||
assert "vh@phasefinal.com" in ua
|
||
|
||
@respx.mock
|
||
def test_alt_screen_never_opens_on_resolve_error(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""alt_screen_never_opens_on_resolve_error [trace]: 404 → run_tui=12; run_async unhit.
|
||
|
||
Directly probes INV-001: session resolution failures MUST short-circuit
|
||
BEFORE the alt-screen opens.
|
||
"""
|
||
respx.post("https://w.example/sessions").mock(
|
||
return_value=httpx.Response(404, json={"error": "unknown_agent_id"})
|
||
)
|
||
sentinel_called = False
|
||
|
||
async def sentinel(self, *a, **kw):
|
||
nonlocal sentinel_called
|
||
sentinel_called = True
|
||
return 0
|
||
|
||
monkeypatch.setattr(RatatoskrApp, "run_async", sentinel)
|
||
rc = run_tui(_args_new())
|
||
assert rc == 12
|
||
assert not sentinel_called
|
||
|
||
@respx.mock
|
||
def test_agent_not_found_on_resolve(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||
"""agent_not_found_on_resolve [error]: --new + 404 → stderr [agent_not_found]; exit 12."""
|
||
respx.post("https://w.example/sessions").mock(
|
||
return_value=httpx.Response(404, json={"error": "unknown_agent_id"})
|
||
)
|
||
rc = run_tui(_args_new())
|
||
err = capsys.readouterr().err
|
||
assert rc == 12
|
||
assert "[agent_not_found]" in err
|
||
assert "agent_id=mimir" in err
|
||
|
||
@respx.mock
|
||
def test_session_api_failed_on_resolve(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||
"""session_api_failed_on_resolve [error]: --new + 500 → [session_api_failed] stderr."""
|
||
respx.post("https://w.example/sessions").mock(
|
||
return_value=httpx.Response(500, content=b"server error")
|
||
)
|
||
rc = run_tui(_args_new())
|
||
err = capsys.readouterr().err
|
||
assert rc == 20
|
||
assert "[session_api_failed]" in err
|
||
assert "status=500" in err
|
||
|
||
@respx.mock
|
||
def test_network_error_on_resolve(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||
"""network_error_on_resolve [error]: --new + ConnectError → [network_error] stderr."""
|
||
respx.post("https://w.example/sessions").mock(side_effect=httpx.ConnectError("down"))
|
||
rc = run_tui(_args_new())
|
||
err = capsys.readouterr().err
|
||
assert rc == 21
|
||
assert "[network_error]" in err
|
||
assert "ConnectError" in err
|
||
|
||
@respx.mock
|
||
def test_stderr_label_format_matches_cli(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||
"""stderr_label_format_matches_cli [trace]: cli._amain and _resolve_then_run produce
|
||
identical stderr lines for AgentNotFound (INV-006).
|
||
"""
|
||
# Re-fetch cli's ParsedArgs from the current module state — test_cli's
|
||
# `importlib.reload(ratatoskr.cli)` rebinds the class, so the top-of-file
|
||
# `from ratatoskr.cli import ParsedArgs` may now refer to a stale class.
|
||
from ratatoskr import cli as cli_mod
|
||
|
||
respx.post("https://w.example/sessions").mock(
|
||
return_value=httpx.Response(404, json={"error": "unknown_agent_id"})
|
||
)
|
||
cli_args = cli_mod.ParsedArgs(
|
||
send_content="x",
|
||
session_id=None,
|
||
new=True,
|
||
agent_id="mimir",
|
||
api_key="k",
|
||
server_url="https://w.example",
|
||
raw=False,
|
||
)
|
||
# Drive cli._amain's error path (--send mode)
|
||
cli_rc = asyncio.run(cli_mod._amain(cli_args))
|
||
cli_err = capsys.readouterr().err
|
||
# Drive _resolve_then_run's error path (TUI mode); _args_new() uses the
|
||
# pre-reload ParsedArgs which still matches tui.run_tui's isinstance check.
|
||
tui_rc = run_tui(_args_new())
|
||
tui_err = capsys.readouterr().err
|
||
# Same exit code, same verbatim stderr line.
|
||
assert cli_rc == 12
|
||
assert tui_rc == 12
|
||
assert cli_err == tui_err
|
||
assert cli_err == "[agent_not_found] agent_id=mimir\n"
|
||
|
||
def test_client_open_after_resolve(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""client_open_after_resolve [trace]: app.client is open at the time run_async runs."""
|
||
snapshot: dict = {}
|
||
|
||
async def capture_run_async(self, *a, **kw):
|
||
snapshot["client_is"] = self.client
|
||
snapshot["closed_during_run"] = self.client.is_closed
|
||
return 0
|
||
|
||
monkeypatch.setattr(RatatoskrApp, "run_async", capture_run_async)
|
||
rc = run_tui(_args_existing())
|
||
assert rc == 0
|
||
assert snapshot["client_is"] is not None
|
||
assert snapshot["closed_during_run"] is False
|
||
|
||
def test_client_lifetime_owned_by_run_tui(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""client_lifetime_owned_by_run_tui [trace]: open during run_async, closed after run_tui.
|
||
|
||
Probes INV-002: the App is a consumer of an externally-owned client;
|
||
the async-with in run_tui closes it, not on_unmount.
|
||
"""
|
||
snapshot: dict = {}
|
||
|
||
async def capture_run_async(self, *a, **kw):
|
||
# During run_async (the alt-screen lifetime) the client is open.
|
||
snapshot["client"] = self.client
|
||
snapshot["closed_during_run"] = self.client.is_closed
|
||
return 0
|
||
|
||
monkeypatch.setattr(RatatoskrApp, "run_async", capture_run_async)
|
||
rc = run_tui(_args_existing())
|
||
assert rc == 0
|
||
client = snapshot["client"]
|
||
assert client is not None
|
||
# Open while the app was running; closed by run_tui's async-with after.
|
||
assert snapshot["closed_during_run"] is False
|
||
assert client.is_closed is True
|
||
|
||
def test_run_tui_closes_client_on_app_exit(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""run_tui_closes_client_on_app_exit: async-with closes client after app.run_async ret."""
|
||
seen_clients: list[httpx.AsyncClient] = []
|
||
|
||
async def fake_run_async(self, *a, **kw):
|
||
seen_clients.append(self.client)
|
||
return 0
|
||
|
||
monkeypatch.setattr(RatatoskrApp, "run_async", fake_run_async)
|
||
rc = run_tui(_args_existing())
|
||
assert rc == 0
|
||
assert len(seen_clients) == 1
|
||
# After run_tui returns, the client should be closed by the async-with
|
||
assert seen_clients[0].is_closed
|
||
|
||
async def test_on_unmount_does_not_close_client(self) -> None:
|
||
"""on_unmount narrowed [trace]: probes INV-002 from the on_unmount side.
|
||
|
||
The complementary check to test_client_lifetime_owned_by_run_tui (which
|
||
patches run_async and so never exercises on_unmount). Here we DO run the
|
||
real on_unmount via Pilot ctrl+d → app teardown, and assert the client
|
||
is still open afterward (close site is run_tui's async-with, which is
|
||
NOT entered in this Pilot-driven test).
|
||
"""
|
||
client = httpx.AsyncClient(
|
||
base_url="https://w.example",
|
||
headers={"Authorization": "Bearer k"},
|
||
timeout=httpx.Timeout(connect=10.0, read=None, write=10.0, pool=10.0),
|
||
)
|
||
app = RatatoskrApp(
|
||
_args_existing(),
|
||
session_id="s-1existing",
|
||
agent_id=None,
|
||
client=client,
|
||
)
|
||
async with app.run_test() as pilot:
|
||
await pilot.pause()
|
||
assert client.is_closed is False
|
||
await pilot.press("ctrl+d")
|
||
await pilot.pause()
|
||
# After app.run_test() teardown, on_unmount has fired. Per INV-002 the
|
||
# client MUST still be open — only run_tui's async-with closes it.
|
||
assert client.is_closed is False
|
||
await client.aclose() # test-side cleanup
|
||
|
||
|
||
class TestRunTui:
|
||
def test_happy_returns_zero_on_quit(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""happy_returns_zero_on_quit [happy,tracer]: run_tui propagates app.run_async exit code."""
|
||
captured: list[ParsedArgs] = []
|
||
|
||
async def fake_run_async(self, *a, **kw):
|
||
captured.append(self.args)
|
||
return 0
|
||
|
||
monkeypatch.setattr(RatatoskrApp, "run_async", fake_run_async)
|
||
rc = run_tui(_args_existing())
|
||
assert rc == 0
|
||
assert len(captured) == 1
|
||
assert captured[0].send_content is None
|
||
|
||
def test_precondition_send_content_none(self) -> None:
|
||
"""precondition_send_content_none [adversarial]: …"""
|
||
bad_args = ParsedArgs(
|
||
send_content="x", # PRE-001 violation
|
||
session_id="s-1",
|
||
new=False,
|
||
agent_id=None,
|
||
api_key="k",
|
||
server_url="https://w.example",
|
||
raw=False,
|
||
)
|
||
with pytest.raises(AssertionError):
|
||
run_tui(bad_args)
|
||
|
||
|
||
# ---- Issue #8: startup agent picker ----------------------------------------
|
||
|
||
|
||
def _args_new_no_agent(**overrides) -> ParsedArgs:
|
||
"""ParsedArgs for bare --new (no --agent) — TUI-mode picker entry."""
|
||
base = dict(
|
||
send_content=None,
|
||
session_id=None,
|
||
new=True,
|
||
agent_id=None, # Issue #8: bare --new, picker drives the choice
|
||
api_key="k",
|
||
server_url="https://w.example",
|
||
raw=False,
|
||
)
|
||
base.update(overrides)
|
||
return ParsedArgs(**base)
|
||
|
||
|
||
_AGENTS_RESP = [
|
||
{
|
||
"agent_id": "mimir",
|
||
"name": "Mimir",
|
||
"description": "Keeper of the Well of Knowledge.",
|
||
},
|
||
{
|
||
"agent_id": "lofn",
|
||
"name": "Lofn",
|
||
"description": "Mediator of secret affairs.",
|
||
},
|
||
]
|
||
|
||
|
||
class TestAgentPickerApp:
|
||
def test_picker_renders_rows(self) -> None:
|
||
"""picker_renders_rows: AgentPickerApp composes one ListItem per agent."""
|
||
from textual.widgets import ListView
|
||
|
||
from ratatoskr.sessions import AgentInfo
|
||
from ratatoskr.tui import AgentPickerApp
|
||
|
||
agents = [
|
||
AgentInfo(
|
||
agent_id="a",
|
||
name="A",
|
||
description="x",
|
||
version=None,
|
||
capabilities=[],
|
||
supported_models=[],
|
||
persona_traits={},
|
||
ui_hints={},
|
||
),
|
||
AgentInfo(
|
||
agent_id="b",
|
||
name="B",
|
||
description="y",
|
||
version=None,
|
||
capabilities=[],
|
||
supported_models=[],
|
||
persona_traits={},
|
||
ui_hints={},
|
||
),
|
||
]
|
||
app = AgentPickerApp(agents)
|
||
|
||
async def probe() -> None:
|
||
async with app.run_test() as pilot:
|
||
lv = app.query_one("#agent-list", ListView)
|
||
assert len(lv.children) == 2
|
||
await pilot.pause()
|
||
app.exit(None)
|
||
|
||
import asyncio
|
||
|
||
asyncio.run(probe())
|
||
|
||
def test_picker_pick_returns_agent_id(self) -> None:
|
||
"""picker_pick_returns_agent_id: highlight idx 1 + Enter → exit value == 'b'."""
|
||
from ratatoskr.sessions import AgentInfo
|
||
from ratatoskr.tui import AgentPickerApp
|
||
|
||
agents = [
|
||
AgentInfo(
|
||
agent_id="a",
|
||
name="A",
|
||
description="x",
|
||
version=None,
|
||
capabilities=[],
|
||
supported_models=[],
|
||
persona_traits={},
|
||
ui_hints={},
|
||
),
|
||
AgentInfo(
|
||
agent_id="b",
|
||
name="B",
|
||
description="y",
|
||
version=None,
|
||
capabilities=[],
|
||
supported_models=[],
|
||
persona_traits={},
|
||
ui_hints={},
|
||
),
|
||
]
|
||
app = AgentPickerApp(agents)
|
||
|
||
async def drive() -> str | None:
|
||
async with app.run_test() as pilot:
|
||
from textual.widgets import ListView
|
||
|
||
lv = app.query_one("#agent-list", ListView)
|
||
lv.index = 1
|
||
await pilot.pause()
|
||
await pilot.press("enter")
|
||
await pilot.pause()
|
||
return app.return_value
|
||
|
||
import asyncio
|
||
|
||
chosen = asyncio.run(drive())
|
||
assert chosen == "b"
|
||
|
||
def test_picker_esc_returns_none(self) -> None:
|
||
"""picker_esc_returns_none: Esc → exit value is None."""
|
||
from ratatoskr.sessions import AgentInfo
|
||
from ratatoskr.tui import AgentPickerApp
|
||
|
||
agents = [
|
||
AgentInfo(
|
||
agent_id="a",
|
||
name="A",
|
||
description="x",
|
||
version=None,
|
||
capabilities=[],
|
||
supported_models=[],
|
||
persona_traits={},
|
||
ui_hints={},
|
||
),
|
||
]
|
||
app = AgentPickerApp(agents)
|
||
|
||
async def drive() -> str | None:
|
||
async with app.run_test() as pilot:
|
||
await pilot.press("escape")
|
||
await pilot.pause()
|
||
return app.return_value
|
||
|
||
import asyncio
|
||
|
||
chosen = asyncio.run(drive())
|
||
assert chosen is None
|
||
|
||
|
||
class TestResolveThenRunWithPicker:
|
||
"""Issue #8: picker integration in _resolve_then_run."""
|
||
|
||
@respx.mock
|
||
def test_picker_happy_path(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""picker_happy_path [happy,tracer]: agents fetched → picker exits with id → create_session.
|
||
|
||
Patches AgentPickerApp.run_async to return 'lofn' (simulating user pick);
|
||
asserts list_agents fired once, POST /sessions body carries agent_id=lofn,
|
||
and RatatoskrApp opens with the chosen identity.
|
||
"""
|
||
agents_route = respx.get("https://w.example/agents").mock(
|
||
return_value=httpx.Response(200, json=_AGENTS_RESP)
|
||
)
|
||
sessions_route = respx.post("https://w.example/sessions").mock(
|
||
return_value=httpx.Response(
|
||
201,
|
||
json={
|
||
**_CREATE_OK_RESP,
|
||
"agent_id": "lofn",
|
||
},
|
||
)
|
||
)
|
||
|
||
from ratatoskr.tui import AgentPickerApp
|
||
|
||
async def picker_returns_lofn(self, *a, **kw):
|
||
return "lofn"
|
||
|
||
monkeypatch.setattr(AgentPickerApp, "run_async", picker_returns_lofn)
|
||
|
||
snapshot: dict = {}
|
||
|
||
async def capture_main(self, *a, **kw):
|
||
snapshot["session_id"] = self.session_id
|
||
snapshot["agent_id"] = self.agent_id
|
||
return 0
|
||
|
||
monkeypatch.setattr(RatatoskrApp, "run_async", capture_main)
|
||
from ratatoskr.tui import run_tui
|
||
|
||
rc = run_tui(_args_new_no_agent())
|
||
assert rc == 0
|
||
assert agents_route.call_count == 1
|
||
assert sessions_route.call_count == 1
|
||
import json as _json
|
||
|
||
body = _json.loads(sessions_route.calls[0].request.content)
|
||
assert body == {"agent_id": "lofn"}
|
||
assert snapshot["agent_id"] == "lofn"
|
||
|
||
@respx.mock
|
||
def test_picker_esc_clean_exit(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""picker_esc_clean_exit: picker returns None → exit 0; no create_session; no main App."""
|
||
agents_route = respx.get("https://w.example/agents").mock(
|
||
return_value=httpx.Response(200, json=_AGENTS_RESP)
|
||
)
|
||
sessions_route = respx.post("https://w.example/sessions").mock(
|
||
return_value=httpx.Response(201, json=_CREATE_OK_RESP)
|
||
)
|
||
|
||
from ratatoskr.tui import AgentPickerApp
|
||
|
||
async def picker_dismissed(self, *a, **kw):
|
||
return None
|
||
|
||
monkeypatch.setattr(AgentPickerApp, "run_async", picker_dismissed)
|
||
|
||
main_called = False
|
||
|
||
async def sentinel(self, *a, **kw):
|
||
nonlocal main_called
|
||
main_called = True
|
||
return 0
|
||
|
||
monkeypatch.setattr(RatatoskrApp, "run_async", sentinel)
|
||
from ratatoskr.tui import run_tui
|
||
|
||
rc = run_tui(_args_new_no_agent())
|
||
assert rc == 0
|
||
assert agents_route.call_count == 1
|
||
assert sessions_route.call_count == 0
|
||
assert main_called is False
|
||
|
||
@respx.mock
|
||
def test_picker_skipped_when_agent_id_provided(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""picker_skipped_when_agent_id_provided: --new --agent mimir → list_agents NOT called."""
|
||
agents_route = respx.get("https://w.example/agents").mock(
|
||
return_value=httpx.Response(200, json=_AGENTS_RESP)
|
||
)
|
||
sessions_route = respx.post("https://w.example/sessions").mock(
|
||
return_value=httpx.Response(201, json=_CREATE_OK_RESP)
|
||
)
|
||
|
||
async def fake_main(self, *a, **kw):
|
||
return 0
|
||
|
||
monkeypatch.setattr(RatatoskrApp, "run_async", fake_main)
|
||
from ratatoskr.tui import run_tui
|
||
|
||
rc = run_tui(_args_new()) # agent_id="mimir"
|
||
assert rc == 0
|
||
assert agents_route.call_count == 0
|
||
assert sessions_route.call_count == 1
|
||
|
||
@respx.mock
|
||
def test_picker_skipped_when_session_mode(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""picker_skipped_when_session_mode: --session s-1 → no list_agents, no create_session."""
|
||
agents_route = respx.get("https://w.example/agents").mock(
|
||
return_value=httpx.Response(200, json=_AGENTS_RESP)
|
||
)
|
||
sessions_route = respx.post("https://w.example/sessions").mock(
|
||
return_value=httpx.Response(201, json=_CREATE_OK_RESP)
|
||
)
|
||
|
||
async def fake_main(self, *a, **kw):
|
||
return 0
|
||
|
||
monkeypatch.setattr(RatatoskrApp, "run_async", fake_main)
|
||
from ratatoskr.tui import run_tui
|
||
|
||
rc = run_tui(_args_existing())
|
||
assert rc == 0
|
||
assert agents_route.call_count == 0
|
||
assert sessions_route.call_count == 0
|
||
|
||
@respx.mock
|
||
def test_picker_list_agents_session_api_failed(
|
||
self,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
capsys: pytest.CaptureFixture[str],
|
||
) -> None:
|
||
"""list_agents 500 → stderr [session_api_failed]; exit 20; picker NOT opened."""
|
||
respx.get("https://w.example/agents").mock(
|
||
return_value=httpx.Response(500, content=b"oops")
|
||
)
|
||
|
||
from ratatoskr.tui import AgentPickerApp
|
||
|
||
picker_called = False
|
||
|
||
async def sentinel(self, *a, **kw):
|
||
nonlocal picker_called
|
||
picker_called = True
|
||
return None
|
||
|
||
monkeypatch.setattr(AgentPickerApp, "run_async", sentinel)
|
||
|
||
main_called = False
|
||
|
||
async def main_sentinel(self, *a, **kw):
|
||
nonlocal main_called
|
||
main_called = True
|
||
return 0
|
||
|
||
monkeypatch.setattr(RatatoskrApp, "run_async", main_sentinel)
|
||
from ratatoskr.tui import run_tui
|
||
|
||
rc = run_tui(_args_new_no_agent())
|
||
assert rc == 20
|
||
err = capsys.readouterr().err
|
||
assert "[session_api_failed]" in err
|
||
assert "status=500" in err
|
||
assert picker_called is False
|
||
assert main_called is False
|
||
|
||
@respx.mock
|
||
def test_picker_empty_list(
|
||
self,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
capsys: pytest.CaptureFixture[str],
|
||
) -> None:
|
||
"""list_agents returns [] → stderr [no_agents]; exit 13; picker NOT opened."""
|
||
respx.get("https://w.example/agents").mock(return_value=httpx.Response(200, json=[]))
|
||
|
||
from ratatoskr.tui import AgentPickerApp
|
||
|
||
picker_called = False
|
||
|
||
async def sentinel(self, *a, **kw):
|
||
nonlocal picker_called
|
||
picker_called = True
|
||
return None
|
||
|
||
monkeypatch.setattr(AgentPickerApp, "run_async", sentinel)
|
||
from ratatoskr.tui import run_tui
|
||
|
||
rc = run_tui(_args_new_no_agent())
|
||
assert rc == 13
|
||
err = capsys.readouterr().err
|
||
assert "[no_agents]" in err
|
||
assert picker_called is False
|