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:
+397
-114
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user