--- contract_version: "2.1" target_module: "ratatoskr.tui" scope: "Restructure `ratatoskr.tui`'s `run_tui` lifecycle so startup errors (`AgentNotFound`, `SessionApiFailed`, network-error-during-create_session) print to real stderr instead of getting eaten by the alt-screen teardown. Move session resolution OUT of `on_mount` (which runs inside the alt-screen) and INTO `run_tui` (sync wrapper, BEFORE `App.run()` opens the alt-screen). `httpx.AsyncClient` ownership moves with it: opened by `run_tui` via async-with; the `RatatoskrApp` instance becomes a consumer of an externally-owned client. `on_mount` shrinks to identity-widget population from pre-resolved state. Mid-session errors during streaming (issue #4 INV-008) continue to render in the alt-screen; only PRE-`App.run()` failures use stderr. No new modules; in-place amendment to issue #4's contract. No semantic change to issue #1/#2/#3 surfaces." depends_on: - "httpx" - "textual" - "ratatoskr.sessions" - "ratatoskr.cli" used_by: [] language: "python" complexity: "medium" estimated_loc: 60 confidence: 0.85 assumptions: - "Textual's `App.run()` enters an alt-screen lifecycle that tears down on `self.exit()` or normal termination. Any content rendered to widgets inside the alt-screen (e.g., RichLog.write) is invisible to the operator after teardown — it lived in the alt-screen buffer, not the operator's scrollback. Real stderr writes survive teardown." - "`httpx.AsyncClient` is safely usable across an `App.run()` boundary: opened in an `async with` block in `run_tui`, accessed by the App via `self.client`, closed by the same `async with` after `App.run()` returns. The client doesn't care about Textual's lifecycle; it's a plain httpx object the App holds a reference to." - "Synchronous `run_tui` is the operator-facing entry point per issue #4's contract (called from `ratatoskr.cli.main` via lazy import). `run_tui` calls `asyncio.run(_resolve_then_run(args))` which is a single async function that handles BOTH the pre-flight HTTP (opening the client, resolving the session) AND the App lifecycle via `await app.run_async()`. Everything runs in one event loop managed by that single `asyncio.run(...)` call — NOT two sequential event loops. `app.run_async()` is Textual's async entry point (as opposed to the sync `app.run()`) and is the correct choice because we're already inside an async context with an active `httpx.AsyncClient`. Mixing a nested `asyncio.run()` inside an existing event loop would be incorrect." - "Tests for the moved error paths use stderr capture (pytest's `capsys`) at the run_tui level, NOT the App.run_test() pilot. Pilot tests cover the in-alt-screen paths (#4's existing TestStreamTurnWorker / TestActionInterrupt / TestActionQuit). Pre-flight errors are App-free; standard stderr-capture works." open_questions: - "Should the pre-flight error label format match the cli's `[session_api_failed] status=... body=...` format exactly (already in cli's `_amain` STEP 2), so operators see the same label whether they hit the error via `--send` or via TUI startup? Draft: yes — same label, same shape, same exit code. Consistency beats per-presenter variance." - "Should `run_tui` emit an `[connecting...]` status line to stderr before the pre-flight HTTP, so a slow connection isn't silently waited on? Draft: no for v1. The pre-flight is fast on a working network (< 1s for POST /sessions); if a slow path turns up, surface in a follow-up." prd: issue: 6 issue_url: "https://gitea.phasefinal.com/vh/ratatoskr/issues/6" body_sha256_16: "6378989fbd465ba8" lock_in_comment_id: null lock_in_sha256_16: null lock_in_at: null pinned_at: "2026-05-23T02:11:18+00:00" dependencies: - issue: 4 path: "src/ratatoskr/tui.py" reason: "In-place contract amendment: `run_tui` STEPS expanded with pre-App.run() session resolution + error routing to stderr; `RatatoskrApp.__init__` signature widens to accept pre-resolved `session_id` / `agent_id` / `client`; `on_mount` STEPS narrowed (no more session-create); `on_unmount` STEPS narrowed (client closed by run_tui's async-with, not on_unmount). Several issue #4 TESTS get restructured: error-on-mount tests become error-on-resolve tests at the run_tui layer." --- # TUI startup error visibility — surface pre-flight errors to real stderr ## Context Issue #4's `RatatoskrApp.on_mount` runs INSIDE the Textual alt-screen and calls `create_session` (when `--new`) to mint a session before the app becomes interactive. When `create_session` raises (`AgentNotFound`, `SessionApiFailed`, network errors), my code today writes a labeled line to the `RichLog` widget and calls `self.exit()`. The exit code is right, but the labeled line is invisible: the alt-screen tears down roughly 200ms after `self.exit()`, and the RichLog buffer goes with it. The operator sees a blank terminal and an exit code — no diagnostic. Surfaced 2026-05-21 (`ratatoskr --new --agent lofn` blanked silently; turned out to be the issue #5 `end_user_id_required` 422; operator had to re-run under `--send` to see the actual error). `--send` mode handles this class of error correctly because stderr labels go to the operator's real terminal, not the alt-screen. This issue restructures the lifecycle so startup-phase errors use the same stderr path that `--send` uses. Session resolution moves OUT of `on_mount` (alt-screen) and INTO `run_tui` (sync wrapper, real terminal). Mid-session errors during streaming continue to render in the alt-screen per issue #4 INV-008 — that path is fine; the user is interactively present and the transcript is visible. ## Data flow **Input change:** none at the operator-facing level. `ratatoskr --new --agent ` and `ratatoskr --session ` both still launch the TUI. **Output change:** - When session-create fails (in `--new` mode) OR when the network won't reach the server, the operator now sees a labeled line on the **real terminal stderr**, not the alt-screen: - `[agent_not_found] agent_id={exc.agent_id}` (exit 12) - `[session_api_failed] status={exc.status} body={exc.body!r}` (exit 20) — `exc.body` is already truncated to 1024 bytes at `SessionApiFailed.__init__` per issue #2 INV-004; `!r` is the repr of that already-truncated bytes value - `[network_error] {type(exc).__name__}: {exc}` (exit 21) - Format matches `ratatoskr.cli._amain`'s existing error labels exactly (same shape, same exit codes) so operators see one consistent vocabulary across `--send` and TUI modes. - When session resolution succeeds, the alt-screen opens and behavior is identical to today's: identity widgets populated, chat pane ready for input. - Mid-session errors during streaming (issue #4 INV-008 set: `SseConnectionDropped`, `SseConnectFailed`, `MalformedSseId`, `MalformedSseData`, `TurnIdFlip`) STILL render in the alt-screen and return the app to idle. **Unchanged.** **Side effects:** - `httpx.AsyncClient` lifetime widens: now spans the pre-flight HTTP AND the App's lifetime, owned by `run_tui` via `async with`. **On disk:** none (unchanged). ## Invariants - **INV-001 [hard]**: Session resolution (mint when `--new`; attach when `--session`) MUST complete BEFORE `App.run()` enters the alt-screen. Errors at this phase MUST print to `sys.stderr` (the real terminal, not a RichLog widget) and MUST cause `run_tui` to return the appropriate exit code WITHOUT calling `App.run()`. The alt-screen MUST NOT open when session resolution fails — operators get a clean stderr diagnostic on their normal terminal, with no flash-and-disappear artifact. - **INV-002 [hard]**: `httpx.AsyncClient` is owned by `run_tui` via `async with`. The client is opened BEFORE the pre-flight session resolution, passed by reference to `RatatoskrApp.__init__`, accessed by the App via `self.client` during streaming, and closed by the same `async with` AFTER `App.run()` returns. The App is a consumer of an externally-owned client; it MUST NOT call `self.client.aclose()` (the `async with` does that). Issue #4's `on_unmount` STEPS narrow accordingly. - **INV-003 [hard]**: `RatatoskrApp.__init__` signature widens to `(args, *, session_id: str, agent_id: str | None, client: httpx.AsyncClient)`. All three are pre-resolved by `run_tui` and REQUIRED at construction. The app no longer mints anything; it consumes pre-resolved state. - **INV-004 [hard]**: `on_mount` STEPS narrow: open the identity Static widget, set `self.state = "idle"`, set `self.hint = HINT_IDLE`. No more session-create branch; no more client-open. The `` carve-out for agent_id (issue #4 INV-002) is preserved — when `--session ` is used without `--agent`, `agent_id` is None and the identity widget renders ` · …` as today. - **INV-005 [hard]**: Mid-session errors during streaming (issue #4 INV-008 set) are UNCHANGED. They render to the RichLog transcript via `_render_event_to_log` / explicit `log.write` and return the app to `idle` state. Only PRE-`App.run()` errors get the new stderr-label treatment. The split is: pre-alt-screen failures → real stderr; in-alt-screen failures → RichLog. This is the load-bearing observability invariant. - **INV-006 [hard]**: Exit codes (12, 20, 21, 0, 3) and label formats MUST match `ratatoskr.cli._amain`'s `[agent_not_found]` / `[session_api_failed]` / `[network_error]` shape verbatim. Operators see one vocabulary regardless of which presenter they're using. - **INV-007 [hard]**: No `core.*` / `worldtree.*` imports (existing boundary; unchanged). ## Out of scope - **General TUI logging infrastructure** (e.g., a structured DiagnosticsLog surface, log levels, log filtering). Each side pane is its own issue. - **Persistent error log file** at `~/.cache/ratatoskr/last-error.log`. Rejected per design-brief §8d ("no cross-process resume, no config dir"). The fix is "make startup errors visible on stderr", not "log everything to disk". - **In-alt-screen restructuring** (e.g., a status bar that surfaces errors at the bottom of the screen during streaming). Issue #4 INV-008 already handles in-alt-screen errors correctly via RichLog; this issue only addresses pre-alt-screen. - **422 → user-friendly hint translation.** When `--new --agent lofn` hits 422 `end_user_id_required`, this issue surfaces the raw label to stderr; the user still has to read the body to understand. Hint translation is issue #5's optional follow-up (deferred there). - **Pre-flight status line** (`[connecting...]` before the HTTP). Deferred; pre-flight is fast on a healthy network. - **Re-entering the picker on failure.** If session-create fails, the TUI exits cleanly; the operator re-launches with corrected args. No retry loop in v1. ## Constraints - **[compatibility]** Spec pin unchanged. The wire surface is unchanged; only the client's invocation timing moves earlier. - **[performance]** No new HTTP round-trips. The same single `POST /sessions` happens once per `--new` launch; just sequenced before `App.run()` instead of inside `on_mount`. - **[security]** Same as today — `Authorization` header on the client, no logged credentials. - **[style]** Async-native at the resolve layer. `run_tui` becomes a thin sync wrapper around `asyncio.run(_resolve_then_run(args))` to keep one entry point. Ruff line-length=100. ## Architecture ``` ratatoskr [shell entry, console-script] │ └─ ratatoskr.cli.main(argv) [sync] │ └─ when args.send_content is None ──► from ratatoskr.tui import run_tui return run_tui(args) │ └─ run_tui(args) [sync] │ ├─ assert PRE-001..PRE-002 └─ asyncio.run(_resolve_then_run(args)) │ ├─ async with httpx.AsyncClient(...) as client: │ │ │ ├─ try: resolve session │ │ IF args.new: info = await create_session(client, args.agent_id) │ │ session_id = info.session_id; agent_id = info.agent_id │ │ ELSE: session_id = args.session_id; agent_id = args.agent_id │ │ except AgentNotFound: stderr label; return 12 ◄── PRE-alt-screen │ │ except SessionApiFailed: stderr label; return 20 ◄── PRE-alt-screen │ │ except (httpx.ConnectError|ReadTimeout|TransportError): stderr; return 21 │ │ │ ├─ # Session resolved; enter alt-screen │ ├─ app = RatatoskrApp(args, session_id, agent_id, client) │ ├─ return await app.run_async() or 0 │ │ │ │ │ ├─ on_mount: populate identity widget; state=idle │ │ ├─ on_input_submitted: spawn _stream_turn_worker │ │ ├─ _stream_turn_worker: stream; mid-session errors → RichLog per INV-008 ◄── IN-alt-screen │ │ └─ on_unmount: nothing (client closed by async with below) │ │ │ └─ # App returned; async with closes client └─ # exit code propagated to cli.main ``` --- ## In-place amendments to issue #4 (the work) This issue's contract is small because the real work is amending issue #4's contract in place. The amendments are pinned here so reviewers see the whole change in one place; the actual contract file at `docs/contracts/issues/4.contract.md` is amended in-place as part of this issue's commit. ### Issue #4 (`ratatoskr.tui`) amendments **`run_tui` STEPS expanded:** ``` FN run_tui(args: ParsedArgs) -> int STEPS: 1. [setup, prescriptive] Validate PRE-001 (isinstance(args, ParsedArgs) and args.send_content is None) 2. [setup, prescriptive] Validate PRE-002 (bool(args.session_id) != bool(args.new)) 3. [sequential, prescriptive] RETURN asyncio.run(_resolve_then_run(args)) FN _resolve_then_run(args: ParsedArgs) -> int # NEW helper ASYNC: yes STEPS: 1. [setup, prescriptive] OPEN httpx.AsyncClient(base_url=args.server_url, headers={"Authorization": f"Bearer {args.api_key}"}, timeout=httpx.Timeout(connect=10.0, read=None, write=10.0, pool=10.0)) via async-with 2. [branch, prescriptive] IF args.new: TRY: info = await create_session(client, args.agent_id) ON AgentNotFound as exc: sys.stderr.write(f"[agent_not_found] agent_id={exc.agent_id}\n") RETURN 12 ON SessionApiFailed as exc: sys.stderr.write(f"[session_api_failed] status={exc.status} body={exc.body!r}\n") RETURN 20 ON (httpx.ConnectError | httpx.ReadTimeout | httpx.TransportError) as exc: sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n") RETURN 21 SET session_id = info.session_id; agent_id = info.agent_id ELSE: SET session_id = args.session_id; agent_id = args.agent_id # agent_id may be None — INV-002 carve-out preserved 3. [sequential, prescriptive] Construct app = RatatoskrApp(args, session_id=session_id, agent_id=agent_id, client=client) 4. [sequential, prescriptive] exit_code = await app.run_async() # Textual's async-runner; lets the same event loop handle the alt-screen 5. [cleanup, prescriptive] RETURN exit_code or 0 ``` **Note**: `app.run_async()` (Textual's async-runner) is used instead of `app.run()` (sync) because we're already in an async context inside the `async with httpx.AsyncClient(...)`. Mixing `asyncio.run(...)` inside an existing event loop would be incorrect; the async variant lets one loop handle both the pre-flight HTTP AND the App lifecycle. **`RatatoskrApp.__init__` signature widens:** ``` def __init__(self, args: ParsedArgs, *, session_id: str, agent_id: str | None, client: httpx.AsyncClient) -> None ``` All three new kwargs are REQUIRED. Stored on self as `self.session_id`, `self.agent_id`, `self.client`. The state machine attributes (`self.state`, `self.active_turn_id`, `self.stream_worker`, `self.hint`) are unchanged. **`on_mount` STEPS narrow:** ``` async def on_mount(self) -> None: STEPS: 1. [setup, prescriptive] assert self.client is not None and self.session_id is not None 2. [sequential, prescriptive] Compute identity: agent_slot = self.agent_id or "" identity = f"{agent_slot} · …{self.session_id[-8:]}" 3. [sequential, prescriptive] Populate widgets: self.sub_title = identity (Header mirror) self.query_one("#identity", Static).update(identity) 4. [sequential, prescriptive] SET self.state = "idle"; self._set_hint(self.HINT_IDLE) ``` No more session-create branch; no more client-open. ERROR_ROUTING for `AgentNotFound` / `SessionApiFailed` / network errors is **removed from on_mount** — those routes now live in `_resolve_then_run`. Issue #4's POST-001 (client-open-after-mount) and POST-002 (session_id non-empty) are still satisfied but by `_resolve_then_run` setting up state, not by `on_mount`'s create call. **`on_unmount` STEPS narrow (or removed):** ``` async def on_unmount(self) -> None: STEPS: (none — client lifetime managed by run_tui's async-with, NOT this hook) ``` Issue #4's existing `on_unmount` test (`unmount_closes_client`) is restructured: client closing now happens via `run_tui`'s async-with exit, which fires after `app.run_async()` returns. The test moves from "ctrl+d → on_unmount → client closed" to "ctrl+d → run_tui returns → client closed". **TESTS amendments (issue #4 in-place):** Removed (or restructured to the run_tui layer): - `agent_not_found_on_mount` → becomes `agent_not_found_on_resolve` at the `_resolve_then_run` layer. Assertion shape: `capsys.readouterr().err` contains `[agent_not_found]`; `run_tui` returns 12; no app instance ever entered alt-screen. - `session_api_failed_on_mount` → `session_api_failed_on_resolve`. - `network_error_on_mount` → `network_error_on_resolve`. - `client_open_after_mount` → `client_open_after_resolve` (client opened by run_tui, accessible via `self.client` once the app is mounted). - `unmount_closes_client` → `run_tui_closes_client_on_app_exit` (asserts the async-with closed the client after `app.run_async()` returned). New TESTS (in the `_resolve_then_run` block at the run_tui layer): - `alt_screen_never_opens_on_resolve_error [trace]`: monkeypatch `RatatoskrApp.run_async` to a sentinel that fails the test if called; set up respx to return 404 from POST /sessions; assert `run_tui` returns 12; assert the sentinel was NEVER invoked. Directly probes INV-001 (alt-screen MUST NOT open). - `client_lifetime_owned_by_run_tui [trace]`: spy on `httpx.AsyncClient.aclose`; successful run; assert exactly one `aclose` call AFTER `app.run_async` returned, NOT during on_unmount. Probes INV-002. - `stderr_label_format_matches_cli [trace]`: assert the stderr label shape (e.g., `[agent_not_found] agent_id=missing`) matches the format emitted by `cli._amain`'s existing handler verbatim. Probes INV-006. Issue #4's `happy_new_session_mount` test stays (now exercises the identity widget population via the pre-resolved state); the assertion on POST /sessions call count moves to the new `_resolve_then_run` test layer. --- ## Acceptance - Issue #4 contract amended in-place; drift-check clean. - Issue #6 contract drift-check clean. - All existing tests + new `_resolve_then_run` coverage GREEN under `uv run pytest tests/`. - `uv run ruff check src/ tests/` clean. - Boundary smoke `tests/test_no_worldtree_imports.py` still passes. - Manual smoke (the original failure mode from 2026-05-21): `ratatoskr --new --agent ` produces a VISIBLE `[agent_not_found]` line on stderr; no screen-blanking artifact; exit code 12. (This is the smoke that surfaced the bug; verify it's now the success case.) - Regression smoke: `ratatoskr --new --agent mimir` against personal Worldtree still works end-to-end (alt-screen opens, chat pane works, Ctrl-D exits clean). Mid-session errors during a streaming turn STILL render in the alt-screen per INV-008 — verify by hitting one (e.g., send a turn, then kill the server side, see `[connection_dropped]` in the transcript, state returns to idle). ## Dependencies - Issue #4 (`ratatoskr.tui` shell) — landed on main; this issue amends its contract. - Issue #5 (`--end-user-id` for per-user agents) — independent; both can land in either order, but #5 + #6 compose naturally (#6 will surface #5's 422 as a visible stderr label instead of a black alt-screen). - Issue #7 (mid-stream robustness, `MalformedSseData`) — landed; #6's pre/in-alt-screen split is orthogonal to #7's empty-data/malformed distinction (different error layers entirely). ## Amendment 2026-06-30 — startup session picker (v1 coverage-audit, slice b2) The v1 coverage-audit found `list_sessions` had **no caller** — the startup session picker (design-brief §4: "single-session-per-launch, with a startup picker invoked when more than one session exists ... plus flags `--session`/ `--new` to skip it") was never built. Bare TUI mode (neither `--session` nor `--new`) was a hard usage error. This adds the picker as a pre-alt-screen resolution step in `_resolve_then_run`, mirroring the existing `AgentPickerApp`. **Locked design (design-brief §4):** the picker is **resume-only** (§4 negative clause "no in-app session creation — `--new` flag only"); shown only when **>1** session exists (exactly 1 auto-resumes; the launch intent is "resume the last session I was poking at"). `--agent` stays a `--new` companion (forbidden in bare mode). **bare + 0 sessions → error** `[no_sessions]` directing the operator to `--new` (honors the "no in-app creation" clause; the friendlier auto-fall-through-to-new alternative is deferred pending operator confirmation). ### `_parse` validation relaxation (ratatoskr.cli._parse) - Bare TUI mode (`send is None` AND no `--session` AND no `--new`) is now VALID → triggers the picker. (Previously `raise UsageError("pass exactly one of --session or --new")` unconditionally.) - `--send` mode still requires exactly one of `--session`/`--new` (non- interactive: no picker can open) → `UsageError("--send requires --session or --new")`. - `--session` + `--new` stays mutually exclusive. - `--agent` in bare mode → `UsageError` (`--agent` belongs to `--new`). ```contract FN SessionPickerApp.__init__(self, sessions: list[SessionInfo]) -> None BRIEF: Textual App[str | None] startup session picker (mirrors AgentPickerApp, issue #8). Opens before RatatoskrApp when bare TUI mode resolves >1 session. `run_async()` returns the chosen session_id (str) or None on Esc/Ctrl-D/Ctrl-C dismissal. Architecturally separate from RatatoskrApp (list_sessions failures + dismissal land before any alt-screen — preserves #6 INV-001). PRE: [PRE-001 hard] sessions is non-empty -- assert sessions (caller resolves 0-session and 1-session cases BEFORE constructing the picker) POST: [POST-001 return_value] run_async() returns sessions[i].session_id for the highlighted row on `pick`, or None on dismiss -- assert result in {s.session_id for s in sessions} | {None} STEPS: 1. [setup, prescriptive] Store sessions; register the Australis theme (mirror AgentPickerApp). 2. [sequential, prescriptive] compose: Header + prompt Static + ListView of one ListItem per session (id-short + agent_id + last_active/name lines) + Footer. 3. [sequential, prescriptive] BINDINGS: enter→action_pick, escape/ctrl+d/ctrl+c→action_dismiss. 4. [branch, prescriptive] action_pick: read ListView.index; if None return (nothing highlighted); else exit(sessions[index].session_id). action_dismiss: exit(None). TESTS: pick_returns_session_id [happy,tracer]: SessionPickerApp([s0, s1]); pilot highlights row 1 + press enter → run_async() returns s1.session_id. dismiss_returns_none [happy]: press escape → run_async() returns None. ctrl_d_dismisses [adversarial]: press ctrl+d → None. FN _resolve_then_run(args) — bare-mode extension (session picker) BRIEF: Before the existing new/resume branches, resolve bare TUI mode (not args.new AND args.session_id is None) via list_sessions + the picker. Sets a local `effective_new` and `resolved_session_id`; the existing branches then run unchanged on those locals. STEPS (inserted at the top of the `async with client` block): 1. [setup, prescriptive] SET effective_new = args.new; resolved_session_id = args.session_id. 2. [branch, prescriptive] IF (not args.new) AND (args.session_id is None): # bare mode a. CALL list_sessions(client) → page; ON SessionApiFailed → stderr `[session_api_failed]` + return 20; ON network error → `[network_error]` + return 21. b. IF not page.items: stderr `[no_sessions] no sessions to resume; launch with --new --agent ` + return 14. c. ELIF len(page.items) == 1: SET resolved_session_id = page.items[0].session_id. # §4: picker only when >1 d. ELSE: SET resolved_session_id = await SessionPickerApp(page.items).run_async(); IF None → return 0 (Esc/Ctrl-D clean exit). 3. [sequential, prescriptive] Replace the two `if args.new` predicates with `if effective_new`; the resume `else` branch asserts + uses `resolved_session_id`. TESTS (in the `_resolve_then_run` block): bare_zero_sessions_errors [error]: bare args; list_sessions → 0 items → stderr contains `[no_sessions]`; return 14; NO POST /sessions, NO picker. bare_one_session_auto_resumes [scenario]: bare args; list_sessions → 1 item (sid="s-solo") → RatatoskrApp constructed with session_id="s-solo"; NO picker shown. bare_multi_opens_picker [scenario,tracer]: bare args; list_sessions → 2 items; picker returns items[1].session_id → RatatoskrApp constructed with that session_id. bare_picker_dismiss_exits_zero [scenario]: bare args; 2 items; picker returns None → return 0; RatatoskrApp NOT constructed. bare_list_sessions_api_failure [error]: bare args; list_sessions raises SessionApiFailed(500) → stderr `[session_api_failed]`; return 20. ```