fix(tui): address Volva code-vs-contract drift (issue #4)
Volva code-review surfaced 8 findings against the TDD-passing
TUI shell. All 8 addressed.
Drift fixes (code):
- Primary: INV-002 + INV-003 require visible Footer-area rendering
of session-identity + Ctrl-C state hint. Implementation stored
the strings in `self.sub_title` (which lands in the Header, not
Footer) and `self.hint` (a plain attribute, never rendered). Fixed
by adding two `Static` widgets (id="identity" and id="hint") in
compose; the `_set_hint()` helper mirrors state into the widget on
every state transition. Same-model TDD missed this because tests
asserted internal state, not visible widget content.
- Reverted `_stream_turn_worker(content, log)` to single-param
`(content)` per the contract FN signature. The widened signature
was a TDD-time workaround for a NoMatches-during-worker
execution; root cause was test timing (added `await pilot.pause()`
before the polling loop in `_submit_and_wait`).
- Restored `exclusive=True` on `self.run_worker(...)` per the
contract STEP 6 spec.
- Added missing `isinstance(args, ParsedArgs)` PRE assertion to
`run_tui`. Required hoisting `from ratatoskr.cli import
ParsedArgs` out of TYPE_CHECKING — runtime import is fine (no
circular dependency: cli lazy-imports tui inside main; tui
imports cli unconditionally at module load).
- Added missing union-type PRE assertion to `_render_event_to_log`.
Contract amendments (precision):
- COMPOSE shape: RichLog `markup=False, highlight=False` (was True,
True). Explanatory comment in-line: bracketed labels like
[cancel_failed] would otherwise be interpreted+stripped as Rich
style spans; the post-Done Markdown rendering still works via
Markdown() Renderable.
- INV-002 reworded: identity rendered via dedicated
Static(id="identity") widget composed adjacent to Footer (Textual's
built-in Footer renders BINDINGS descriptions; a sibling Static
carries custom content in the same visual region).
- on_mount POST-003 amended to allow `agent_id is None` when
--session is used without --agent (matches INV-002 carve-out;
GET /sessions/{id} agent lookup is out of scope for this shell).
- run_tui happy_returns_zero_on_quit test description clarified:
App.run() is sync and can't be driven by Pilot, so run_tui's
wrapping behavior is tested via monkeypatch; the piloted Ctrl-D
exit path is covered separately by TestActionQuit.
Test fixes:
- footer_identity_visible_first_frame, footer_hint_flips_to_cancel,
streaming_first_ctrl_c_cancels: now query the Static(#identity) /
Static(#hint) widgets via `widget.render()` instead of asserting
on `app.sub_title` / `app.hint` internal state. The internal
state still exists (mirror), but the load-bearing assertion is
on visible widget content.
Meta-note from Volva: "TDD pass caught most stream/session/error
mechanics, but tested internal state where the contract required
visible Footer behavior, so same-model TDD would plausibly miss the
primary drift." Calibration shape continues across all four issues:
the post-TDD cross-model review consistently catches assert-boundary
+ observability-shape gaps the test-author's hypotheses don't cover
(#1: 4 findings, #2: 3, #3: 5, #4: 8).
164/164 tests GREEN; ruff clean; both contract drift checks clean.
This commit is contained in:
@@ -79,7 +79,7 @@ The shell is the load-bearing primary surface. Together with `--send`, it makes
|
||||
## Invariants
|
||||
|
||||
- **INV-001 [hard]**: `ratatoskr.cli` MUST NOT import `textual` at module scope. The cli-to-tui dispatch in `main` uses a function-local `from ratatoskr.tui import run_tui` inside the branch that runs ONLY when `--send` was omitted. Verified by the existing `test_no_textual_import_in_cli` static-grep test (issue #3 INV-001), which scans `cli.py` for `import textual` / `from textual`. The `--send` path never reaches the lazy import, so the import boundary holds for scripted callers.
|
||||
- **INV-002 [hard]**: The Footer widget always displays an agent slot + the LAST 8 chars of `session_id` (design-brief §4 session-identity-always-visible invariant). The format is `<agent_slot> · …<session_id_tail8>` with a literal `·` separator and `…` prefix. The agent slot is `args.agent_id` (when `--new`), OR `SessionInfo.agent_id` (when `--new` AND a successful create_session populates it), OR the literal string `<unknown>` (when `--session <id>` was used AND agent_id is not present in args — a `GET /sessions/{id}` lookup is out of scope for this shell, see `## Out of scope`). The `<unknown>` placeholder is an ACCEPTED satisfaction of "session-identity-always-visible" — it signals to the dev that the agent is opaque from this launch but the session_id tail is still anchored. This MUST appear by the first frame after `on_mount` completes; the App MUST NOT render the chat pane in a state where the agent slot OR the session_id tail is absent.
|
||||
- **INV-002 [hard]**: The visible Footer-area UI always displays an agent slot + the LAST 8 chars of `session_id` via a dedicated `Static(id="identity")` widget composed adjacent to `Footer()` (Textual's built-in Footer renders BINDINGS descriptions and doesn't naturally accept custom content; a sibling Static carries the identity string in the same visual region). The session-identity-always-visible invariant is design-brief §4. The format is `<agent_slot> · …<session_id_tail8>` with a literal `·` separator and `…` prefix. The agent slot is `args.agent_id` (when `--new`), OR `SessionInfo.agent_id` (when `--new` AND a successful create_session populates it), OR the literal string `<unknown>` (when `--session <id>` was used AND agent_id is not present in args — a `GET /sessions/{id}` lookup is out of scope for this shell, see `## Out of scope`). The `<unknown>` placeholder is an ACCEPTED satisfaction of "session-identity-always-visible" — it signals to the dev that the agent is opaque from this launch but the session_id tail is still anchored. This MUST appear by the first frame after `on_mount` completes; the App MUST NOT render the chat pane in a state where the agent slot OR the session_id tail is absent.
|
||||
- **INV-003 [hard]**: Two-stage Ctrl-C state machine (design-brief §8c):
|
||||
- **idle state** (no turn in flight): footer hint = `"Ctrl-C twice to exit"`; first Ctrl-C → `app.exit(0)`.
|
||||
- **streaming state** (turn in flight): footer hint = `"Ctrl-C to cancel"`; Ctrl-C → spawn `cancel_turn` server-side, transition to **cancelling state**.
|
||||
@@ -159,7 +159,7 @@ STEPS:
|
||||
2. [sequential, flexibility=prescriptive] Construct app = RatatoskrApp(args)
|
||||
3. [sequential, flexibility=prescriptive] RETURN app.run() — Textual's sync runner; manages its own asyncio loop
|
||||
TESTS:
|
||||
happy_returns_zero_on_quit [happy,tracer]: construct args with --session s-1; mock the SSE endpoint; Pilot presses ctrl+d immediately; run_tui returns 0
|
||||
happy_returns_zero_on_quit [happy,tracer]: construct args with --session s-1; monkeypatch RatatoskrApp.run to capture invocation and return 0; run_tui returns 0; the captured app was constructed with the passed args (verifies run_tui correctly wraps App.run). The piloted Ctrl-D exit path is covered separately by TestActionQuit.test_idle_ctrl_d_exits_zero — App.run() is sync and can't be driven by Pilot, so run_tui's wrapping behavior is tested via monkeypatch.
|
||||
precondition_send_content_none [adversarial]: args with send_content="x" → AssertionError before run() (PRE-001 catches the misuse)
|
||||
```
|
||||
|
||||
@@ -179,8 +179,10 @@ BINDINGS:
|
||||
- ("ctrl+d", "quit", "Exit immediately")
|
||||
COMPOSE shape (declarative — implementer chooses CSS file vs inline):
|
||||
Header()
|
||||
RichLog(id="transcript", wrap=True, markup=True, highlight=True)
|
||||
RichLog(id="transcript", wrap=True, markup=False, highlight=False) # markup=False: bracketed labels like [cancel_failed] render verbatim instead of being interpreted-and-stripped as Rich style spans. The post-Done markdown render uses Markdown() Renderable which renders regardless of widget-level markup.
|
||||
Input(id="prompt", placeholder="Type a message and press Enter")
|
||||
Static("", id="identity") # INV-002: visible session-identity strip; rendered by on_mount
|
||||
Static(HINT_IDLE, id="hint") # INV-003: visible Ctrl-C state hint; updated on state transitions
|
||||
Footer()
|
||||
INV-WIRE-001: One AsyncClient lifecycle per app lifetime (INV-007).
|
||||
INV-WIRE-002: state transitions strictly idle ↔ streaming ↔ cancelling per INV-003.
|
||||
@@ -192,7 +194,7 @@ BRIEF: Lifecycle hook. Opens the httpx.AsyncClient, mints or attaches the sessio
|
||||
PRE: [PRE-001 hard] self.client is None (on_mount fires once per app instance) -- assert self.client is None
|
||||
POST: [POST-001 state_change] self.client is an open httpx.AsyncClient bound to args.server_url with the Bearer auth header
|
||||
POST: [POST-002 state_change] self.session_id is non-empty (either from args.session_id or from a successful create_session)
|
||||
POST: [POST-003 state_change] self.agent_id is non-empty (from args.agent_id when --new; from SessionInfo.agent_id when --session)
|
||||
POST: [POST-003 state_change] self.agent_id is non-empty when args.new (from SessionInfo.agent_id after create_session) OR self.agent_id is the value of args.agent_id when args.session is used (may be None per INV-002 carve-out — `GET /sessions/{id}` agent lookup is explicitly out of scope for this shell)
|
||||
POST: [POST-004 side_effect] Footer subtitle shows `<agent_id> · …<session_id[-8:]>` (INV-002 session-identity-always-visible)
|
||||
POST: [POST-005 state_change] self.state == "idle"; the footer hint widget shows "Ctrl-C twice to exit"
|
||||
ERROR_ROUTING:
|
||||
|
||||
Reference in New Issue
Block a user