feat(sessions,cli,tui): issues #5 + #6 + worldtree-dev consumer-API follow-up

Issue #6 (TUI startup error visibility): restructure run_tui lifecycle so
pre-App.run() failures land on real stderr instead of getting eaten by
the alt-screen teardown. New _resolve_then_run async helper opens the
AsyncClient via async-with, does pre-flight session resolution, routes
AgentNotFound / SessionApiFailed / network errors to sys.stderr (verbatim
same labels + exit codes as cli._amain), then constructs RatatoskrApp
with pre-resolved state and awaits app.run_async(). RatatoskrApp.__init__
signature widens to (args, *, session_id, agent_id, client) — all three
required. on_mount narrows to identity-widget population; on_unmount
becomes a no-op (client lifetime owned by run_tui's async-with).

Issue #5 (--end-user-id for per-end-user agents): sessions.create_session
gains keyword-only end_user_id kwarg with PRE-003 non-empty assertion;
ParsedArgs.end_user_id field added (default None); --end-user-id flag
with non-empty validation; _amain + _resolve_then_run thread it to their
create_session calls. RATATOSKR_END_USER_ID env-var fallback
(flag > env > None) per the post-2026-05-23 amendment; env.sh (gitignored)
ships "ratatoskr-tui" as project-stable partition default.

Worldtree-dev consumer-API follow-up (althing 01KSBARG2B8M): User-Agent
header added (ratatoskr/<version> (vh@phasefinal.com), version pulled via
importlib.metadata) to both AsyncClient constructions so server logs can
distinguish ratatoskr traffic from other consumers.

Volva code-review (2 rounds on #6) found 8 test-precision gaps + 1 PRE
assertion drift, all Category 1 fixed: missing PRE-001 at
_resolve_then_run entry; Rule separator assertions on markdown render;
RichLog-write spy on empty submit; input-cleared + no-new-worker on
cancelling busy; worker.cancel observation on three force-exit paths;
on_unmount-no-close focused test (the prior client-lifetime test patched
run_async so on_unmount was never exercised); happy --new resolve test
verifying POST count + identity propagation.

Issues #2/#3/#4/#5 contracts amended in-place to reflect:
- create_session widened (PRE-003, body construction step, body shape POST)
- ParsedArgs description + _parse_args STEPS + _amain create_session call
  + new TESTS for end_user_id + env-var fallback
- _resolve_then_run STEPS + new TEST entries; on_mount narrowed;
  INV-007 amended for new client ownership
- Post-#6 adjustment note on issue #5 (_resolve_then_run replaces
  on_mount as the threading site since #6 moved session resolution out
  of the alt-screen)

188 tests GREEN; ruff clean. Bumps to v0.1.0 — first minor release, the
load-bearing reason is RatatoskrApp.__init__'s breaking signature change
(additive end_user_id alone wouldn't have triggered a minor pre-v1.x).

Files Gitea issues #9 (spec-pin refresh v0.19.0 → v0.22.1), #10 (track
Worldtree #196 subject:{type,id} migration), #11 (AdminEvents pane auth
prerequisite admin.events.read). Infra-ops pinged via althing for
agents.call:lofn scope add (broker pattern; they forwarded to
worldtree-dev because personal Worldtree exposes no public
scope-mutation endpoint).
This commit is contained in:
vh
2026-05-23 14:34:53 -07:00
parent c713208585
commit 804c2df6eb
14 changed files with 1422 additions and 281 deletions
+397 -114
View File
@@ -74,6 +74,32 @@ def _spy_writes(monkeypatch) -> list:
return writes
def _resolved_app(
args: ParsedArgs,
*,
session_id: str | None = None,
agent_id: str | None = None,
client: httpx.AsyncClient | None = None,
) -> RatatoskrApp:
"""Construct RatatoskrApp with pre-resolved state (issue #6 lifecycle).
Production path: `run_tui` → `_resolve_then_run` opens AsyncClient, mints
or attaches session, then constructs the App with the resolved tuple. This
helper inlines that shape so tests bypass the pre-flight without
re-implementing it. The client is opened here (and leaks at test teardown
— acceptable; respx mocks all network calls and pytest exits cleanly).
"""
sid = session_id if session_id is not None else (args.session_id or "s-default")
aid = args.agent_id if agent_id is None else agent_id
if client is None:
client = httpx.AsyncClient(
base_url=args.server_url,
headers={"Authorization": f"Bearer {args.api_key}"},
timeout=httpx.Timeout(connect=10.0, read=None, write=10.0, pool=10.0),
)
return RatatoskrApp(args, session_id=sid, agent_id=aid, client=client)
SID = SseId(42, 5)
@@ -228,13 +254,16 @@ class TestCancelViaSse:
class TestAppMount:
@respx.mock
"""on_mount narrows per issue #6: only identity-widget population.
Session resolution + AsyncClient open + error-on-resolve are exercised at
the `_resolve_then_run` layer (see TestResolveThenRun); only happy mount
paths remain here, exercised with pre-resolved state via _resolved_app.
"""
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())
"""happy_new_session_mount [happy,tracer]: identity populated from pre-resolved state."""
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
async with app.run_test() as pilot:
await pilot.pause()
assert app.session_id == "s-new12345"
@@ -244,74 +273,22 @@ class TestAppMount:
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"))
"""happy_existing_session_mount: identity shows <unknown> when agent_id is None."""
app = _resolved_app(_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 → <unknown> · …<tail>
assert "<unknown>" 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]: …"""
"""footer_identity_visible_first_frame [trace]: identity widget rendered first frame."""
from textual.widgets import Static
respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(201, json=_CREATE_OK_RESP)
)
app = RatatoskrApp(_args_new())
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
async with app.run_test() as pilot:
await pilot.pause()
identity_widget = app.query_one("#identity", Static)
@@ -320,32 +297,6 @@ class TestAppMount:
assert "·" in rendered
assert app.session_id[-8:] in rendered
@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
@@ -365,7 +316,7 @@ class TestOnInputSubmitted:
"""happy_submit_echoes_and_spawns [happy,tracer]: …"""
monkeypatch.setattr(RatatoskrApp, "_stream_turn_worker", _noop_worker)
writes = _spy_writes(monkeypatch)
app = RatatoskrApp(_args_existing())
app = _resolved_app(_args_existing())
async with app.run_test() as pilot:
await pilot.pause()
inp = app.query_one("#prompt", Input)
@@ -383,15 +334,19 @@ class TestOnInputSubmitted:
) -> None:
"""empty_submit_no_op [trace]: '' + Enter → no change; no worker spawned."""
monkeypatch.setattr(RatatoskrApp, "_stream_turn_worker", _noop_worker)
app = RatatoskrApp(_args_existing())
app = _resolved_app(_args_existing())
async with app.run_test() as pilot:
await pilot.pause()
# Spy AFTER mount so identity-widget writes (if any) aren't counted.
writes = _spy_writes(monkeypatch)
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
# POST: no RichLog write fires on empty submit
assert writes == []
@respx.mock
async def test_submit_during_streaming_shows_busy_notice(
@@ -400,7 +355,7 @@ class TestOnInputSubmitted:
"""submit_during_streaming_shows_busy_notice [adversarial]: …"""
monkeypatch.setattr(RatatoskrApp, "_stream_turn_worker", _noop_worker)
writes = _spy_writes(monkeypatch)
app = RatatoskrApp(_args_existing())
app = _resolved_app(_args_existing())
async with app.run_test() as pilot:
await pilot.pause()
inp = app.query_one("#prompt", Input)
@@ -427,16 +382,21 @@ class TestOnInputSubmitted:
"""submit_during_cancelling_shows_busy_notice [adversarial]: …"""
monkeypatch.setattr(RatatoskrApp, "_stream_turn_worker", _noop_worker)
writes = _spy_writes(monkeypatch)
app = RatatoskrApp(_args_existing())
app = _resolved_app(_args_existing())
async with app.run_test() as pilot:
await pilot.pause()
app.state = "cancelling" # bypass the natural transition for the test
assert app.stream_worker is None # no live worker before non-idle submit
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"
# POST-005 (from issue #4 on_input_submitted contract):
# input cleared; NO new worker spawned during non-idle submit.
assert inp.value == ""
assert app.stream_worker is None
@respx.mock
async def test_footer_hint_flips_to_cancel(
@@ -446,7 +406,7 @@ class TestOnInputSubmitted:
from textual.widgets import Static
monkeypatch.setattr(RatatoskrApp, "_stream_turn_worker", _noop_worker)
app = RatatoskrApp(_args_existing())
app = _resolved_app(_args_existing())
async with app.run_test() as pilot:
await pilot.pause()
hint_widget = app.query_one("#hint", Static)
@@ -528,7 +488,7 @@ class TestStreamTurnWorker:
writes = _spy_writes(monkeypatch)
app = RatatoskrApp(_args_existing())
app = _resolved_app(_args_existing())
async with app.run_test() as pilot:
await pilot.pause()
await _submit_and_wait(app, pilot, "hi")
@@ -536,9 +496,12 @@ class TestStreamTurnWorker:
# 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
# The post-Done markdown render uses rich Rule + Markdown — non-string writes.
# INV-005: BOTH separator (Rule) AND markdown render must be present in non-raw.
from rich.markdown import Markdown
from rich.rule import Rule
assert any(isinstance(w, Markdown) for w in writes)
assert any(isinstance(w, Rule) for w in writes)
@respx.mock
async def test_raw_flag_skips_markdown_render(
@@ -558,12 +521,15 @@ class TestStreamTurnWorker:
).mock(return_value=_sse_resp(stream))
writes = _spy_writes(monkeypatch)
app = RatatoskrApp(_args_existing(raw=True))
app = _resolved_app(_args_existing(raw=True))
async with app.run_test() as pilot:
await pilot.pause()
await _submit_and_wait(app, pilot, "x")
# INV-005: with --raw, NEITHER Rule separator NOR Markdown render appears.
from rich.markdown import Markdown
from rich.rule import Rule
assert not any(isinstance(w, Markdown) for w in writes)
assert not any(isinstance(w, Rule) for w in writes)
@respx.mock
async def test_error_terminal_returns_to_idle(
@@ -591,7 +557,7 @@ class TestStreamTurnWorker:
).mock(return_value=_sse_resp(stream))
writes = _spy_writes(monkeypatch)
app = RatatoskrApp(_args_existing())
app = _resolved_app(_args_existing())
async with app.run_test() as pilot:
await pilot.pause()
await _submit_and_wait(app, pilot, "x")
@@ -616,7 +582,7 @@ class TestStreamTurnWorker:
).mock(return_value=_sse_resp(stream))
writes = _spy_writes(monkeypatch)
app = RatatoskrApp(_args_existing())
app = _resolved_app(_args_existing())
async with app.run_test() as pilot:
await pilot.pause()
await _submit_and_wait(app, pilot, "x")
@@ -645,7 +611,7 @@ class TestStreamTurnWorker:
return_value=_sse_resp(_GatedAfterFirst())
)
app = RatatoskrApp(_args_existing())
app = _resolved_app(_args_existing())
async with app.run_test() as pilot:
await pilot.pause()
inp = app.query_one("#prompt", Input)
@@ -673,7 +639,7 @@ class TestStreamTurnWorker:
return_value=httpx.Response(404, json={"error": "session_not_found"})
)
writes = _spy_writes(monkeypatch)
app = RatatoskrApp(_args_existing())
app = _resolved_app(_args_existing())
async with app.run_test() as pilot:
await pilot.pause()
await _submit_and_wait(app, pilot, "x")
@@ -699,7 +665,7 @@ class TestStreamTurnWorker:
return_value=_sse_resp(_DropAfter())
)
writes = _spy_writes(monkeypatch)
app = RatatoskrApp(_args_existing())
app = _resolved_app(_args_existing())
async with app.run_test() as pilot:
await pilot.pause()
await _submit_and_wait(app, pilot, "x")
@@ -719,7 +685,7 @@ class TestStreamTurnWorker:
"https://w.example/sessions/s-1existing/messages"
).mock(return_value=_sse_resp(stream))
writes = _spy_writes(monkeypatch)
app = RatatoskrApp(_args_existing())
app = _resolved_app(_args_existing())
async with app.run_test() as pilot:
await pilot.pause()
await _submit_and_wait(app, pilot, "x")
@@ -756,7 +722,7 @@ class TestStreamTurnWorker:
return original(event, log=log, raw=raw)
monkeypatch.setattr(tui_mod, "_render_event_to_log", spy)
app = RatatoskrApp(_args_existing())
app = _resolved_app(_args_existing())
async with app.run_test() as pilot:
await pilot.pause()
await _submit_and_wait(app, pilot, "x")
@@ -767,7 +733,7 @@ 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())
app = _resolved_app(_args_existing())
async with app.run_test() as pilot:
await pilot.pause()
assert app.state == "idle"
@@ -805,7 +771,7 @@ class TestActionInterrupt:
side_effect=cancel_handler
)
app = RatatoskrApp(_args_existing())
app = _resolved_app(_args_existing())
async with app.run_test() as pilot:
await pilot.pause()
inp = app.query_one("#prompt", Input)
@@ -858,7 +824,7 @@ class TestActionInterrupt:
return_value=_sse_resp(_NeverYields())
)
app = RatatoskrApp(_args_existing())
app = _resolved_app(_args_existing())
async with app.run_test() as pilot:
await pilot.pause()
inp = app.query_one("#prompt", Input)
@@ -867,22 +833,54 @@ class TestActionInterrupt:
await pilot.pause()
assert app.state == "streaming"
assert app.active_turn_id is None
# Capture worker reference + spy on its .cancel() before ctrl+c
worker_ref = app.stream_worker
assert worker_ref is not None
cancel_calls: list = []
original_cancel = type(worker_ref).cancel
monkeypatch.setattr(
type(worker_ref),
"cancel",
lambda self: (cancel_calls.append(self), original_cancel(self))[-1],
)
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
# action_interrupt MUST cancel the stream worker on the no-active_turn_id force-exit path
assert worker_ref in cancel_calls
@respx.mock
async def test_cancelling_second_ctrl_c_force_exits(self) -> None:
async def test_cancelling_second_ctrl_c_force_exits(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""cancelling_second_ctrl_c_force_exits [scenario]: …"""
app = RatatoskrApp(_args_existing())
# Set up a real live stream worker (gated, hangs forever) so we can
# observe action_interrupt's cancel() call on the second-Ctrl-C path.
monkeypatch.setattr(RatatoskrApp, "_stream_turn_worker", _noop_worker)
app = _resolved_app(_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.stream_worker is not None
app.state = "cancelling" # bypass the natural transition for the test
worker_ref = app.stream_worker
cancel_calls: list = []
original_cancel = type(worker_ref).cancel
monkeypatch.setattr(
type(worker_ref),
"cancel",
lambda self: (cancel_calls.append(self), original_cancel(self))[-1],
)
await pilot.press("ctrl+c")
await pilot.pause()
assert app.return_value == 3
# Second-Ctrl-C in cancelling state MUST cancel the in-flight worker
assert worker_ref in cancel_calls
@respx.mock
async def test_cancel_failed_swallowed(self) -> None:
@@ -911,7 +909,7 @@ class TestActionInterrupt:
side_effect=cancel_handler
)
app = RatatoskrApp(_args_existing())
app = _resolved_app(_args_existing())
async with app.run_test() as pilot:
await pilot.pause()
inp = app.query_one("#prompt", Input)
@@ -940,7 +938,7 @@ 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())
app = _resolved_app(_args_existing())
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("ctrl+d")
@@ -969,7 +967,7 @@ class TestActionQuit:
respx.post("https://w.example/sessions/s-1existing/messages").mock(
return_value=_sse_resp(_GatedAfterFirst())
)
app = RatatoskrApp(_args_existing())
app = _resolved_app(_args_existing())
async with app.run_test() as pilot:
await pilot.pause()
inp = app.query_one("#prompt", Input)
@@ -980,26 +978,311 @@ class TestActionQuit:
if app.active_turn_id == 42:
break
await pilot.pause(0.02)
# Capture worker + spy on cancel before ctrl+d
worker_ref = app.stream_worker
assert worker_ref is not None
cancel_calls: list = []
original_cancel = type(worker_ref).cancel
monkeypatch.setattr(
type(worker_ref),
"cancel",
lambda self: (cancel_calls.append(self), original_cancel(self))[-1],
)
await pilot.press("ctrl+d")
await pilot.pause()
gate.set()
assert app.return_value == 0
assert cancel_route.call_count == 0
# POST-002: Ctrl-D MUST cancel the in-flight stream worker (abandon-and-exit)
assert worker_ref in cancel_calls
from ratatoskr.tui import run_tui # noqa: E402
class TestResolveThenRun:
"""Tests at the `_resolve_then_run` layer — pre-`App.run()` session
resolution + AsyncClient ownership + stderr error routing per issue #6.
"""
@respx.mock
def test_happy_new_session_resolve(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""happy_new_session_resolve [happy]: --new path through _resolve_then_run.
Verifies POST /sessions count + SessionInfo propagation to RatatoskrApp's
pre-resolved state (session_id / agent_id / client). The corresponding
TestAppMount.test_happy_new_session_mount uses _resolved_app and bypasses
_resolve_then_run entirely; this test exercises the production resolve
path with a real POST /sessions mock.
"""
sessions_route = respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(201, json=_CREATE_OK_RESP)
)
snapshot: dict = {}
async def capture_run_async(self, *a, **kw):
snapshot["session_id"] = self.session_id
snapshot["agent_id"] = self.agent_id
snapshot["client"] = self.client
snapshot["client_open"] = not self.client.is_closed
return 0
monkeypatch.setattr(RatatoskrApp, "run_async", capture_run_async)
rc = run_tui(_args_new())
assert rc == 0
# Exactly one POST /sessions invocation by _resolve_then_run
assert sessions_route.call_count == 1
# SessionInfo fields propagated into the constructed App
assert snapshot["session_id"] == "s-new12345"
assert snapshot["agent_id"] == "mimir"
assert snapshot["client"] is not None
assert snapshot["client_open"] is True
@respx.mock
def test_happy_new_with_end_user_id_resolve(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""happy_new_with_end_user_id_resolve [happy]: args.end_user_id threads into POST body.
Issue #5 amends #4: _resolve_then_run's create_session call now forwards
args.end_user_id (renamed from the contract's _mount target, since #6
moved session resolution out of on_mount into _resolve_then_run).
"""
import json as _json
sessions_route = respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(201, json=_CREATE_OK_RESP)
)
async def fake_run_async(self, *a, **kw):
return 0
monkeypatch.setattr(RatatoskrApp, "run_async", fake_run_async)
rc = run_tui(_args_new(end_user_id="alice"))
assert rc == 0
assert sessions_route.call_count == 1
body = _json.loads(sessions_route.calls[0].request.content)
assert body == {"agent_id": "mimir", "end_user_id": "alice"}
@respx.mock
def test_user_agent_header_sent(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""user_agent_header_sent [trace]: outbound requests carry the ratatoskr User-Agent.
Worldtree-dev (althing 2026-05-23) requested consumers send `User-Agent:
ratatoskr/<version> (<contact>)` so server logs can distinguish ratatoskr
traffic from other consumers.
"""
sessions_route = respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(201, json=_CREATE_OK_RESP)
)
async def fake_run_async(self, *a, **kw):
return 0
monkeypatch.setattr(RatatoskrApp, "run_async", fake_run_async)
rc = run_tui(_args_new())
assert rc == 0
ua = sessions_route.calls[0].request.headers["User-Agent"]
assert ua.startswith("ratatoskr/")
assert "vh@phasefinal.com" in ua
@respx.mock
def test_alt_screen_never_opens_on_resolve_error(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""alt_screen_never_opens_on_resolve_error [trace]: 404 → run_tui=12; run_async unhit.
Directly probes INV-001: session resolution failures MUST short-circuit
BEFORE the alt-screen opens.
"""
respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(404, json={"error": "unknown_agent_id"})
)
sentinel_called = False
async def sentinel(self, *a, **kw):
nonlocal sentinel_called
sentinel_called = True
return 0
monkeypatch.setattr(RatatoskrApp, "run_async", sentinel)
rc = run_tui(_args_new())
assert rc == 12
assert not sentinel_called
@respx.mock
def test_agent_not_found_on_resolve(
self, capsys: pytest.CaptureFixture[str]
) -> None:
"""agent_not_found_on_resolve [error]: --new + 404 → stderr [agent_not_found]; exit 12."""
respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(404, json={"error": "unknown_agent_id"})
)
rc = run_tui(_args_new())
err = capsys.readouterr().err
assert rc == 12
assert "[agent_not_found]" in err
assert "agent_id=mimir" in err
@respx.mock
def test_session_api_failed_on_resolve(
self, capsys: pytest.CaptureFixture[str]
) -> None:
"""session_api_failed_on_resolve [error]: --new + 500 → [session_api_failed] stderr."""
respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(500, content=b"server error")
)
rc = run_tui(_args_new())
err = capsys.readouterr().err
assert rc == 20
assert "[session_api_failed]" in err
assert "status=500" in err
@respx.mock
def test_network_error_on_resolve(
self, capsys: pytest.CaptureFixture[str]
) -> None:
"""network_error_on_resolve [error]: --new + ConnectError → [network_error] stderr."""
respx.post("https://w.example/sessions").mock(side_effect=httpx.ConnectError("down"))
rc = run_tui(_args_new())
err = capsys.readouterr().err
assert rc == 21
assert "[network_error]" in err
assert "ConnectError" in err
@respx.mock
def test_stderr_label_format_matches_cli(
self, capsys: pytest.CaptureFixture[str]
) -> None:
"""stderr_label_format_matches_cli [trace]: cli._amain and _resolve_then_run produce
identical stderr lines for AgentNotFound (INV-006).
"""
# Re-fetch cli's ParsedArgs from the current module state — test_cli's
# `importlib.reload(ratatoskr.cli)` rebinds the class, so the top-of-file
# `from ratatoskr.cli import ParsedArgs` may now refer to a stale class.
from ratatoskr import cli as cli_mod
respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(404, json={"error": "unknown_agent_id"})
)
cli_args = cli_mod.ParsedArgs(
send_content="x",
session_id=None,
new=True,
agent_id="mimir",
api_key="k",
server_url="https://w.example",
raw=False,
)
# Drive cli._amain's error path (--send mode)
cli_rc = asyncio.run(cli_mod._amain(cli_args))
cli_err = capsys.readouterr().err
# Drive _resolve_then_run's error path (TUI mode); _args_new() uses the
# pre-reload ParsedArgs which still matches tui.run_tui's isinstance check.
tui_rc = run_tui(_args_new())
tui_err = capsys.readouterr().err
# Same exit code, same verbatim stderr line.
assert cli_rc == 12
assert tui_rc == 12
assert cli_err == tui_err
assert cli_err == "[agent_not_found] agent_id=mimir\n"
def test_client_open_after_resolve(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""client_open_after_resolve [trace]: app.client is open at the time run_async runs."""
snapshot: dict = {}
async def capture_run_async(self, *a, **kw):
snapshot["client_is"] = self.client
snapshot["closed_during_run"] = self.client.is_closed
return 0
monkeypatch.setattr(RatatoskrApp, "run_async", capture_run_async)
rc = run_tui(_args_existing())
assert rc == 0
assert snapshot["client_is"] is not None
assert snapshot["closed_during_run"] is False
def test_client_lifetime_owned_by_run_tui(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""client_lifetime_owned_by_run_tui [trace]: open during run_async, closed after run_tui.
Probes INV-002: the App is a consumer of an externally-owned client;
the async-with in run_tui closes it, not on_unmount.
"""
snapshot: dict = {}
async def capture_run_async(self, *a, **kw):
# During run_async (the alt-screen lifetime) the client is open.
snapshot["client"] = self.client
snapshot["closed_during_run"] = self.client.is_closed
return 0
monkeypatch.setattr(RatatoskrApp, "run_async", capture_run_async)
rc = run_tui(_args_existing())
assert rc == 0
client = snapshot["client"]
assert client is not None
# Open while the app was running; closed by run_tui's async-with after.
assert snapshot["closed_during_run"] is False
assert client.is_closed is True
def test_run_tui_closes_client_on_app_exit(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""run_tui_closes_client_on_app_exit: async-with closes client after app.run_async ret."""
seen_clients: list[httpx.AsyncClient] = []
async def fake_run_async(self, *a, **kw):
seen_clients.append(self.client)
return 0
monkeypatch.setattr(RatatoskrApp, "run_async", fake_run_async)
rc = run_tui(_args_existing())
assert rc == 0
assert len(seen_clients) == 1
# After run_tui returns, the client should be closed by the async-with
assert seen_clients[0].is_closed
async def test_on_unmount_does_not_close_client(self) -> None:
"""on_unmount narrowed [trace]: probes INV-002 from the on_unmount side.
The complementary check to test_client_lifetime_owned_by_run_tui (which
patches run_async and so never exercises on_unmount). Here we DO run the
real on_unmount via Pilot ctrl+d → app teardown, and assert the client
is still open afterward (close site is run_tui's async-with, which is
NOT entered in this Pilot-driven test).
"""
client = httpx.AsyncClient(
base_url="https://w.example",
headers={"Authorization": "Bearer k"},
timeout=httpx.Timeout(connect=10.0, read=None, write=10.0, pool=10.0),
)
app = RatatoskrApp(
_args_existing(),
session_id="s-1existing",
agent_id=None,
client=client,
)
async with app.run_test() as pilot:
await pilot.pause()
assert client.is_closed is False
await pilot.press("ctrl+d")
await pilot.pause()
# After app.run_test() teardown, on_unmount has fired. Per INV-002 the
# client MUST still be open — only run_tui's async-with closes it.
assert client.is_closed is False
await client.aclose() # test-side cleanup
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."""
"""happy_returns_zero_on_quit [happy,tracer]: run_tui propagates app.run_async exit code."""
captured: list[ParsedArgs] = []
def fake_run(self) -> int:
async def fake_run_async(self, *a, **kw):
captured.append(self.args)
return 0
monkeypatch.setattr(RatatoskrApp, "run", fake_run)
monkeypatch.setattr(RatatoskrApp, "run_async", fake_run_async)
rc = run_tui(_args_existing())
assert rc == 0
assert len(captured) == 1