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
+62
View File
@@ -137,6 +137,68 @@ class TestCreateSession:
await create_session(client, "")
assert route.call_count == 0
@respx.mock
async def test_happy_create_with_end_user_id(self) -> None:
"""happy_create_with_end_user_id [happy]: body carries both keys (issue #5)."""
import json as _json
route = respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(
201,
json={
"session_id": "s-new",
"agent_id": "lofn",
"message_count": 0,
"created_at": "2026-05-22T12:00:00+00:00",
"last_active": "2026-05-22T12:00:00+00:00",
"metadata": {},
},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
info = await create_session(client, "lofn", end_user_id="alice")
body = _json.loads(route.calls[0].request.content)
# INV: body MUST be exactly {"agent_id": ..., "end_user_id": ...} — byte-for-byte
assert body == {"agent_id": "lofn", "end_user_id": "alice"}
assert info.session_id == "s-new"
assert info.agent_id == "lofn"
@respx.mock
async def test_default_omits_end_user_id(self) -> None:
"""default_omits_end_user_id [trace]: omit kwarg → body has no end_user_id (INV-002)."""
import json as _json
route = respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(
201,
json={
"session_id": "s-new",
"agent_id": "mimir",
"message_count": 0,
"created_at": "2026-05-22T12:00:00+00:00",
"last_active": "2026-05-22T12:00:00+00:00",
"metadata": {},
},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
await create_session(client, "mimir")
body = _json.loads(route.calls[0].request.content)
# Exact equality — no end_user_id key in the body when the kwarg is omitted
assert body == {"agent_id": "mimir"}
assert "end_user_id" not in body
@respx.mock
async def test_empty_end_user_id(self) -> None:
"""empty_end_user_id [adversarial]: '' → AssertionError before HTTP (PRE-003)."""
route = respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(201, content=b"{}")
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AssertionError):
await create_session(client, "mimir", end_user_id="")
assert route.call_count == 0
def _list_item(
*,