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:
@@ -0,0 +1,205 @@
|
||||
---
|
||||
contract_version: "2.1"
|
||||
target_module: "ratatoskr.tui"
|
||||
scope: "Design-brief §5 v1 entry point: reshape the TUI from vertical-stack single-pane to Horizontal two-column with `TabbedContent` on the right; first (and only v1) tab is `Tools`, which consumes `ToolStart` / `ToolResult` SSE events that previously rendered inline in the transcript. Pure in-place amendment to issue #4 + #12 — no new modules, no new files apart from this contract. The CLI (`ratatoskr.cli`) is unaffected: `--send` mode is non-interactive and keeps its current inline tool-event rendering. Substrate move only: persona / admin-events / bifrost-state / server-log panes stay deferred (blocked on remote-Worldtree topology + admin scope + opt-in flag). The TabbedContent shape makes them additive — when a blocker resolves the new pane plugs in as another TabPane sibling without further layout work."
|
||||
depends_on:
|
||||
- "textual"
|
||||
used_by: []
|
||||
language: "python"
|
||||
complexity: "medium"
|
||||
estimated_loc: 180
|
||||
confidence: 0.85
|
||||
assumptions:
|
||||
- "Textual's `TabbedContent(*titles, initial='')` + `TabPane(title, *children, id=...)` is the right primitive for the right-column tabs (verified API at textual.widgets._tabbed_content). One `TabPane(\"Tools\", tools_log, id=\"tools-tab\")` in v1; additional siblings get appended as Persona/AdminEvents/BifrostState/ServerLog land."
|
||||
- "Textual's `Horizontal` + `Vertical` containers compose the two-column split (verified at textual.containers). Width via CSS `width: 2fr` on the left container + `width: 1fr` on the right container gives the 2:1 chat-primary split."
|
||||
- "**The presenter contract amendment is small and tightly scoped**: `TuiPresenterState.render` gains a `tools_log: RichLog` parameter alongside the existing `log: RichLog` (main transcript) + `thinking_widget: Static`. `ToolStart` / `ToolResult` events route to `tools_log`; every other event (Text, Thinking, WorkerPhase, Done, Error, Cancelled, TextBoundary) keeps its existing routing to `log` + `thinking_widget`. The plain-label fallback path in `_plain_label` (issue #12 INV-009 render-exception recovery) keeps its current shape — only the routing target changes."
|
||||
- "**Tool events are routed, not duplicated**. The brief's §5 wording 'side pane (inline-from-SSE for v1)' factors tool events OUT of the main transcript. A consumer who wants to debug a tool-using turn now reads the Tools pane; the main transcript stays focused on assistant text. Trade-off: a fast-skim of the transcript no longer shows tool activity inline; if that hurts the debug ergonomics empirically, a follow-up issue can add a one-line `· tool_used name=...` breadcrumb to the transcript as a compromise. v1 commits to the cleaner split."
|
||||
- "**Demoted-prefix style stays consistent across panes**. ToolStart in the Tools pane renders as `· tool_start: name=foo args={...}` — the same `· ` ASCII prefix issue #12 INV-005 established for demoted telemetry in the main transcript. Pane separation handles the visual hierarchy; prefix style stays cross-pane consistent so the operator's mental model is portable."
|
||||
- "**Input field retains focus across tab switches** (design-brief §5 invariant: 'Tab key (Ctrl+1..5) jumps between tabs without losing focus on the input field'). INV-016 is the load-bearing invariant; the assumption about Textual's default behavior is just an implementation hint. If Textual's default `TabbedContent.active = ...` programmatic assignment preserves Input focus (current observed behavior), no extra code is needed. If a future Textual version regresses on this, the implementation MUST add `self.query_one('#prompt', Input).focus()` immediately after the `.active = ` assignment in `action_focus_tools` to satisfy INV-016. The test `test_ctrl_1_preserves_input_focus` is the regression guard; if it fails, the fix is the explicit `.focus()` call, not relaxing the invariant. Pre-existing INV-007 (Input always-focused except during error sub-states) is preserved verbatim."
|
||||
- "**Status footer gains a `current-pane-name` element** — design-brief §5 calls for it explicitly. v1 only has one tab so the indicator stable-renders \"Tools\". Wiring it as a separate Static (`id='pane-name'`) docked alongside identity + hint makes it trivially extend when more tabs land — the widget is in place; the value will become dynamic in the future multi-tab issue. **No tab-switch handler wiring lands in #13.** The `on_mount` flow populates `pane-name` once with the literal string \"Tools\" and never updates it. Adding event-handler plumbing in v1 (a `@on(TabbedContent.TabActivated)` handler, etc.) is out of scope — that's a deliberate deferral, not an implementer's call."
|
||||
- "**The existing `· thinking-current` widget keeps its position** — docked to the top of the left column (was docked top of the whole App; now docked top of the left Vertical container). Pre-amendment dock-fix from v0.2.1 stays; the scope of the dock just narrows from \"App\" to \"left column\" so it doesn't bleed into the right column's TabbedContent area."
|
||||
- "**Width split is fixed `2fr : 1fr` for v1**. User-resizable splits are textual-native (via `Splitter` or similar), but adding interactive resize is its own UX surface. v1 ships fixed; if the right pane proves cramped on narrow terminals operators will tell us. Out of scope."
|
||||
- "**TabbedContent's CSS classes**: the right column's TabbedContent + its tabbed-content wrappers (`#tabbed-content`, `.--tabs`, etc.) come with Textual's default styling. No custom CSS for tab strip in v1; if the visual feels wrong adjust later. The contract specifies the structure; the chrome stays Textual-default."
|
||||
- "**Test strategy**: existing TestStreamTurnWorker tests for Text/Thinking/Done/Error/Cancelled routing stay GREEN unchanged (they assert what shows in the transcript log; that still shows the same content). ToolStart/ToolResult tests get adjusted to assert routing to `tools_log` instead of `log`. New tests cover layout shape (Horizontal parent exists, TabbedContent with tools-tab on the right) + Ctrl+1 binding + tools_log writes for tool events."
|
||||
- "**Issue #12's INV-009 render-exception fallback path** stays correct: `_plain_label(event)` is still callable; the render method's except branch writes the labeled-string to the appropriate Widget (tools_log for tool events; log for everything else). The fallback writes to the same routed widget as a successful render — failure mode preserves the routing invariant."
|
||||
open_questions:
|
||||
- "Should the Tools tab show a count badge when new tool events arrive while the user is on a future Persona/AdminEvents tab? Draft: no for v1 — only one tab, so the question is moot. When persona/admin-events panes land, revisit: a small `[N]` badge on the tab header (`'Tools [3]'`) would help operators not miss tool activity that happens off-screen. Defer to a follow-up that touches multiple panes."
|
||||
- "Should the Tools pane support filter-by-tool-name (e.g., show only `kb_search` results)? Draft: no for v1 — flat scroll matches the design brief's posture. The volume of tool events per turn is small enough that scrollback handles the use case. Revisit if mimir-style heavy-tool agents produce visible-cluttering volume."
|
||||
- "Should the v1 Ctrl+1 binding be Ctrl+1 specifically, or `t` for 'tools' (no modifier)? Draft: Ctrl+1 — design brief specifies `Ctrl+1..5` as the family; matches the Ctrl-prefix discipline already used by Ctrl-C / Ctrl-D bindings. Plain-letter bindings would steal letter input from the Input field; Ctrl-prefixed is the standard escape."
|
||||
prd:
|
||||
issue: 13
|
||||
issue_url: "https://gitea.phasefinal.com/vh/ratatoskr/issues/13"
|
||||
body_sha256_16: "52c8f886a9cc986a"
|
||||
lock_in_comment_id: null
|
||||
lock_in_sha256_16: null
|
||||
lock_in_at: null
|
||||
pinned_at: "2026-05-24T02:28:28+00:00"
|
||||
dependencies:
|
||||
- issue: 4
|
||||
path: "src/ratatoskr/tui.py"
|
||||
reason: "In-place contract amendment: `RatatoskrApp.compose()` reshapes from vertical-stack to Horizontal 2-col + TabbedContent right; `RatatoskrApp.DEFAULT_CSS` reshapes to scope dock rules to the new left/right containers; `RatatoskrApp.__init__` is unchanged (state attributes carry over); `RatatoskrApp.on_mount` gains pane-name widget population + tools_log lookup; `RatatoskrApp.BINDINGS` gains `Ctrl+1` → `action_focus_tools`; existing `action_interrupt` / `action_quit` unchanged."
|
||||
- issue: 12
|
||||
path: "src/ratatoskr/tui.py"
|
||||
reason: "In-place contract amendment: `TuiPresenterState.render` signature widens to accept `tools_log: RichLog` alongside `log` + `thinking_widget`; ToolStart/ToolResult branches write to `tools_log` instead of `log`. `_stream_turn_worker` does the new lookup (`self.query_one('#tools-log', RichLog)`) and threads it through. All other event branches stay verbatim."
|
||||
---
|
||||
|
||||
# TUI layout reshape + Tools pane — §5 v1 entry point
|
||||
|
||||
## Context
|
||||
|
||||
`ratatoskr.tui` ships v0.x as a single-pane Textual app: Header / thinking-current widget / transcript RichLog / Input / identity + hint Statics / Footer, vertical-stacked via `dock: top` / `dock: bottom` CSS (v0.2.1 layout fix). Design-brief §5 commits the product to a multi-pane debug-observability dashboard. Most of §5's panes (Persona, AdminEvents, BifrostState widget, ServerLog) are blocked on substrate that isn't here — remote-Worldtree topology blocks file-tail-based panes; issue #11's `admin.events.read` scope blocks the admin surfaces.
|
||||
|
||||
The unblocked v1 entry point is **layout reshape + Tools pane together**: reshape the TUI into the Horizontal two-column shape the design brief specifies, with `TabbedContent` on the right populated by a single `Tools` tab that consumes the existing `ToolStart` / `ToolResult` SSE events. No new endpoints; no scope grants; no cross-repo coordination. The shape is the foundation; subsequent panes plug in additively.
|
||||
|
||||
## Data flow
|
||||
|
||||
**Input** (unchanged from issue #4):
|
||||
- `args: ParsedArgs`, `session_id: str`, `agent_id: str | None`, `client: httpx.AsyncClient`.
|
||||
- SSE event stream from `ratatoskr.sse_client.stream_turn`.
|
||||
|
||||
**Output** (unchanged):
|
||||
- Exit code via `App.exit(code)`.
|
||||
|
||||
**Internal routing change**:
|
||||
- `ToolStart` / `ToolResult` events route to `tools_log: RichLog` (Tools pane) instead of the main transcript `log: RichLog`.
|
||||
- All other event types (`Text`, `Thinking`, `WorkerPhase`, `TextBoundary`, `Done`, `Error`, `Cancelled`) keep their existing routing.
|
||||
|
||||
## Layout shape (post-amendment)
|
||||
|
||||
```
|
||||
RatatoskrApp(App[int]):
|
||||
compose():
|
||||
yield Header()
|
||||
yield Horizontal(
|
||||
Vertical(
|
||||
Static(id="thinking-current"), # dock: top of left column
|
||||
RichLog(id="transcript"), # height: 1fr (fills middle)
|
||||
Input(id="prompt"), # dock: bottom of left column
|
||||
id="left-column",
|
||||
),
|
||||
Vertical(
|
||||
TabbedContent(
|
||||
TabPane("Tools", RichLog(id="tools-log"), id="tools-tab"),
|
||||
# future: TabPane("Persona", …, id="persona-tab"), etc.
|
||||
id="side-panes",
|
||||
),
|
||||
id="right-column",
|
||||
),
|
||||
id="main-row",
|
||||
)
|
||||
yield Static(id="identity") # dock: bottom of App
|
||||
yield Static(id="pane-name") # dock: bottom of App (new in §5)
|
||||
yield Static(id="hint") # dock: bottom of App
|
||||
yield Footer()
|
||||
```
|
||||
|
||||
**DEFAULT_CSS reshape:**
|
||||
|
||||
```css
|
||||
#main-row { height: 1fr; }
|
||||
#left-column { width: 2fr; }
|
||||
#right-column { width: 1fr; }
|
||||
#thinking-current { dock: top; height: auto; }
|
||||
#transcript { height: 1fr; }
|
||||
#prompt { dock: bottom; }
|
||||
#identity { dock: bottom; height: 1; }
|
||||
#pane-name { dock: bottom; height: 1; }
|
||||
#hint { dock: bottom; height: 1; }
|
||||
```
|
||||
|
||||
Dock rules scope to the right container (left column for `thinking-current`/`prompt`; App for `identity`/`pane-name`/`hint`). The left-column `prompt` Input docks to the bottom of its column, not the App, so the right column's TabbedContent extends full height beside it.
|
||||
|
||||
## Presenter routing (amendment to issue #12)
|
||||
|
||||
```
|
||||
FN TuiPresenterState.render(
|
||||
event: Event,
|
||||
*,
|
||||
log: RichLog,
|
||||
thinking_widget: Static,
|
||||
tools_log: RichLog, # NEW (issue #13)
|
||||
raw: bool,
|
||||
) -> None
|
||||
```
|
||||
|
||||
Steps (only the ToolStart/ToolResult cases change; every other case keeps issue #12's behavior verbatim):
|
||||
|
||||
- `ToolStart` → write `· tool_start: name=<name> args=<args!r>` to `tools_log` (not `log`).
|
||||
- `ToolResult` → write `· tool_result: name=<name> duration_ms=<n> result=<r!r:.200>` to `tools_log` (not `log`).
|
||||
- All other events → unchanged routing per issue #12 INV-005.
|
||||
- Render-exception fallback (`_plain_label(event)`): write to `tools_log` if the event is `ToolStart`/`ToolResult`; write to `log` otherwise. Routing preservation under failure.
|
||||
|
||||
## Keybindings (amendment)
|
||||
|
||||
```
|
||||
BINDINGS: ClassVar[list[Binding]] = [
|
||||
Binding("ctrl+c", "interrupt", "Cancel / Exit", priority=True),
|
||||
Binding("ctrl+d", "quit", "Exit immediately", priority=True),
|
||||
Binding("ctrl+1", "focus_tools", "Tools tab", priority=False), # NEW
|
||||
]
|
||||
|
||||
def action_focus_tools(self) -> None:
|
||||
self.query_one(TabbedContent).active = "tools-tab"
|
||||
# Input focus is preserved by Textual's default behavior — TabbedContent
|
||||
# doesn't steal focus when `.active` is set programmatically.
|
||||
```
|
||||
|
||||
`Ctrl+1` is the v1 entry of the design-brief `Ctrl+1..5` family. `Ctrl+2..5` get added by subsequent issues as Persona/AdminEvents/BifrostState/ServerLog land. The binding does NOT steal Input focus — the test asserts `Input` keeps focus across the tab switch.
|
||||
|
||||
## Status footer (amendment)
|
||||
|
||||
New `Static(id="pane-name")` widget alongside the existing `identity` + `hint` widgets. v1 renders the literal string `"Tools"` set once at `on_mount`; the widget never updates after that. Dynamic updating + tab-switch handler wiring is **out of scope for #13** — it lands in the multi-tab follow-up that introduces the second TabPane. An implementer who adds a `@on(TabbedContent.TabActivated)` handler in this issue is out of spec.
|
||||
|
||||
## Invariants
|
||||
|
||||
- **INV-013**: Layout is `Horizontal` two-column. Left column width = 2fr; right column width = 1fr.
|
||||
- **INV-014**: `ToolStart` / `ToolResult` events route to `tools_log` (Tools pane), never to `log` (transcript).
|
||||
- **INV-015**: Every other event type (`Text`, `Thinking`, `WorkerPhase`, `TextBoundary`, `Done`, `Error`, `Cancelled`) keeps its issue-#12 routing target (`log` for chronological entries; `thinking_widget` for live deltas).
|
||||
- **INV-016**: Input retains keyboard focus across `Ctrl+1` tab switch.
|
||||
- **INV-017**: `thinking-current` Static docks to the top of the **left column**, not the whole App — TabbedContent's vertical extent on the right is independent of thinking-runs starting/stopping.
|
||||
- **INV-018**: CLI mode (`ratatoskr.cli._amain`) is unaffected. CLI keeps inline `· tool_start: …` / `· tool_result: …` rendering on stderr per issue #12 INV-005.
|
||||
|
||||
## TESTS (additions / changes to test_tui.py)
|
||||
|
||||
```
|
||||
- test_compose_has_horizontal_main_row: RatatoskrApp.compose() yields a Horizontal with id="main-row" containing left-column + right-column children.
|
||||
- test_compose_right_column_has_tabbed_content: query_one("#side-panes", TabbedContent) is non-None; one TabPane child with title="Tools" id="tools-tab".
|
||||
- test_compose_left_column_has_transcript_input: query_one("#left-column", Vertical) contains #transcript (RichLog) + #prompt (Input).
|
||||
- test_tools_log_present: query_one("#tools-log", RichLog) is non-None; lives inside the tools-tab TabPane.
|
||||
- test_pane_name_widget_renders_tools: query_one("#pane-name", Static).renderable == "Tools" (v1 static).
|
||||
- test_tool_start_routes_to_tools_log: stream_turn emits ToolStart → tools_log receives the line; transcript RichLog does NOT receive it.
|
||||
- test_tool_result_routes_to_tools_log: stream_turn emits ToolResult → tools_log receives the line; transcript does NOT receive it.
|
||||
- test_text_event_still_routes_to_transcript: stream_turn emits Text("hello") → transcript receives it; tools_log does NOT.
|
||||
- test_thinking_event_still_routes_to_thinking_widget: thinking deltas continue to update #thinking-current Static, not tools_log.
|
||||
- test_done_event_still_routes_to_transcript: Done event renders `[done] …` in transcript, not tools_log.
|
||||
- test_ctrl_1_activates_tools_tab: simulate Ctrl+1 → TabbedContent.active == "tools-tab".
|
||||
- test_ctrl_1_preserves_input_focus: simulate Ctrl+1 while Input is focused → Input is still focused afterwards.
|
||||
- test_plain_label_fallback_routes_tool_events_to_tools_log: simulate render exception on a ToolStart → tools_log gets the _plain_label fallback string; transcript doesn't.
|
||||
```
|
||||
|
||||
Existing tests that need adjustment (NOT rewrite):
|
||||
- Any test that asserted `log.write(...)` was called with a `tool_start: …` / `tool_result: …` string changes its target widget to `tools_log` instead.
|
||||
- TestAppMount tests gain `tools_log` widget lookup assertions.
|
||||
|
||||
## ERROR_ROUTING (unchanged)
|
||||
|
||||
All error routing from issues #4 / #6 / #7 / #12 stays verbatim. The tools_log routing change is internal to the presenter; error paths (`SseConnectFailed`, `SseConnectionDropped`, `MalformedSseId`, `MalformedSseData`, `TurnIdFlip`) all write their labeled lines to `log` (the main transcript). Reason: errors are turn-terminal and need to be visible in the operator's primary attention surface; routing them to the Tools pane would hide them behind a tab switch.
|
||||
|
||||
## Layout-spec snapshot (after this issue lands)
|
||||
|
||||
```
|
||||
+────────────────────────────────+──────────────────────+
|
||||
| · thinking-current | ┌─ Tools ─────────┐ |
|
||||
| | │ · tool_start:.. │ |
|
||||
| ❯ user-typed line | │ · tool_result.. │ |
|
||||
| assistant streaming text... | │ │ |
|
||||
| [done] turn_id=… duration=… | │ │ |
|
||||
| | │ │ |
|
||||
| | │ │ |
|
||||
| [prompt: type and press Enter]| └─────────────────┘ |
|
||||
+────────────────────────────────+──────────────────────+
|
||||
| agent · …sess_id Tools Ctrl-C twice to exit |
|
||||
+───────────────────────────────────────────────────────+
|
||||
```
|
||||
|
||||
(Width split 2fr:1fr; tab strip is Textual-default.)
|
||||
+25
-10
@@ -32,17 +32,28 @@ separate dev team rather than an in-tree Worldtree tool.
|
||||
|
||||
## Current state / in-flight
|
||||
|
||||
_As of 2026-05-24 (post-v0.3.0 startup agent picker):_
|
||||
_As of 2026-05-24 (post-v0.4.0 §5 entry point: layout reshape +
|
||||
Tools pane):_
|
||||
|
||||
**Status: v0.3.0 shipped.** Eight core issues complete (`sse_client`
|
||||
**Status: v0.4.0 shipped.** Nine core issues complete (`sse_client`
|
||||
#1, `sessions` #2, `cli` #3, `tui` #4, `--end-user-id` #5, TUI
|
||||
startup error visibility #6, presenter contract semantics amendment
|
||||
#12, startup agent picker #8) + robustness fix #7 (MalformedSseData
|
||||
+ empty-skip) + v0.2.1 TUI layout fix. 227/227 tests GREEN; ruff
|
||||
clean.
|
||||
#12, startup agent picker #8, §5 layout reshape + Tools pane #13)
|
||||
+ robustness fix #7 (MalformedSseData + empty-skip) + v0.2.1 TUI
|
||||
layout fix. 236/236 tests GREEN; ruff clean.
|
||||
|
||||
**§5 v1 entry point shipped (issue #13).** TUI now Horizontal
|
||||
two-column: left = chat surface (transcript + thinking-current +
|
||||
prompt); right = TabbedContent with single Tools tab (RichLog
|
||||
receiving ToolStart/ToolResult events). Routing-not-duplication:
|
||||
tool events leave the main transcript entirely. Ctrl+1 activates
|
||||
Tools tab without losing Input focus (INV-016). New `pane-name`
|
||||
Static in the footer (static "Tools" v1; dynamic when more tabs
|
||||
land). CLI mode (--send) unaffected by design — INV-018.
|
||||
|
||||
Last commits on `main`:
|
||||
- v0.3.0 feat(sessions,cli,tui): issue #8 — startup agent picker
|
||||
- v0.4.0 feat(tui): issue #13 — §5 layout reshape + Tools pane
|
||||
- `d30be12` feat(sessions,cli,tui): issue #8 — startup agent picker (v0.3.0)
|
||||
- `c85f6bd` fix(tui): anchor layout via dock so Input never moves (v0.2.1)
|
||||
- `3b9c610` feat(cli,tui): issue #12 — presenter contract semantics amendment (v0.2.0)
|
||||
- `8282156` snapshot: persistent-memory Heimdall scope-model foot-gun
|
||||
@@ -64,10 +75,14 @@ Last commits on `main`:
|
||||
"a lot better" interactively.
|
||||
|
||||
**Outstanding operator-side todos:**
|
||||
- **Interactive TUI picker eyeball** — `source env.sh && uv run
|
||||
ratatoskr --new` (no flags after) should show the picker; pick
|
||||
lofn; type a message; verify response streams cleanly. Auto-pick
|
||||
smoke confirmed the wiring; visual confirmation pending.
|
||||
- **Interactive §5 layout eyeball** — `source env.sh && uv run
|
||||
ratatoskr --new --agent mimir`, ask a tool-using question
|
||||
("search your KB for X"). Confirm: left column shows chat /
|
||||
thinking; right column's Tools tab shows tool_start +
|
||||
tool_result with `· ` prefix; Ctrl+1 doesn't break input focus;
|
||||
no width-clamp issues on the operator's terminal. Programmatic
|
||||
smoke confirmed all the routing + binding; visual confirmation
|
||||
pending.
|
||||
- **Post-v0.2.1 TUI multi-turn eyeball** — confirm thinking-run
|
||||
bouncing is gone across multiple turns; the layout fix has only
|
||||
been confirmed for a single turn so far.
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "ratatoskr"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
+94
-30
@@ -16,7 +16,19 @@ from typing import ClassVar, Literal
|
||||
import httpx
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.widgets import Footer, Header, Input, Label, ListItem, ListView, RichLog, Static
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.widgets import (
|
||||
Footer,
|
||||
Header,
|
||||
Input,
|
||||
Label,
|
||||
ListItem,
|
||||
ListView,
|
||||
RichLog,
|
||||
Static,
|
||||
TabbedContent,
|
||||
TabPane,
|
||||
)
|
||||
|
||||
from ratatoskr.cli import USER_AGENT, ParsedArgs, _format_duration_ms, _format_usage
|
||||
from ratatoskr.sessions import (
|
||||
@@ -114,12 +126,18 @@ class TuiPresenterState:
|
||||
*,
|
||||
log: RichLog,
|
||||
thinking_widget: Static,
|
||||
tools_log: RichLog,
|
||||
raw: bool,
|
||||
) -> None:
|
||||
"""Render one Worldtree SSE event with the TUI hierarchy + coalescing.
|
||||
|
||||
Two-views-of-thinking decoupling: per-delta updates go to
|
||||
`thinking_widget`; one closed entry per run goes to `log`.
|
||||
|
||||
Issue #13: `ToolStart` / `ToolResult` events route to `tools_log`
|
||||
(the Tools pane in the right column) instead of `log`. Every other
|
||||
event keeps its issue-#12 routing.
|
||||
|
||||
Exceptions are caught at the presenter boundary (INV-009 fallback).
|
||||
"""
|
||||
assert isinstance(
|
||||
@@ -194,12 +212,14 @@ class TuiPresenterState:
|
||||
))
|
||||
return
|
||||
if isinstance(event, ToolStart):
|
||||
log.write(_dim(
|
||||
# Issue #13 INV-014: tool events route to the Tools pane.
|
||||
tools_log.write(_dim(
|
||||
f"· tool_start: name={event.name} args={event.arguments!r}"
|
||||
))
|
||||
return
|
||||
if isinstance(event, ToolResult):
|
||||
log.write(_dim(
|
||||
# Issue #13 INV-014: tool events route to the Tools pane.
|
||||
tools_log.write(_dim(
|
||||
f"· tool_result: name={event.name} duration_ms={event.duration_ms} "
|
||||
f"result={event.result!r:.200}"
|
||||
))
|
||||
@@ -213,8 +233,13 @@ class TuiPresenterState:
|
||||
# INV-009 + POST-007 fallback: write pre-amendment plain-label line for
|
||||
# the original event AND a render_error line with the class name only
|
||||
# (NO exception message — security clause). Volva F1 fix.
|
||||
log.write(_plain_label(event))
|
||||
log.write(f"[render_error] {type(exc).__name__}")
|
||||
#
|
||||
# Issue #13: routing-under-failure preservation — ToolStart/ToolResult
|
||||
# fallback writes go to tools_log (the routed destination per
|
||||
# INV-014), not the transcript. Every other event falls back to log.
|
||||
target = tools_log if isinstance(event, (ToolStart, ToolResult)) else log
|
||||
target.write(_plain_label(event))
|
||||
target.write(f"[render_error] {type(exc).__name__}")
|
||||
|
||||
|
||||
class AgentPickerApp(App[str | None]):
|
||||
@@ -281,13 +306,24 @@ class AgentPickerApp(App[str | None]):
|
||||
class RatatoskrApp(App[int]):
|
||||
"""Textual TUI shell — single chat pane."""
|
||||
|
||||
# Issue #12 follow-up: anchor layout so Input never moves.
|
||||
# Pre-fix: every widget was auto-stacked. RichLog grew with content,
|
||||
# thinking-current toggled display 0↔N rows per thinking-run — both pushed
|
||||
# Input around mid-turn. Fix: dock the chrome to the top/bottom edges and
|
||||
# let RichLog (the only `1fr` widget) absorb all layout reflows internally
|
||||
# via its scroll viewport, so screen-relative positions stay stable.
|
||||
# Issue #13: Horizontal two-column layout per design-brief §5.
|
||||
# Left column (2fr) is the chat surface; right column (1fr) is the
|
||||
# TabbedContent housing side panes. v1 has only the Tools tab.
|
||||
#
|
||||
# Dock rules narrow to per-container scope so thinking-current toggling
|
||||
# in the left column doesn't reflow the right column's TabbedContent.
|
||||
# The v0.2.1 layout-stability property is preserved within the left
|
||||
# column by docking thinking-current top + prompt bottom of that column.
|
||||
DEFAULT_CSS = """
|
||||
#main-row {
|
||||
height: 1fr;
|
||||
}
|
||||
#left-column {
|
||||
width: 2fr;
|
||||
}
|
||||
#right-column {
|
||||
width: 1fr;
|
||||
}
|
||||
#thinking-current {
|
||||
dock: top;
|
||||
height: auto;
|
||||
@@ -302,6 +338,10 @@ class RatatoskrApp(App[int]):
|
||||
dock: bottom;
|
||||
height: 1;
|
||||
}
|
||||
#pane-name {
|
||||
dock: bottom;
|
||||
height: 1;
|
||||
}
|
||||
#hint {
|
||||
dock: bottom;
|
||||
height: 1;
|
||||
@@ -311,6 +351,10 @@ class RatatoskrApp(App[int]):
|
||||
BINDINGS: ClassVar[list[Binding]] = [
|
||||
Binding("ctrl+c", "interrupt", "Cancel / Exit", priority=True),
|
||||
Binding("ctrl+d", "quit", "Exit immediately", priority=True),
|
||||
# Issue #13: §5 keybinding family Ctrl+1..5 jumps between side panes
|
||||
# without losing Input focus (INV-016). v1 only has Tools; Ctrl+2..5
|
||||
# land as Persona/AdminEvents/BifrostState/ServerLog panes ship.
|
||||
Binding("ctrl+1", "focus_tools", "Tools tab", priority=False),
|
||||
]
|
||||
|
||||
HINT_IDLE = "Ctrl-C twice to exit"
|
||||
@@ -337,25 +381,29 @@ class RatatoskrApp(App[int]):
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header()
|
||||
# Issue #12 follow-up: thinking-current sits at the TOP under Header (via
|
||||
# DEFAULT_CSS `dock: top`). Pre-fix it lived between hint and Footer in
|
||||
# the auto-stacked flow, so its display=True/False toggle per
|
||||
# thinking-run pushed Input + identity + hint up/down on every cycle.
|
||||
# Docking top + RichLog filling middle stabilises Input's screen
|
||||
# position; thinking-current grows/shrinks under Header where the
|
||||
# reflow doesn't affect anything else.
|
||||
yield Static("", id="thinking-current")
|
||||
# markup=False so labeled lines like "[cancel_failed] ..." render verbatim
|
||||
# (Rich would otherwise interpret square-bracket spans as style markup and
|
||||
# strip them). The post-Done markdown render uses Markdown() directly which
|
||||
# is a Rich Renderable and renders correctly without widget-level markup=True.
|
||||
yield RichLog(id="transcript", wrap=True, markup=False, highlight=False)
|
||||
yield Input(id="prompt", placeholder="Type a message and press Enter")
|
||||
# INV-002 + INV-003: visible identity + hint widgets (Footer-area rendering).
|
||||
# Textual's built-in Footer renders BINDINGS descriptions; these Static widgets
|
||||
# carry the session-identity and Ctrl-C-state strings the contract requires be
|
||||
# always-visible.
|
||||
# Issue #13: Horizontal two-column layout. Left column = chat surface
|
||||
# (thinking-current docked top, transcript fills middle, prompt docked
|
||||
# bottom). Right column = TabbedContent for side panes (v1: Tools only).
|
||||
# markup=False on RichLog so labeled lines like "[cancel_failed] ..."
|
||||
# render verbatim; Rich would otherwise interpret bracket spans as
|
||||
# style markup. The post-Done markdown render uses Markdown() directly
|
||||
# which is a Rich Renderable and renders correctly without
|
||||
# widget-level markup=True.
|
||||
with Horizontal(id="main-row"):
|
||||
with Vertical(id="left-column"):
|
||||
yield Static("", id="thinking-current")
|
||||
yield RichLog(id="transcript", wrap=True, markup=False, highlight=False)
|
||||
yield Input(id="prompt", placeholder="Type a message and press Enter")
|
||||
with Vertical(id="right-column"):
|
||||
with TabbedContent(id="side-panes"):
|
||||
with TabPane("Tools", id="tools-tab"):
|
||||
yield RichLog(
|
||||
id="tools-log", wrap=True, markup=False, highlight=False
|
||||
)
|
||||
# INV-002 + INV-003: visible identity + hint widgets (Footer-area).
|
||||
# INV (issue #13): pane-name widget displays current side-pane name.
|
||||
yield Static("", id="identity")
|
||||
yield Static("Tools", id="pane-name")
|
||||
yield Static(self.HINT_IDLE, id="hint")
|
||||
yield Footer()
|
||||
|
||||
@@ -411,13 +459,19 @@ class RatatoskrApp(App[int]):
|
||||
assert content
|
||||
log = self.query_one("#transcript", RichLog)
|
||||
thinking_widget = self.query_one("#thinking-current", Static)
|
||||
# Issue #13: tools_log routes ToolStart/ToolResult into the Tools pane.
|
||||
tools_log = self.query_one("#tools-log", RichLog)
|
||||
presenter = TuiPresenterState()
|
||||
try:
|
||||
async for event in stream_turn(self.client, self.session_id, content):
|
||||
if self.active_turn_id is None:
|
||||
self.active_turn_id = event.sse_id.turn_id
|
||||
presenter.render(
|
||||
event, log=log, thinking_widget=thinking_widget, raw=self.args.raw
|
||||
event,
|
||||
log=log,
|
||||
thinking_widget=thinking_widget,
|
||||
tools_log=tools_log,
|
||||
raw=self.args.raw,
|
||||
)
|
||||
if isinstance(event, (Done, Error, Cancelled)):
|
||||
break
|
||||
@@ -468,6 +522,16 @@ class RatatoskrApp(App[int]):
|
||||
self.stream_worker.cancel()
|
||||
self.exit(0)
|
||||
|
||||
def action_focus_tools(self) -> None:
|
||||
"""Issue #13: Ctrl+1 activates the Tools tab. INV-016 preserves Input focus.
|
||||
|
||||
Current Textual behavior preserves Input focus when TabbedContent.active is
|
||||
set programmatically. If a future Textual regresses on that, add an
|
||||
explicit `self.query_one('#prompt', Input).focus()` after the assignment
|
||||
— `test_ctrl_1_preserves_input_focus` is the regression guard.
|
||||
"""
|
||||
self.query_one("#side-panes", TabbedContent).active = "tools-tab"
|
||||
|
||||
|
||||
def run_tui(args: ParsedArgs) -> int:
|
||||
"""Sync entry point — delegates to the async resolve-then-run flow.
|
||||
|
||||
+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