Files
ratatoskr/docs/contracts/issues/4.contract.md
T
vh 61c3941ec3 fix(client): disable SSE read timeout — caught by personal Worldtree smoke
First manual smoke against personal Worldtree (10.250.50.152:8081)
produced httpx.ReadTimeout mid-stream after the worker_phase
BuildingPrompt event. Root cause: httpx's default 5s read timeout
killed the connection during mimir's thinking phase (LLM streaming
has multi-second idle gaps between SSE events).

Fix at the caller layer (where the AsyncClient is owned):
- cli._amain and tui.on_mount now construct AsyncClient with
  timeout=httpx.Timeout(connect=10.0, read=None, write=10.0, pool=10.0).
  read=None disables the SSE-killing timeout; connect/write/pool
  keep modest timeouts so true network failures still surface
  promptly.

Defense in depth in sse_client.stream_turn:
- ERROR_ROUTING now also catches httpx.ReadTimeout (was just
  ReadError | RemoteProtocolError) and surfaces it as
  SseConnectionDropped, so if a caller misconfigures their client
  the failure is at least a named exception the presenters handle.

Contract amendments (in-place):
- Issue #1: new [compatibility] constraint documents the read=None
  recommendation; ERROR_ROUTING for stream_turn lists ReadTimeout
  alongside ReadError/RemoteProtocolError.
- Issues #3 + #4: AsyncClient construction step now spells out the
  timeout shape explicitly.

