contract(issue#4): author ratatoskr.tui shell + amend issue #3 cli
Issue #4: Textual TUI shell — the interactive primary presenter (design-brief §1, §5). Single chat-pane App[int] subclass + sync run_tui(args) entry. Composes existing sessions + sse_client modules (no forked API-consumption code, per design-brief §8b). Six FN blocks: run_tui, RatatoskrApp class + on_mount + on_unmount, on_input_submitted, _stream_turn_worker, _render_event_to_log, action_interrupt, action_quit, _cancel_via_sse. Nine hard invariants codifying: - INV-001: lazy-import boundary so cli.py STILL doesn't import textual at module scope (issue #3's INV-001 carried forward) - INV-002: session-identity-always-visible footer (`<agent> · …<tail8>`) with explicit `<unknown>` carve-out for --session without --agent - INV-003: two-stage Ctrl-C state machine (idle/streaming/cancelling) per design-brief §8c - INV-005: markdown default-on with --raw opt-out; deliberately produces streaming-deltas + post-Done markdown re-render (accepted v1 trade-off, Static-then-commit refactor deferred) - INV-007: one AsyncClient per app lifetime - INV-008: mid-session errors → idle (don't exit); only initial session-create errors exit Concurrent in-place amendment of issue #3's contract: - --send becomes optional; when omitted, send_content=None is the TUI-mode marker - --raw flag added to ParsedArgs - main dispatches via lazy `from ratatoskr.tui import run_tui` when send_content is None - _parse_args + main TESTS sections updated (no_send_marks_tui_mode replaces usage_no_send; new raw_flag_default_false / raw_flag_set / no_send_dispatches_to_tui) Volva paraphrase round on issue #4: 5 findings, all amended. (1) INV-002 `<unknown>` carve-out wording. (2) Idle "Ctrl-C twice to exit" hint kept per design-brief §8c's conservative-by-design rationale; INV-003 spells out the intentional one-press-from-idle discrepancy. (3) Markdown double-render trade-off made explicit in INV-005. (4) Submit-during-streaming now writes `[busy] turn in flight; input ignored` (visible notice, not silent swallow). (5) `{!r:.200}` format spec kept with explanatory inline comment. Both contracts drift-check clean. prd: pinned to issue #4 body SHA b1e73e7d2e3dd453 at 2026-05-21T06:21:37+00:00.
This commit is contained in:
@@ -201,14 +201,19 @@ STEPS:
|
||||
RETURN 11
|
||||
ON SystemExit as exc:
|
||||
RETURN int(exc.code) if exc.code is not None else 0
|
||||
2. [sequential, flexibility=prescriptive] RETURN asyncio.run(_amain(args))
|
||||
2. [branch, flexibility=prescriptive] IF args.send_content is None:
|
||||
# TUI mode (issue #4 amendment) — 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))
|
||||
TESTS:
|
||||
happy_returns_amain_exit_code [happy,tracer]: argv specifies a complete --send invocation; monkeypatch _amain to return 0 → main returns 0
|
||||
usage_error_no_send [error]: argv=[] → main returns 10; stderr has "[usage_error]"; _amain never called
|
||||
usage_error_both_session_and_new [error]: argv has both --session and --new → returns 10; stderr "[usage_error]"
|
||||
auth_error_missing_key [error]: argv specifies --send/--new/--agent but neither --api-key nor WORLDTREE_API_KEY is set → returns 11; stderr "[auth_error]"; _amain never called
|
||||
no_argv_uses_sys_argv [trace]: argv=None → _parse_args is called with sys.argv[1:] (monkeypatched argparse capture confirms)
|
||||
help_exits_cleanly [happy]: argv=["--help"] → main returns 0 (or whatever code argparse exits with); _amain never called; help text was printed to stdout by argparse
|
||||
no_send_dispatches_to_tui [happy]: argv omits --send → main lazy-imports run_tui and calls it (NOT _amain); send_content marker is None (issue #4 amendment)
|
||||
```
|
||||
|
||||
```contract
|
||||
@@ -245,16 +250,19 @@ ERROR_ROUTING:
|
||||
state_recovery: none (no resources acquired before _parse_args)
|
||||
STEPS:
|
||||
1. [setup, flexibility=prescriptive] Construct argparse.ArgumentParser:
|
||||
--send <content> (required, str, non-empty)
|
||||
--send <content> (str, OPTIONAL — issue #4 amendment: omitted → TUI mode marker; if passed, must be non-empty)
|
||||
--session <id> (str, optional)
|
||||
--new (bool flag, default False)
|
||||
--agent <id> (str, optional — required-with-validation in step 3)
|
||||
--api-key <key> (str, optional — env fallback in step 4)
|
||||
--server <url> (str, optional — env fallback + default in step 5)
|
||||
--raw (bool flag, default False — issue #4 amendment: TUI markdown opt-out)
|
||||
2. [sequential, flexibility=prescriptive] Parse argv:
|
||||
TRY: ns = parser.parse_args(argv if argv is not None else sys.argv[1:])
|
||||
ON SystemExit:
|
||||
Re-raise as UsageError with argparse's captured message
|
||||
2a. [branch, flexibility=prescriptive] IF ns.send is not None AND not ns.send:
|
||||
RAISE UsageError("--send content must be non-empty") # empty-string --send still invalid
|
||||
3. [branch, flexibility=prescriptive] Validate session/new/agent triad per INV-004:
|
||||
IF ns.session and ns.new: RAISE UsageError("--session and --new are mutually exclusive")
|
||||
IF NOT ns.session AND NOT ns.new: RAISE UsageError("pass exactly one of --session or --new")
|
||||
@@ -266,12 +274,13 @@ STEPS:
|
||||
5. [sequential, flexibility=prescriptive] Resolve server_url per INV-006:
|
||||
server_url = ns.server or os.environ.get("WORLDTREE_API_URL") or "http://localhost:8000"
|
||||
6. [cleanup] RETURN ParsedArgs(
|
||||
send_content=ns.send,
|
||||
send_content=ns.send, # may be None (TUI marker, issue #4 amendment)
|
||||
session_id=ns.session,
|
||||
new=ns.new,
|
||||
agent_id=ns.agent,
|
||||
api_key=api_key,
|
||||
server_url=server_url,
|
||||
raw=ns.raw, # issue #4 amendment
|
||||
)
|
||||
TESTS:
|
||||
happy_new [happy,tracer]: argv=["--send", "hi", "--new", "--agent", "mimir", "--api-key", "k"] → ParsedArgs(send_content="hi", session_id=None, new=True, agent_id="mimir", api_key="k", server_url="http://localhost:8000")
|
||||
@@ -281,7 +290,9 @@ TESTS:
|
||||
server_default [trace]: argv omits --server and WORLDTREE_API_URL is unset → ParsedArgs.server_url=="http://localhost:8000"
|
||||
server_env_fallback [trace]: monkeypatch WORLDTREE_API_URL="http://t.local:9000"; argv omits --server → ParsedArgs.server_url=="http://t.local:9000"
|
||||
server_flag_beats_env [trace]: monkeypatch WORLDTREE_API_URL="env"; argv has --server "flag" → ParsedArgs.server_url=="flag"
|
||||
usage_no_send [error]: argv=["--new", "--agent", "mimir", "--api-key", "k"] → UsageError (argparse required-flag)
|
||||
no_send_marks_tui_mode [happy]: argv=["--new", "--agent", "mimir", "--api-key", "k"] → ParsedArgs.send_content=None (TUI marker; issue #4 amendment — was UsageError pre-#4)
|
||||
raw_flag_default_false [trace]: --raw absent → ParsedArgs.raw==False (issue #4 amendment)
|
||||
raw_flag_set [trace]: --raw → ParsedArgs.raw==True (issue #4 amendment)
|
||||
usage_both_session_and_new [adversarial]: argv has both --session and --new → UsageError("mutually exclusive")
|
||||
usage_neither_session_nor_new [adversarial]: argv has --send but neither --session nor --new → UsageError("pass exactly one")
|
||||
usage_new_without_agent [adversarial]: argv=["--send","hi","--new","--api-key","k"] → UsageError("--agent is required when --new")
|
||||
|
||||
@@ -0,0 +1,462 @@
|
||||
---
|
||||
contract_version: "2.1"
|
||||
target_module: "ratatoskr.tui"
|
||||
scope: "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."
|
||||
depends_on:
|
||||
- "textual"
|
||||
- "httpx"
|
||||
- "ratatoskr.sse_client"
|
||||
- "ratatoskr.sessions"
|
||||
- "ratatoskr.cli"
|
||||
used_by: []
|
||||
language: "python"
|
||||
complexity: "medium"
|
||||
estimated_loc: 320
|
||||
confidence: 0.8
|
||||
assumptions:
|
||||
- "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."
|
||||
open_questions:
|
||||
- "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."
|
||||
prd:
|
||||
issue: 4
|
||||
issue_url: "https://gitea.phasefinal.com/vh/ratatoskr/issues/4"
|
||||
body_sha256_16: "b1e73e7d2e3dd453"
|
||||
lock_in_comment_id: null
|
||||
lock_in_sha256_16: null
|
||||
lock_in_at: null
|
||||
pinned_at: "2026-05-21T06:21:37+00:00"
|
||||
dependencies:
|
||||
- issue: 1
|
||||
path: "src/ratatoskr/sse_client.py"
|
||||
reason: "Code-level import: TUI calls stream_turn() and cancel_turn() and renders the typed Event union."
|
||||
- issue: 2
|
||||
path: "src/ratatoskr/sessions.py"
|
||||
reason: "Code-level import: TUI calls create_session() and reads SessionInfo when --new is passed."
|
||||
- issue: 3
|
||||
path: "src/ratatoskr/cli.py"
|
||||
reason: "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).
|
||||
- `12` — `AgentNotFound` on initial `--new` session creation.
|
||||
- `20` — `SessionApiFailed` on initial session creation.
|
||||
- `21` — `httpx.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 Footer widget always displays an agent slot + the LAST 8 chars of `session_id` (design-brief §4 session-identity-always-visible invariant). The format is `<agent_slot> · …<session_id_tail8>` with a literal `·` separator and `…` prefix. The agent slot is `args.agent_id` (when `--new`), OR `SessionInfo.agent_id` (when `--new` AND a successful create_session populates it), OR the literal string `<unknown>` (when `--session <id>` was used AND agent_id is not present in args — a `GET /sessions/{id}` lookup is out of scope for this shell, see `## Out of scope`). The `<unknown>` placeholder is an ACCEPTED satisfaction of "session-identity-always-visible" — it signals to the dev that the agent is opaque from this launch but the session_id tail is still anchored. This MUST appear by the first frame after `on_mount` completes; the App MUST NOT render the chat pane in a state where the agent slot OR the session_id tail is absent.
|
||||
- **INV-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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
```contract
|
||||
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; mock the SSE endpoint; Pilot presses ctrl+d immediately; run_tui returns 0
|
||||
precondition_send_content_none [adversarial]: args with send_content="x" → AssertionError before run() (PRE-001 catches the misuse)
|
||||
```
|
||||
|
||||
```contract
|
||||
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=True, highlight=True)
|
||||
Input(id="prompt", placeholder="Type a message and press Enter")
|
||||
Footer()
|
||||
INV-WIRE-001: One AsyncClient lifecycle per app lifetime (INV-007).
|
||||
INV-WIRE-002: state transitions strictly idle ↔ streaming ↔ cancelling per INV-003.
|
||||
```
|
||||
|
||||
```contract
|
||||
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 (from args.agent_id when --new; from SessionInfo.agent_id when --session)
|
||||
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}"})
|
||||
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
|
||||
```
|
||||
|
||||
```contract
|
||||
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"
|
||||
```
|
||||
|
||||
```contract
|
||||
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)
|
||||
```
|
||||
|
||||
```contract
|
||||
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]"
|
||||
```
|
||||
|
||||
```contract
|
||||
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
|
||||
```
|
||||
|
||||
```contract
|
||||
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)
|
||||
```
|
||||
|
||||
```contract
|
||||
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
|
||||
```
|
||||
|
||||
```contract
|
||||
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.
|
||||
Reference in New Issue
Block a user