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`).
44 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 | 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. |
|
python | medium | 320 | 0.8 |
|
|
|
|
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(fromratatoskr.cli._parse_args) carryingsession_idxornew,agent_id(whennew),api_key,server_url,raw: bool, andsend_content: None(the TUI-mode marker —--sendwas 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).12—AgentNotFoundon initial--newsession creation.20—SessionApiFailedon initial session creation.21—httpx.ConnectError/httpx.ReadTimeout/httpx.TransportErrorbefore 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_clientmodules). - Terminal raw-mode + alternate-screen via Textual's lifecycle.
- One
httpx.AsyncClientopened inon_mount, closed inon_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.cliMUST NOT importtextualat module scope. The cli-to-tui dispatch inmainuses a function-localfrom ratatoskr.tui import run_tuiinside the branch that runs ONLY when--sendwas omitted. Verified by the existingtest_no_textual_import_in_clistatic-grep test (issue #3 INV-001), which scanscli.pyforimport textual/from textual. The--sendpath 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_idvia a dedicatedStatic(id="identity")widget composed adjacent toFooter()(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 isargs.agent_id(when--new), ORSessionInfo.agent_id(when--newAND a successful create_session populates it), OR the literal string<unknown>(when--session <id>was used AND agent_id is not present in args — aGET /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 afteron_mountcompletes; 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 → spawncancel_turnserver-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
Cancelledterminal event arrives (orDone/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.
- idle state (no turn in flight): footer hint =
- 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;
--rawis the opt-out. With markdown enabled,Textevent deltas stream as raw text appended to the RichLog as they arrive (no mid-stream markdown attempt — partial markdown like**helwould render ugly), and onDonea 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 inopen_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.AsyncClientper app lifetime — opened inon_mount, closed inon_unmountvia 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) coverssrc/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
DataTableofGET /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}/messageshistory 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 --newis the recording probe. - Cross-process resume — per design-brief §8d, deferred to v2. Transcript is per-launch.
/admin/eventsSSE consumption — admin observability surface lands with the AdminEvents pane issue.reconnect_turnmid-session — if a stream drops mid-turn, the TUI renders the error and returns to idle. In-process reconnect withLast-Event-IDresume is a separate issue (the underlyingsse_client.reconnect_turnis 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 throughsse_client+sessions. - [performance] Streaming MUST NOT buffer the turn before rendering.
Textdeltas write to RichLog as they arrive. The post-Done markdown render reads the accumulatedDone.responsefield from the terminal event — no client-side re-aggregation from individual deltas. - [security] TUI does not log
Authorizationheader,--api-keyvalue, 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:
parser.add_argument("--send", required=True)→parser.add_argument("--send", default=None).send_contentbecomesstr | None.- The non-empty-content check now ONLY fires when
--sendwas passed:if ns.send is not None and not ns.send: raise UsageError("--send content must be non-empty"). --rawflag added:parser.add_argument("--raw", action="store_true"). Maps toParsedArgs.raw: bool.
ParsedArgs amendments:
send_content: str | None(wasstr).- 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_sendtest (which asserted UsageError when --send is missing) is REMOVED —--sendis now optional. A new testtest_no_send_dispatches_to_tuiasserts that_parse_args(["--session", "s-1", "--api-key", "k"])returnsParsedArgs(send_content=None, ...)without raising. - A new test in TestMain asserts that
main(["--session", "s-1", "--api-key", "k"])callsrun_tui(monkeypatched) rather than_amain. - The
--rawflag gets coverage:_parse_argstest that--rawsetsargs.raw=Trueand 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.