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
+15 -10
View File
@@ -94,11 +94,12 @@ Convention-aligned with issue #1: caller owns the `httpx.AsyncClient` and Author
---
```contract
FN create_session(client: httpx.AsyncClient, agent_id: str) -> SessionInfo
BRIEF: POST /sessions with {"agent_id": agent_id} to create a new conversation session. Returns SessionInfo populated from the 201 response.
FN create_session(client: httpx.AsyncClient, agent_id: str, *, end_user_id: str | None = None) -> SessionInfo
BRIEF: POST /sessions with {"agent_id": agent_id} (and {"end_user_id": end_user_id} when non-None) to create a new conversation session. Returns SessionInfo populated from the 201 response. Per issue #5: keyword-only `end_user_id` for per-end-user agents (lofn etc.); default-None preserves the pre-#5 baseline.
PRE: [PRE-001 hard] client is not None -- assert client is not None
PRE: [PRE-002 hard] agent_id is a non-empty string -- assert agent_id and isinstance(agent_id, str)
POST: [POST-001 side_effect] exactly one POST to /sessions was issued with body {"agent_id": agent_id} -- assert mock_router.calls.call_count == 1 and json.loads(req.content) == {"agent_id": agent_id}
PRE: [PRE-003 hard, issue #5] end_user_id is None OR a non-empty string -- assert end_user_id is None or (isinstance(end_user_id, str) and end_user_id)
POST: [POST-001 side_effect] exactly one POST to /sessions was issued; body is {"agent_id": agent_id} when end_user_id is None, OR {"agent_id": agent_id, "end_user_id": end_user_id} when non-None (issue #5 INV-002: omitting the field when None is NOT the same as sending empty)
POST: [POST-002 return_value] returns SessionInfo with session_id, agent_id, created_at, last_active, metadata populated from response -- assert all 5 fields non-None
POST: [POST-003 return_value] returns SessionInfo where message_count == response["message_count"] (typically 0 for a fresh session) and list-only fields carry the create-origin fixed defaults per INV-001 -- assert info.message_count is not None and info.name is None and info.archived is False and info.tags == []
ERROR_ROUTING:
@@ -109,19 +110,20 @@ ERROR_ROUTING:
HTTP 422 validation_failed:
local_handling: raise SessionApiFailed(status=422, body=resp.content[:1024])
flow_control: abort
state_recovery: none (typically client bug; surface for debugging)
state_recovery: none (typically client bug; surface for debugging. Issue #5: a `end_user_id_required` 422 indicates the agent requires --end-user-id; raw label is honest, hint translation deferred.)
httpx.HTTPStatusError (other status):
local_handling: raise SessionApiFailed(status=resp.status_code, body=resp.content[:1024])
flow_control: abort
state_recovery: none
STEPS:
1. [setup, flexibility=prescriptive] Validate inputs per PRE-001, PRE-002
2. [sequential, flexibility=prescriptive] CALL client.post("/sessions", json={"agent_id": agent_id})
1. [setup, flexibility=prescriptive] Validate inputs per PRE-001, PRE-002, PRE-003
2. [sequential, flexibility=prescriptive] Build body = {"agent_id": agent_id}; IF end_user_id is not None: body["end_user_id"] = end_user_id
3. [sequential, flexibility=prescriptive] CALL client.post("/sessions", json=body)
tool: { destructive: false, idempotent: false, read_only: false, open_world: false }
3. [branch, flexibility=prescriptive] IF resp.status_code == 404: RAISE AgentNotFound
4. [branch, flexibility=prescriptive] IF resp.status_code == 404: RAISE AgentNotFound
ELIF resp.status_code != 201: RAISE SessionApiFailed
4. [sequential] Parse resp.json() → body
5. [cleanup] RETURN SessionInfo(
5. [sequential] Parse resp.json() → body
6. [cleanup] RETURN SessionInfo(
session_id=body["session_id"],
agent_id=body["agent_id"],
created_at=body["created_at"],
@@ -135,11 +137,14 @@ STEPS:
TESTS:
happy_create [happy,tracer]: mock returns 201 with full body → returns SessionInfo with all create-side fields populated; list-only fields are at create-origin defaults (name=None, archived=False, tags=[])
happy_create_with_metadata [happy]: response includes metadata={"model": "glm5-turbo"} → SessionInfo.metadata == {"model": "glm5-turbo"}
request_body_shape [trace]: outbound JSON body is exactly {"agent_id": <arg>} — no Bifrost field, no extra keys
request_body_shape [trace]: outbound JSON body is exactly {"agent_id": <arg>} when end_user_id omitted — no Bifrost field, no extra keys
unknown_agent_id [error]: mock returns 404 → raises AgentNotFound(agent_id="mimir")
validation_failed [error]: mock returns 422 → raises SessionApiFailed(status=422); body truncated to ≤1024 bytes
unexpected_status_truncates [error]: mock returns 500 with 5000-byte body → SessionApiFailed; .body is exactly the first 1024 bytes
empty_agent_id [adversarial]: agent_id="" → AssertionError; no HTTP issued
happy_create_with_end_user_id [happy, issue #5]: end_user_id="alice" → outbound JSON body == {"agent_id": "mimir", "end_user_id": "alice"} byte-for-byte; SessionInfo populated as today
default_omits_end_user_id [trace, issue #5]: omit end_user_id kwarg → outbound JSON body == {"agent_id": "mimir"} (no end_user_id key); preserves the pre-#5 baseline
empty_end_user_id [adversarial, issue #5]: end_user_id="" → AssertionError before HTTP (PRE-003)
```
```contract