v1 coverage-audit slice b2. The audit found list_sessions had no caller — the startup session picker (design-brief §4) was never built; bare TUI mode was a hard usage error. Add SessionPickerApp (mirrors AgentPickerApp) and resolve bare mode in _resolve_then_run. - Bare TUI mode (no --session/--new) now valid → session picker. Resolution: 0 sessions -> [no_sessions] exit 14 (resume-only per §4 "no in-app creation, --new only"); exactly 1 -> auto-resume (§4 "picker only when >1"); >=2 -> SessionPickerApp -> resume pick (Esc/Ctrl-D -> exit 0). - cli._parse: bare TUI valid; --send still requires one flag; --agent forbidden in bare mode. run_tui PRE-002 xor -> mutually-exclusive. - Contract #6 amended (SessionPickerApp + bare-mode resolution) + validated. TDD: 3 picker pilot tests + 5 resolution tests + 3 cli validation tests. Suite 528 green; touched code ruff-clean. Design note: bare + 0 sessions errors (honors §4's no-in-app-creation clause); the friendlier auto-fall-through-to-new is deferred pending operator preference.
27 KiB
contract_version, target_module, scope, depends_on, used_by, language, complexity, estimated_loc, confidence, assumptions, open_questions, prd, dependencies
| contract_version | target_module | scope | depends_on | used_by | language | complexity | estimated_loc | confidence | assumptions | open_questions | prd | dependencies | |||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 2.1 | ratatoskr.tui | 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. |
|
python | medium | 60 | 0.85 |
|
|
|
|
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(<code>). 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 <id> and ratatoskr --session <id> both still launch the TUI.
Output change:
- When session-create fails (in
--newmode) 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.bodyis already truncated to 1024 bytes atSessionApiFailed.__init__per issue #2 INV-004;!ris 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--sendand 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.AsyncClientlifetime widens: now spans the pre-flight HTTP AND the App's lifetime, owned byrun_tuiviaasync with.
On disk: none (unchanged).
Invariants
- INV-001 [hard]: Session resolution (mint when
--new; attach when--session) MUST complete BEFOREApp.run()enters the alt-screen. Errors at this phase MUST print tosys.stderr(the real terminal, not a RichLog widget) and MUST causerun_tuito return the appropriate exit code WITHOUT callingApp.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.AsyncClientis owned byrun_tuiviaasync with. The client is opened BEFORE the pre-flight session resolution, passed by reference toRatatoskrApp.__init__, accessed by the App viaself.clientduring streaming, and closed by the sameasync withAFTERApp.run()returns. The App is a consumer of an externally-owned client; it MUST NOT callself.client.aclose()(theasync withdoes that). Issue #4'son_unmountSTEPS 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 byrun_tuiand REQUIRED at construction. The app no longer mints anything; it consumes pre-resolved state. - INV-004 [hard]:
on_mountSTEPS narrow: open the identity Static widget, setself.state = "idle", setself.hint = HINT_IDLE. No more session-create branch; no more client-open. The<unknown>carve-out for agent_id (issue #4 INV-002) is preserved — when--session <id>is used without--agent,agent_idis None and the identity widget renders<unknown> · …<tail>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/ explicitlog.writeand return the app toidlestate. 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 lofnhits 422end_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 /sessionshappens once per--newlaunch; just sequenced beforeApp.run()instead of insideon_mount. - [security] Same as today —
Authorizationheader on the client, no logged credentials. - [style] Async-native at the resolve layer.
run_tuibecomes a thin sync wrapper aroundasyncio.run(_resolve_then_run(args))to keep one entry point. Ruff line-length=100.
Architecture
ratatoskr <args> [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 "<unknown>"
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→ becomesagent_not_found_on_resolveat the_resolve_then_runlayer. Assertion shape:capsys.readouterr().errcontains[agent_not_found];run_tuireturns 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 viaself.clientonce the app is mounted).unmount_closes_client→run_tui_closes_client_on_app_exit(asserts the async-with closed the client afterapp.run_async()returned).
New TESTS (in the _resolve_then_run block at the run_tui layer):
alt_screen_never_opens_on_resolve_error [trace]: monkeypatchRatatoskrApp.run_asyncto a sentinel that fails the test if called; set up respx to return 404 from POST /sessions; assertrun_tuireturns 12; assert the sentinel was NEVER invoked. Directly probes INV-001 (alt-screen MUST NOT open).client_lifetime_owned_by_run_tui [trace]: spy onhttpx.AsyncClient.aclose; successful run; assert exactly oneaclosecall AFTERapp.run_asyncreturned, 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 bycli._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_runcoverage GREEN underuv run pytest tests/. uv run ruff check src/ tests/clean.- Boundary smoke
tests/test_no_worldtree_imports.pystill passes. - Manual smoke (the original failure mode from 2026-05-21):
ratatoskr --new --agent <nonexistent>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 mimiragainst 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.tuishell) — landed on main; this issue amends its contract. - Issue #5 (
--end-user-idfor 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 NoneAND no--sessionAND no--new) is now VALID → triggers the picker. (Previouslyraise UsageError("pass exactly one of --session or --new")unconditionally.) --sendmode still requires exactly one of--session/--new(non- interactive: no picker can open) →UsageError("--send requires --session or --new").--session+--newstays mutually exclusive.--agentin bare mode →UsageError(--agentbelongs to--new).
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 <id>` + 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.