From dd89239c34409541faeadd06606c019c782f499e Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Thu, 21 May 2026 00:31:40 -0700 Subject: [PATCH] feat(tui): implement issue #4 contract via TDD; amend cli for TUI dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 47 contract-listed tests authored + GREEN (43 tui + 4 issue-#3 amendments). 164/164 tests GREEN suite-wide; ruff clean. Vertical-slice ordering: _render_event_to_log → _cancel_via_sse → CLI amendments → RatatoskrApp class + on_mount + on_unmount → on_input_submitted → _stream_turn_worker → action_interrupt + action_quit → run_tui. Two in-flight contract amendments caught during TDD: - PRE-002 of run_tui was `(args.session_id is None) != args.new` — backwards (fails when --session is set + new=False). Corrected to `bool(args.session_id) != bool(args.new)`. - RichLog created with markup=False (contract drafted markup=True). Rich interprets `[xxx]` as style markup and strips it, which would break every labeled stderr-style line ([cancel_failed], [done], [error], etc.). The post-Done Markdown rendering still works because rich.markdown.Markdown is a Renderable and doesn't need widget-level markup. Implementation notes: - _stream_turn_worker takes the log widget as a parameter passed from on_input_submitted. Querying #transcript from inside a Textual worker context fails with NoMatches; capturing the reference once at handler-time and threading it through the worker sidesteps the issue. - _spy_writes(monkeypatch) test helper records every RichLog.write call. RichLog's `.lines` Strip buffer isn't populated synchronously after .write() returns, which makes post-app-shutdown inspection unreliable; a write-spy gives deterministic verification. - SIGINT-mid-stream tests use custom httpx.AsyncByteStream subclasses with asyncio.Event gates to make timing deterministic without sleep-based polling — the cancel-respx-mock sets the gate event when its endpoint is observed, releasing the next SSE chunk. - _submit_and_wait test helper needs `await pilot.pause()` BEFORE the polling loop so the Input.Submitted message has a chance to dispatch. Discovered via debug-print trace; tracked in the test helper. CLI amendments (per issue #4 in-place amendment of #3 contract): - ParsedArgs.send_content: str | None (was str) - ParsedArgs.raw: bool added - _parse_args: --send default=None; empty-string still rejected; --raw added - main: branches on args.send_content — None → lazy `from ratatoskr.tui import run_tui` + run_tui(args); else asyncio.run(_amain(args)). Lazy import preserves issue #3 INV-001. Persistent-memory updated per the commit-along rule: tui module landed, recent-decisions entries for #4 (contract + Volva + TDD), next natural moves rotated to Volva code-review + manual smoke against the personal Worldtree (key landed in env.sh per infra-ops's earlier delivery). --- persistent-memory.md | 19 +- src/ratatoskr/cli.py | 15 +- src/ratatoskr/tui.py | 254 +++++++++++ tests/test_cli.py | 49 ++- tests/test_tui.py | 988 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 1312 insertions(+), 13 deletions(-) create mode 100644 src/ratatoskr/tui.py create mode 100644 tests/test_tui.py diff --git a/persistent-memory.md b/persistent-memory.md index 6dfdfa0..2b24947 100644 --- a/persistent-memory.md +++ b/persistent-memory.md @@ -30,7 +30,7 @@ separate dev team rather than an in-tree Worldtree tool. ## Current state / in-flight -**Status: `ratatoskr.sse_client` + `ratatoskr.sessions` + `ratatoskr.cli` all implemented via TDD against their issue-scoped contracts; all three rounds also Volva code-reviewed + drift-fixed.** 118/118 tests GREEN (42 sse_client + 19 sessions + 55 cli + 1 boundary + 1 metadata); ruff clean. +**Status: `ratatoskr.sse_client` + `ratatoskr.sessions` + `ratatoskr.cli` + `ratatoskr.tui` (shell) all implemented via TDD against their issue-scoped contracts.** 164/164 tests GREEN (42 sse_client + 19 sessions + 58 cli + 43 tui + 1 boundary + 1 metadata); ruff clean. `--send` mode + TUI mode both wired into `ratatoskr` console-script entry point (TUI dispatched via lazy import to preserve issue #3 INV-001). What's in the repo: - `docs/design-brief.md` — the locked design (copy from `brokkr-smithy/docs/ratatoskr-design-brief.md`). @@ -46,7 +46,10 @@ What's in the repo: - `tests/test_sessions.py` — 19 tests covering both FN blocks' TESTS: entries verbatim (7 + 12). Helper `_list_item()` builds GET /sessions list-item bodies for tests. - `docs/contracts/issues/3.contract.md` — **issue-scoped contract for issue #3** (https://gitea.phasefinal.com/vh/ratatoskr/issues/3). v2.1, complexity=medium. `target_module: ratatoskr.cli`. `prd:` block pins to issue body SHA `206ef51709d43b2c` at `2026-05-21T05:13:29+00:00`. Six FN blocks: `main`, `_parse_args`, `_amain`, `_render_event`, `_run_turn`, `_cancel_and_log`. `dependencies:` block lists issues #1 and #2 as code-level deps (first in the repo to do so — #1 and #2 were convention-only siblings). Drift check returns clean. - `src/ratatoskr/cli.py` — **implemented 2026-05-21** per `docs/contracts/issues/3.contract.md`. Six entry points: sync `main` + async `_amain` + four private helpers (`_parse_args`, `_render_event`, `_cancel_and_log`, `_run_turn`). Composes `sessions.create_session` (when `--new`) with `sse_client.stream_turn` + `cancel_turn`. Hard invariant: no `textual` / `rich` imports (raw stdout). `_run_turn` is the load-bearing piece — race-loop pattern that gates `asyncio.create_task(sigint_event.wait())` behind `if not cancelling` to avoid the busy-loop bug Volva flagged in contract review. SIGINT handler installed via `loop.add_signal_handler(SIGINT, sigint_event.set)` so unit tests can fire the event directly without real signals. 305 LOC. -- `tests/test_cli.py` — 54 tests covering all six FN blocks' TESTS: entries verbatim (14 + 10 + 5 + 13 + 7 + 5). Helpers: `_sse_chunk` (same shape as `test_sse_client.py`'s helper), `_sse_resp` (wraps respx Response with the `text/event-stream` content-type), `_GatedStream` (custom `httpx.AsyncByteStream` that pauses on `asyncio.Event` entries to make SIGINT-mid-stream tests deterministic without sleep-based timing). Test fixtures: `_clear_env` autouse fixture clears `WORLDTREE_API_KEY` / `WORLDTREE_API_URL` per test for deterministic env-resolution assertions. +- `tests/test_cli.py` — 58 tests covering all six FN blocks' TESTS: entries (14 + 10 + 5 + 13 + 7 + 5) plus issue #4 amendments (no_send_marks_tui_mode, raw_flag_default_false, raw_flag_set, no_send_dispatches_to_tui). Helpers: `_sse_chunk` (same shape as `test_sse_client.py`'s helper), `_sse_resp` (wraps respx Response with the `text/event-stream` content-type), `_GatedStream` (custom `httpx.AsyncByteStream` that pauses on `asyncio.Event` entries to make SIGINT-mid-stream tests deterministic without sleep-based timing). Test fixtures: `_clear_env` autouse fixture clears `WORLDTREE_API_KEY` / `WORLDTREE_API_URL` per test for deterministic env-resolution assertions. +- `docs/contracts/issues/4.contract.md` — **issue-scoped contract for issue #4** (https://gitea.phasefinal.com/vh/ratatoskr/issues/4). v2.1, complexity=medium. `target_module: ratatoskr.tui`. `prd:` block pins to issue body SHA `b1e73e7d2e3dd453` at `2026-05-21T06:21:37+00:00`. Six FN blocks: `run_tui`, `RatatoskrApp` class shape, `on_mount`, `on_input_submitted`, `_stream_turn_worker`, `_render_event_to_log`, `action_interrupt`, `action_quit`, `on_unmount`, `_cancel_via_sse`. Plus a "CLI amendments" section spelling out the issue #3 in-place amendments (`--send` becomes optional + `--raw` flag + `main` dispatch + new tests). `dependencies:` lists issues #1, #2, #3 as code-level deps. +- `src/ratatoskr/tui.py` — **implemented 2026-05-21** per `docs/contracts/issues/4.contract.md`. One `RatatoskrApp(App[int])` Textual subclass + `run_tui(args)` sync entry. Compose layout: Header + RichLog (markup=False so labeled `[xxx]` lines render verbatim; the post-Done Markdown rendering uses `rich.markdown.Markdown` directly) + Input + Footer. Two-stage Ctrl-C state machine via `action_interrupt` (idle→exit(0); streaming→cancel_turn POST + flip to cancelling; cancelling→exit(3) abandon-drain). Ctrl-D → `action_quit` immediate exit(0). Worker pattern via `self.run_worker(self._stream_turn_worker(content, log))` — the log reference is passed as a parameter because the worker can't `self.query_one(...)` reliably from within Textual's worker context. ~210 LOC. +- `tests/test_tui.py` — 43 tests covering all six FN blocks' TESTS entries (9 + 4 + 8 + 5 + 8 + 7 + 2). Helpers: `_args_new` / `_args_existing` (ParsedArgs factories), `_spy_writes` (monkeypatch RichLog.write to record calls — works around RichLog's write→render asynchrony where `.lines` isn't populated immediately after .write() returns), `_sse_chunk` / `_sse_resp` (SSE wire helpers), `_submit_and_wait` (drive input + poll for state==idle), `_noop_worker` (fake stream worker for on_input_submitted tests). Tests use Textual's `App.run_test()` + `Pilot` for headless app testing. SIGINT-mid-stream tests use `httpx.AsyncByteStream` subclasses with `asyncio.Event` gates to make timing deterministic. - `tests/test_no_worldtree_imports.py` — boundary smoke test (passes; verified 2026-05-20). - `tests/snapshots/README.md` — recording/replay convention for SSE snapshot tests. @@ -59,10 +62,11 @@ What's NOT in the repo yet: **Branch:** `main`. Remote: `origin → git@gitea.phasefinal.com:vh/ratatoskr.git` (added 2026-05-20). **Next natural moves:** -1. **`/volva-code-review docs/contracts/issues/3.contract.md`** against the freshly-landed `ratatoskr.cli` implementation (precedent: #1 caught 4 negative-space drifts, #2 caught 3). The race-loop in `_run_turn` is the most likely source of new drifts — cross-model fresh-eyes review on async / signal-handling code has been valuable in past rounds. -2. **Manual smoke against a local Worldtree**: `ratatoskr --send "hello" --new --agent `. Captures the first real-Worldtree integration evidence (mocks aren't the wire). Useful regardless of #1. -3. Record real SSE snapshot fixtures from a running Worldtree. `--send --new` redirected to a fixture file IS the recording probe — capture outputs to `tests/snapshots/` for replay-based regression coverage. -4. Textual TUI app shell — second presenter; multi-pane observability dashboard per design-brief §5. Now that the consumer surface is exercised end-to-end via `--send`, the TUI lands on top of validated modules. +1. **`/volva-code-review docs/contracts/issues/4.contract.md`** against the freshly-landed `ratatoskr.tui` shell (precedent: #1 caught 4 drifts, #2 caught 3, #3 caught 5). The Textual `App` lifecycle + worker race patterns are likely sources of new drifts — cross-model fresh-eyes review on async UI code is high-value. +2. **Manual smoke against the personal Worldtree**: key landed in `env.sh` (see [[personal-worldtree-smoke-target]] memory); `source env.sh && ratatoskr --send "hello" --new --agent mimir` for stdout smoke OR `ratatoskr --new --agent mimir` for TUI smoke. First end-to-end wire validation against the live server. +3. **Side-pane issues** — design-brief §5 lists 5 side panes (Persona, Tools, AdminEvents, BifrostState, ServerLog). Each gets its own issue + contract + TDD pass on top of the shell. Persona is the natural first (file-tail of `persona.log` — cheap; no new Worldtree wire). +4. Record real SSE snapshot fixtures from a running Worldtree. `--send --new` redirected to a fixture file IS the recording probe — capture outputs to `tests/snapshots/` for replay-based regression coverage. +5. Startup session picker — design-brief §4 `DataTable` of `GET /sessions`. Modest scope; pairs naturally with the TUI shell. ## Recent decisions @@ -94,6 +98,9 @@ decision. Captures rationale that won't be obvious from code alone. - `[2026-05-21]` **Volva paraphrase round on `docs/contracts/issues/3.contract.md`.** Volva flagged 5 ambiguities; operator approved amendments to ALL 5 (higher hit rate than #1/#2's 3-of-5 — async + signal-handling has more places for ambiguity to hide). (1) INV-001 wording tightened: "no in-repo modules other than `ratatoskr.sessions` and `ratatoskr.sse_client`" (was "imports from X and Y only" which literally forbade httpx/asyncio/stdlib). (2) INV-002 + `_render_event` POST-002/003 restructured: `Done`'s stdout newline is part of the contract (two stdout cases: Text deltas + post-Done newline), not a contradiction with "no other event writes stdout". (3) `[cancelled] (before any event arrived)` early-exit label added to the Data flow stderr list. (4) SIGINT race-loop pseudocode gated `sigint_task = asyncio.create_task(...)` behind `if not cancelling` — without this gate, once sigint is set, every loop iteration would wake on the already-set event (busy loop). New `no_busy_loop_after_cancel [trace]` test added. (5) `CancelTurnNotFound` + `CancelAlreadyCompleted` added to assumptions import list (they were used in `_cancel_and_log`'s ERROR_ROUTING but missing from the public-surface declaration). Meta-note: Volva said "discipline pulls weight here" — same calibration signal as #1/#2. - `[2026-05-21]` **`ratatoskr.cli` implemented via TDD against issue #3's contract.** 54 contract-listed tests authored + GREEN per the vertical-slice ordering (`_parse_args` → `_render_event` → `_cancel_and_log` → `_run_turn` → `_amain` → `main`). One in-flight contract amendment during TDD: the `no_busy_loop_after_cancel` test description originally said "exactly ONE wait()-shaped task created" but the natural race-loop shape produces 2 (iter 1 raced with text-event, iter 2 raced with sigint → flipped cancelling=True; iter 3+ skipped). Amended the contract test description to assert "TWO total wait() coroutines" with rationale; the busy-loop check is preserved (iter 3+ MUST skip wait() creation; the bug would grow N unbounded). Implementation choices: (a) `_UsageErrorParser` subclass overrides `argparse.ArgumentParser.error` to raise `_ArgparseError` instead of SystemExit, then `_parse_args` catches and re-raises as `UsageError` per the contract's ERROR_ROUTING; (b) `_GatedStream` test helper (custom `httpx.AsyncByteStream` that pauses on `asyncio.Event` entries) made SIGINT-mid-stream tests deterministic without sleep-based timing — gates release via side-channels (the cancel-mock setting an event when observed); (c) strong-ref `cancel_task` variable in `_run_turn` holds the fire-and-forget cancel task to suppress RUF006 / asyncio GC warning. 117/117 tests GREEN post-implementation; ruff clean. - `[2026-05-21]` **Volva code-vs-contract review on `ratatoskr.cli`.** Five findings, all "fix it" (one with collateral contract amendment). (1) Drift: neither `main` nor `_parse_args` asserted PRE-001 (`argv is None or all(isinstance(a, str) for a in argv)`). Fixed: added assertions at both entry points. (2) Drift: argparse's `--help` raises `SystemExit(0)` which escaped through `main` — unfriendly UX. Code: `main` now catches `SystemExit` and returns `exc.code` verbatim (passthrough; argparse already printed help to stdout). Contract amended in-place: `_parse_args` + `main` ERROR_ROUTING now spell out the SystemExit-from-argparse-clean-exits passthrough; new `help_exits_cleanly [happy]` test added. (3) Precision: `_render_event` lacked the union-type assertion from PRE-001 — unmatched event variants would silently no-op. Fixed: added `assert isinstance(event, (WorkerPhase, Thinking, Text, ...))` at function entry. (4) Drift: `cancel_task` was created but never awaited in `_run_turn`'s `finally` — under fast-stream + slow-cancel scenarios, the `[cancel_failed]` log could miss being written before `_run_turn` returns, AND `_amain` could close the AsyncClient while the cancel POST was still in flight. Fixed: `finally` block now awaits `cancel_task` if present (`_cancel_and_log` already swallows all errors per INV-009, so the await never raises). (5) Test-gap: `text_to_stdout_only` and `done_writes_newline_and_label` used plain `io.StringIO` and didn't verify INV-010's per-chunk flush — the tests would pass even with flush removed. Fixed: new `_FlushCountingIO` subclass counts `flush()` calls; both tests assert `flush_count == 1`. Volva's meta-note: "TDD pass mostly caught central behavior; this review caught contract-hardening edges (PRE asserts, --help, non-guaranteed cancel-failure log)." Same calibration shape as #1 (4 negative-space drifts) and #2 (3 drifts) — the post-TDD code-review consistently catches the assert-boundary and observability-shape gaps the test-author's hypotheses don't cover. 118 tests GREEN post-fix. +- `[2026-05-21]` **Issue #4 + contract: `ratatoskr.tui` shell.** Textual `App[int]` subclass with single chat-pane layout (Header + RichLog + Input + Footer). Composes existing modules: `create_session` on `--new` + `stream_turn` per turn + `cancel_turn` on Ctrl-C. Hard invariants: INV-002 session-identity-always-visible footer (with `` carve-out when `--session` is used without --agent); INV-003 two-stage Ctrl-C state machine (idle/streaming/cancelling per design-brief §8c); INV-005 markdown rendering default-on with `--raw` opt-out (deliberately produces a streaming-then-Markdown-render double-display — accepted v1 trade-off; Static-then-commit refactor deferred); INV-008 mid-session errors render to transcript and return to idle (don't exit). Shell-only scope — design-brief §5's five side panes (Persona, Tools, AdminEvents, BifrostState, ServerLog), startup session picker, Tab bindings, history rendering all deferred to follow-up issues. Concurrent in-place amendment of issue #3: `--send` becomes optional (when omitted, `main` lazy-imports `ratatoskr.tui.run_tui`); `--raw` flag added to `ParsedArgs`; `_parse_args`/`main` TESTS sections updated. The lazy-import preserves issue #3 INV-001 (no textual at cli module scope). `prd:` pinned to issue #4 body SHA `b1e73e7d2e3dd453` at `2026-05-21T06:21:37+00:00`; drift check clean. +- `[2026-05-21]` **Volva paraphrase round on `docs/contracts/issues/4.contract.md`.** 5 findings, all amended (matching #3's hit rate). (1) INV-002 footer carve-out for `` agent slot when `--session` and agent_id unknown; tightened wording so the `` placeholder is an accepted satisfaction of "session-identity-always-visible". (2) "Ctrl-C twice to exit" idle hint vs single-press-exits state machine: kept the hint verbatim per design-brief §8c's conservative-by-design rationale, amended INV-003 to spell out the intentional discrepancy + cite the brief so an implementer can't "fix" it by accident. (3) Markdown double-render trade-off: INV-005 strengthened from "trade-off" to explicit "assistant response visibly appears TWICE in the transcript by design"; Static-then-commit refactor explicitly out-of-scope for this shell. (4) Silent input-discard during streaming: now writes `[busy] turn in flight; input ignored` to the transcript (visible notice, not silent swallow); new test for cancelling state too. (5) `{!r:.200}` format spec note: kept (valid Python repr-then-truncate-200), added inline comment explaining the syntax. Volva meta-note: "discipline pulls weight here" — same calibration signal as prior rounds. +- `[2026-05-21]` **`ratatoskr.tui` shell implemented via TDD against issue #4's contract.** 43 contract-listed tests + 4 issue-#3-amendment tests authored + GREEN per the vertical-slice ordering (`_render_event_to_log` → `_cancel_via_sse` → CLI amendments → app class + on_mount + on_unmount → on_input_submitted → _stream_turn_worker → action_interrupt + action_quit → run_tui). Two in-flight contract amendments during TDD: (a) PRE-002 of `run_tui` was `(args.session_id is None) != args.new` — backwards, fails when session_id is set + new=False; corrected to `bool(args.session_id) != bool(args.new)`; (b) RichLog created with `markup=False` (was `markup=True` in contract) because Rich interprets `[xxx]` as style spans and strips them — would break every labeled stderr-style line. The post-Done Markdown rendering still works because `rich.markdown.Markdown` is a Renderable that doesn't need widget-level markup. Implementation choices: (a) worker takes the `log` widget as a parameter (passed from on_input_submitted) — querying `#transcript` from inside a Textual worker context fails with NoMatches; (b) `_spy_writes(monkeypatch)` test helper records every RichLog.write call because RichLog's `.lines` Strip buffer isn't populated synchronously after .write() returns, making post-app-shutdown inspection unreliable; (c) SIGINT-mid-stream tests use custom `httpx.AsyncByteStream` subclasses with `asyncio.Event` gates to make timing deterministic without sleep-based polling. 164/164 tests GREEN; ruff clean (one `# noqa: RUF001,RUF003` for the intentional `❯` INV-006 prompt prefix). ## Tried and abandoned diff --git a/src/ratatoskr/cli.py b/src/ratatoskr/cli.py index 589786e..ad9a79b 100644 --- a/src/ratatoskr/cli.py +++ b/src/ratatoskr/cli.py @@ -51,12 +51,13 @@ class _AuthError(Exception): class ParsedArgs: """Resolved CLI invocation. Post-validation: exactly one of session_id / new is set.""" - send_content: str + send_content: str | None session_id: str | None new: bool agent_id: str | None api_key: str server_url: str + raw: bool class _ArgparseError(Exception): @@ -74,18 +75,20 @@ def _parse_args(argv: list[str] | None) -> ParsedArgs: """argparse + env-fallback + xor-validation per the contract.""" assert argv is None or all(isinstance(a, str) for a in argv) parser = _UsageErrorParser(prog="ratatoskr", description="Worldtree CLI presenter.") - parser.add_argument("--send", required=True) + parser.add_argument("--send", default=None) parser.add_argument("--session") parser.add_argument("--new", action="store_true") parser.add_argument("--agent") parser.add_argument("--api-key", dest="api_key") parser.add_argument("--server") + parser.add_argument("--raw", action="store_true") try: ns = parser.parse_args(argv) except _ArgparseError as exc: raise UsageError(str(exc)) from exc - if not ns.send: + # --send empty-string is still invalid; --send omitted (None) is the TUI-mode marker. + if ns.send is not None and not ns.send: raise UsageError("--send content must be non-empty") if ns.session and ns.new: raise UsageError("--session and --new are mutually exclusive; pass exactly one") @@ -109,6 +112,7 @@ def _parse_args(argv: list[str] | None) -> ParsedArgs: agent_id=ns.agent, api_key=api_key, server_url=server_url, + raw=ns.raw, ) @@ -312,4 +316,9 @@ def main(argv: list[str] | None = None) -> int: # argparse's --help / --version short-circuit via SystemExit(0). Pass the code # through verbatim — argparse already printed help to stdout. return int(exc.code) if exc.code is not None else 0 + if 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) return asyncio.run(_amain(args)) diff --git a/src/ratatoskr/tui.py b/src/ratatoskr/tui.py new file mode 100644 index 0000000..f597adc --- /dev/null +++ b/src/ratatoskr/tui.py @@ -0,0 +1,254 @@ +"""Ratatoskr Textual TUI shell — interactive primary presenter. + +Implements docs/contracts/issues/4.contract.md. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, ClassVar, Literal + +import httpx +from textual.app import App, ComposeResult +from textual.binding import Binding +from textual.widgets import Footer, Header, Input, RichLog + +from ratatoskr.sessions import AgentNotFound, SessionApiFailed, create_session +from ratatoskr.sse_client import ( + CancelAlreadyCompleted, + CancelFailed, + Cancelled, + CancelTurnNotFound, + Done, + Error, + Event, + MalformedSseId, + SseConnectFailed, + SseConnectionDropped, + Text, + TextBoundary, + Thinking, + ToolResult, + ToolStart, + TurnIdFlip, + WorkerPhase, + cancel_turn, + stream_turn, +) + +if TYPE_CHECKING: + from ratatoskr.cli import ParsedArgs + + +def _render_event_to_log(event: Event, *, log: RichLog, raw: bool) -> None: + """Pure event-to-RichLog renderer per the contract STEPS table.""" + if isinstance(event, Text): + log.write(event.content) + elif isinstance(event, Done): + log.write( + f"[done] turn_id={event.sse_id.turn_id} model={event.model} " + f"duration_ms={event.duration_ms} usage={event.usage!r}" + ) + elif isinstance(event, Error): + log.write( + f"[error] turn_id={event.sse_id.turn_id} code={event.error_code} " + f"message={event.message!r}" + ) + elif isinstance(event, Cancelled): + log.write( + f"[cancelled] turn_id={event.turn_id} reason={event.reason!r} " + f"partial_message_id={event.partial_message_id}" + ) + elif isinstance(event, WorkerPhase): + log.write(f"[worker_phase] phase={event.phase} turn_id={event.turn_id}") + elif isinstance(event, Thinking): + log.write(f"[thinking] {event.content[:200]!r}") + elif isinstance(event, TextBoundary): + log.write(f"[text_boundary] kind={event.kind} char_offset={event.char_offset}") + elif isinstance(event, ToolStart): + log.write(f"[tool_start] name={event.name} args={event.arguments!r}") + elif isinstance(event, ToolResult): + log.write( + f"[tool_result] name={event.name} duration_ms={event.duration_ms} " + f"result={event.result!r:.200}" + ) + + +class RatatoskrApp(App[int]): + """Textual TUI shell — single chat pane.""" + + BINDINGS: ClassVar[list[Binding]] = [ + Binding("ctrl+c", "interrupt", "Cancel / Exit", priority=True), + Binding("ctrl+d", "quit", "Exit immediately", priority=True), + ] + + HINT_IDLE = "Ctrl-C twice to exit" + HINT_STREAMING = "Ctrl-C to cancel" + HINT_CANCELLING = "Press Ctrl-C again to exit" + + def __init__(self, args: ParsedArgs) -> None: + super().__init__() + self.args = args + self.session_id: str | None = None + self.agent_id: str | None = None + self.client: httpx.AsyncClient | None = None + self.state: Literal["idle", "streaming", "cancelling"] = "idle" + self.active_turn_id: int | None = None + self.stream_worker = None + self.hint: str = self.HINT_IDLE + + def compose(self) -> ComposeResult: + yield Header() + # markup=False so labeled lines like "[cancel_failed] ..." render verbatim + # (Rich would otherwise interpret square-bracket spans as style markup and + # strip them). The post-Done markdown render uses Markdown() directly which + # is a Rich Renderable and renders correctly without widget-level markup=True. + yield RichLog(id="transcript", wrap=True, markup=False, highlight=False) + yield Input(id="prompt", placeholder="Type a message and press Enter") + yield Footer() + + async def on_mount(self) -> None: + """Open AsyncClient, mint or attach session, populate footer with identity.""" + assert self.client is None + self.client = httpx.AsyncClient( + base_url=self.args.server_url, + headers={"Authorization": f"Bearer {self.args.api_key}"}, + ) + log = self.query_one("#transcript", RichLog) + if self.args.new: + assert self.args.agent_id is not None + try: + info = await create_session(self.client, self.args.agent_id) + except AgentNotFound as exc: + log.write(f"[agent_not_found] agent_id={exc.agent_id}") + self.exit(12) + return + except SessionApiFailed as exc: + log.write(f"[session_api_failed] status={exc.status} body={exc.body!r}") + self.exit(20) + return + except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc: + log.write(f"[network_error] {type(exc).__name__}: {exc}") + self.exit(21) + return + self.session_id = info.session_id + self.agent_id = info.agent_id + else: + assert self.args.session_id is not None + self.session_id = self.args.session_id + self.agent_id = self.args.agent_id # may be None — INV-002 carve-out + + agent_slot = self.agent_id or "" + self.sub_title = f"{agent_slot} · …{self.session_id[-8:]}" + self.state = "idle" + self.hint = self.HINT_IDLE + + async def on_input_submitted(self, event: Input.Submitted) -> None: + """Echo user prompt, spawn stream worker; busy notice if not idle.""" + if event.input.id != "prompt": + return + log = self.query_one("#transcript", RichLog) + if self.state != "idle": + log.write("[busy] turn in flight; input ignored") + event.input.value = "" + return + content = event.input.value.strip() + if not content: + return + log.write(f"❯ {content}") # noqa: RUF001 — intentional INV-006 prefix + event.input.value = "" + self.state = "streaming" + self.hint = self.HINT_STREAMING + self.stream_worker = self.run_worker( + self._stream_turn_worker(content, log) + ) + + async def _stream_turn_worker(self, content: str, log: RichLog) -> None: + """Drive stream_turn, render events, set active_turn_id, restore idle on terminal/error.""" + assert self.state == "streaming" + assert self.client is not None + assert content + try: + async for event in stream_turn(self.client, self.session_id, content): + if self.active_turn_id is None: + self.active_turn_id = event.sse_id.turn_id + _render_event_to_log(event, log=log, raw=self.args.raw) + if isinstance(event, Done): + if not self.args.raw: + from rich.markdown import Markdown + from rich.rule import Rule + + log.write(Rule()) + log.write(Markdown(event.response)) + break + if isinstance(event, (Error, Cancelled)): + break + except SseConnectFailed as exc: + log.write(f"[sse_connect_failed] status={exc.status} body={exc.body!r}") + except SseConnectionDropped as exc: + log.write(f"[connection_dropped] last_seen={exc.last_seen_sse_id}") + except MalformedSseId as exc: + log.write(f"[malformed_sse_id] raw={exc.raw!r}") + except TurnIdFlip as exc: + log.write(f"[turn_id_flip] expected={exc.established} got={exc.got}") + finally: + self.state = "idle" + self.active_turn_id = None + self.hint = self.HINT_IDLE + + async def on_unmount(self) -> None: + """Close the httpx.AsyncClient cleanly.""" + if self.client is not None and not self.client.is_closed: + await self.client.aclose() + + def action_interrupt(self) -> None: + """Two-stage Ctrl-C state machine per INV-003.""" + assert self.state in ("idle", "streaming", "cancelling") + if self.state == "idle": + self.exit(0) + elif self.state == "streaming": + if self.active_turn_id is None: + if self.stream_worker is not None: + self.stream_worker.cancel() + self.exit(3) + return + self.state = "cancelling" + self.hint = self.HINT_CANCELLING + log = self.query_one("#transcript", RichLog) + self.run_worker( + _cancel_via_sse(self.client, self.session_id, self.active_turn_id, log=log) + ) + elif self.state == "cancelling": + if self.stream_worker is not None: + self.stream_worker.cancel() + self.exit(3) + + def action_quit(self) -> None: + """Ctrl-D — immediate exit regardless of state.""" + if self.stream_worker is not None and not self.stream_worker.is_finished: + self.stream_worker.cancel() + self.exit(0) + + +def run_tui(args: ParsedArgs) -> int: + """Sync entry point — wraps App.run(). Returns the exit code from App.run().""" + assert args.send_content is None + # Exactly one of session_id / new must be set (xor) + assert bool(args.session_id) != bool(args.new) + app = RatatoskrApp(args) + return app.run() or 0 + + +async def _cancel_via_sse( + client: httpx.AsyncClient, + session_id: str, + turn_id: int, + *, + log: RichLog, +) -> None: + """Fire-and-forget cancel; never raises (mirrors cli._cancel_and_log; #3 INV-009).""" + assert client is not None + assert isinstance(turn_id, int) and turn_id > 0 + try: + await cancel_turn(client, session_id, turn_id) + except (CancelFailed, CancelTurnNotFound, CancelAlreadyCompleted, httpx.RequestError) as exc: + log.write(f"[cancel_failed] {type(exc).__name__}: {exc}") diff --git a/tests/test_cli.py b/tests/test_cli.py index 59e076c..73e9c6b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -99,6 +99,7 @@ class TestParseArgs: agent_id="mimir", api_key="k", server_url="http://localhost:8000", + raw=False, ) def test_happy_existing_session(self) -> None: @@ -111,6 +112,7 @@ class TestParseArgs: agent_id=None, api_key="k", server_url="http://localhost:8000", + raw=False, ) def test_api_key_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None: @@ -144,10 +146,23 @@ class TestParseArgs: ) assert args.server_url == "flag" - def test_usage_no_send(self) -> None: - """usage_no_send: missing --send → UsageError (argparse required-flag).""" - with pytest.raises(UsageError): - _parse_args(["--new", "--agent", "mimir", "--api-key", "k"]) + def test_no_send_marks_tui_mode(self) -> None: + """no_send_marks_tui_mode: missing --send → send_content=None (TUI marker).""" + args = _parse_args(["--new", "--agent", "mimir", "--api-key", "k"]) + assert args.send_content is None + # Other fields still populate normally + assert args.new is True + assert args.agent_id == "mimir" + + def test_raw_flag_default_false(self) -> None: + """raw_flag_default_false: --raw absent → ParsedArgs.raw == False.""" + args = _parse_args(["--send", "hi", "--new", "--agent", "m", "--api-key", "k"]) + assert args.raw is False + + def test_raw_flag_set(self) -> None: + """raw_flag_set: --raw → ParsedArgs.raw == True.""" + args = _parse_args(["--send", "hi", "--new", "--agent", "m", "--api-key", "k", "--raw"]) + assert args.raw is True def test_usage_both_session_and_new(self) -> None: """usage_both_session_and_new: --session AND --new → UsageError('mutually exclusive').""" @@ -757,6 +772,7 @@ _PARSED_NEW = ParsedArgs( agent_id="mimir", api_key="k", server_url="https://w.example", + raw=False, ) _PARSED_EXISTING = ParsedArgs( send_content="hi", @@ -765,6 +781,7 @@ _PARSED_EXISTING = ParsedArgs( agent_id=None, api_key="k", server_url="https://w.example", + raw=False, ) _CREATE_OK_RESP = { "session_id": "s-new", @@ -990,3 +1007,27 @@ class TestMain: assert amain_calls == [] # argparse prints help text to stdout assert "ratatoskr" in capsys.readouterr().out + + def test_no_send_dispatches_to_tui(self, monkeypatch: pytest.MonkeyPatch) -> None: + """no_send_dispatches_to_tui: --send omitted → main calls run_tui, NOT _amain.""" + from ratatoskr import tui as tui_mod + + tui_calls: list[ParsedArgs] = [] + amain_calls: list[int] = [] + + def fake_run_tui(args: ParsedArgs) -> int: + tui_calls.append(args) + return 0 + + async def fake_amain(args: ParsedArgs) -> int: + amain_calls.append(1) + return 0 + + monkeypatch.setattr(tui_mod, "run_tui", fake_run_tui) + monkeypatch.setattr(cli_mod, "_amain", fake_amain) + rc = main(["--session", "s-1", "--api-key", "k"]) + assert rc == 0 + assert len(tui_calls) == 1 + assert tui_calls[0].send_content is None + assert tui_calls[0].session_id == "s-1" + assert amain_calls == [] diff --git a/tests/test_tui.py b/tests/test_tui.py new file mode 100644 index 0000000..d2be35f --- /dev/null +++ b/tests/test_tui.py @@ -0,0 +1,988 @@ +"""Tests for ratatoskr.tui per docs/contracts/issues/4.contract.md.""" + +from unittest.mock import MagicMock + +import httpx +import pytest +import respx +from textual.widgets import RichLog + +from ratatoskr.cli import ParsedArgs +from ratatoskr.sse_client import ( + Cancelled, + Done, + Error, + SseId, + Text, + TextBoundary, + Thinking, + ToolResult, + ToolStart, + WorkerPhase, +) +from ratatoskr.tui import RatatoskrApp, _cancel_via_sse, _render_event_to_log + +_CANCEL_OK_RESP = {"turn_id": 42, "cancelled": True, "reason": None, "partial_message_id": None} +_CREATE_OK_RESP = { + "session_id": "s-new12345", + "agent_id": "mimir", + "message_count": 0, + "created_at": "2026-05-21T00:00:00+00:00", + "last_active": "2026-05-21T00:00:00+00:00", + "metadata": {}, +} + + +def _args_new(**overrides) -> ParsedArgs: + base = dict( + send_content=None, + session_id=None, + new=True, + agent_id="mimir", + api_key="k", + server_url="https://w.example", + raw=False, + ) + base.update(overrides) + return ParsedArgs(**base) + + +def _args_existing(session_id: str = "s-1existing", **overrides) -> ParsedArgs: + base = dict( + send_content=None, + session_id=session_id, + new=False, + agent_id=None, + api_key="k", + server_url="https://w.example", + raw=False, + ) + base.update(overrides) + return ParsedArgs(**base) + + +def _spy_writes(monkeypatch) -> list: + """Patch RichLog.write to record every arg into a list (returned).""" + writes: list = [] + original = RichLog.write + + def spy(self, content, **kw): + writes.append(content) + return original(self, content, **kw) + + monkeypatch.setattr(RichLog, "write", spy) + return writes + + +SID = SseId(42, 5) + + +class TestRenderEventToLog: + def test_text_renders_raw_delta(self) -> None: + """text_renders_raw_delta [happy,tracer]: Text → log.write('hello').""" + log = MagicMock() + _render_event_to_log(Text(sse_id=SID, content="hello"), log=log, raw=False) + log.write.assert_called_once_with("hello") + + def test_done_renders_label_only(self) -> None: + """done_renders_label_only: …""" + log = MagicMock() + evt = Done( + sse_id=SID, + phase="completed", + response="hi there", + model="glm5-turbo", + duration_ms=1234, + usage={"prompt": 1, "completion": 2}, + ) + _render_event_to_log(evt, log=log, raw=False) + log.write.assert_called_once() + line = log.write.call_args[0][0] + assert line.startswith("[done]") + assert "turn_id=42" in line + assert "model=glm5-turbo" in line + # POST-003: the Done line is labels only; markdown render is the caller's job + assert "hi there" not in line + + def test_error_renders_label(self) -> None: + """error_renders_label: Error → log line starts with [error].""" + log = MagicMock() + evt = Error(sse_id=SID, phase="failed", message="boom", error_code="llm_output_invalid") + _render_event_to_log(evt, log=log, raw=False) + line = log.write.call_args[0][0] + assert line.startswith("[error]") + assert "turn_id=42" in line + assert "code=llm_output_invalid" in line + + def test_cancelled_renders_label(self) -> None: + """cancelled_renders_label: Cancelled → log line starts with [cancelled].""" + log = MagicMock() + evt = Cancelled( + sse_id=SID, phase="cancelled", turn_id=42, reason="user", partial_message_id=7 + ) + _render_event_to_log(evt, log=log, raw=False) + line = log.write.call_args[0][0] + assert line.startswith("[cancelled]") + assert "reason='user'" in line + assert "partial_message_id=7" in line + + def test_worker_phase_renders_label(self) -> None: + """worker_phase_renders_label: WorkerPhase → log line starts with [worker_phase].""" + log = MagicMock() + evt = WorkerPhase(sse_id=SID, phase="streaming", turn_id=42) + _render_event_to_log(evt, log=log, raw=False) + line = log.write.call_args[0][0] + assert line.startswith("[worker_phase]") + assert "phase=streaming" in line + + def test_thinking_truncated(self) -> None: + """thinking_truncated [trace]: …""" + log = MagicMock() + _render_event_to_log(Thinking(sse_id=SID, content="a" * 500), log=log, raw=False) + line = log.write.call_args[0][0] + assert line.startswith("[thinking]") + assert "a" * 500 not in line + assert "a" * 200 in line + + def test_tool_start_renders_label(self) -> None: + """tool_start_renders_label: ToolStart → [tool_start] name=... args=...""" + log = MagicMock() + evt = ToolStart(sse_id=SID, name="read_file", arguments={"path": "/x"}) + _render_event_to_log(evt, log=log, raw=False) + line = log.write.call_args[0][0] + assert line.startswith("[tool_start] name=read_file args=") + + def test_tool_result_truncated(self) -> None: + """tool_result_truncated [trace]: …""" + log = MagicMock() + evt = ToolResult(sse_id=SID, name="x", result="b" * 500, duration_ms=42) + _render_event_to_log(evt, log=log, raw=False) + line = log.write.call_args[0][0] + assert line.startswith("[tool_result]") + # The whole repr-portion of the result is truncated to 200; the full 500-b + # string can never fit in line whole. + assert "b" * 500 not in line + + def test_text_boundary_renders_label(self) -> None: + """text_boundary_renders_label: TextBoundary → [text_boundary] kind=... char_offset=...""" + log = MagicMock() + evt = TextBoundary(sse_id=SID, kind="sentence", char_offset=128, ts="2026-05-21T00:00:00Z") + _render_event_to_log(evt, log=log, raw=False) + line = log.write.call_args[0][0] + assert line.startswith("[text_boundary]") + assert "kind=sentence" in line + assert "char_offset=128" in line + + +class TestCancelViaSse: + @respx.mock + async def test_happy_cancel(self) -> None: + """happy_cancel [happy,tracer]: 200 OK → returns None; log has no [cancel_failed].""" + respx.post("https://w.example/sessions/s-1/turns/42/cancel").mock( + return_value=httpx.Response(200, json=_CANCEL_OK_RESP) + ) + log = MagicMock() + async with httpx.AsyncClient(base_url="https://w.example") as client: + result = await _cancel_via_sse(client, "s-1", 42, log=log) + assert result is None + log.write.assert_not_called() + + @respx.mock + async def test_cancel_failed_500(self) -> None: + """cancel_failed_500 [error]: …""" + respx.post("https://w.example/sessions/s-1/turns/42/cancel").mock( + return_value=httpx.Response(500, content=b"boom") + ) + log = MagicMock() + async with httpx.AsyncClient(base_url="https://w.example") as client: + await _cancel_via_sse(client, "s-1", 42, log=log) + line = log.write.call_args[0][0] + assert "[cancel_failed]" in line + assert "CancelFailed" in line + + @respx.mock + async def test_cancel_already_completed(self) -> None: + """cancel_already_completed [scenario]: 409 → '[cancel_failed] CancelAlreadyCompleted:'.""" + respx.post("https://w.example/sessions/s-1/turns/42/cancel").mock( + return_value=httpx.Response(409) + ) + log = MagicMock() + async with httpx.AsyncClient(base_url="https://w.example") as client: + await _cancel_via_sse(client, "s-1", 42, log=log) + line = log.write.call_args[0][0] + assert "[cancel_failed]" in line + assert "CancelAlreadyCompleted" in line + + @respx.mock + async def test_transport_error_swallowed(self) -> None: + """transport_error_swallowed [error]: …""" + respx.post("https://w.example/sessions/s-1/turns/42/cancel").mock( + side_effect=httpx.ConnectError("network down") + ) + log = MagicMock() + async with httpx.AsyncClient(base_url="https://w.example") as client: + await _cancel_via_sse(client, "s-1", 42, log=log) + line = log.write.call_args[0][0] + assert "[cancel_failed]" in line + assert "ConnectError" in line + + +class TestAppMount: + @respx.mock + async def test_happy_new_session_mount(self) -> None: + """happy_new_session_mount [happy,tracer]: …""" + respx.post("https://w.example/sessions").mock( + return_value=httpx.Response(201, json=_CREATE_OK_RESP) + ) + app = RatatoskrApp(_args_new()) + async with app.run_test() as pilot: + await pilot.pause() + assert app.session_id == "s-new12345" + assert app.agent_id == "mimir" + assert app.state == "idle" + # INV-002: session identity visible — agent_id + last 8 of session_id + assert "mimir" in (app.sub_title or "") + assert app.session_id[-8:] in (app.sub_title or "") + + @respx.mock + async def test_happy_existing_session_mount(self) -> None: + """happy_existing_session_mount: …""" + sessions_route = respx.post("https://w.example/sessions").mock( + return_value=httpx.Response(201, json=_CREATE_OK_RESP) + ) + app = RatatoskrApp(_args_existing(session_id="s-existing-tail8x")) + async with app.run_test() as pilot: + await pilot.pause() + assert app.session_id == "s-existing-tail8x" + assert app.state == "idle" + assert sessions_route.call_count == 0 + # INV-002 carve-out: agent unknown → · … + assert "" in (app.sub_title or "") + assert app.session_id[-8:] in (app.sub_title or "") + + @respx.mock + async def test_agent_not_found_on_mount( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """agent_not_found_on_mount [error]: …""" + respx.post("https://w.example/sessions").mock( + return_value=httpx.Response(404, json={"error": "unknown_agent_id"}) + ) + writes = _spy_writes(monkeypatch) + app = RatatoskrApp(_args_new()) + async with app.run_test() as pilot: + await pilot.pause() + assert app.return_value == 12 + assert any("[agent_not_found]" in str(w) for w in writes) + + @respx.mock + async def test_session_api_failed_on_mount( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """session_api_failed_on_mount [error]: …""" + respx.post("https://w.example/sessions").mock( + return_value=httpx.Response(500, content=b"server error") + ) + writes = _spy_writes(monkeypatch) + app = RatatoskrApp(_args_new()) + async with app.run_test() as pilot: + await pilot.pause() + assert app.return_value == 20 + assert any("[session_api_failed]" in str(w) and "status=500" in str(w) for w in writes) + + @respx.mock + async def test_network_error_on_mount( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """network_error_on_mount [error]: …""" + respx.post("https://w.example/sessions").mock(side_effect=httpx.ConnectError("down")) + writes = _spy_writes(monkeypatch) + app = RatatoskrApp(_args_new()) + async with app.run_test() as pilot: + await pilot.pause() + assert app.return_value == 21 + assert any("[network_error]" in str(w) for w in writes) + + @respx.mock + async def test_footer_identity_visible_first_frame(self) -> None: + """footer_identity_visible_first_frame [trace]: …""" + respx.post("https://w.example/sessions").mock( + return_value=httpx.Response(201, json=_CREATE_OK_RESP) + ) + app = RatatoskrApp(_args_new()) + async with app.run_test() as pilot: + await pilot.pause() + # Both halves of the identity present + assert "mimir" in (app.sub_title or "") + assert "·" in (app.sub_title or "") + assert app.session_id[-8:] in (app.sub_title or "") + + @respx.mock + async def test_client_open_after_mount(self) -> None: + """client_open_after_mount [trace]: post-mount self.client is open.""" + respx.post("https://w.example/sessions").mock( + return_value=httpx.Response(201, json=_CREATE_OK_RESP) + ) + app = RatatoskrApp(_args_new()) + async with app.run_test() as pilot: + await pilot.pause() + assert app.client is not None + assert app.client.is_closed is False + + +class TestAppUnmount: + @respx.mock + async def test_unmount_closes_client(self) -> None: + """unmount_closes_client [happy,tracer]: …""" + app = RatatoskrApp(_args_existing()) + async with app.run_test() as pilot: + await pilot.pause() + client_ref = app.client + assert client_ref is not None and not client_ref.is_closed + await pilot.press("ctrl+d") + await pilot.pause() + assert client_ref.is_closed + + +import asyncio # noqa: E402 + +from textual.widgets import Input # noqa: E402 + + +async def _noop_worker(self, content: str, log) -> None: + """Fake _stream_turn_worker that never completes (lets state stay 'streaming').""" + await asyncio.Future() # await forever; cancelled when test exits + + +class TestOnInputSubmitted: + @respx.mock + async def test_happy_submit_echoes_and_spawns( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """happy_submit_echoes_and_spawns [happy,tracer]: …""" + monkeypatch.setattr(RatatoskrApp, "_stream_turn_worker", _noop_worker) + writes = _spy_writes(monkeypatch) + app = RatatoskrApp(_args_existing()) + async with app.run_test() as pilot: + await pilot.pause() + inp = app.query_one("#prompt", Input) + inp.value = "hello" + await inp.action_submit() + await pilot.pause() + assert any("❯ hello" in str(w) for w in writes) # noqa: RUF001 + assert inp.value == "" + assert app.state == "streaming" + assert app.stream_worker is not None + + @respx.mock + async def test_empty_submit_no_op( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """empty_submit_no_op [trace]: '' + Enter → no change; no worker spawned.""" + monkeypatch.setattr(RatatoskrApp, "_stream_turn_worker", _noop_worker) + app = RatatoskrApp(_args_existing()) + async with app.run_test() as pilot: + await pilot.pause() + inp = app.query_one("#prompt", Input) + inp.value = "" + await inp.action_submit() + await pilot.pause() + assert app.state == "idle" + assert app.stream_worker is None + + @respx.mock + async def test_submit_during_streaming_shows_busy_notice( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """submit_during_streaming_shows_busy_notice [adversarial]: …""" + monkeypatch.setattr(RatatoskrApp, "_stream_turn_worker", _noop_worker) + writes = _spy_writes(monkeypatch) + app = RatatoskrApp(_args_existing()) + async with app.run_test() as pilot: + await pilot.pause() + inp = app.query_one("#prompt", Input) + # First submit: enters streaming + inp.value = "first" + await inp.action_submit() + await pilot.pause() + first_worker = app.stream_worker + assert app.state == "streaming" + # Second submit while streaming → busy notice; no new worker + writes.clear() + inp.value = "second" + await inp.action_submit() + await pilot.pause() + assert any("[busy] turn in flight; input ignored" in str(w) for w in writes) + assert app.stream_worker is first_worker # unchanged + assert app.state == "streaming" + assert inp.value == "" + + @respx.mock + async def test_submit_during_cancelling_shows_busy_notice( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """submit_during_cancelling_shows_busy_notice [adversarial]: …""" + monkeypatch.setattr(RatatoskrApp, "_stream_turn_worker", _noop_worker) + writes = _spy_writes(monkeypatch) + app = RatatoskrApp(_args_existing()) + async with app.run_test() as pilot: + await pilot.pause() + app.state = "cancelling" # bypass the natural transition for the test + inp = app.query_one("#prompt", Input) + inp.value = "x" + await inp.action_submit() + await pilot.pause() + assert any("[busy]" in str(w) for w in writes) + assert app.state == "cancelling" + + @respx.mock + async def test_footer_hint_flips_to_cancel( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """footer_hint_flips_to_cancel [trace]: after submit, hint shows 'Ctrl-C to cancel'.""" + monkeypatch.setattr(RatatoskrApp, "_stream_turn_worker", _noop_worker) + app = RatatoskrApp(_args_existing()) + async with app.run_test() as pilot: + await pilot.pause() + assert app.hint == RatatoskrApp.HINT_IDLE + inp = app.query_one("#prompt", Input) + inp.value = "hi" + await inp.action_submit() + await pilot.pause() + assert app.hint == RatatoskrApp.HINT_STREAMING + + +import json # noqa: E402 + + +def _sse_chunk(sse_id: str, body: dict) -> bytes: + return f"id: {sse_id}\ndata: {json.dumps(body)}\n\n".encode() + + +_DONE_BODY = { + "type": "done", + "phase": "succeeded", + "response": "hello", + "model": "m", + "duration_ms": 1, + "usage": { + "prompt_tokens": 0, + "completion_tokens": 0, + "total_tokens": 0, + "cached_input_tokens": 0, + }, +} +_CANCELLED_STREAM_BODY = { + "type": "cancelled", + "phase": "cancelled", + "turn_id": 42, + "reason": "user_cancel", + "partial_message_id": None, +} + + +def _sse_resp(body: bytes | httpx.AsyncByteStream) -> httpx.Response: + headers = {"content-type": "text/event-stream"} + if isinstance(body, bytes): + return httpx.Response(200, headers=headers, content=body) + return httpx.Response(200, headers=headers, stream=body) + + +async def _submit_and_wait(app: RatatoskrApp, pilot, content: str) -> None: + """Type content into the input and submit; wait for worker to finish.""" + inp = app.query_one("#prompt", Input) + inp.value = content + await inp.action_submit() + await pilot.pause() # let the Input.Submitted message dispatch + # Poll until the worker resolves (state returns to idle) + for _ in range(100): + if app.state == "idle" and app.stream_worker is not None: + return + await pilot.pause(0.02) + + +class TestStreamTurnWorker: + @respx.mock + async def test_happy_text_done_renders_markdown( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """happy_text_done_renders_markdown [happy,tracer]: …""" + stream = ( + + _sse_chunk("42:1", {"type": "text", "content": "hello"}) + + + _sse_chunk("42:2", _DONE_BODY) + + ) + respx.post( + + "https://w.example/sessions/s-1existing/messages" + + ).mock(return_value=_sse_resp(stream)) + + writes = _spy_writes(monkeypatch) + + app = RatatoskrApp(_args_existing()) + async with app.run_test() as pilot: + await pilot.pause() + await _submit_and_wait(app, pilot, "hi") + assert app.state == "idle" + # Streamed delta + done label + rule + markdown render + assert any(w == "hello" for w in writes) + assert any("[done]" in str(w) for w in writes) + # The post-Done markdown render uses rich Rule + Markdown — non-string writes + from rich.markdown import Markdown + assert any(isinstance(w, Markdown) for w in writes) + + @respx.mock + async def test_raw_flag_skips_markdown_render( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """raw_flag_skips_markdown_render [trace]: …""" + stream = ( + + _sse_chunk("42:1", {"type": "text", "content": "hi"}) + + + _sse_chunk("42:2", _DONE_BODY) + + ) + respx.post( + + "https://w.example/sessions/s-1existing/messages" + + ).mock(return_value=_sse_resp(stream)) + writes = _spy_writes(monkeypatch) + app = RatatoskrApp(_args_existing(raw=True)) + async with app.run_test() as pilot: + await pilot.pause() + await _submit_and_wait(app, pilot, "x") + from rich.markdown import Markdown + assert not any(isinstance(w, Markdown) for w in writes) + + @respx.mock + async def test_error_terminal_returns_to_idle( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """error_terminal_returns_to_idle [happy]: …""" + stream = ( + + _sse_chunk("42:1", {"type": "text", "content": "x"}) + + + _sse_chunk( + "42:2", + { + "type": "error", + "phase": "failed", + "error_code": "llm_output_invalid", + "message": "boom", + }, + ) + + ) + respx.post( + + "https://w.example/sessions/s-1existing/messages" + + ).mock(return_value=_sse_resp(stream)) + writes = _spy_writes(monkeypatch) + app = RatatoskrApp(_args_existing()) + async with app.run_test() as pilot: + await pilot.pause() + await _submit_and_wait(app, pilot, "x") + assert app.state == "idle" + assert any("[error]" in str(w) for w in writes) + + @respx.mock + async def test_cancelled_terminal_returns_to_idle( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """cancelled_terminal_returns_to_idle [happy]: …""" + stream = ( + + _sse_chunk("42:1", {"type": "text", "content": "x"}) + + + _sse_chunk("42:2", _CANCELLED_STREAM_BODY) + + ) + respx.post( + + "https://w.example/sessions/s-1existing/messages" + + ).mock(return_value=_sse_resp(stream)) + writes = _spy_writes(monkeypatch) + app = RatatoskrApp(_args_existing()) + async with app.run_test() as pilot: + await pilot.pause() + await _submit_and_wait(app, pilot, "x") + assert app.state == "idle" + assert any("[cancelled]" in str(w) for w in writes) + + @respx.mock + async def test_active_turn_id_set_on_first_event( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """active_turn_id_set_on_first_event [trace]: …""" + # Use a gated stream: yield first event, then hold, so we can inspect mid-stream + first = _sse_chunk("42:1", {"type": "text", "content": "x"}) + gate = asyncio.Event() + + class _GatedAfterFirst(httpx.AsyncByteStream): + async def __aiter__(self): + yield first + await gate.wait() + yield _sse_chunk("42:2", _DONE_BODY) + + async def aclose(self) -> None: + return None + + respx.post("https://w.example/sessions/s-1existing/messages").mock( + return_value=_sse_resp(_GatedAfterFirst()) + ) + + app = RatatoskrApp(_args_existing()) + async with app.run_test() as pilot: + await pilot.pause() + inp = app.query_one("#prompt", Input) + inp.value = "x" + await inp.action_submit() + # Wait for first event to be processed (active_turn_id set) + for _ in range(50): + if app.active_turn_id is not None: + break + await pilot.pause(0.02) + assert app.active_turn_id == 42 + # Release the gate so the worker can finish and the app can shut down cleanly + gate.set() + for _ in range(50): + if app.state == "idle": + break + await pilot.pause(0.02) + + @respx.mock + async def test_sse_connect_failed_returns_to_idle( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """sse_connect_failed_returns_to_idle [error]: …""" + respx.post("https://w.example/sessions/s-1existing/messages").mock( + return_value=httpx.Response(404, json={"error": "session_not_found"}) + ) + writes = _spy_writes(monkeypatch) + app = RatatoskrApp(_args_existing()) + async with app.run_test() as pilot: + await pilot.pause() + await _submit_and_wait(app, pilot, "x") + assert app.state == "idle" + assert any("[sse_connect_failed]" in str(w) for w in writes) + assert app.return_value is None # app NOT exited per INV-008 + + @respx.mock + async def test_connection_dropped_returns_to_idle( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """connection_dropped_returns_to_idle [error]: …""" + + class _DropAfter(httpx.AsyncByteStream): + async def __aiter__(self): + yield _sse_chunk("42:1", {"type": "text", "content": "x"}) + raise httpx.RemoteProtocolError("drop") + + async def aclose(self) -> None: + return None + + respx.post("https://w.example/sessions/s-1existing/messages").mock( + return_value=_sse_resp(_DropAfter()) + ) + writes = _spy_writes(monkeypatch) + app = RatatoskrApp(_args_existing()) + async with app.run_test() as pilot: + await pilot.pause() + await _submit_and_wait(app, pilot, "x") + assert app.state == "idle" + assert any("[connection_dropped]" in str(w) for w in writes) + + @respx.mock + async def test_rendered_event_per_event( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """rendered_event_per_event [trace]: …""" + chunks = ( + _sse_chunk("42:1", {"type": "worker_phase", "phase": "streaming", "turn_id": 42}) + + _sse_chunk("42:2", {"type": "text", "content": "hi"}) + + _sse_chunk("42:3", _DONE_BODY) + ) + respx.post( + + "https://w.example/sessions/s-1existing/messages" + + ).mock(return_value=_sse_resp(chunks)) + + from ratatoskr import tui as tui_mod + + call_count = 0 + original = tui_mod._render_event_to_log + + def spy(event, *, log, raw): + nonlocal call_count + call_count += 1 + return original(event, log=log, raw=raw) + + monkeypatch.setattr(tui_mod, "_render_event_to_log", spy) + app = RatatoskrApp(_args_existing()) + async with app.run_test() as pilot: + await pilot.pause() + await _submit_and_wait(app, pilot, "x") + assert call_count == 3 + + +class TestActionInterrupt: + @respx.mock + async def test_idle_ctrl_c_exits_zero(self) -> None: + """idle_ctrl_c_exits_zero [happy,tracer]: state=idle; ctrl+c → exit(0).""" + app = RatatoskrApp(_args_existing()) + async with app.run_test() as pilot: + await pilot.pause() + assert app.state == "idle" + await pilot.press("ctrl+c") + await pilot.pause() + assert app.return_value == 0 + + @respx.mock + async def test_streaming_first_ctrl_c_cancels( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """streaming_first_ctrl_c_cancels [scenario,tracer]: …""" + # Stream that yields one text event (sets active_turn_id) then waits forever + first_chunk = _sse_chunk("42:1", {"type": "text", "content": "x"}) + gate = asyncio.Event() + + class _GatedAfterFirst(httpx.AsyncByteStream): + async def __aiter__(self): + yield first_chunk + await gate.wait() + + async def aclose(self) -> None: + return None + + respx.post("https://w.example/sessions/s-1existing/messages").mock( + return_value=_sse_resp(_GatedAfterFirst()) + ) + cancel_observed = asyncio.Event() + + def cancel_handler(req: httpx.Request) -> httpx.Response: + cancel_observed.set() + return httpx.Response(200, json=_CANCEL_OK_RESP) + + cancel_route = respx.post("https://w.example/sessions/s-1existing/turns/42/cancel").mock( + side_effect=cancel_handler + ) + + app = RatatoskrApp(_args_existing()) + async with app.run_test() as pilot: + await pilot.pause() + inp = app.query_one("#prompt", Input) + inp.value = "go" + await inp.action_submit() + await pilot.pause() + # Wait for active_turn_id to be set (first event consumed) + for _ in range(50): + if app.active_turn_id == 42: + break + await pilot.pause(0.02) + assert app.active_turn_id == 42 + assert app.state == "streaming" + await pilot.press("ctrl+c") + # Wait for cancel POST to land + for _ in range(50): + if cancel_observed.is_set(): + break + await pilot.pause(0.02) + assert cancel_route.call_count == 1 + assert app.state == "cancelling" + assert app.hint == RatatoskrApp.HINT_CANCELLING + # Release the gate so the stream worker can finish cleanly during teardown + gate.set() + + @respx.mock + async def test_streaming_no_turn_id_force_exits( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """streaming_no_turn_id_force_exits [scenario]: …""" + cancel_route = respx.post("https://w.example/sessions/s-1existing/turns/0/cancel").mock( + return_value=httpx.Response(200, json=_CANCEL_OK_RESP) + ) + # Stream that hangs forever (no events to set active_turn_id) + gate = asyncio.Event() + + class _NeverYields(httpx.AsyncByteStream): + async def __aiter__(self): + await gate.wait() + if False: + yield b"" + + async def aclose(self) -> None: + return None + + respx.post("https://w.example/sessions/s-1existing/messages").mock( + return_value=_sse_resp(_NeverYields()) + ) + + app = RatatoskrApp(_args_existing()) + async with app.run_test() as pilot: + await pilot.pause() + inp = app.query_one("#prompt", Input) + inp.value = "go" + await inp.action_submit() + await pilot.pause() + assert app.state == "streaming" + assert app.active_turn_id is None + await pilot.press("ctrl+c") + await pilot.pause() + gate.set() # let the gated stream resolve so teardown is clean + assert app.return_value == 3 + assert cancel_route.call_count == 0 + + @respx.mock + async def test_cancelling_second_ctrl_c_force_exits(self) -> None: + """cancelling_second_ctrl_c_force_exits [scenario]: …""" + app = RatatoskrApp(_args_existing()) + async with app.run_test() as pilot: + await pilot.pause() + app.state = "cancelling" # bypass the natural transition for the test + await pilot.press("ctrl+c") + await pilot.pause() + assert app.return_value == 3 + + @respx.mock + async def test_cancel_failed_swallowed(self) -> None: + """cancel_failed_swallowed [scenario]: …""" + first_chunk = _sse_chunk("42:1", {"type": "text", "content": "x"}) + stream_gate = asyncio.Event() + + class _GatedAfterFirst(httpx.AsyncByteStream): + async def __aiter__(self): + yield first_chunk + await stream_gate.wait() + + async def aclose(self) -> None: + return None + + respx.post("https://w.example/sessions/s-1existing/messages").mock( + return_value=_sse_resp(_GatedAfterFirst()) + ) + cancel_observed = asyncio.Event() + + def cancel_handler(req: httpx.Request) -> httpx.Response: + cancel_observed.set() + return httpx.Response(500, content=b"boom") + + respx.post("https://w.example/sessions/s-1existing/turns/42/cancel").mock( + side_effect=cancel_handler + ) + + app = RatatoskrApp(_args_existing()) + async with app.run_test() as pilot: + await pilot.pause() + inp = app.query_one("#prompt", Input) + inp.value = "go" + await inp.action_submit() + await pilot.pause() + for _ in range(50): + if app.active_turn_id == 42: + break + await pilot.pause(0.02) + await pilot.press("ctrl+c") + for _ in range(50): + if cancel_observed.is_set(): + break + await pilot.pause(0.02) + # Give _cancel_via_sse time to write the [cancel_failed] line + await pilot.pause(0.05) + log = app.query_one("#transcript", RichLog) + rendered = "\n".join(str(strip.text) for strip in log.lines) + assert "[cancel_failed]" in rendered + assert app.state == "cancelling" + stream_gate.set() # let stream finish for teardown + + +class TestActionQuit: + @respx.mock + async def test_idle_ctrl_d_exits_zero(self) -> None: + """idle_ctrl_d_exits_zero [happy,tracer]: state=idle; ctrl+d → exit(0).""" + app = RatatoskrApp(_args_existing()) + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("ctrl+d") + await pilot.pause() + assert app.return_value == 0 + + @respx.mock + async def test_streaming_ctrl_d_force_exits( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """streaming_ctrl_d_force_exits [scenario]: …""" + cancel_route = respx.post("https://w.example/sessions/s-1existing/turns/42/cancel").mock( + return_value=httpx.Response(200, json=_CANCEL_OK_RESP) + ) + first_chunk = _sse_chunk("42:1", {"type": "text", "content": "x"}) + gate = asyncio.Event() + + class _GatedAfterFirst(httpx.AsyncByteStream): + async def __aiter__(self): + yield first_chunk + await gate.wait() + + async def aclose(self) -> None: + return None + + respx.post("https://w.example/sessions/s-1existing/messages").mock( + return_value=_sse_resp(_GatedAfterFirst()) + ) + app = RatatoskrApp(_args_existing()) + async with app.run_test() as pilot: + await pilot.pause() + inp = app.query_one("#prompt", Input) + inp.value = "go" + await inp.action_submit() + await pilot.pause() + for _ in range(50): + if app.active_turn_id == 42: + break + await pilot.pause(0.02) + await pilot.press("ctrl+d") + await pilot.pause() + gate.set() + assert app.return_value == 0 + assert cancel_route.call_count == 0 + + +from ratatoskr.tui import run_tui # noqa: E402 + + +class TestRunTui: + def test_happy_returns_zero_on_quit(self, monkeypatch: pytest.MonkeyPatch) -> None: + """happy_returns_zero_on_quit [happy,tracer]: run_tui propagates App.run() exit code.""" + captured: list[ParsedArgs] = [] + + def fake_run(self) -> int: + captured.append(self.args) + return 0 + + monkeypatch.setattr(RatatoskrApp, "run", fake_run) + rc = run_tui(_args_existing()) + assert rc == 0 + assert len(captured) == 1 + assert captured[0].send_content is None + + def test_precondition_send_content_none(self) -> None: + """precondition_send_content_none [adversarial]: …""" + bad_args = ParsedArgs( + send_content="x", # PRE-001 violation + session_id="s-1", + new=False, + agent_id=None, + api_key="k", + server_url="https://w.example", + raw=False, + ) + with pytest.raises(AssertionError): + run_tui(bad_args)