feat(cli): implement issue #3 contract via TDD
54 contract-listed tests authored + GREEN per the vertical-slice ordering (_parse_args → _render_event → _cancel_and_log → _run_turn → _amain → main). 117/117 tests GREEN suite-wide; ruff clean. The _run_turn race-loop is the load-bearing piece. Per iteration, the await on the next event is raced against sigint_event.wait() when NOT cancelling. Once SIGINT fires (with last_turn_id known), _cancel_and_log is spawned, cancelling=True flips, and subsequent iterations skip wait()-task creation entirely — the bug Volva flagged in contract review would otherwise busy-wake on the already-set event each iteration. Implementation notes: - _UsageErrorParser subclasses argparse.ArgumentParser and overrides error() to raise _ArgparseError instead of calling sys.exit; _parse_args catches and re-raises as UsageError per the contract's ERROR_ROUTING. - _GatedStream test helper (custom httpx.AsyncByteStream that pauses on asyncio.Event entries) makes SIGINT-mid-stream tests deterministic without sleep-based timing — gates release via side-channels (the cancel-mock sets an event when its endpoint is observed). - _sse_resp test helper wraps respx Response with the text/event-stream content-type, dedupes the boilerplate across the 13 _run_turn tests. - Strong-ref cancel_task local in _run_turn holds the fire-and-forget cancel task to suppress RUF006 / asyncio GC warning. One in-flight contract amendment during TDD: no_busy_loop_after_cancel test description originally said "exactly ONE wait()-shaped task" but the natural race-loop shape produces 2 (iter 1 raced w/ text, iter 2 raced w/ sigint → flipped cancelling; iter 3+ skipped). Amended to "TWO total wait() coroutines" with rationale; the busy-loop check is preserved (iter 3+ MUST skip). Persistent-memory updated per the commit-along rule: new module landed, recent-decisions log entries for #3 (contract + Volva paraphrase + TDD), next natural moves rotated to /volva-code-review on the implementation.
This commit is contained in:
@@ -0,0 +1,959 @@
|
||||
"""Tests for ratatoskr.cli per docs/contracts/issues/3.contract.md."""
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from ratatoskr import cli as cli_mod
|
||||
from ratatoskr.cli import (
|
||||
ParsedArgs,
|
||||
UsageError,
|
||||
_amain,
|
||||
_AuthError,
|
||||
_cancel_and_log,
|
||||
_parse_args,
|
||||
_render_event,
|
||||
_run_turn,
|
||||
main,
|
||||
)
|
||||
from ratatoskr.sse_client import (
|
||||
Cancelled,
|
||||
Done,
|
||||
Error,
|
||||
SseId,
|
||||
Text,
|
||||
TextBoundary,
|
||||
Thinking,
|
||||
ToolResult,
|
||||
ToolStart,
|
||||
WorkerPhase,
|
||||
)
|
||||
|
||||
|
||||
def _sse_chunk(sse_id: str, body: dict[str, object]) -> bytes:
|
||||
return f"id: {sse_id}\ndata: {json.dumps(body)}\n\n".encode()
|
||||
|
||||
|
||||
def _sse_resp(body: bytes | httpx.AsyncByteStream) -> httpx.Response:
|
||||
"""Wrap an SSE response body (bytes or stream) with the right content-type."""
|
||||
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)
|
||||
|
||||
|
||||
_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_BODY = {
|
||||
"type": "cancelled",
|
||||
"phase": "cancelled",
|
||||
"turn_id": 42,
|
||||
"reason": "user_cancel",
|
||||
"partial_message_id": None,
|
||||
}
|
||||
_CANCEL_OK_RESP = {"turn_id": 42, "cancelled": True, "reason": None, "partial_message_id": None}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Tests assert env-resolution behavior; default to a clean slate per test."""
|
||||
monkeypatch.delenv("WORLDTREE_API_KEY", raising=False)
|
||||
monkeypatch.delenv("WORLDTREE_API_URL", raising=False)
|
||||
|
||||
|
||||
class TestParseArgs:
|
||||
def test_happy_new(self) -> None:
|
||||
"""happy_new [happy,tracer]: --send --new --agent --api-key → full ParsedArgs."""
|
||||
args = _parse_args(["--send", "hi", "--new", "--agent", "mimir", "--api-key", "k"])
|
||||
assert args == ParsedArgs(
|
||||
send_content="hi",
|
||||
session_id=None,
|
||||
new=True,
|
||||
agent_id="mimir",
|
||||
api_key="k",
|
||||
server_url="http://localhost:8000",
|
||||
)
|
||||
|
||||
def test_happy_existing_session(self) -> None:
|
||||
"""happy_existing_session: --send --session --api-key → ParsedArgs with session_id."""
|
||||
args = _parse_args(["--send", "hi", "--session", "s-1", "--api-key", "k"])
|
||||
assert args == ParsedArgs(
|
||||
send_content="hi",
|
||||
session_id="s-1",
|
||||
new=False,
|
||||
agent_id=None,
|
||||
api_key="k",
|
||||
server_url="http://localhost:8000",
|
||||
)
|
||||
|
||||
def test_api_key_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""api_key_from_env: WORLDTREE_API_KEY env var fills in when --api-key omitted."""
|
||||
monkeypatch.setenv("WORLDTREE_API_KEY", "from-env")
|
||||
args = _parse_args(["--send", "hi", "--new", "--agent", "mimir"])
|
||||
assert args.api_key == "from-env"
|
||||
|
||||
def test_api_key_flag_beats_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""api_key_flag_beats_env: explicit --api-key wins over WORLDTREE_API_KEY."""
|
||||
monkeypatch.setenv("WORLDTREE_API_KEY", "env")
|
||||
args = _parse_args(["--send", "hi", "--new", "--agent", "m", "--api-key", "flag"])
|
||||
assert args.api_key == "flag"
|
||||
|
||||
def test_server_default(self) -> None:
|
||||
"""server_default: no --server, no WORLDTREE_API_URL → http://localhost:8000."""
|
||||
args = _parse_args(["--send", "hi", "--new", "--agent", "m", "--api-key", "k"])
|
||||
assert args.server_url == "http://localhost:8000"
|
||||
|
||||
def test_server_env_fallback(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""server_env_fallback: WORLDTREE_API_URL fills in when --server omitted."""
|
||||
monkeypatch.setenv("WORLDTREE_API_URL", "http://t.local:9000")
|
||||
args = _parse_args(["--send", "hi", "--new", "--agent", "m", "--api-key", "k"])
|
||||
assert args.server_url == "http://t.local:9000"
|
||||
|
||||
def test_server_flag_beats_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""server_flag_beats_env: explicit --server wins over WORLDTREE_API_URL."""
|
||||
monkeypatch.setenv("WORLDTREE_API_URL", "env")
|
||||
args = _parse_args(
|
||||
["--send", "hi", "--new", "--agent", "m", "--api-key", "k", "--server", "flag"]
|
||||
)
|
||||
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_usage_both_session_and_new(self) -> None:
|
||||
"""usage_both_session_and_new: --session AND --new → UsageError('mutually exclusive')."""
|
||||
with pytest.raises(UsageError, match="mutually exclusive"):
|
||||
_parse_args(
|
||||
["--send", "hi", "--session", "s", "--new", "--agent", "m", "--api-key", "k"]
|
||||
)
|
||||
|
||||
def test_usage_neither_session_nor_new(self) -> None:
|
||||
"""usage_neither_session_nor_new: neither flag → UsageError('pass exactly one')."""
|
||||
with pytest.raises(UsageError, match="pass exactly one"):
|
||||
_parse_args(["--send", "hi", "--api-key", "k"])
|
||||
|
||||
def test_usage_new_without_agent(self) -> None:
|
||||
"""usage_new_without_agent: --new without --agent → UsageError."""
|
||||
with pytest.raises(UsageError, match="--agent is required when --new"):
|
||||
_parse_args(["--send", "hi", "--new", "--api-key", "k"])
|
||||
|
||||
def test_usage_session_with_agent(self) -> None:
|
||||
"""usage_session_with_agent: --session AND --agent → UsageError."""
|
||||
with pytest.raises(UsageError, match="forbidden with --session"):
|
||||
_parse_args(["--send", "hi", "--session", "s-1", "--agent", "x", "--api-key", "k"])
|
||||
|
||||
def test_auth_missing(self) -> None:
|
||||
"""auth_missing: no --api-key and no env → _AuthError."""
|
||||
with pytest.raises(_AuthError, match="no API key"):
|
||||
_parse_args(["--send", "hi", "--new", "--agent", "mimir"])
|
||||
|
||||
def test_empty_send(self) -> None:
|
||||
"""empty_send: --send '' → UsageError (non-empty enforced)."""
|
||||
with pytest.raises(UsageError):
|
||||
_parse_args(["--send", "", "--new", "--agent", "x", "--api-key", "k"])
|
||||
|
||||
|
||||
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()
|
||||
stderr = io.StringIO()
|
||||
_render_event(Text(sse_id=SID, content="hello"), stdout=stdout, stderr=stderr)
|
||||
assert stdout.getvalue() == "hello"
|
||||
assert stderr.getvalue() == ""
|
||||
|
||||
def test_done_writes_newline_and_label(self) -> None:
|
||||
"""done_writes_newline_and_label: stdout=="\\n"; stderr "[done]" + turn_id + model."""
|
||||
stdout = io.StringIO()
|
||||
stderr = io.StringIO()
|
||||
evt = Done(
|
||||
sse_id=SID,
|
||||
phase="completed",
|
||||
response="hi",
|
||||
model="glm5-turbo",
|
||||
duration_ms=1234,
|
||||
usage={"prompt": 10, "completion": 5},
|
||||
)
|
||||
_render_event(evt, stdout=stdout, stderr=stderr)
|
||||
assert stdout.getvalue() == "\n"
|
||||
out_err = stderr.getvalue()
|
||||
assert out_err.startswith("[done]")
|
||||
assert "turn_id=42" in out_err
|
||||
assert "model=glm5-turbo" in out_err
|
||||
assert "duration_ms=1234" in out_err
|
||||
|
||||
def test_error_to_stderr_only(self) -> None:
|
||||
"""error_to_stderr_only: Error → stderr "[error]" with code; stdout empty."""
|
||||
stdout = io.StringIO()
|
||||
stderr = io.StringIO()
|
||||
evt = Error(sse_id=SID, phase="failed", message="boom", error_code="llm_output_invalid")
|
||||
_render_event(evt, stdout=stdout, stderr=stderr)
|
||||
assert stdout.getvalue() == ""
|
||||
out_err = stderr.getvalue()
|
||||
assert out_err.startswith("[error]")
|
||||
assert "turn_id=42" in out_err
|
||||
assert "code=llm_output_invalid" in out_err
|
||||
|
||||
def test_cancelled_to_stderr_only(self) -> None:
|
||||
"""cancelled_to_stderr_only: Cancelled → stderr "[cancelled]" + reason + partial id."""
|
||||
stdout = io.StringIO()
|
||||
stderr = io.StringIO()
|
||||
evt = Cancelled(
|
||||
sse_id=SID, phase="cancelled", turn_id=42, reason="user", partial_message_id=7
|
||||
)
|
||||
_render_event(evt, stdout=stdout, stderr=stderr)
|
||||
assert stdout.getvalue() == ""
|
||||
out_err = stderr.getvalue()
|
||||
assert out_err.startswith("[cancelled]")
|
||||
assert "reason='user'" in out_err
|
||||
assert "partial_message_id=7" in out_err
|
||||
|
||||
def test_worker_phase_to_stderr(self) -> None:
|
||||
"""worker_phase_to_stderr: WorkerPhase → stderr "[worker_phase]"; stdout empty."""
|
||||
stdout = io.StringIO()
|
||||
stderr = io.StringIO()
|
||||
evt = WorkerPhase(sse_id=SID, phase="streaming", turn_id=42)
|
||||
_render_event(evt, stdout=stdout, stderr=stderr)
|
||||
assert stdout.getvalue() == ""
|
||||
assert stderr.getvalue().startswith("[worker_phase]")
|
||||
|
||||
def test_thinking_truncated(self) -> None:
|
||||
"""thinking_truncated [trace]: …"""
|
||||
stdout = io.StringIO()
|
||||
stderr = io.StringIO()
|
||||
_render_event(Thinking(sse_id=SID, content="a" * 500), stdout=stdout, stderr=stderr)
|
||||
out_err = stderr.getvalue()
|
||||
assert out_err.startswith("[thinking]")
|
||||
assert "a" * 500 not in out_err
|
||||
assert "a" * 200 in out_err
|
||||
|
||||
def test_tool_start_to_stderr(self) -> None:
|
||||
"""tool_start_to_stderr: ToolStart → stderr "[tool_start] name=... args=..."."""
|
||||
stdout = io.StringIO()
|
||||
stderr = io.StringIO()
|
||||
evt = ToolStart(sse_id=SID, name="read_file", arguments={"path": "/x"})
|
||||
_render_event(evt, stdout=stdout, stderr=stderr)
|
||||
assert stdout.getvalue() == ""
|
||||
out_err = stderr.getvalue()
|
||||
assert out_err.startswith("[tool_start] name=read_file args=")
|
||||
|
||||
def test_tool_result_truncated(self) -> None:
|
||||
"""tool_result_truncated [trace]: ToolResult.result repr truncated to ≤200 chars."""
|
||||
stdout = io.StringIO()
|
||||
stderr = io.StringIO()
|
||||
evt = ToolResult(sse_id=SID, name="x", result="b" * 500, duration_ms=42)
|
||||
_render_event(evt, stdout=stdout, stderr=stderr)
|
||||
out_err = stderr.getvalue()
|
||||
assert out_err.startswith("[tool_result]")
|
||||
# the contract uses `{event.result!r:.200}` — 200 chars max of repr output
|
||||
assert "b" * 500 not in out_err
|
||||
|
||||
def test_text_boundary_to_stderr(self) -> None:
|
||||
"""text_boundary_to_stderr: TextBoundary → stderr "[text_boundary]"; stdout empty."""
|
||||
stdout = io.StringIO()
|
||||
stderr = io.StringIO()
|
||||
evt = TextBoundary(sse_id=SID, kind="sentence", char_offset=128, ts="2026-05-21T00:00:00Z")
|
||||
_render_event(evt, stdout=stdout, stderr=stderr)
|
||||
assert stdout.getvalue() == ""
|
||||
out_err = stderr.getvalue()
|
||||
assert out_err.startswith("[text_boundary]")
|
||||
assert "kind=sentence" in out_err
|
||||
assert "char_offset=128" in out_err
|
||||
|
||||
def test_invariant_inv003_stderr_only(self) -> None:
|
||||
"""invariant_inv003_stderr_only [scenario]: …"""
|
||||
for evt in [
|
||||
WorkerPhase(sse_id=SID, phase="x", turn_id=42),
|
||||
Thinking(sse_id=SID, content="x"),
|
||||
TextBoundary(sse_id=SID, kind="x", char_offset=0, ts="t"),
|
||||
ToolStart(sse_id=SID, name="x", arguments={}),
|
||||
ToolResult(sse_id=SID, name="x", result=None, duration_ms=0),
|
||||
Error(sse_id=SID, phase="failed", message="m", error_code="e"),
|
||||
Cancelled(
|
||||
sse_id=SID, phase="cancelled", turn_id=42, reason="r", partial_message_id=None
|
||||
),
|
||||
]:
|
||||
stdout = io.StringIO()
|
||||
stderr = io.StringIO()
|
||||
_render_event(evt, stdout=stdout, stderr=stderr)
|
||||
assert stdout.getvalue() == "", f"INV-002 violated for {type(evt).__name__}"
|
||||
|
||||
|
||||
class TestCancelAndLog:
|
||||
@respx.mock
|
||||
async def test_happy_cancel(self) -> None:
|
||||
"""happy_cancel [happy,tracer]: 200 OK → returns None; stderr empty."""
|
||||
respx.post("https://w.example/sessions/s-1/turns/42/cancel").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={"turn_id": 42, "cancelled": True, "reason": None, "partial_message_id": None},
|
||||
)
|
||||
)
|
||||
stderr = io.StringIO()
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
result = await _cancel_and_log(client, "s-1", 42, stderr=stderr)
|
||||
assert result is None
|
||||
assert stderr.getvalue() == ""
|
||||
|
||||
@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")
|
||||
)
|
||||
stderr = io.StringIO()
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
await _cancel_and_log(client, "s-1", 42, stderr=stderr)
|
||||
out = stderr.getvalue()
|
||||
assert "[cancel_failed]" in out
|
||||
assert "CancelFailed" in out
|
||||
|
||||
@respx.mock
|
||||
async def test_cancel_already_completed(self) -> None:
|
||||
"""cancel_already_completed [scenario]: …"""
|
||||
respx.post("https://w.example/sessions/s-1/turns/42/cancel").mock(
|
||||
return_value=httpx.Response(409)
|
||||
)
|
||||
stderr = io.StringIO()
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
await _cancel_and_log(client, "s-1", 42, stderr=stderr)
|
||||
out = stderr.getvalue()
|
||||
assert "[cancel_failed]" in out
|
||||
assert "CancelAlreadyCompleted" in out
|
||||
|
||||
@respx.mock
|
||||
async def test_cancel_turn_not_found(self) -> None:
|
||||
"""cancel_turn_not_found [scenario]: 404 → returns None; stderr CancelTurnNotFound."""
|
||||
respx.post("https://w.example/sessions/s-1/turns/42/cancel").mock(
|
||||
return_value=httpx.Response(404)
|
||||
)
|
||||
stderr = io.StringIO()
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
await _cancel_and_log(client, "s-1", 42, stderr=stderr)
|
||||
out = stderr.getvalue()
|
||||
assert "[cancel_failed]" in out
|
||||
assert "CancelTurnNotFound" in out
|
||||
|
||||
@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")
|
||||
)
|
||||
stderr = io.StringIO()
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
await _cancel_and_log(client, "s-1", 42, stderr=stderr)
|
||||
out = stderr.getvalue()
|
||||
assert "[cancel_failed]" in out
|
||||
assert "ConnectError" in out
|
||||
|
||||
|
||||
class _GatedStream(httpx.AsyncByteStream):
|
||||
"""SSE byte stream: list of (bytes | asyncio.Event); Event entries pause until set."""
|
||||
|
||||
def __init__(self, items: list[bytes | asyncio.Event]) -> None:
|
||||
self._items = items
|
||||
|
||||
async def __aiter__(self): # type: ignore[no-untyped-def]
|
||||
for item in self._items:
|
||||
if isinstance(item, asyncio.Event):
|
||||
await item.wait()
|
||||
else:
|
||||
yield item
|
||||
|
||||
async def aclose(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class TestRunTurn:
|
||||
@respx.mock
|
||||
async def test_happy_text_then_done(self) -> None:
|
||||
"""happy_text_then_done [happy,tracer]: …"""
|
||||
stream = _sse_chunk(
|
||||
"42:1", {"type": "text", "content": "hello"}
|
||||
) + _sse_chunk("42:2", _DONE_BODY)
|
||||
respx.post("https://w.example/sessions/s-1/messages").mock(
|
||||
return_value=_sse_resp(stream)
|
||||
)
|
||||
sigint = asyncio.Event()
|
||||
stdout = io.StringIO()
|
||||
stderr = io.StringIO()
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
exit_code = await _run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
|
||||
assert exit_code == 0
|
||||
assert stdout.getvalue() == "hello\n"
|
||||
assert "[done]" in stderr.getvalue()
|
||||
|
||||
@respx.mock
|
||||
async def test_error_terminal(self) -> None:
|
||||
"""error_terminal: text + error → exit 2; stderr has [error]."""
|
||||
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-1/messages").mock(
|
||||
return_value=_sse_resp(stream)
|
||||
)
|
||||
sigint = asyncio.Event()
|
||||
stdout, stderr = io.StringIO(), io.StringIO()
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
exit_code = await _run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
|
||||
assert exit_code == 2
|
||||
assert "[error]" in stderr.getvalue()
|
||||
|
||||
@respx.mock
|
||||
async def test_cancelled_terminal_server(self) -> None:
|
||||
"""cancelled_terminal_server: text + cancelled → exit 3; stderr has [cancelled]."""
|
||||
stream = _sse_chunk("42:1", {"type": "text", "content": "x"}) + _sse_chunk(
|
||||
"42:2", _CANCELLED_BODY
|
||||
)
|
||||
respx.post("https://w.example/sessions/s-1/messages").mock(
|
||||
return_value=_sse_resp(stream)
|
||||
)
|
||||
sigint = asyncio.Event()
|
||||
stdout, stderr = io.StringIO(), io.StringIO()
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
exit_code = await _run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
|
||||
assert exit_code == 3
|
||||
assert "[cancelled]" in stderr.getvalue()
|
||||
|
||||
@respx.mock
|
||||
async def test_sse_connect_failed_404(self) -> None:
|
||||
"""sse_connect_failed_404 [error]: 404 → exit 20; stderr [sse_connect_failed] status=404."""
|
||||
respx.post("https://w.example/sessions/missing/messages").mock(
|
||||
return_value=httpx.Response(404, json={"error": "session_not_found"})
|
||||
)
|
||||
sigint = asyncio.Event()
|
||||
stdout, stderr = io.StringIO(), io.StringIO()
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
exit_code = await _run_turn(
|
||||
client, "missing", "hi", sigint, stdout=stdout, stderr=stderr
|
||||
)
|
||||
assert exit_code == 20
|
||||
out = stderr.getvalue()
|
||||
assert "[sse_connect_failed]" in out
|
||||
assert "status=404" in out
|
||||
|
||||
@respx.mock
|
||||
async def test_connection_dropped(self) -> None:
|
||||
"""connection_dropped [error]: RemoteProtocolError mid-stream → exit 21."""
|
||||
|
||||
class _DropAfter(httpx.AsyncByteStream):
|
||||
def __init__(self, chunks: list[bytes]) -> None:
|
||||
self._chunks = chunks
|
||||
|
||||
async def __aiter__(self): # type: ignore[no-untyped-def]
|
||||
for c in self._chunks:
|
||||
yield c
|
||||
raise httpx.RemoteProtocolError("simulated mid-stream drop")
|
||||
|
||||
async def aclose(self) -> None:
|
||||
return None
|
||||
|
||||
first = _sse_chunk("42:1", {"type": "text", "content": "x"})
|
||||
respx.post("https://w.example/sessions/s-1/messages").mock(
|
||||
return_value=httpx.Response(
|
||||
200, headers={"content-type": "text/event-stream"}, stream=_DropAfter([first])
|
||||
)
|
||||
)
|
||||
sigint = asyncio.Event()
|
||||
stdout, stderr = io.StringIO(), io.StringIO()
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
exit_code = await _run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
|
||||
assert exit_code == 21
|
||||
assert "[connection_dropped]" in stderr.getvalue()
|
||||
|
||||
@respx.mock
|
||||
async def test_malformed_sse_id(self) -> None:
|
||||
"""malformed_sse_id [error]: id without seq → exit 22; stderr [malformed_sse_id]."""
|
||||
stream = b"id: 42\ndata: {\"type\": \"text\", \"content\": \"x\"}\n\n"
|
||||
respx.post("https://w.example/sessions/s-1/messages").mock(
|
||||
return_value=_sse_resp(stream)
|
||||
)
|
||||
sigint = asyncio.Event()
|
||||
stdout, stderr = io.StringIO(), io.StringIO()
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
exit_code = await _run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
|
||||
assert exit_code == 22
|
||||
assert "[malformed_sse_id]" in stderr.getvalue()
|
||||
|
||||
@respx.mock
|
||||
async def test_turn_id_flip(self) -> None:
|
||||
"""turn_id_flip [error]: …"""
|
||||
stream = _sse_chunk("42:1", {"type": "text", "content": "x"}) + _sse_chunk(
|
||||
"99:2", {"type": "text", "content": "y"}
|
||||
)
|
||||
respx.post("https://w.example/sessions/s-1/messages").mock(
|
||||
return_value=_sse_resp(stream)
|
||||
)
|
||||
sigint = asyncio.Event()
|
||||
stdout, stderr = io.StringIO(), io.StringIO()
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
exit_code = await _run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
|
||||
assert exit_code == 22
|
||||
out = stderr.getvalue()
|
||||
assert "[turn_id_flip]" in out
|
||||
assert "expected=42" in out
|
||||
assert "got=99" in out
|
||||
|
||||
@respx.mock
|
||||
async def test_sigint_before_first_event(self) -> None:
|
||||
"""sigint_before_first_event [scenario]: …"""
|
||||
gate = asyncio.Event()
|
||||
# Stream never yields anything until gate (the gate is never set; the test exits via sigint)
|
||||
stream = _GatedStream([gate])
|
||||
respx.post("https://w.example/sessions/s-1/messages").mock(
|
||||
return_value=_sse_resp(stream)
|
||||
)
|
||||
cancel_route = respx.post("https://w.example/sessions/s-1/turns").mock(
|
||||
return_value=httpx.Response(200, json=_CANCEL_OK_RESP)
|
||||
)
|
||||
sigint = asyncio.Event()
|
||||
sigint.set() # SIGINT before _run_turn even starts
|
||||
stdout, stderr = io.StringIO(), io.StringIO()
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
exit_code = await asyncio.wait_for(
|
||||
_run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr), timeout=2.0
|
||||
)
|
||||
assert exit_code == 3
|
||||
assert "[cancelled] (before any event arrived)" in stderr.getvalue()
|
||||
assert cancel_route.call_count == 0
|
||||
|
||||
@respx.mock
|
||||
async def test_sigint_mid_stream_drains_to_cancelled(self) -> None:
|
||||
"""sigint_mid_stream_drains_to_cancelled [scenario,tracer]: …"""
|
||||
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-1/turns/42/cancel").mock(
|
||||
side_effect=cancel_handler
|
||||
)
|
||||
text_chunk = _sse_chunk("42:1", {"type": "text", "content": "x"})
|
||||
cancelled_chunk = _sse_chunk("42:2", _CANCELLED_BODY)
|
||||
stream = _GatedStream([text_chunk, cancel_observed, cancelled_chunk])
|
||||
respx.post("https://w.example/sessions/s-1/messages").mock(
|
||||
return_value=_sse_resp(stream)
|
||||
)
|
||||
|
||||
sigint = asyncio.Event()
|
||||
stdout, stderr = io.StringIO(), io.StringIO()
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
task = asyncio.create_task(
|
||||
_run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
|
||||
)
|
||||
# Wait for the first event to flush to stdout (signals last_turn_id is set)
|
||||
for _ in range(50):
|
||||
if "x" in stdout.getvalue():
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
else:
|
||||
task.cancel()
|
||||
pytest.fail("text event never reached stdout")
|
||||
sigint.set()
|
||||
exit_code = await asyncio.wait_for(task, timeout=2.0)
|
||||
assert exit_code == 3
|
||||
assert cancel_route.call_count == 1
|
||||
|
||||
@respx.mock
|
||||
async def test_sigint_twice_issues_one_cancel(self) -> None:
|
||||
"""sigint_twice_issues_one_cancel [scenario]: sigint set twice → one cancel POST."""
|
||||
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-1/turns/42/cancel").mock(
|
||||
side_effect=cancel_handler
|
||||
)
|
||||
text_chunk = _sse_chunk("42:1", {"type": "text", "content": "x"})
|
||||
cancelled_chunk = _sse_chunk("42:2", _CANCELLED_BODY)
|
||||
stream = _GatedStream([text_chunk, cancel_observed, cancelled_chunk])
|
||||
respx.post("https://w.example/sessions/s-1/messages").mock(
|
||||
return_value=_sse_resp(stream)
|
||||
)
|
||||
|
||||
sigint = asyncio.Event()
|
||||
stdout, stderr = io.StringIO(), io.StringIO()
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
task = asyncio.create_task(
|
||||
_run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
|
||||
)
|
||||
for _ in range(50):
|
||||
if "x" in stdout.getvalue():
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
sigint.set()
|
||||
# Set again — should be no-op (event is already set; idempotent)
|
||||
sigint.set()
|
||||
exit_code = await asyncio.wait_for(task, timeout=2.0)
|
||||
assert exit_code == 3
|
||||
assert cancel_route.call_count == 1
|
||||
|
||||
@respx.mock
|
||||
async def test_no_busy_loop_after_cancel(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""no_busy_loop_after_cancel [trace]: only one sigint_event.wait()-task created."""
|
||||
cancel_observed = asyncio.Event()
|
||||
|
||||
def cancel_handler(req: httpx.Request) -> httpx.Response:
|
||||
cancel_observed.set()
|
||||
return httpx.Response(200, json=_CANCEL_OK_RESP)
|
||||
|
||||
respx.post("https://w.example/sessions/s-1/turns/42/cancel").mock(
|
||||
side_effect=cancel_handler
|
||||
)
|
||||
text_chunk = _sse_chunk("42:1", {"type": "text", "content": "x"})
|
||||
cancelled_chunk = _sse_chunk("42:2", _CANCELLED_BODY)
|
||||
stream = _GatedStream([text_chunk, cancel_observed, cancelled_chunk])
|
||||
respx.post("https://w.example/sessions/s-1/messages").mock(
|
||||
return_value=_sse_resp(stream)
|
||||
)
|
||||
|
||||
sigint = asyncio.Event()
|
||||
wait_call_count = 0
|
||||
original_wait = sigint.wait
|
||||
|
||||
async def counting_wait() -> bool:
|
||||
nonlocal wait_call_count
|
||||
wait_call_count += 1
|
||||
return await original_wait()
|
||||
|
||||
monkeypatch.setattr(sigint, "wait", counting_wait)
|
||||
|
||||
stdout, stderr = io.StringIO(), io.StringIO()
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
task = asyncio.create_task(
|
||||
_run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
|
||||
)
|
||||
for _ in range(50):
|
||||
if "x" in stdout.getvalue():
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
sigint.set()
|
||||
exit_code = await asyncio.wait_for(task, timeout=2.0)
|
||||
assert exit_code == 3
|
||||
# INV-007 + busy-loop fix: sigint.wait() created at most once per pre-cancelling
|
||||
# iteration. For text → sigint → cancelled, that's iter 1 (raced w/ text) and
|
||||
# iter 2 (raced w/ sigint; flipped cancelling=True). Iter 3+ MUST skip wait()
|
||||
# creation entirely — the busy-loop bug would make this number grow unbounded.
|
||||
assert wait_call_count == 2
|
||||
|
||||
@respx.mock
|
||||
async def test_cancel_failed_drains_anyway(self) -> None:
|
||||
"""cancel_failed_drains_anyway [scenario]: …"""
|
||||
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-1/turns/42/cancel").mock(
|
||||
side_effect=cancel_handler
|
||||
)
|
||||
text_chunk = _sse_chunk("42:1", {"type": "text", "content": "x"})
|
||||
cancelled_chunk = _sse_chunk("42:2", _CANCELLED_BODY)
|
||||
stream = _GatedStream([text_chunk, cancel_observed, cancelled_chunk])
|
||||
respx.post("https://w.example/sessions/s-1/messages").mock(
|
||||
return_value=_sse_resp(stream)
|
||||
)
|
||||
|
||||
sigint = asyncio.Event()
|
||||
stdout, stderr = io.StringIO(), io.StringIO()
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
task = asyncio.create_task(
|
||||
_run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
|
||||
)
|
||||
for _ in range(50):
|
||||
if "x" in stdout.getvalue():
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
sigint.set()
|
||||
exit_code = await asyncio.wait_for(task, timeout=2.0)
|
||||
assert exit_code == 3
|
||||
# INV-009: cancel POST failed but stream still drained to cancelled terminal
|
||||
assert "[cancel_failed]" in stderr.getvalue()
|
||||
assert "[cancelled]" in stderr.getvalue()
|
||||
|
||||
@respx.mock
|
||||
async def test_render_called_once_per_event(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""render_called_once_per_event [trace]: spy → _render_event call_count == event count."""
|
||||
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-1/messages").mock(
|
||||
return_value=httpx.Response(
|
||||
200, headers={"content-type": "text/event-stream"}, content=b"".join(chunks)
|
||||
)
|
||||
)
|
||||
|
||||
call_count = 0
|
||||
from ratatoskr import cli as cli_mod
|
||||
|
||||
original = cli_mod._render_event
|
||||
|
||||
def spy(event, **kw): # type: ignore[no-untyped-def]
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return original(event, **kw)
|
||||
|
||||
monkeypatch.setattr(cli_mod, "_render_event", spy)
|
||||
|
||||
sigint = asyncio.Event()
|
||||
stdout, stderr = io.StringIO(), io.StringIO()
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
exit_code = await _run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
|
||||
assert exit_code == 0
|
||||
assert call_count == 3
|
||||
|
||||
|
||||
_PARSED_NEW = ParsedArgs(
|
||||
send_content="hi",
|
||||
session_id=None,
|
||||
new=True,
|
||||
agent_id="mimir",
|
||||
api_key="k",
|
||||
server_url="https://w.example",
|
||||
)
|
||||
_PARSED_EXISTING = ParsedArgs(
|
||||
send_content="hi",
|
||||
session_id="s-1",
|
||||
new=False,
|
||||
agent_id=None,
|
||||
api_key="k",
|
||||
server_url="https://w.example",
|
||||
)
|
||||
_CREATE_OK_RESP = {
|
||||
"session_id": "s-new",
|
||||
"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": {},
|
||||
}
|
||||
|
||||
|
||||
class TestAmain:
|
||||
@respx.mock
|
||||
async def test_happy_new_session_then_stream(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""happy_new_session_then_stream [happy,tracer]: …"""
|
||||
respx.post("https://w.example/sessions").mock(
|
||||
return_value=httpx.Response(201, json=_CREATE_OK_RESP)
|
||||
)
|
||||
sse_body = _sse_chunk("42:1", {"type": "text", "content": "hello"}) + _sse_chunk(
|
||||
"42:2", _DONE_BODY
|
||||
)
|
||||
respx.post("https://w.example/sessions/s-new/messages").mock(
|
||||
return_value=_sse_resp(sse_body)
|
||||
)
|
||||
exit_code = await _amain(_PARSED_NEW)
|
||||
assert exit_code == 0
|
||||
captured = capsys.readouterr()
|
||||
err = captured.err
|
||||
assert "[create_session]" in err
|
||||
assert "[done]" in err
|
||||
assert err.index("[create_session]") < err.index("[done]")
|
||||
|
||||
@respx.mock
|
||||
async def test_happy_existing_session(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""happy_existing_session: --session, no create POST; just SSE stream → exit 0."""
|
||||
sessions_route = respx.post("https://w.example/sessions").mock(
|
||||
return_value=httpx.Response(201, json=_CREATE_OK_RESP)
|
||||
)
|
||||
sse_body = _sse_chunk("42:1", {"type": "text", "content": "hi"}) + _sse_chunk(
|
||||
"42:2", _DONE_BODY
|
||||
)
|
||||
respx.post("https://w.example/sessions/s-1/messages").mock(
|
||||
return_value=_sse_resp(sse_body)
|
||||
)
|
||||
exit_code = await _amain(_PARSED_EXISTING)
|
||||
assert exit_code == 0
|
||||
assert sessions_route.call_count == 0
|
||||
|
||||
@respx.mock
|
||||
async def test_agent_not_found_exits_12(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""agent_not_found_exits_12 [error]: POST /sessions → 404 → exit 12; no stream_turn."""
|
||||
respx.post("https://w.example/sessions").mock(
|
||||
return_value=httpx.Response(404, json={"error": "unknown_agent_id"})
|
||||
)
|
||||
stream_route = respx.post("https://w.example/sessions/s-new/messages").mock(
|
||||
return_value=httpx.Response(200)
|
||||
)
|
||||
exit_code = await _amain(_PARSED_NEW)
|
||||
assert exit_code == 12
|
||||
assert "[agent_not_found]" in capsys.readouterr().err
|
||||
assert stream_route.call_count == 0
|
||||
|
||||
@respx.mock
|
||||
async def test_session_api_failed_exits_20(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""session_api_failed_exits_20: POST /sessions → 500 → exit 20; [session_api_failed]."""
|
||||
respx.post("https://w.example/sessions").mock(
|
||||
return_value=httpx.Response(500, content=b"server error")
|
||||
)
|
||||
exit_code = await _amain(_PARSED_NEW)
|
||||
assert exit_code == 20
|
||||
err = capsys.readouterr().err
|
||||
assert "[session_api_failed]" in err
|
||||
assert "status=500" in err
|
||||
|
||||
@respx.mock
|
||||
async def test_connect_error_exits_21(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""connect_error_exits_21: httpx.ConnectError on POST /sessions → exit 21."""
|
||||
respx.post("https://w.example/sessions").mock(side_effect=httpx.ConnectError("down"))
|
||||
exit_code = await _amain(_PARSED_NEW)
|
||||
assert exit_code == 21
|
||||
assert "[network_error]" in capsys.readouterr().err
|
||||
|
||||
@respx.mock
|
||||
async def test_sigint_handler_installed_and_removed(self) -> None:
|
||||
"""sigint_handler_installed_and_removed [trace]: signal handler add/remove paired."""
|
||||
sse_body = _sse_chunk("42:1", {"type": "text", "content": "x"}) + _sse_chunk(
|
||||
"42:2", _DONE_BODY
|
||||
)
|
||||
respx.post("https://w.example/sessions/s-1/messages").mock(
|
||||
return_value=_sse_resp(sse_body)
|
||||
)
|
||||
loop = asyncio.get_running_loop()
|
||||
original_add = loop.add_signal_handler
|
||||
original_remove = loop.remove_signal_handler
|
||||
add_calls: list[int] = []
|
||||
remove_calls: list[int] = []
|
||||
|
||||
def add_spy(sig, callback, *args): # type: ignore[no-untyped-def]
|
||||
add_calls.append(sig)
|
||||
return original_add(sig, callback, *args)
|
||||
|
||||
def remove_spy(sig): # type: ignore[no-untyped-def]
|
||||
remove_calls.append(sig)
|
||||
return original_remove(sig)
|
||||
|
||||
loop.add_signal_handler = add_spy # type: ignore[method-assign]
|
||||
loop.remove_signal_handler = remove_spy # type: ignore[method-assign]
|
||||
try:
|
||||
exit_code = await _amain(_PARSED_EXISTING)
|
||||
finally:
|
||||
loop.add_signal_handler = original_add # type: ignore[method-assign]
|
||||
loop.remove_signal_handler = original_remove # type: ignore[method-assign]
|
||||
import signal as _sig
|
||||
|
||||
assert exit_code == 0
|
||||
assert add_calls == [_sig.SIGINT]
|
||||
assert remove_calls == [_sig.SIGINT]
|
||||
|
||||
def test_no_textual_import(self) -> None:
|
||||
"""no_textual_import [scenario]: …"""
|
||||
import importlib
|
||||
import sys
|
||||
|
||||
# Clear any prior textual import to make this test honest in isolation
|
||||
textual_was_imported = "textual" in sys.modules
|
||||
# We cannot reliably remove textual mid-suite (other tests might rely on it via dev deps),
|
||||
# so the assertion is: importing ratatoskr.cli does not REQUIRE textual.
|
||||
importlib.reload(__import__("ratatoskr.cli", fromlist=["_amain"]))
|
||||
# The boundary is the INV-001 import-only rule. If ratatoskr/cli.py grew an
|
||||
# `import textual` directly, the import would still succeed (textual is installed)
|
||||
# but the source-level boundary is the load-bearing check — covered by a static-grep
|
||||
# smoke test pattern. Do that here:
|
||||
import pathlib
|
||||
|
||||
src = pathlib.Path(__file__).parent.parent / "src" / "ratatoskr" / "cli.py"
|
||||
text = src.read_text()
|
||||
for forbidden in ("import textual", "from textual", "import rich", "from rich"):
|
||||
assert forbidden not in text, f"INV-001 violation: cli.py contains '{forbidden}'"
|
||||
_ = textual_was_imported # avoid unused warning
|
||||
|
||||
|
||||
class TestMain:
|
||||
def test_happy_returns_amain_exit_code(
|
||||
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""happy_returns_amain_exit_code [happy,tracer]: …"""
|
||||
|
||||
async def fake_amain(args: ParsedArgs) -> int:
|
||||
assert args.send_content == "hi"
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(cli_mod, "_amain", fake_amain)
|
||||
rc = main(["--send", "hi", "--new", "--agent", "m", "--api-key", "k"])
|
||||
assert rc == 0
|
||||
|
||||
def test_usage_error_no_send(
|
||||
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""usage_error_no_send: empty argv → exit 10; stderr [usage_error]; _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([])
|
||||
assert rc == 10
|
||||
assert "[usage_error]" in capsys.readouterr().err
|
||||
assert amain_calls == []
|
||||
|
||||
def test_usage_error_both_session_and_new(
|
||||
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""usage_error_both_session_and_new: both flags → exit 10; [usage_error]."""
|
||||
|
||||
async def fake_amain(args: ParsedArgs) -> int:
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(cli_mod, "_amain", fake_amain)
|
||||
rc = main(["--send", "hi", "--session", "s", "--new", "--agent", "m", "--api-key", "k"])
|
||||
assert rc == 10
|
||||
assert "[usage_error]" in capsys.readouterr().err
|
||||
|
||||
def test_auth_error_missing_key(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""auth_error_missing_key: …"""
|
||||
# _clear_env fixture has already deleted WORLDTREE_API_KEY
|
||||
rc = main(["--send", "hi", "--new", "--agent", "m"])
|
||||
assert rc == 11
|
||||
assert "[auth_error]" in capsys.readouterr().err
|
||||
|
||||
def test_no_argv_uses_sys_argv(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""no_argv_uses_sys_argv [trace]: argv=None → _parse_args reads sys.argv[1:]."""
|
||||
monkeypatch.setattr(
|
||||
"sys.argv",
|
||||
["ratatoskr", "--send", "hi", "--new", "--agent", "m", "--api-key", "k"],
|
||||
)
|
||||
|
||||
async def fake_amain(args: ParsedArgs) -> int:
|
||||
assert args.send_content == "hi"
|
||||
assert args.agent_id == "m"
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(cli_mod, "_amain", fake_amain)
|
||||
rc = main(None)
|
||||
assert rc == 0
|
||||
Reference in New Issue
Block a user