Smoke after fix: SSE stream consumed cleanly, agent responded,
[done] turn_id=88 model=qwen3.6-35-a3b duration_ms=2351. Stdout-only
(2>/dev/null) returned clean agent text + exit 0 — INV-002
stdout/stderr split holds end-to-end against real wire. Wire-compat
envelope (personal v0.16.2 vs ratatoskr's v0.19.0 pin) confirmed.

164/164 tests GREEN; ruff clean; all three drift checks clean.

Note: TUI mode not smoke-tested from this CC session (needs a TTY;
operator-side check via `source env.sh && uv run ratatoskr --new
--agent mimir`).
2026-05-21 00:58:42 -07:00

44 KiB
Raw Blame History

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 Implement the Textual TUI shell — the interactive primary presenter (design-brief §1, §5). One `RatatoskrApp` Textual `App[int]` subclass plus a sync `run_tui(args) -> int` entry point. Layout: Header + RichLog (transcript) + Input (prompt) + Footer (session-identity-always-visible per design-brief §4). Bindings: two-stage Ctrl-C (cancel → exit per §8c); Ctrl-D immediate exit. Markdown rendering on agent output by default; `--raw` opt-out. Composes `ratatoskr.sessions.create_session` (when `--new`) with `ratatoskr.sse_client.stream_turn` + `cancel_turn`. Also amends `ratatoskr.cli`: `--send` becomes optional, and when omitted `main` dispatches to `ratatoskr.tui.run_tui` via lazy import (preserving INV-001 of issue #3 — `ratatoskr.cli` still does not import `textual` directly; the lazy import lives inside a code path the `--send` flow never enters). Ships ONLY the chat-pane shell — design-brief §5's five side panes (Persona, Tools, AdminEvents, BifrostState, ServerLog), the startup session picker, Tab bindings, and history rendering are deliberately deferred to follow-up issues, each in its own contract.
textual
httpx
ratatoskr.sse_client
ratatoskr.sessions
ratatoskr.cli
python medium 320 0.8
Worldtree spec pin (`docs/conversation-api-spec.md` at v1.0, repo SHA `55101e909abcd2219833266b6f905c5bc956e0f0`) unchanged. The TUI consumes the same `sse_client` + `sessions` surfaces as issue #3; wire-level changes are insulated through those modules.
Textual ≥ 0.85 (per pyproject) provides `App[T]`, `App.run()`, `App.run_test()` + `Pilot`, `RichLog` widget with markdown-via-`rich.markdown.Markdown` rendering, `Input` widget with `Input.Submitted` event, declarative `BINDINGS` class attribute mapping key chords to actions, `@on()` decorator + `on_<event>` naming convention.
Issues #1, #2, #3 are landed on main and their public surfaces are stable. The TUI imports `stream_turn`, `cancel_turn`, the `Event` discriminated union (`WorkerPhase | Thinking | Text | TextBoundary | ToolStart | ToolResult | Done | Error | Cancelled`), all error types from `ratatoskr.sse_client`; and `create_session`, `AgentNotFound`, `SessionApiFailed` from `ratatoskr.sessions`.
`httpx.AsyncClient(base_url=server_url, headers={'Authorization': f'Bearer {api_key}'})` is opened inside the App lifecycle (on_mount) and closed in on_unmount. The TUI owns its client; it does not share a client with `_amain` (the TUI path bypasses `_amain` entirely).
`App.run_test()` provides a headless `Pilot` that drives the app from pytest. Pilot supports `pilot.press(...)` for key simulation and `pilot.pause()` to let pending tasks resolve. Widget queries via `app.query_one(...)` work in test mode.
Streaming-markdown partial rendering: streaming raw text mid-turn then re-rendering as Markdown on Done is the cleanest UX, but requires RichLog line-replacement (uncertain support) OR a separate `Static` for the active turn + a 'commit' on Done. Draft: stream raw text into RichLog; on Done, append a separator + the full markdown render below (acknowledging a small redundancy). If empirically ugly, refactor to Static-then-commit in a follow-up — same shape as design-brief §6's `--no-stream-formatting` punt.
Textual `BINDINGS` priority for `ctrl+c` vs `Input` widget focus: when `Input` is focused, does `ctrl+c` reach the app's binding or get consumed by the input widget? Draft: declare the binding with `priority=True` to ensure the app sees it regardless of focus. If `priority=True` interferes with input editing, fall back to a custom `Input` subclass that surfaces ctrl+c.
Should the TUI persist transcript across restarts? Per design-brief §8d ('reconnect, not resume-across-process') the answer is no — fresh transcript every launch. Confirming this is in scope of the shell contract (deferred), not punted.
issue issue_url body_sha256_16 lock_in_comment_id lock_in_sha256_16 lock_in_at pinned_at
4 #4 b1e73e7d2e3dd453 null null null 2026-05-21T06:21:37+00:00
issue path reason
1 src/ratatoskr/sse_client.py Code-level import: TUI calls stream_turn() and cancel_turn() and renders the typed Event union.
issue path reason
2 src/ratatoskr/sessions.py Code-level import: TUI calls create_session() and reads SessionInfo when --new is passed.
issue path reason
3 src/ratatoskr/cli.py This issue AMENDS cli — `--send` becomes optional; when omitted, `main` lazy-imports and dispatches to `ratatoskr.tui.run_tui`. The INV-001 (no textual in cli) is preserved by the lazy-import shape.

TUI shell — Textual app, single chat pane, two-stage Ctrl-C

Context

ratatoskr.tui is the interactive primary presenter described in docs/design-brief.md §1 + §5. One RatatoskrApp Textual App[int] plus a sync run_tui(args) -> int entry point. The TUI is what the user gets when they invoke ratatoskr WITHOUT --send — the headline product the design brief commits to, with --send (issue #3) as the scriptable sibling.

This issue ships ONLY the shell — a single chat-pane app with markdown rendering, two-stage Ctrl-C, and the session-identity-always-visible footer. Design-brief §5's five side panes (Persona, Tools, AdminEvents, BifrostState, ServerLog), the startup session picker, Tab bindings, and history rendering are deliberately deferred to follow-up issues. Each side pane is one issue; this issue is the foundation they hang off.

The shell is the load-bearing primary surface. Together with --send, it makes good on the design-brief §8b architectural claim ("share the consumer, branch the presenter"): both presenters consume the same sse_client + sessions modules without forking the API-consumption code, and the two-stage Ctrl-C semantics that --send cannot express (it has no idle state) come into their own here.

Data flow

Input:

  • ParsedArgs (from ratatoskr.cli._parse_args) carrying session_id xor new, agent_id (when new), api_key, server_url, raw: bool, and send_content: None (the TUI-mode marker — --send was omitted).
  • User keypresses + Input widget submissions during the app's lifetime.

Output:

  • Textual screen renders (terminal escape sequences via Textual's renderer).
  • Process exit code via App.run() return value:
    • 0 — clean exit (Ctrl-C-while-idle or Ctrl-D).
    • 3 — force-exit from cancelling state OR a SIGINT-before-first-event situation (mirrors --send's code 3).
    • 12AgentNotFound on initial --new session creation.
    • 20SessionApiFailed on initial session creation.
    • 21httpx.ConnectError / httpx.ReadTimeout / httpx.TransportError before any session was established. Mid-session network errors do NOT exit the app; they're rendered as error lines in the transcript and the input remains active for retry.

Side effects:

  • Outbound HTTP only (via sessions + sse_client modules).
  • Terminal raw-mode + alternate-screen via Textual's lifecycle.
  • One httpx.AsyncClient opened in on_mount, closed in on_unmount.
  • One in-flight stream worker per turn (cancelled-then-replaced on the rare event-loop race; Textual's worker manager handles the bookkeeping).

On disk: none. Per design-brief §8d, no cross-process resume — fresh transcript per launch.

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 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.
    • cancelling state (cancel POSTed, draining): footer hint = "Press Ctrl-C again to exit"; Ctrl-C → app.exit(3) (force-exit, abandon the in-flight drain server-side).
    • After the Cancelled terminal event arrives (or Done/Error), state returns to idle and footer hint resets.
    • Note on the idle-hint discrepancy: the idle-state hint reads "Ctrl-C twice to exit" but a single Ctrl-C from idle DOES exit. This is intentional per design-brief §8c's "The footer-hint state transition is load-bearing — the dev needs to see that the next Ctrl-C will exit, otherwise they hit it again expecting another cancel and lose their session." The hint is conservative-by-design — it pre-warns the dev about the worst-case (streaming→cancel→exit) flow rather than the literal idle case (one press exits). Implementers MUST use the literal string "Ctrl-C twice to exit" (NOT something more accurate like "Ctrl-C to exit"); changing it would diverge from the design-brief's locked UX.
  • INV-004 [hard]: Ctrl-D is bound to app.exit(0) unconditionally — immediate exit regardless of state. Abandons any in-flight turn (server-side stall watchdog handles the orphan per spec).
  • INV-005 [hard]: Markdown rendering on agent output is default-on; --raw is the opt-out. With markdown enabled, Text event deltas stream as raw text appended to the RichLog as they arrive (no mid-stream markdown attempt — partial markdown like **hel would render ugly), and on Done a separator + the full markdown-rendered assistant message is appended below the streamed deltas. This means the assistant's response visibly appears TWICE in the transcript by design — once as the streamed raw deltas, once as the post-Done markdown render — separated by a horizontal-rule separator. This is the v1 accepted trade-off for streaming-visibility-without-mid-stream-markdown-ugliness; the cleaner Static-then-commit pattern (streaming into a replaceable widget, then committing the markdown version in place) is documented in open_questions: as the follow-up if the double-display proves empirically noisy. Implementers MUST NOT attempt the Static-then-commit pattern in this shell — it's deferred. With --raw, only the streamed deltas appear; no post-Done re-render; no double-display.
  • INV-006 [hard]: User-prompt echo in the transcript MUST visibly distinguish user input from assistant output. Format: <content> for user lines (with a literal prefix); assistant lines have no prefix. The prefix is also a screen-reader-friendly affordance.
  • INV-007 [hard]: One httpx.AsyncClient per app lifetime — opened in on_mount, closed in on_unmount via the async-with context manager pattern. The client is NOT recreated per turn (would burn the TCP connection pool).
  • INV-008 [hard]: Mid-session network/protocol errors (SseConnectionDropped, SseConnectFailed, MalformedSseId, TurnIdFlip) during a streaming turn render as error lines in the transcript and return the app to idle state — they do NOT exit the app. Only initial session-create errors exit (per Data flow exit codes).
  • INV-009 [hard]: No core.* / worldtree.* imports. The boundary smoke (tests/test_no_worldtree_imports.py) covers src/ratatoskr/ as a whole including the new tui.py.

Out of scope

  • All five side panes (Persona, Tools, AdminEvents, BifrostState, ServerLog per design-brief §5) — each gets its own follow-up issue. The shell ships ONLY the chat pane.
  • Startup session picker (design-brief §4 DataTable of GET /sessions) — require --session <id> OR --new --agent <id> for now; the picker is its own issue.
  • Tab bindings (Ctrl+1..5) — they pair with the side panes; come later with the first side pane that needs them.
  • Multi-turn history rendering on session-attach--session <id> opens with a blank transcript; GET /sessions/{id}/messages history replay is deferred until a workflow demands it.
  • Recorded SSE snapshot fixtures — captured by a separate follow-up issue. The TUI benefits from fixtures but doesn't author them; --send --new is the recording probe.
  • Cross-process resume — per design-brief §8d, deferred to v2. Transcript is per-launch.
  • /admin/events SSE consumption — admin observability surface lands with the AdminEvents pane issue.
  • reconnect_turn mid-session — if a stream drops mid-turn, the TUI renders the error and returns to idle. In-process reconnect with Last-Event-ID resume is a separate issue (the underlying sse_client.reconnect_turn is implemented; the TUI doesn't invoke it yet).
  • Bifrost-binding consumer support — not a Ratatoskr concern (per design-brief §6 negative clauses).
  • --quiet / --no-stream-formatting — deferred per design-brief §6. Add only if streaming text + post-Done markdown render proves empirically noisy.

Constraints

  • [compatibility] Module must work against the spec pin (55101e909abcd2219833266b6f905c5bc956e0f0, Worldtree v0.19.0). The TUI is insulated from wire-level changes through sse_client + sessions.
  • [performance] Streaming MUST NOT buffer the turn before rendering. Text deltas write to RichLog as they arrive. The post-Done markdown render reads the accumulated Done.response field from the terminal event — no client-side re-aggregation from individual deltas.
  • [security] TUI does not log Authorization header, --api-key value, or full event bodies. Persistence is per-launch (no disk writes); transcript content is in-memory only.
  • [style] Async-native. Textual's worker pattern (self.run_worker(coro, exclusive=True)) drives the stream loop; no manual thread management. App[int] for typed exit codes. ruff line-length=100 (per pyproject).

Architecture

ratatoskr <args>                                 [shell entry, registered in pyproject]
  │
  └─ ratatoskr.cli.main(argv) [sync]
       │
       ├─ _parse_args(argv)                       [returns ParsedArgs; --send now optional]
       │
       └─ branch on args.send_content:
            │
            ├─ args.send_content is not None  ──► asyncio.run(_amain(args))       [issue #3 path]
            │
            └─ args.send_content is None      ──► from ratatoskr.tui import run_tui   [lazy import]
                                                  return run_tui(args)
                                                       │
                                                       └─ RatatoskrApp(args).run()
                                                            │
                                                            ├─ on_mount: open AsyncClient, create_session (if --new),
                                                            │            init footer with agent_id + session_id_tail8
                                                            ├─ on_input_submitted: spawn _stream_turn_worker(content)
                                                            │   _stream_turn_worker:
                                                            │     for event in stream_turn(...):
                                                            │       _render_event_to_log(event, log)
                                                            │       on Done: if not raw, append separator + markdown render
                                                            │     transition: streaming → idle
                                                            ├─ action_interrupt (ctrl+c): two-stage state machine per INV-003
                                                            ├─ action_quit (ctrl+d): app.exit(0)
                                                            └─ on_unmount: close AsyncClient

FN run_tui(args: ParsedArgs) -> int
BRIEF: Sync entry point called from `ratatoskr.cli.main`'s lazy-import branch. Constructs the `RatatoskrApp` with the parsed args and runs it under Textual's loop; returns the App's exit code.
PRE: [PRE-001 hard] args is a ParsedArgs with args.send_content is None (TUI mode marker) -- assert isinstance(args, ParsedArgs) and args.send_content is None
PRE: [PRE-002 hard] exactly one of args.session_id / args.new is set -- assert bool(args.session_id) != bool(args.new) (post-_parse_args xor validation, hold-over assertion)
POST: [POST-001 return_value] returns the exit code from App.run() (0, 3, 12, 20, 21 per Data flow table)
ERROR_ROUTING:
  (none at this level — App.run() catches its own exceptions and surfaces them as exit codes; uncaught errors propagate as Python exceptions to the cli's main wrapper)
STEPS:
  1. [setup, flexibility=prescriptive] Validate inputs per PRE-001..PRE-002
  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; 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)
CLASS RatatoskrApp(textual.app.App[int])
BRIEF: Textual app — single chat pane shell. Holds the parsed args, the active session_id, the httpx.AsyncClient, and the Ctrl-C state machine. Exposes the bindings + the worker coordination for stream_turn / cancel_turn.
PROPERTIES:
  args: ParsedArgs                          # passed in __init__
  session_id: str | None                    # set in on_mount (after create_session if --new)
  agent_id: str | None                      # set in on_mount
  client: httpx.AsyncClient | None          # opened in on_mount, closed in on_unmount
  state: Literal["idle", "streaming", "cancelling"]   # INV-003 state machine
  active_turn_id: int | None                # set when first event of a turn yields; cleared on terminal
  stream_worker: textual.worker.Worker | None  # the current _stream_turn_worker task
BINDINGS:
  - ("ctrl+c", "interrupt", "Cancel / Exit")     # priority=True so Input doesn't consume it; see open_questions
  - ("ctrl+d", "quit", "Exit immediately")
COMPOSE shape (declarative — implementer chooses CSS file vs inline):
  Header()
  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.
FN RatatoskrApp.on_mount(self) -> None
BRIEF: Lifecycle hook. Opens the httpx.AsyncClient, mints or attaches the session, populates the footer with agent_id + session_id_tail8, sets state to "idle".
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 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:
  AgentNotFound (from create_session):
    local_handling: append `[agent_not_found] agent_id={exc.agent_id}` to RichLog as an error line; call self.exit(12)
    flow_control: abort the mount (the app exits before user can interact)
    state_recovery: none
  SessionApiFailed (from create_session):
    local_handling: append `[session_api_failed] status={exc.status} body={exc.body!r}` to RichLog; self.exit(20)
    flow_control: abort
    state_recovery: none
  httpx.ConnectError | httpx.ReadTimeout | httpx.TransportError (from create_session):
    local_handling: append `[network_error] {type(exc).__name__}: {exc}` to RichLog; self.exit(21)
    flow_control: abort
    state_recovery: none
STEPS:
  1. [setup, flexibility=prescriptive] Validate PRE-001
  2. [sequential, flexibility=prescriptive] Open AsyncClient: self.client = 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)) — read=None disables the SSE-killing 5s default (issue #1 [compatibility] constraint)
  3. [branch, flexibility=prescriptive] IF args.new:
       TRY: info = await create_session(self.client, args.agent_id)
       ON AgentNotFound | SessionApiFailed | httpx.ConnectError | httpx.ReadTimeout | httpx.TransportError: handle per ERROR_ROUTING (append + exit)
       SET self.session_id = info.session_id; self.agent_id = info.agent_id
     ELSE:
       SET self.session_id = args.session_id; self.agent_id = args.agent_id (when args.session is used, --agent is forbidden per issue #3 INV-004 — agent_id may be empty here)
       (When session_id is set but agent_id unknown: optionally GET /sessions/{id} for it; OUT OF SCOPE for this shell — display `<unknown> · …<tail>` instead. Single follow-up if it becomes noisy.)
  4. [sequential, flexibility=prescriptive] Update footer: self.sub_title = f"{self.agent_id or '<unknown>'} · …{self.session_id[-8:]}"; render hint "Ctrl-C twice to exit"
  5. [sequential, flexibility=prescriptive] SET self.state = "idle"
TESTS:
  happy_new_session_mount [happy,tracer]: --new --agent mimir; respx mocks POST /sessions → 201; Pilot.pause() → app.session_id is "s-new", app.agent_id is "mimir", footer text contains "mimir · …", state=="idle"
  happy_existing_session_mount [happy]: --session s-1; no POST /sessions; Pilot.pause() → app.session_id is "s-1", footer shows agent (or <unknown>), state=="idle"
  agent_not_found_on_mount [error]: --new; POST /sessions → 404 → app exits 12; RichLog contains "[agent_not_found]"
  session_api_failed_on_mount [error]: --new; POST /sessions → 500 → app exits 20; RichLog contains "[session_api_failed]"
  network_error_on_mount [error]: --new; POST /sessions raises httpx.ConnectError → app exits 21; RichLog contains "[network_error]"
  footer_identity_visible_first_frame [trace]: INV-002 — after Pilot.pause(), query the footer and assert the agent_id + session_id_tail8 substring is present before any other interaction
  client_open_after_mount [trace]: post-mount self.client is not None and is_closed is False
FN RatatoskrApp.on_input_submitted(self, event: Input.Submitted) -> None
BRIEF: Handler for the Input widget's Submitted event (user pressed Enter). Echoes the user's prompt with the ` ` prefix (INV-006), clears the input field, and spawns a worker to stream the turn. When the app is not idle, the submission is discarded WITH a visible transcript notice (NOT silently) — surprises the user less than a silent swallow.
PRE: [PRE-001 hard] event.input.id == "prompt" (the prompt Input widget) -- if event.input.id != "prompt": return (Textual routes by id; assert is defensive)
PRE: [PRE-002 hard] self.state == "idle" — submissions during streaming/cancelling are discarded with a visible notice (INV-003) -- if self.state != "idle": write "[busy] turn in flight; input ignored" to RichLog; event.input.value = ""; return
POST: [POST-001 side_effect] on idle submit: RichLog contains a new line " {content}" (INV-006 user-prompt format)
POST: [POST-002 side_effect] on idle submit: event.input.value == "" (input cleared)
POST: [POST-003 state_change] on idle submit: self.state transitions to "streaming"; self.stream_worker is the live Worker for this turn
POST: [POST-004 side_effect] on idle submit: footer hint updates to "Ctrl-C to cancel"
POST: [POST-005 side_effect] on non-idle submit: RichLog contains "[busy] turn in flight; input ignored"; event.input.value == ""; state unchanged; NO new worker spawned
STEPS:
  1. [setup, flexibility=prescriptive] IF event.input.id != "prompt": RETURN
     IF self.state != "idle":
       Append "[busy] turn in flight; input ignored" to RichLog  # visible notice, not silent swallow
       Clear event.input.value
       RETURN
  2. [sequential, flexibility=prescriptive] content = event.input.value.strip(); IF NOT content: RETURN (don't send empty messages)
  3. [sequential, flexibility=prescriptive] Append " {content}" to RichLog
  4. [sequential, flexibility=prescriptive] Clear event.input.value
  5. [sequential, flexibility=prescriptive] SET self.state = "streaming"; update footer hint to "Ctrl-C to cancel"
  6. [sequential, flexibility=prescriptive] SET self.stream_worker = self.run_worker(self._stream_turn_worker(content), exclusive=True)
TESTS:
  happy_submit_echoes_and_spawns [happy,tracer]: state=idle; Pilot types "hello" + Enter; RichLog contains " hello"; input cleared; state=="streaming"; stream_worker is not None
  empty_submit_no_op [trace]: type "" + Enter → no change to RichLog; no worker spawned; state stays idle
  submit_during_streaming_shows_busy_notice [adversarial]: state=streaming; Pilot types "x" + Enter → RichLog contains "[busy] turn in flight; input ignored"; input cleared; no new worker; only the original worker is alive; state stays "streaming"
  submit_during_cancelling_shows_busy_notice [adversarial]: state=cancelling; Pilot types "x" + Enter → same as above; state stays "cancelling"
  footer_hint_flips_to_cancel [trace]: after submit, footer hint shows "Ctrl-C to cancel"
FN RatatoskrApp._stream_turn_worker(self, content: str) -> None
BRIEF: Worker coroutine spawned by `on_input_submitted`. Drives `stream_turn`, renders each event into the RichLog via `_render_event_to_log`, captures `active_turn_id` from the first event for the Ctrl-C cancel path, and transitions state back to "idle" after the terminal event (or on a mid-session error).
PRE: [PRE-001 hard] self.state == "streaming" (set by on_input_submitted before spawn) -- assert self.state == "streaming"
PRE: [PRE-002 hard] self.client is not None (set in on_mount) -- assert self.client is not None
PRE: [PRE-003 hard] content is non-empty (caller validated in on_input_submitted) -- assert content
POST: [POST-001 state_change] after terminal event OR error, self.state == "idle"; self.active_turn_id is None; footer hint reset to "Ctrl-C twice to exit"
POST: [POST-002 side_effect] each event passed through _render_event_to_log exactly once (until terminal OR until cancel-induced abort)
POST: [POST-003 side_effect] for Done events with NOT args.raw: a separator line + the markdown-rendered Done.response appended to RichLog (INV-005)
POST: [POST-004 state_change] active_turn_id is set to event.sse_id.turn_id on the FIRST yielded event (for cancel_turn use by action_interrupt)
ERROR_ROUTING:
  SseConnectFailed | SseConnectionDropped | MalformedSseId | TurnIdFlip:
    local_handling: append `[<label>] <details>` to RichLog (mirror cli.py's error labels)
    flow_control: abort (the iteration aborts; finally-block restores state)
    state_recovery: state → idle; footer hint reset; active_turn_id cleared. (INV-008: mid-session errors do NOT exit the app.)
  asyncio.CancelledError (from action_interrupt force-exit OR Worker.cancel()):
    local_handling: none — propagate to let Textual's worker manager clean up
    flow_control: abort
    state_recovery: state → idle; active_turn_id cleared. (cancel_task was already spawned by action_interrupt.)
STEPS:
  1. [setup, flexibility=prescriptive] Validate inputs per PRE-001..PRE-003
  2. [loop, flexibility=prescriptive] TRY: async for event in stream_turn(self.client, self.session_id, content):
       IF self.active_turn_id is None: SET self.active_turn_id = event.sse_id.turn_id  # POST-004
       _render_event_to_log(event, log=self.query_one("#transcript", RichLog), raw=self.args.raw)
       IF isinstance(event, Done):
         IF NOT self.args.raw:
           Append a horizontal-rule separator to RichLog
           Render Markdown(event.response) into RichLog  # INV-005 post-Done markdown render
         BREAK (terminal; iteration done)
       IF isinstance(event, (Error, Cancelled)):
         BREAK (terminal)
     CATCH SseConnectFailed | SseConnectionDropped | MalformedSseId | TurnIdFlip as exc:
       Append `[<label>] <details>` to RichLog per cli.py's error-label format
  3. [cleanup, flexibility=prescriptive] FINALLY:
       SET self.state = "idle"; self.active_turn_id = None; reset footer hint to "Ctrl-C twice to exit"
TESTS:
  happy_text_done_renders_markdown [happy,tracer]: mock yields text("hello") + done(response="hello"); after Pilot.pause(), RichLog contains "hello" (the streamed delta) AND below it a separator + the markdown render of "hello"; state → idle
  raw_flag_skips_markdown_render [trace]: --raw; mock yields text + done; RichLog has the streamed delta but NO separator + markdown re-render
  error_terminal_returns_to_idle [happy]: mock yields text + error → RichLog has "[error]" label; state → idle (NOT app exit per INV-008)
  cancelled_terminal_returns_to_idle [happy]: mock yields text + cancelled → "[cancelled]" label; state → idle
  active_turn_id_set_on_first_event [trace]: mock yields text(42:1) then waits; after first render, self.active_turn_id == 42 (verifies the cancel path can pick it up)
  sse_connect_failed_returns_to_idle [error]: mock returns 404 → "[sse_connect_failed]" label in RichLog; state → idle; app does NOT exit (INV-008)
  connection_dropped_returns_to_idle [error]: mock raises RemoteProtocolError mid-stream → "[connection_dropped]" label; state → idle
  rendered_event_per_event [trace]: spy on _render_event_to_log; mock yields N events; call_count == N (terminal events included, since Done/Error/Cancelled also render through it)
FN _render_event_to_log(event: Event, *, log: RichLog, raw: bool) -> None
BRIEF: Pure event-to-RichLog renderer. Routes `Text` event deltas (raw text appended to the log) and labels every other Event variant (consistent with cli.py's `_render_event` but writes to a RichLog widget instead of stdout/stderr). The post-Done markdown render is NOT this function's job — it lives in `_stream_turn_worker` so the contract concern (per-event labeling) stays separate from the per-turn concern (post-Done markdown).
PRE: [PRE-001 hard] event is an instance of one of the Event union variants -- assert isinstance(event, (WorkerPhase, Thinking, Text, TextBoundary, ToolStart, ToolResult, Done, Error, Cancelled))
POST: [POST-001 side_effect] for Text events: log received event.content as a streamed delta (no newline appended per delta — RichLog handles chunk-by-chunk display)
POST: [POST-002 side_effect] for non-Text events: log received exactly one labeled line per event
POST: [POST-003 side_effect] Done event renders the same label format as cli.py's `_render_event` (turn_id from sse_id, model, duration_ms, usage); the post-Done markdown render is the caller's responsibility (NOT this function's)
STEPS:
  1. [setup, flexibility=prescriptive] Match on type(event)
  2. [branch, flexibility=prescriptive] Same case-table as cli._render_event but writing log.write(...) instead of stdout/stderr:
     CASE Text: log.write(event.content) (raw text; RichLog handles wrap)
     CASE Done: log.write(f"[done] turn_id={event.sse_id.turn_id} model={event.model} duration_ms={event.duration_ms} usage={event.usage!r}")
     CASE Error: log.write(f"[error] turn_id={event.sse_id.turn_id} code={event.error_code} message={event.message!r}")
     CASE Cancelled: log.write(f"[cancelled] turn_id={event.turn_id} reason={event.reason!r} partial_message_id={event.partial_message_id}")
     CASE WorkerPhase: log.write(f"[worker_phase] phase={event.phase} turn_id={event.turn_id}")
     CASE Thinking: log.write(f"[thinking] {event.content[:200]!r}")
     CASE TextBoundary: log.write(f"[text_boundary] kind={event.kind} char_offset={event.char_offset}")
     CASE ToolStart: log.write(f"[tool_start] name={event.name} args={event.arguments!r}")
     CASE ToolResult: log.write(f"[tool_result] name={event.name} duration_ms={event.duration_ms} result={event.result!r:.200}")
     # Note on {!r:.200}: this is valid Python f-string syntax — `!r` converts via repr(), then `:.200` is the format spec which for strings truncates to 200 chars. The composition yields a repr() that is at most 200 chars long (quotes count). Mirrors cli.py's _render_event for consistency.
TESTS:
  text_renders_raw_delta [happy,tracer]: Text(content="hello") → log received "hello" (verify via log.lines or a spy on log.write)
  done_renders_label_only [happy]: Done(...) → log line starts with "[done]"; does NOT include the post-Done markdown render (caller's job)
  error_renders_label [happy]: Error(...) → log line starts with "[error]"
  cancelled_renders_label [happy]: Cancelled(...) → log line starts with "[cancelled]"
  worker_phase_renders_label [happy]: WorkerPhase → "[worker_phase]"
  thinking_truncated [trace]: Thinking(content="a"*500) → log line shows only first 200 chars in repr
  tool_start_renders_label [happy]: ToolStart → "[tool_start]"
  tool_result_truncated [trace]: ToolResult(result="b"*500) → repr truncated to ≤200 chars
  text_boundary_renders_label [happy]: TextBoundary → "[text_boundary]"
FN RatatoskrApp.action_interrupt(self) -> None
BRIEF: Ctrl-C handler (declared in BINDINGS). Implements the INV-003 two-stage state machine. Idle → exit(0); streaming → cancel_turn + transition to cancelling; cancelling → exit(3) (force-exit, abandon drain).
PRE: [PRE-001 hard] self.state is one of ("idle", "streaming", "cancelling") -- assert self.state in ("idle", "streaming", "cancelling")
POST: [POST-001 side_effect] state transitions follow INV-003 (idle→exit, streaming→cancelling, cancelling→exit)
POST: [POST-002 side_effect] when transitioning streaming→cancelling, exactly one POST /sessions/{id}/turns/{active_turn_id}/cancel was issued (via a spawned _cancel_via_sse worker)
POST: [POST-003 side_effect] when transitioning streaming→cancelling, footer hint updates to "Press Ctrl-C again to exit"
ERROR_ROUTING:
  CancelFailed | CancelTurnNotFound | CancelAlreadyCompleted (during streaming→cancelling cancel POST):
    local_handling: append `[cancel_failed] <details>` to RichLog (handled inside _cancel_via_sse)
    flow_control: skip — INV-009 of issue #3 carried forward; the in-flight stream is still draining and will reach its own terminal
    state_recovery: state stays "cancelling" until the stream-worker terminates; then the worker's finally block restores idle
STEPS:
  1. [setup, flexibility=prescriptive] Validate PRE-001
  2. [branch, flexibility=prescriptive]
     IF self.state == "idle":
       self.exit(0)
     ELIF self.state == "streaming":
       IF self.active_turn_id is None:
         # First event hadn't arrived yet — no cancel possible. Force-cancel the worker and exit non-zero.
         IF self.stream_worker is not None: self.stream_worker.cancel()
         self.exit(3)
         RETURN
       SET self.state = "cancelling"
       update footer hint to "Press Ctrl-C again to exit"
       self.run_worker(_cancel_via_sse(self.client, self.session_id, self.active_turn_id, log=self.query_one("#transcript", RichLog)))
     ELIF self.state == "cancelling":
       # Second Ctrl-C — force exit, abandon drain
       IF self.stream_worker is not None: self.stream_worker.cancel()
       self.exit(3)
TESTS:
  idle_ctrl_c_exits_zero [happy,tracer]: state=idle; Pilot.press("ctrl+c") → app.exit was called with code 0
  streaming_first_ctrl_c_cancels [scenario,tracer]: state=streaming; active_turn_id=42; Pilot.press("ctrl+c"); state → cancelling; respx tracked one POST /sessions/{id}/turns/42/cancel; footer hint shows "Press Ctrl-C again to exit"
  streaming_no_turn_id_force_exits [scenario]: state=streaming; active_turn_id is None (first event hadn't arrived); Pilot.press("ctrl+c") → app.exit(3); worker cancelled; no cancel_turn POST
  cancelling_second_ctrl_c_force_exits [scenario]: state=cancelling; Pilot.press("ctrl+c") → app.exit(3); worker cancelled
  cancel_failed_swallowed [scenario]: state=streaming; active_turn_id=42; cancel POST returns 500 → "[cancel_failed]" in RichLog; state stays cancelling until stream-worker terminates
FN RatatoskrApp.action_quit(self) -> None
BRIEF: Ctrl-D handler (declared in BINDINGS). Immediate exit regardless of state. Abandons any in-flight turn server-side (the spec's stall watchdog handles the orphan).
PRE: (none — always callable)
POST: [POST-001 side_effect] self.exit(0) was called
POST: [POST-002 side_effect] if self.stream_worker is alive, it was cancelled (cleanup; the asyncio task and the AsyncClient are cleaned up by Textual's lifecycle / on_unmount regardless)
STEPS:
  1. [sequential, flexibility=prescriptive] IF self.stream_worker is not None AND not self.stream_worker.is_finished: self.stream_worker.cancel()
  2. [sequential, flexibility=prescriptive] self.exit(0)
TESTS:
  idle_ctrl_d_exits_zero [happy,tracer]: state=idle; Pilot.press("ctrl+d") → exit(0)
  streaming_ctrl_d_force_exits [scenario]: state=streaming; Pilot.press("ctrl+d") → exit(0); worker cancelled; no cancel_turn POST attempted (Ctrl-D is the abandon-and-exit path)
FN RatatoskrApp.on_unmount(self) -> None
BRIEF: Lifecycle hook. Closes the httpx.AsyncClient cleanly. Textual fires on_unmount as part of app shutdown.
PRE: (none)
POST: [POST-001 state_change] self.client is closed (httpx.AsyncClient.is_closed is True) OR was already None (app never reached on_mount due to early failure)
STEPS:
  1. [branch, flexibility=prescriptive] IF self.client is not None AND NOT self.client.is_closed:
       await self.client.aclose()
TESTS:
  unmount_closes_client [happy,tracer]: --session s-1; Pilot.press("ctrl+d") to trigger shutdown; after exit, app.client.is_closed is True
FN _cancel_via_sse(client: httpx.AsyncClient, session_id: str, turn_id: int, *, log: RichLog) -> None
BRIEF: TUI's analog of cli's `_cancel_and_log` — calls `sse_client.cancel_turn`, swallows all exceptions per INV-009 of issue #3, writes `[cancel_failed]` to the RichLog on failure. Used as a fire-and-forget worker spawned by `action_interrupt`.
PRE: [PRE-001 hard] client is not None
PRE: [PRE-002 hard] turn_id is a positive int
POST: [POST-001 side_effect] exactly one POST /sessions/{id}/turns/{turn_id}/cancel was issued
POST: [POST-002 side_effect] on cancel exception, log received a "[cancel_failed]" line
POST: [POST-003 exception] never raises
ERROR_ROUTING:
  CancelFailed | CancelTurnNotFound | CancelAlreadyCompleted | httpx.RequestError:
    local_handling: log.write(f"[cancel_failed] {type(exc).__name__}: {exc}")
    flow_control: skip (swallow)
    state_recovery: none
STEPS:
  1. [setup, flexibility=prescriptive] Validate PRE
  2. [sequential, flexibility=prescriptive] TRY: await cancel_turn(client, session_id, turn_id)
     CATCH the routed exceptions: log the labeled line; return None
TESTS:
  happy_cancel [happy,tracer]: mock returns 200 → returns None; log has no [cancel_failed]
  cancel_failed_500 [error]: mock returns 500 → log has "[cancel_failed] CancelFailed: ..."; no exception escapes
  cancel_already_completed [scenario]: mock returns 409 → "[cancel_failed] CancelAlreadyCompleted:"; no exception escapes
  transport_error_swallowed [error]: respx raises httpx.ConnectError → "[cancel_failed] ConnectError"; no exception escapes

CLI amendments (issue #3 contract concurrent amendment)

This issue also amends ratatoskr.cli to make --send optional and to dispatch to the TUI when omitted. The amendments are small and pinned here so reviewers see them in one place. They land in the same commit as the TUI implementation.

_parse_args amendments:

  1. parser.add_argument("--send", required=True)parser.add_argument("--send", default=None). send_content becomes str | None.
  2. The non-empty-content check now ONLY fires when --send was passed: if ns.send is not None and not ns.send: raise UsageError("--send content must be non-empty").
  3. --raw flag added: parser.add_argument("--raw", action="store_true"). Maps to ParsedArgs.raw: bool.

ParsedArgs amendments:

  • send_content: str | None (was str).
  • New field: raw: bool (default False).

main amendments:

ON args.send_content is None:
  # TUI mode — lazy import preserves INV-001 (no textual in cli at module scope)
  from ratatoskr.tui import run_tui
  RETURN run_tui(args)
ELSE:
  RETURN asyncio.run(_amain(args))

Test amendments to issue #3's TESTS:

  • The existing test_usage_no_send test (which asserted UsageError when --send is missing) is REMOVED — --send is now optional. A new test test_no_send_dispatches_to_tui asserts that _parse_args(["--session", "s-1", "--api-key", "k"]) returns ParsedArgs(send_content=None, ...) without raising.
  • A new test in TestMain asserts that main(["--session", "s-1", "--api-key", "k"]) calls run_tui (monkeypatched) rather than _amain.
  • The --raw flag gets coverage: _parse_args test that --raw sets args.raw=True and absence leaves it False.

The contract for issue #3 is amended IN-PLACE to reflect these changes (the FN signatures + ERROR_ROUTING + TESTS for _parse_args and main). Both contracts MUST be drift-check clean after this issue's commits land.