Files
ratatoskr/docs/contracts/issues/5.contract.md
T
vh 804c2df6eb 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).
2026-05-23 14:34:53 -07:00

191 lines
14 KiB
Markdown

---
contract_version: "2.1"
target_module: "ratatoskr.sessions"
scope: "Add `end_user_id` support to the POST /sessions flow so per-end-user agents (lofn confirmed; presumably the Persona/Vili family) can be smoked. Small surface change distributed across three existing modules via in-place contract amendments: `ratatoskr.sessions.create_session` gains a keyword-only `end_user_id: str | None = None` parameter that is threaded into the POST body when non-None; `ratatoskr.cli` adds an `--end-user-id <id>` flag + corresponding `ParsedArgs.end_user_id: str | None` field + threading through `_amain`; `ratatoskr.tui.on_mount` threads `args.end_user_id` into its `create_session` call. No new modules, no new files (apart from this contract). Default-omitted preserves backwards compatibility: existing `mimir` smoke flows that don't pass `--end-user-id` continue to work unchanged."
depends_on:
- "httpx"
used_by: []
language: "python"
complexity: "low"
estimated_loc: 40
confidence: 0.9
assumptions:
- "Worldtree spec pin (`docs/conversation-api-spec.md` at v1.0, repo SHA `55101e909abcd2219833266b6f905c5bc956e0f0`, v0.19.0) declares `end_user_id` as a field on POST /sessions — required by some agents (lofn confirmed via 422 `end_user_id_required` on 2026-05-21 smoke), optional/ignored by others (mimir doesn't reject when omitted)."
- "The 422 response shape is `{\"detail\":{\"error_code\":\"end_user_id_required\",\"message\":\"...\"}}` per the smoke evidence. The existing `SessionApiFailed` exception handler in `_amain` / `on_mount` already maps 422s to exit 20 / `[session_api_failed]` label, so missing `--end-user-id` for a per-user agent surfaces as that label rather than a more specific hint. Defer 422-→-hint translation to a follow-up issue."
- "`end_user_id` is a free-form string from the operator's perspective. Per worldtree-dev (althing 01KSBARG2B8M, 2026-05-23): it's a *runtime partition key* — same value → same long-term-memory + persona/valence partition; different values → fully isolated partitions. The server validates it as non-empty; ratatoskr also rejects empty client-side (INV-001)."
- "**Env-var fallback amended 2026-05-23**: `$RATATOSKR_END_USER_ID` populates the field when the flag is omitted. Resolution order: `--end-user-id` flag > `$RATATOSKR_END_USER_ID` > None. `env.sh` ships `RATATOSKR_END_USER_ID=\"ratatoskr-tui\"` as the project-stable default. Original posture rejected env-var fallback as 'papering over isolation'; revised after worldtree-dev's guidance that the realistic use case (single-operator debugging) wants partition continuity. The override path preserves isolation when needed."
- "**Forthcoming breaking change (Worldtree #196)**: the `end_user_id` field is being replaced by polymorphic `subject: {type, id}` at a future v0.22.x / v0.23.0. Spec is LOCKED, substrate not yet shipped. Don't pre-implement; migrate when the substrate change lands (deprecation warnings will fire per call as heads-up). Tracked as a separate ratatoskr issue."
open_questions:
- "Should the 422 `end_user_id_required` error_code trigger a user-friendly hint suggesting `--end-user-id <id>` rather than the raw body? Draft: no for v1 — the raw `[session_api_failed]` label is honest about what came back. Add the hint in a follow-up if the bare label proves empirically confusing."
- "Should `ratatoskr.sessions.list_sessions` also accept `end_user_id` to filter by end-user-id? Spec allows it. Draft: no for this issue (out of scope; list_sessions has no consumer yet in the CLI/TUI — `--send` and the TUI shell only call create). File if the startup-session-picker issue needs it."
prd:
issue: 5
issue_url: "https://gitea.phasefinal.com/vh/ratatoskr/issues/5"
body_sha256_16: "03fe1fa547235f32"
lock_in_comment_id: null
lock_in_sha256_16: null
lock_in_at: null
pinned_at: "2026-05-22T05:03:17+00:00"
dependencies:
- issue: 2
path: "src/ratatoskr/sessions.py"
reason: "In-place contract amendment: `create_session` signature widens to accept `end_user_id`; POST body construction gains a conditional field; new TESTS entries for present + omitted cases."
- issue: 3
path: "src/ratatoskr/cli.py"
reason: "In-place contract amendment: `_parse_args` adds `--end-user-id` flag; `ParsedArgs` gains `end_user_id: str | None`; `_amain` threads it into the `create_session(...)` call when `args.new`."
- issue: 4
path: "src/ratatoskr/tui.py"
reason: "In-place contract amendment: `RatatoskrApp.on_mount` threads `self.args.end_user_id` into its `create_session(...)` call when `args.new`."
---
# end_user_id support — POST /sessions parameter for per-user agents
## Context
Manual smoke against personal Worldtree on 2026-05-21 (`ratatoskr --send "test" --new --agent lofn`) returned a 422 from POST /sessions:
```
[session_api_failed] status=422 body=b'{"detail":{"error_code":"end_user_id_required","message":"end_user_id is required (non-empty string) for Lofn sessions"}}'
```
The `lofn` agent (and presumably others in the Persona/Vili family) requires an `end_user_id` field in the create-session body to scope state per end-user. Ratatoskr's current `create_session(client, agent_id)` only sends `{"agent_id": agent_id}`, so per-user agents are unreachable. Mimir, the agent used for prior smoke validation, doesn't require `end_user_id` and continues to work unchanged.
This issue threads `end_user_id` through the small chain: CLI flag → `ParsedArgs``_amain` / `on_mount``create_session` → POST body. The change is small (a keyword-only parameter widening + one new CLI flag + arg-passing in two places) but touches three existing contracts (#2, #3, #4) in-place. This issue's own contract is mostly a coordinating record + the new test additions.
## Data flow
**Input:**
- Operator passes `--end-user-id <id>` on the CLI when invoking against a per-user agent.
- Worldtree's POST /sessions endpoint accepts `end_user_id: str` as an optional field; rejects with 422 `end_user_id_required` when omitted for an agent that requires it.
**Output:**
- POST /sessions body becomes `{"agent_id": <agent>, "end_user_id": <id>}` when `--end-user-id` was passed; remains `{"agent_id": <agent>}` when omitted.
- Returned `SessionInfo` is unchanged shape (the server response doesn't change; only the request body widens).
**Side effects:** outbound HTTP only (no new state). No persistence — operator passes `--end-user-id` on each invocation; ratatoskr doesn't remember it.
## Invariants
- **INV-001 [hard]**: `end_user_id`, when passed via `--end-user-id`, MUST be a non-empty string. Empty-string `--end-user-id ""` raises `UsageError` BEFORE any HTTP call (mirrors the existing `--send ""` empty-check in `_parse_args`). Server-side validation also rejects empty, so client-side rejection is friendlier.
- **INV-002 [hard]**: `create_session(...)` MUST omit the `end_user_id` field from the POST body when the kwarg is `None`. This preserves the existing 2-field body shape for agents that don't require `end_user_id` (mimir today; other agents in the future). Sending an empty-string `end_user_id` is NOT equivalent to omitting it (server rejects empty; INV-001 catches empty before HTTP).
- **INV-003 [hard]**: Backwards compatibility: all existing `mimir` smoke flows that don't pass `--end-user-id` continue to work unchanged. The CLI's `--end-user-id` flag is OPTIONAL (no default required); `_parse_args` succeeds without it; `_amain` / `on_mount` call `create_session(client, agent_id)` (no end_user_id kwarg) when the flag wasn't passed, identical to today's behavior.
- **INV-004 [hard]**: No `core.*` / `worldtree.*` imports (existing boundary; this issue doesn't change it).
## Out of scope
- **422 `end_user_id_required` → user-friendly hint.** The raw `[session_api_failed] status=422 body=...` label is honest; the message in the body (`"end_user_id is required (non-empty string) for Lofn sessions"`) is reasonably clear. Hint translation deferred to a follow-up if the bare label proves empirically confusing.
- **`list_sessions` filter by `end_user_id`.** Spec allows it; no consumer needs it yet (the startup-session-picker issue is a separate ticket).
- **Environment-variable fallback for `end_user_id`.** Auto-defaulting (e.g., `$USER`, `$RATATOSKR_END_USER_ID`) would paper over the per-user-isolation intent. Explicit flag only.
- **TUI startup error visibility** — when `--end-user-id` is missing for a per-user agent, the TUI's `[session_api_failed]` line still gets eaten by the alt-screen teardown. That's issue #6's scope, not this one.
- **Empty-data SSE crash** — separate issue #7; mid-stream JSONDecodeError on empty `sse.data` is independent of `end_user_id`.
- **Other per-agent-required fields.** If Worldtree later adds another required-by-some-agents field, file a sibling issue; don't generalize this one prematurely.
## Constraints
- **[compatibility]** Spec pin unchanged. The `end_user_id` field is already in the v0.19.0 spec; we're just starting to use it.
- **[performance]** No new round-trips; no new state. The change is one extra optional field in an existing POST body.
- **[security]** `end_user_id` is logged as part of `[create_session]` (alongside `session_id`, `agent_id`) — operator's choice of identifier may carry semantic meaning, but it's not auth-bearing. Don't redact.
- **[style]** Keyword-only parameter for `end_user_id` (matches `persist_partial` on `cancel_turn`). Ruff line-length=100.
---
## In-place amendments (the work)
This issue's contract is small because the real work is amendments to issues #2, #3, #4. The amendments are pinned here so reviewers see the whole change in one place; the actual contract files at `docs/contracts/issues/2.contract.md`, `3.contract.md`, `4.contract.md` are amended in-place as part of this issue's commit.
### Issue #2 (`ratatoskr.sessions`) amendments
**`create_session` signature widens:**
```contract
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 optionally {"end_user_id": end_user_id} when non-None. Returns SessionInfo populated from the 201 response.
PRE: [PRE-001 hard] client is not None
PRE: [PRE-002 hard] agent_id is a non-empty string
PRE: [PRE-003 hard] 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
... (other POST/ERROR_ROUTING unchanged) ...
STEPS:
...
2. [sequential, flexibility=prescriptive] Build body: 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)
...
```
**New TESTS entries:**
- `happy_create_with_end_user_id [happy]`: pass `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]`: omit `end_user_id` kwarg → outbound JSON body == `{"agent_id": "mimir"}` (no end_user_id key); preserves the issue #2 baseline.
- `empty_end_user_id [adversarial]`: `end_user_id=""` → AssertionError before HTTP (PRE-003).
### Issue #3 (`ratatoskr.cli`) amendments
**`_parse_args` STEPS gain a new flag + env-var fallback (amended 2026-05-23):**
```
1. [setup] Construct argparse.ArgumentParser:
... (existing flags) ...
--end-user-id <id> (str, optional — non-empty if passed; validated in step 2b)
2a. [branch] IF ns.send is not None AND not ns.send: UsageError("--send content must be non-empty") (unchanged)
2b. [branch, NEW] IF ns.end_user_id is not None AND not ns.end_user_id: UsageError("--end-user-id must be non-empty when passed")
... (rest unchanged) ...
5b. [sequential, 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 wins; env fallback active when flag omitted; None when neither set.)
6. [cleanup] RETURN ParsedArgs(
... existing fields ...,
end_user_id=end_user_id,
)
```
**`ParsedArgs` gains `end_user_id: str | None`** (default semantics handled by argparse default=None).
**`_amain` threads through to `create_session`:**
```
IF args.new:
TRY: info = await create_session(client, args.agent_id, end_user_id=args.end_user_id)
... existing error handling unchanged ...
```
**New TESTS:**
- `happy_new_with_end_user_id [happy]`: argv includes `--end-user-id alice` → ParsedArgs.end_user_id == "alice".
- `end_user_id_default_none [trace]`: argv omits `--end-user-id` AND env unset → ParsedArgs.end_user_id is None.
- `empty_end_user_id [adversarial]`: argv has `--end-user-id ""` → UsageError.
- `end_user_id_from_env [trace, 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, amended 2026-05-23]`: env set + flag passed → flag wins.
- Update `_amain`'s `happy_new_session_then_stream` test: respx assertion on the POST /sessions body now confirms `end_user_id` is OR isn't present per the test variant.
### Issue #4 (`ratatoskr.tui`) amendments
**Post-#6 adjustment:** issue #6 (landed 2026-05-23, after this contract was drafted)
moved session resolution OUT of `on_mount` (alt-screen) into `_resolve_then_run`
(pre-`App.run_async()`). The equivalent end_user_id threading site is therefore
`_resolve_then_run`'s `create_session` call, not `on_mount`'s. `on_mount`
no longer calls `create_session` at all.
**`_resolve_then_run` STEPS gain end_user_id threading (was on_mount pre-#6):**
```
2. [branch] IF args.new:
TRY: info = await create_session(client, args.agent_id, end_user_id=args.end_user_id)
... existing error handling unchanged ...
```
**New TEST (renamed _mount → _resolve per #6):**
- `happy_new_with_end_user_id_resolve [happy]`: `_args_new(end_user_id="alice")`;
respx mocks POST /sessions; assert outbound body has `end_user_id: alice`.
---
## Acceptance
- All three amended contracts (#2, #3, #4) drift-check clean.
- All existing tests + new `end_user_id` coverage GREEN under `uv run pytest tests/`.
- `uv run ruff check src/ tests/` clean.
- Boundary smoke `tests/test_no_worldtree_imports.py` still passes.
- Manual smoke: `source env.sh && uv run ratatoskr --send "test" --new --agent lofn --end-user-id ratatoskr-dev` succeeds against personal Worldtree (session creates, stream consumes to `[done]`).
- The pre-existing `mimir` smoke flow without `--end-user-id` STILL works (regression check).