feat(tui): issue #13 — §5 layout reshape + Tools pane (v0.4.0)

Reshape the TUI from vertical-stack single-pane to Horizontal
two-column with TabbedContent on the right; v1 has a single Tools
tab that consumes ToolStart/ToolResult SSE events previously
rendered inline in the transcript. Foundation for the rest of
design-brief §5; subsequent panes (Persona/AdminEvents/BifrostState/
ServerLog) plug in as sibling TabPanes when their substrate
blockers resolve.

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

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

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

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

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

Live smoke against personal Worldtree's mimir: tool-using turn
(KB search) populated tools_log with tool_start + tool_result for
search_library + read_note; transcript stayed chat-only with
worker_phase + thinking. Routing-not-duplication confirmed
end-to-end.
This commit is contained in:
vh
2026-05-23 21:41:16 -07:00
parent d30be12deb
commit 24e4371ec7
6 changed files with 700 additions and 204 deletions
+374 -162
View File
@@ -15,6 +15,7 @@ from ratatoskr.sse_client import (
SseId,
Text,
Thinking,
ToolResult,
ToolStart,
WorkerPhase,
)
@@ -120,9 +121,27 @@ class TestTuiPresenterState:
log = MagicMock()
widget = MagicMock()
state = TuiPresenterState()
state.render(Thinking(sse_id=SID, content="a"), log=log, thinking_widget=widget, raw=False)
state.render(Thinking(sse_id=SID, content="b"), log=log, thinking_widget=widget, raw=False)
state.render(Thinking(sse_id=SID, content="c"), log=log, thinking_widget=widget, raw=False)
state.render(
Thinking(sse_id=SID, content="a"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
raw=False,
)
state.render(
Thinking(sse_id=SID, content="b"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
raw=False,
)
state.render(
Thinking(sse_id=SID, content="c"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
raw=False,
)
# Widget updated 3 times — once per delta — with cumulative content
assert widget.update.call_count == 3
# Latest call shows the full accumulated content (under 200 chars so no truncation)
@@ -141,12 +160,25 @@ class TestTuiPresenterState:
log = MagicMock()
widget = MagicMock()
state = TuiPresenterState()
state.render(Thinking(sse_id=SID, content="a"), log=log, thinking_widget=widget, raw=False)
state.render(Thinking(sse_id=SID, content="b"), log=log, thinking_widget=widget, raw=False)
state.render(
Thinking(sse_id=SID, content="a"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
raw=False,
)
state.render(
Thinking(sse_id=SID, content="b"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
raw=False,
)
state.render(
WorkerPhase(sse_id=SID, phase="streaming", turn_id=42),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
raw=False,
)
# Closure wrote "· thinking: ab"; then worker_phase wrote "· worker_phase: ..."
@@ -168,7 +200,13 @@ class TestTuiPresenterState:
state = TuiPresenterState()
# Push 500 chars across multiple deltas.
long = "x" * 500
state.render(Thinking(sse_id=SID, content=long), log=log, thinking_widget=widget, raw=False)
state.render(
Thinking(sse_id=SID, content=long),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
raw=False,
)
last_update = widget.update.call_args_list[-1][0][0]
# …-prefix + last-200 = 201 chars
assert last_update.startswith("…")
@@ -185,13 +223,20 @@ class TestTuiPresenterState:
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, raw=False)
state.render(
Thinking(sse_id=SID, content="x"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
raw=False,
)
assert widget.display is True
# Closure (WorkerPhase) → widget hidden
state.render(
WorkerPhase(sse_id=SID, phase="streaming", turn_id=42),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
raw=False,
)
assert widget.display is False
@@ -206,14 +251,30 @@ class TestTuiPresenterState:
widget = MagicMock()
state = TuiPresenterState()
state.render(
Thinking(sse_id=SID, content="first"), log=log, thinking_widget=widget, raw=False
Thinking(sse_id=SID, content="first"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
raw=False,
)
state.render(Text(sse_id=SID, content="hi"), log=log, thinking_widget=widget, raw=False)
state.render(
Thinking(sse_id=SID, content="second"), log=log, thinking_widget=widget, raw=False
Text(sse_id=SID, content="hi"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
raw=False,
)
state.render(
Thinking(sse_id=SID, content="second"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
raw=False,
)
# Close the second run with a Done.
state.render(_make_tui_done(), log=log, thinking_widget=widget, raw=True)
state.render(
_make_tui_done(), log=log, thinking_widget=widget, tools_log=MagicMock(), raw=True
)
# Count closed thinking entries — now dim RichText; plain text starts with "· thinking:".
thinking_entries = [_text_of(call[0][0]) for call in log.write.call_args_list]
thinking_entries = [t for t in thinking_entries if t.startswith("· thinking:")]
@@ -234,7 +295,13 @@ class TestTuiPresenterState:
widget.update.side_effect = AttributeError("widget gone (msg should NOT leak)")
state = TuiPresenterState()
# Should not raise; should write a fallback labeled line + a [render_error] line.
state.render(Thinking(sse_id=SID, content="x"), log=log, thinking_widget=widget, raw=False)
state.render(
Thinking(sse_id=SID, content="x"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
raw=False,
)
writes = [call[0][0] for call in log.write.call_args_list if isinstance(call[0][0], str)]
# POST-007: plain-label fallback for the original Thinking event (pre-amendment shape).
assert any(w.startswith("[thinking]") for w in writes), writes
@@ -252,6 +319,7 @@ class TestTuiPresenterState:
Thinking(sse_id=SID, content="x"),
log=MagicMock(),
thinking_widget=MagicMock(),
tools_log=MagicMock(),
raw=False,
)
s2 = TuiPresenterState()
@@ -271,6 +339,7 @@ class TestTuiPresenterState:
Thinking(sse_id=SID, content="partial"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
raw=False,
)
state.render(
@@ -279,6 +348,7 @@ class TestTuiPresenterState:
),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
raw=False,
)
# Closed thinking entries are now dim RichText; terminal labels are plain str.
@@ -299,8 +369,16 @@ class TestTuiPresenterState:
log = MagicMock()
widget = MagicMock()
state = TuiPresenterState()
state.render(Text(sse_id=SID, content="hi"), log=log, thinking_widget=widget, raw=False)
state.render(_make_tui_done(), log=log, thinking_widget=widget, raw=False)
state.render(
Text(sse_id=SID, content="hi"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
raw=False,
)
state.render(
_make_tui_done(), log=log, thinking_widget=widget, tools_log=MagicMock(), raw=False
)
writes = [c[0][0] for c in log.write.call_args_list]
# Text stream wrote "hi" with no prefix.
assert "hi" in writes
@@ -320,8 +398,16 @@ class TestTuiPresenterState:
log = MagicMock()
widget = MagicMock()
state = TuiPresenterState()
state.render(Text(sse_id=SID, content="hi"), log=log, thinking_widget=widget, raw=True)
state.render(_make_tui_done(), log=log, thinking_widget=widget, raw=True)
state.render(
Text(sse_id=SID, content="hi"),
log=log,
thinking_widget=widget,
tools_log=MagicMock(),
raw=True,
)
state.render(
_make_tui_done(), log=log, thinking_widget=widget, tools_log=MagicMock(), raw=True
)
writes = [c[0][0] for c in log.write.call_args_list]
assert not any(isinstance(w, Rule) for w in writes)
assert not any(isinstance(w, Markdown) for w in writes)
@@ -340,6 +426,7 @@ class TestTuiPresenterState:
WorkerPhase(sse_id=SID, phase="streaming", turn_id=42),
log=log,
thinking_widget=MagicMock(),
tools_log=MagicMock(),
raw=False,
)
renderable = log.write.call_args[0][0]
@@ -369,25 +456,72 @@ class TestTuiPresenterState:
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, raw=True)
state.render(terminal, log=log, thinking_widget=widget, tools_log=MagicMock(), raw=True)
# Belt-and-braces: widget cleared + hidden on EVERY terminal event.
widget.update.assert_called_with("")
assert widget.display is False, type(terminal).__name__
def test_tool_start_demoted(self) -> None:
"""tool_start_demoted [trace]: ToolStart → RichLog line starts with "· tool_start:" """
def test_tool_start_routes_to_tools_log(self) -> None:
"""tool_start_routes_to_tools_log [INV-014]: ToolStart writes to tools_log, NOT transcript.
Issue #13: tool events route to the dedicated Tools pane (right column).
Pre-#13 wrote them to the main transcript with `· tool_start:` prefix.
Post-#13 the prefix is preserved but the destination shifts.
"""
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
tools_log = MagicMock()
state = TuiPresenterState()
state.render(
ToolStart(sse_id=SID, name="read_file", arguments={"path": "/x"}),
log=log,
thinking_widget=MagicMock(),
tools_log=tools_log,
raw=False,
)
# Demoted telemetry is wrapped in dim RichText; check plain content.
assert _text_of(log.write.call_args[0][0]).startswith("· tool_start:")
# INV-014: write went to tools_log
assert tools_log.write.called
assert _text_of(tools_log.write.call_args[0][0]).startswith("· tool_start:")
# INV-014: transcript was NOT written to
assert not log.write.called
def test_tool_result_routes_to_tools_log(self) -> None:
"""tool_result_routes_to_tools_log [INV-014]: ToolResult → tools_log, NOT transcript."""
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
tools_log = MagicMock()
state = TuiPresenterState()
state.render(
ToolResult(sse_id=SID, name="read_file", result="ok", duration_ms=12),
log=log,
thinking_widget=MagicMock(),
tools_log=tools_log,
raw=False,
)
assert tools_log.write.called
assert _text_of(tools_log.write.call_args[0][0]).startswith("· tool_result:")
assert not log.write.called
def test_text_event_does_not_route_to_tools_log(self) -> None:
"""text_event_does_not_route_to_tools_log [INV-015]: Text → transcript, NOT tools_log."""
from ratatoskr.tui import TuiPresenterState
log = MagicMock()
tools_log = MagicMock()
state = TuiPresenterState()
state.render(
Text(sse_id=SID, content="hello"),
log=log,
thinking_widget=MagicMock(),
tools_log=tools_log,
raw=False,
)
assert log.write.called
assert log.write.call_args[0][0] == "hello"
# INV-015: tools_log was NOT written to
assert not tools_log.write.called
def test_text_no_prefix(self) -> None:
"""text_no_prefix [trace]: Text → RichLog line has no `·` prefix, no demotion."""
@@ -396,7 +530,11 @@ class TestTuiPresenterState:
log = MagicMock()
state = TuiPresenterState()
state.render(
Text(sse_id=SID, content="hello"), log=log, thinking_widget=MagicMock(), raw=False
Text(sse_id=SID, content="hello"),
log=log,
thinking_widget=MagicMock(),
tools_log=MagicMock(),
raw=False,
)
line = log.write.call_args[0][0]
# Pure content, no demotion prefix.
@@ -409,10 +547,15 @@ class TestTuiPresenterState:
log = MagicMock()
state = TuiPresenterState()
state.render(
_make_tui_done(duration_ms=5467), log=log, thinking_widget=MagicMock(), raw=True
_make_tui_done(duration_ms=5467),
log=log,
thinking_widget=MagicMock(),
tools_log=MagicMock(),
raw=True,
)
done_line = next(
c[0][0] for c in log.write.call_args_list
c[0][0]
for c in log.write.call_args_list
if isinstance(c[0][0], str) and c[0][0].startswith("[done]")
)
assert "duration=5.5s" in done_line
@@ -431,10 +574,15 @@ class TestTuiPresenterState:
"cached_input_tokens": 0,
}
state.render(
_make_tui_done(usage=usage), log=log, thinking_widget=MagicMock(), raw=True
_make_tui_done(usage=usage),
log=log,
thinking_widget=MagicMock(),
tools_log=MagicMock(),
raw=True,
)
done_line = next(
c[0][0] for c in log.write.call_args_list
c[0][0]
for c in log.write.call_args_list
if isinstance(c[0][0], str) and c[0][0].startswith("[done]")
)
assert "usage 6756 in → 126 out (6882 total, 0 cached)" in done_line
@@ -456,16 +604,16 @@ def _text_of(write_arg: object) -> str:
return "" # Markdown / Rule / etc. — not text content
def _make_tui_done(
*, duration_ms: int = 1, usage: dict[str, int] | None = None
) -> Done:
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 {
usage=usage
if usage is not None
else {
"prompt_tokens": 0,
"completion_tokens": 0,
"total_tokens": 0,
@@ -572,6 +720,104 @@ class TestAppMount:
assert app.session_id[-8:] in rendered
# Issue #13 — TUI layout reshape + Tools pane (§5 v1 entry point)
class TestLayoutShape:
"""INV-013 + INV-014 + INV-017: Horizontal two-column layout with Tools tab."""
async def test_main_row_is_horizontal(self) -> None:
"""main_row_is_horizontal [tracer]: compose() yields Horizontal#main-row."""
from textual.containers import Horizontal
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
async with app.run_test() as pilot:
await pilot.pause()
row = app.query_one("#main-row", Horizontal)
assert row is not None
async def test_left_column_has_transcript_and_prompt(self) -> None:
"""left_column_has_transcript_and_prompt: left column = transcript + prompt + thinking."""
from textual.containers import Vertical
from textual.widgets import Input, RichLog, Static
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
async with app.run_test() as pilot:
await pilot.pause()
left = app.query_one("#left-column", Vertical)
assert left is not None
transcript = app.query_one("#transcript", RichLog)
prompt = app.query_one("#prompt", Input)
thinking = app.query_one("#thinking-current", Static)
# Widgets are inside the left column (descendant check)
assert transcript in left.walk_children()
assert prompt in left.walk_children()
assert thinking in left.walk_children()
async def test_right_column_has_tabbed_content_with_tools_tab(self) -> None:
"""right_column_has_tabbed_content_with_tools_tab: #side-panes + TabPane#tools-tab."""
from textual.widgets import TabbedContent, TabPane
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
async with app.run_test() as pilot:
await pilot.pause()
tabbed = app.query_one("#side-panes", TabbedContent)
assert tabbed is not None
tools_tab = app.query_one("#tools-tab", TabPane)
assert tools_tab is not None
async def test_tools_log_inside_tools_tab(self) -> None:
"""tools_log_inside_tools_tab: tools-log RichLog is a descendant of tools-tab TabPane."""
from textual.widgets import RichLog, TabPane
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
async with app.run_test() as pilot:
await pilot.pause()
tools_tab = app.query_one("#tools-tab", TabPane)
tools_log = app.query_one("#tools-log", RichLog)
assert tools_log in tools_tab.walk_children()
async def test_pane_name_widget_renders_tools(self) -> None:
"""pane_name_widget_renders_tools [INV-pane-name]: #pane-name == 'Tools' on first frame."""
from textual.widgets import Static
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
async with app.run_test() as pilot:
await pilot.pause()
pane_name = app.query_one("#pane-name", Static)
rendered = str(pane_name.render())
assert rendered == "Tools"
async def test_ctrl_1_activates_tools_tab(self) -> None:
"""ctrl_1_activates_tools_tab [tracer]: Ctrl+1 → TabbedContent.active == 'tools-tab'."""
from textual.widgets import TabbedContent
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("ctrl+1")
await pilot.pause()
tabbed = app.query_one("#side-panes", TabbedContent)
assert tabbed.active == "tools-tab"
async def test_ctrl_1_preserves_input_focus(self) -> None:
"""ctrl_1_preserves_input_focus [INV-016]: Ctrl+1 does NOT steal focus from Input."""
from textual.widgets import Input
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
async with app.run_test() as pilot:
await pilot.pause()
prompt = app.query_one("#prompt", Input)
prompt.focus()
await pilot.pause()
assert app.focused is prompt
await pilot.press("ctrl+1")
await pilot.pause()
assert app.focused is prompt, (
f"INV-016: Input focus must survive Ctrl+1 tab switch; got focused={app.focused}"
)
import asyncio # noqa: E402
from textual.widgets import Input # noqa: E402
@@ -584,9 +830,7 @@ async def _noop_worker(self, content: str) -> None:
class TestOnInputSubmitted:
@respx.mock
async def test_happy_submit_echoes_and_spawns(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
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)
@@ -603,9 +847,7 @@ class TestOnInputSubmitted:
assert app.stream_worker is not None
@respx.mock
async def test_empty_submit_no_op(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
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())
@@ -673,9 +915,7 @@ class TestOnInputSubmitted:
assert app.stream_worker is None
@respx.mock
async def test_footer_hint_flips_to_cancel(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
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
@@ -743,22 +983,14 @@ async def _submit_and_wait(app: RatatoskrApp, pilot, content: str) -> None:
class TestStreamTurnWorker:
@respx.mock
async def test_happy_text_done_renders_markdown(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
async def test_happy_text_done_renders_markdown(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""happy_text_done_renders_markdown [happy,tracer]: …"""
stream = (
_sse_chunk("42:1", {"type": "text", "content": "hello"})
+ _sse_chunk("42:2", _DONE_BODY)
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)
)
respx.post(
"https://w.example/sessions/s-1existing/messages"
).mock(return_value=_sse_resp(stream))
writes = _spy_writes(monkeypatch)
@@ -774,26 +1006,19 @@ class TestStreamTurnWorker:
# INV-005: BOTH separator (Rule) AND markdown render must be present in non-raw.
from rich.markdown import Markdown
from rich.rule import Rule
assert any(isinstance(w, Markdown) for w in writes)
assert any(isinstance(w, Rule) for w in writes)
@respx.mock
async def test_raw_flag_skips_markdown_render(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
async def test_raw_flag_skips_markdown_render(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""raw_flag_skips_markdown_render [trace]: …"""
stream = (
_sse_chunk("42:1", {"type": "text", "content": "hi"})
+ _sse_chunk("42:2", _DONE_BODY)
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)
)
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:
@@ -802,19 +1027,14 @@ class TestStreamTurnWorker:
# INV-005: with --raw, NEITHER Rule separator NOR Markdown render appears.
from rich.markdown import Markdown
from rich.rule import Rule
assert not any(isinstance(w, Markdown) for w in writes)
assert not any(isinstance(w, Rule) for w in writes)
@respx.mock
async def test_error_terminal_returns_to_idle(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
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(
stream = _sse_chunk("42:1", {"type": "text", "content": "x"}) + _sse_chunk(
"42:2",
{
"type": "error",
@@ -823,13 +1043,9 @@ class TestStreamTurnWorker:
"message": "boom",
},
)
respx.post("https://w.example/sessions/s-1existing/messages").mock(
return_value=_sse_resp(stream)
)
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:
@@ -843,18 +1059,12 @@ class TestStreamTurnWorker:
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)
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)
)
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:
@@ -864,9 +1074,7 @@ class TestStreamTurnWorker:
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:
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"})
@@ -952,12 +1160,11 @@ class TestStreamTurnWorker:
) -> 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"
_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)
)
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:
@@ -970,20 +1177,16 @@ class TestStreamTurnWorker:
assert app.return_value is None
@respx.mock
async def test_rendered_event_per_event(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
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))
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.
@@ -1018,9 +1221,7 @@ class TestActionInterrupt:
assert app.return_value == 0
@respx.mock
async def test_streaming_first_ctrl_c_cancels(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
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"})
@@ -1077,9 +1278,7 @@ class TestActionInterrupt:
gate.set()
@respx.mock
async def test_streaming_no_turn_id_force_exits(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
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)
@@ -1222,9 +1421,7 @@ class TestActionQuit:
assert app.return_value == 0
@respx.mock
async def test_streaming_ctrl_d_force_exits(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
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)
@@ -1315,9 +1512,7 @@ class TestResolveThenRun:
assert snapshot["client_open"] is True
@respx.mock
def test_happy_new_with_end_user_id_resolve(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
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
@@ -1363,9 +1558,7 @@ class TestResolveThenRun:
assert "vh@phasefinal.com" in ua
@respx.mock
def test_alt_screen_never_opens_on_resolve_error(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
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
@@ -1387,9 +1580,7 @@ class TestResolveThenRun:
assert not sentinel_called
@respx.mock
def test_agent_not_found_on_resolve(
self, capsys: pytest.CaptureFixture[str]
) -> None:
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"})
@@ -1401,9 +1592,7 @@ class TestResolveThenRun:
assert "agent_id=mimir" in err
@respx.mock
def test_session_api_failed_on_resolve(
self, capsys: pytest.CaptureFixture[str]
) -> None:
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")
@@ -1415,9 +1604,7 @@ class TestResolveThenRun:
assert "status=500" in err
@respx.mock
def test_network_error_on_resolve(
self, capsys: pytest.CaptureFixture[str]
) -> None:
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())
@@ -1427,9 +1614,7 @@ class TestResolveThenRun:
assert "ConnectError" in err
@respx.mock
def test_stderr_label_format_matches_cli(
self, capsys: pytest.CaptureFixture[str]
) -> None:
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).
"""
@@ -1501,9 +1686,7 @@ class TestResolveThenRun:
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:
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] = []
@@ -1621,14 +1804,24 @@ class TestAgentPickerApp:
agents = [
AgentInfo(
agent_id="a", name="A", description="x",
version=None, capabilities=[], supported_models=[],
persona_traits={}, ui_hints={},
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={},
agent_id="b",
name="B",
description="y",
version=None,
capabilities=[],
supported_models=[],
persona_traits={},
ui_hints={},
),
]
app = AgentPickerApp(agents)
@@ -1641,6 +1834,7 @@ class TestAgentPickerApp:
app.exit(None)
import asyncio
asyncio.run(probe())
def test_picker_pick_returns_agent_id(self) -> None:
@@ -1650,14 +1844,24 @@ class TestAgentPickerApp:
agents = [
AgentInfo(
agent_id="a", name="A", description="x",
version=None, capabilities=[], supported_models=[],
persona_traits={}, ui_hints={},
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={},
agent_id="b",
name="B",
description="y",
version=None,
capabilities=[],
supported_models=[],
persona_traits={},
ui_hints={},
),
]
app = AgentPickerApp(agents)
@@ -1674,6 +1878,7 @@ class TestAgentPickerApp:
return app.return_value
import asyncio
chosen = asyncio.run(drive())
assert chosen == "b"
@@ -1684,9 +1889,14 @@ class TestAgentPickerApp:
agents = [
AgentInfo(
agent_id="a", name="A", description="x",
version=None, capabilities=[], supported_models=[],
persona_traits={}, ui_hints={},
agent_id="a",
name="A",
description="x",
version=None,
capabilities=[],
supported_models=[],
persona_traits={},
ui_hints={},
),
]
app = AgentPickerApp(agents)
@@ -1698,6 +1908,7 @@ class TestAgentPickerApp:
return app.return_value
import asyncio
chosen = asyncio.run(drive())
assert chosen is None
@@ -1742,11 +1953,13 @@ class TestResolveThenRunWithPicker:
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"
@@ -1777,6 +1990,7 @@ class TestResolveThenRunWithPicker:
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
@@ -1784,9 +1998,7 @@ class TestResolveThenRunWithPicker:
assert main_called is False
@respx.mock
def test_picker_skipped_when_agent_id_provided(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
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)
@@ -1800,15 +2012,14 @@ class TestResolveThenRunWithPicker:
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:
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)
@@ -1822,6 +2033,7 @@ class TestResolveThenRunWithPicker:
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
@@ -1858,6 +2070,7 @@ class TestResolveThenRunWithPicker:
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
@@ -1873,9 +2086,7 @@ class TestResolveThenRunWithPicker:
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=[])
)
respx.get("https://w.example/agents").mock(return_value=httpx.Response(200, json=[]))
from ratatoskr.tui import AgentPickerApp
@@ -1888,6 +2099,7 @@ class TestResolveThenRunWithPicker:
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