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
+16 -2
View File
@@ -55,12 +55,14 @@ The Textual TUI is the eventual primary product (design-brief §5) — separate
- Environment: `WORLDTREE_API_KEY` (fallback for `--api-key`), `WORLDTREE_API_URL` (fallback for `--server`).
**Parsed arguments** (`ParsedArgs` frozen dataclass):
- `send_content: str` — the user message text (`--send <content>`, required).
- `send_content: str | None` — the user message text (`--send <content>`); None signals TUI mode (issue #4 amendment).
- `session_id: str | None` — existing session (`--session <id>`); mutex with `new`.
- `new: bool` — mint a fresh session (`--new`); mutex with `session_id`.
- `agent_id: str | None` — required iff `new=True`.
- `api_key: str` — resolved from `--api-key` then `$WORLDTREE_API_KEY`.
- `server_url: str` — resolved from `--server`, then `$WORLDTREE_API_URL`, then `http://localhost:8000`.
- `raw: bool` — `--raw` opt-out from markdown rendering (issue #4 amendment).
- `end_user_id: str | None` — `--end-user-id <id>` for per-end-user agents (issue #5 amendment); default None preserves the pre-#5 baseline for agents that don't require it (mimir).
**Output (stdout):**
- Raw text deltas from `Text` events, written without trailing newline per chunk. A single trailing newline is written after the terminal `Done` event so the next shell prompt lands on a fresh line.
@@ -257,12 +259,15 @@ STEPS:
--api-key <key> (str, optional — env fallback in step 4)
--server <url> (str, optional — env fallback + default in step 5)
--raw (bool flag, default False — issue #4 amendment: TUI markdown opt-out)
--end-user-id <id> (str, optional, default None — issue #5: required by per-end-user agents; if passed, must be non-empty)
2. [sequential, flexibility=prescriptive] Parse argv:
TRY: ns = parser.parse_args(argv if argv is not None else sys.argv[1:])
ON SystemExit:
Re-raise as UsageError with argparse's captured message
2a. [branch, flexibility=prescriptive] IF ns.send is not None AND not ns.send:
RAISE UsageError("--send content must be non-empty") # empty-string --send still invalid
2b. [branch, flexibility=prescriptive, issue #5] IF ns.end_user_id is not None AND not ns.end_user_id:
RAISE UsageError("--end-user-id must be non-empty when passed") # mirrors empty-send check
3. [branch, flexibility=prescriptive] Validate session/new/agent triad per INV-004:
IF ns.session and ns.new: RAISE UsageError("--session and --new are mutually exclusive")
IF NOT ns.session AND NOT ns.new: RAISE UsageError("pass exactly one of --session or --new")
@@ -273,6 +278,9 @@ STEPS:
IF NOT api_key: RAISE _AuthError("no API key (set --api-key or WORLDTREE_API_KEY)")
5. [sequential, flexibility=prescriptive] Resolve server_url per INV-006:
server_url = ns.server or os.environ.get("WORLDTREE_API_URL") or "http://localhost:8000"
5b. [sequential, flexibility=prescriptive, issue #5 amended 2026-05-23] Resolve end_user_id with env-var fallback:
end_user_id = ns.end_user_id or os.environ.get("RATATOSKR_END_USER_ID") or None
# Flag > $RATATOSKR_END_USER_ID > None. env.sh ships "ratatoskr-tui" as the project-stable partition.
6. [cleanup] RETURN ParsedArgs(
send_content=ns.send, # may be None (TUI marker, issue #4 amendment)
session_id=ns.session,
@@ -281,6 +289,7 @@ STEPS:
api_key=api_key,
server_url=server_url,
raw=ns.raw, # issue #4 amendment
end_user_id=end_user_id, # issue #5 amendment (env-var fallback amended 2026-05-23)
)
TESTS:
happy_new [happy,tracer]: argv=["--send", "hi", "--new", "--agent", "mimir", "--api-key", "k"] → ParsedArgs(send_content="hi", session_id=None, new=True, agent_id="mimir", api_key="k", server_url="http://localhost:8000")
@@ -299,6 +308,11 @@ TESTS:
usage_session_with_agent [adversarial]: argv=["--send","hi","--session","s-1","--agent","x","--api-key","k"] → UsageError("--agent is required with --new and forbidden with --session")
auth_missing [error]: argv lacks --api-key and WORLDTREE_API_KEY unset → _AuthError
empty_send [adversarial]: argv=["--send", "", "--new", "--agent", "x", "--api-key", "k"] → UsageError (non-empty enforced via argparse type=lambda or explicit check)
happy_new_with_end_user_id [happy, issue #5]: argv includes --end-user-id alice → ParsedArgs.end_user_id == "alice"
end_user_id_default_none [trace, issue #5]: argv omits --end-user-id AND env unset → ParsedArgs.end_user_id is None
empty_end_user_id [adversarial, issue #5]: argv has --end-user-id "" → UsageError("--end-user-id must be non-empty when passed")
end_user_id_from_env [trace, issue #5 amended 2026-05-23]: env RATATOSKR_END_USER_ID="ratatoskr-tui"; flag omitted → ParsedArgs.end_user_id == "ratatoskr-tui"
end_user_id_flag_beats_env [trace, issue #5 amended 2026-05-23]: env set + flag passed → flag wins
```
```contract
@@ -325,7 +339,7 @@ ERROR_ROUTING:
STEPS:
1. [setup, flexibility=prescriptive] OPEN 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)) via async-with — read=None disables the SSE-killing 5s default (issue #1 [compatibility] constraint)
2. [branch, flexibility=prescriptive] IF args.new:
TRY: info = await sessions.create_session(client, args.agent_id)
TRY: info = await sessions.create_session(client, args.agent_id, end_user_id=args.end_user_id) # issue #5: thread end_user_id when set; None preserves pre-#5 body shape
ON AgentNotFound as exc:
WRITE stderr; RETURN 12
ON SessionApiFailed as exc: