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
+68
View File
@@ -86,6 +86,7 @@ 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)
monkeypatch.delenv("RATATOSKR_END_USER_ID", raising=False)
class TestParseArgs:
@@ -196,6 +197,42 @@ class TestParseArgs:
with pytest.raises(UsageError):
_parse_args(["--send", "", "--new", "--agent", "x", "--api-key", "k"])
def test_happy_new_with_end_user_id(self) -> None:
"""happy_new_with_end_user_id [happy]: --end-user-id alice → end_user_id='alice'."""
args = _parse_args(
["--send", "hi", "--new", "--agent", "lofn", "--api-key", "k",
"--end-user-id", "alice"]
)
assert args.end_user_id == "alice"
assert args.agent_id == "lofn"
def test_end_user_id_default_none(self) -> None:
"""end_user_id_default_none [trace]: omit --end-user-id → ParsedArgs.end_user_id is None."""
args = _parse_args(["--send", "hi", "--new", "--agent", "m", "--api-key", "k"])
assert args.end_user_id is None
def test_empty_end_user_id(self) -> None:
"""empty_end_user_id [adversarial]: --end-user-id '' → UsageError (mirrors empty --send)."""
with pytest.raises(UsageError, match="--end-user-id"):
_parse_args(
["--send", "hi", "--new", "--agent", "m", "--api-key", "k", "--end-user-id", ""]
)
def test_end_user_id_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""end_user_id_from_env [trace]: $RATATOSKR_END_USER_ID fills when flag omitted."""
monkeypatch.setenv("RATATOSKR_END_USER_ID", "ratatoskr-tui")
args = _parse_args(["--send", "hi", "--new", "--agent", "m", "--api-key", "k"])
assert args.end_user_id == "ratatoskr-tui"
def test_end_user_id_flag_beats_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""end_user_id_flag_beats_env [trace]: explicit --end-user-id wins over env."""
monkeypatch.setenv("RATATOSKR_END_USER_ID", "from-env")
args = _parse_args(
["--send", "hi", "--new", "--agent", "m", "--api-key", "k",
"--end-user-id", "from-flag"]
)
assert args.end_user_id == "from-flag"
SID = SseId(42, 5)
@@ -817,6 +854,16 @@ _PARSED_NEW = ParsedArgs(
server_url="https://w.example",
raw=False,
)
_PARSED_NEW_WITH_END_USER = ParsedArgs(
send_content="hi",
session_id=None,
new=True,
agent_id="mimir",
api_key="k",
server_url="https://w.example",
raw=False,
end_user_id="alice",
)
_PARSED_EXISTING = ParsedArgs(
send_content="hi",
session_id="s-1",
@@ -837,6 +884,27 @@ _CREATE_OK_RESP = {
class TestAmain:
@respx.mock
async def test_user_agent_header_sent(self) -> None:
"""user_agent_header_sent [trace]: outbound requests carry the ratatoskr User-Agent.
Worldtree-dev (althing 2026-05-23) requested consumers send User-Agent
so server logs can distinguish ratatoskr traffic.
"""
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-new/messages").mock(
return_value=_sse_resp(sse_body)
)
await _amain(_PARSED_NEW)
ua = sessions_route.calls[0].request.headers["User-Agent"]
assert ua.startswith("ratatoskr/")
assert "vh@phasefinal.com" in ua
@respx.mock
async def test_happy_new_session_then_stream(self, capsys: pytest.CaptureFixture[str]) -> None:
"""happy_new_session_then_stream [happy,tracer]: …"""