fix(cli): address Volva code-vs-contract drift (issue #3)

Volva code-review surfaced 5 findings against the TDD-passing
implementation; all 5 addressed.

Drift fixes (code):
- Add `assert argv is None or all(isinstance(a, str) for a in argv)`
  at both `main` and `_parse_args` entry points (PRE-001 was unenforced).
- `main` now catches `SystemExit` and returns `exc.code` verbatim —
  argparse's --help (SystemExit(0)) was escaping through main as an
  unhandled exception. Contract amended in-place to spell out the
  SystemExit-from-argparse-clean-exits passthrough in both
  `main` and `_parse_args` ERROR_ROUTING. New `help_exits_cleanly`
  test added per the contract amendment.
- Add the PRE-001 union-type assert at `_render_event` entry —
  unmatched Event variants would have silently no-op'd.
- `_run_turn` now awaits `cancel_task` in the `finally` block before
  returning. Under fast-stream + slow-cancel scenarios the
  `[cancel_failed]` line could miss being written before _run_turn
  returns, AND _amain could close the AsyncClient while the cancel
  POST was still in flight. `_cancel_and_log` swallows all errors
  per INV-009 so the await is safe.

Test gap fix:
- New `_FlushCountingIO` subclass counts flush() calls;
  `test_text_to_stdout_only` and `test_done_writes_newline_and_label`
  now assert `flush_count == 1` to verify INV-010 (per-chunk flush).
  Previously the tests would have passed even with flush removed.

Meta-note carried in persistent-memory: TDD caught central behavior
(stdout/stderr routing, exit-code mapping, create-session ordering,
SIGINT idempotence); the cross-model code review consistently catches
assert-boundary + observability-shape gaps across all three issues
(#1: 4 findings, #2: 3 findings, #3: 5 findings).

118/118 tests GREEN; ruff clean; drift check clean.
This commit is contained in:
2026-05-20 22:59:26 -07:00
parent db27774c51
commit 9717fb80e2
4 changed files with 64 additions and 9 deletions
+36 -3
View File
@@ -34,6 +34,18 @@ from ratatoskr.sse_client import (
)
class _FlushCountingIO(io.StringIO):
"""StringIO subclass that counts flush() calls — used to verify INV-010."""
def __init__(self) -> None:
super().__init__()
self.flush_count = 0
def flush(self) -> None:
self.flush_count += 1
super().flush()
def _sse_chunk(sse_id: str, body: dict[str, object]) -> bytes:
return f"id: {sse_id}\ndata: {json.dumps(body)}\n\n".encode()
@@ -176,15 +188,16 @@ SID = SseId(42, 5)
class TestRenderEvent:
def test_text_to_stdout_only(self) -> None:
"""text_to_stdout_only [happy,tracer]: Text → stdout=="hello"; stderr empty; flushed."""
stdout = io.StringIO()
stdout = _FlushCountingIO()
stderr = io.StringIO()
_render_event(Text(sse_id=SID, content="hello"), stdout=stdout, stderr=stderr)
assert stdout.getvalue() == "hello"
assert stderr.getvalue() == ""
assert stdout.flush_count == 1 # INV-010: per-chunk flush
def test_done_writes_newline_and_label(self) -> None:
"""done_writes_newline_and_label: stdout=="\\n"; stderr "[done]" + turn_id + model."""
stdout = io.StringIO()
"""done_writes_newline_and_label: stdout=="\\n" (flushed); stderr "[done]" labels."""
stdout = _FlushCountingIO()
stderr = io.StringIO()
evt = Done(
sse_id=SID,
@@ -196,6 +209,7 @@ class TestRenderEvent:
)
_render_event(evt, stdout=stdout, stderr=stderr)
assert stdout.getvalue() == "\n"
assert stdout.flush_count == 1 # INV-010: post-Done newline flushed
out_err = stderr.getvalue()
assert out_err.startswith("[done]")
assert "turn_id=42" in out_err
@@ -957,3 +971,22 @@ class TestMain:
monkeypatch.setattr(cli_mod, "_amain", fake_amain)
rc = main(None)
assert rc == 0
def test_help_exits_cleanly(
self,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""help_exits_cleanly [happy]: --help → main returns 0; _amain never called."""
amain_calls: list[int] = []
async def fake_amain(args: ParsedArgs) -> int:
amain_calls.append(1)
return 0
monkeypatch.setattr(cli_mod, "_amain", fake_amain)
rc = main(["--help"])
assert rc == 0
assert amain_calls == []
# argparse prints help text to stdout
assert "ratatoskr" in capsys.readouterr().out