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:
+374
-162
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user