feat(tui): implement issue #4 contract via TDD; amend cli for TUI dispatch

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).
This commit is contained in:
vh
2026-05-21 00:31:40 -07:00
parent 9d469d5c67
commit dd89239c34
5 changed files with 1312 additions and 13 deletions
+45 -4
View File
@@ -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 == []