Compare commits

...

4 Commits

Author SHA1 Message Date
vh e62208d8e3 feat(#2): consume GET /sessions/{id}/tools — Tools-pane inventory hydrate
v1 coverage-audit Tier-2 quick win. The owner-scoped tool-inventory
endpoint (#183) had no caller; wire it into the TUI Tools pane.

- sessions.py: get_session_tools (GET /sessions/{id}/tools) — owner-
  scoped (consumer key, no admin scope), 200 -> parsed dict verbatim,
  non-200 -> SessionApiFailed. Mirrors get_persona_state / get_me.
- tui.py: _format_tool_inventory helper + _hydrate_session_tools
  best-effort worker (mirrors _hydrate_persona), wired unconditionally
  in on_mount. Writes the merged {agent_id, builtin_tools,
  bifrost_tools} inventory the LLM saw at turn-fire into the Tools
  pane + audits; never crashes on failure.
- Covers the design-brief 5 "Tools widget" via the reachable owner
  endpoint (the admin variant stays a gap only for cross-user debug).
- Contract #2 amended (FN) + validated. TDD: 3 wrapper tests + 1
  format-helper unit + 2 hydrate integration tests. Coverage: REST
  10/40. Suite 544 green; touched code ruff-clean.
2026-06-30 22:50:12 -07:00
vh 0205b81319 memory: b1 heid-code-review panel — zero findings (cross-model-verified)
Gróa + Hulda + Regin each independently reviewed stream_turn_resilient
vs contract #1 (artifact-only) → all three zero findings. Records the
clean bill + the calibration signal (prescriptive contract + TDD =
confirmation, not discovery).
2026-06-30 22:38:40 -07:00
vh 387ac4ab2c feat(#2): consume GET /me + GET /capabilities via --whoami one-shot
v1 coverage-audit slice (capabilities+me). Both endpoints had no
caller; add them as cheap boot-time debug primitives.

- sessions.py: get_me (GET /me — identity/whoami) + get_capabilities
  (GET /capabilities — Echo ephemeral-template discovery). Mirror
  get_persona_state: 200 -> parsed dict verbatim, non-200 ->
  SessionApiFailed. Freeform dicts (frozen OpenAPI types both as
  objects).
- cli.py: new --whoami one-shot mode (mirrors --send). Fetches both,
  prints an identity + capabilities report, exits. Standalone probe:
  mutually exclusive with --send/--session/--new/--agent; opens no
  session. New ParsedArgs.whoami field + main() dispatch.
- Contract #2 amended (2 FNs) + validated. TDD: 5 wrapper tests +
  5 cli tests (validation + mode + error). Coverage map: REST 9/40.
  Suite 538 green; touched code ruff-clean.

Audit note: /capabilities is the Echo ephemeral-template discovery
endpoint, not a generic server-caps endpoint (coverage-map framing
corrected). TUI-surfacing of /me + /capabilities deferred.
2026-06-30 22:21:59 -07:00
vh 5c1b9816d4 feat(#6): startup session picker for bare TUI mode
v1 coverage-audit slice b2. The audit found list_sessions had no
caller — the startup session picker (design-brief §4) was never built;
bare TUI mode was a hard usage error. Add SessionPickerApp (mirrors
AgentPickerApp) and resolve bare mode in _resolve_then_run.

- Bare TUI mode (no --session/--new) now valid → session picker.
  Resolution: 0 sessions -> [no_sessions] exit 14 (resume-only per
  §4 "no in-app creation, --new only"); exactly 1 -> auto-resume
  (§4 "picker only when >1"); >=2 -> SessionPickerApp -> resume pick
  (Esc/Ctrl-D -> exit 0).
- cli._parse: bare TUI valid; --send still requires one flag; --agent
  forbidden in bare mode. run_tui PRE-002 xor -> mutually-exclusive.
- Contract #6 amended (SessionPickerApp + bare-mode resolution) +
  validated. TDD: 3 picker pilot tests + 5 resolution tests + 3 cli
  validation tests. Suite 528 green; touched code ruff-clean.

Design note: bare + 0 sessions errors (honors §4's no-in-app-creation
clause); the friendlier auto-fall-through-to-new is deferred pending
operator preference.
2026-06-30 22:05:14 -07:00
12 changed files with 1034 additions and 37 deletions
+74
View File
@@ -206,3 +206,77 @@ TESTS:
limit_above_max [adversarial]: limit=300 → AssertionError; no HTTP issued limit_above_max [adversarial]: limit=300 → AssertionError; no HTTP issued
empty_cursor [adversarial]: cursor="" → AssertionError; no HTTP issued empty_cursor [adversarial]: cursor="" → AssertionError; no HTTP issued
``` ```
## Amendment 2026-06-30 — boot-time introspection reads (v1 coverage-audit: capabilities+me)
The v1 coverage-audit added two read-only server-introspection endpoints as
cheap debug primitives (surfaced via a new `ratatoskr --whoami` one-shot). Both
mirror `get_persona_state`: GET, 200 → parsed dict verbatim, any non-200 →
`SessionApiFailed`. The frozen OpenAPI types both responses as freeform objects,
so the wrappers return `dict[str, Any]` (not a typed dataclass).
```contract
FN get_me(client: httpx.AsyncClient) -> dict[str, Any]
BRIEF: GET /me — the authenticated principal's identity + key metadata (spec §GET /me). Boot-time whoami: verify the key without agent-config side effects. Returns parsed JSON verbatim; spec documents {user_id, scopes, tier, display_name?, key_id?, key_label?, ...} with optional fields OMITTED (not null). Read-only, rate-exempt, no audit emission.
PRE: [PRE-001 hard] client is not None -- assert client is not None
POST: [POST-001 return_value] on 200 returns resp.json() unmodified -- assert result == resp.json()
ERROR_ROUTING:
HTTP non-200 (incl. 401 bad/absent key when auth enabled):
local_handling: raise SessionApiFailed(status=resp.status_code, body=resp.content)
flow_control: abort
state_recovery: none (caller decides: bad key → re-key; degraded tier="unknown" is still a 200)
STEPS:
1. [setup, prescriptive] assert client is not None
2. [sequential, prescriptive] resp = await client.get("/me")
3. [branch, prescriptive] IF resp.status_code == 200: RETURN resp.json(); ELSE RAISE SessionApiFailed
TESTS:
happy_authenticated [happy,tracer]: 200 {user_id, scopes, tier, key_id} → dict returned verbatim
anonymous_dev_mode: 200 {user_id:"anonymous", tier:"anonymous"} → dict; no key_* fields (omitted)
401_raises [error]: 401 → SessionApiFailed(status=401)
FN get_capabilities(client: httpx.AsyncClient) -> dict[str, Any]
BRIEF: GET /capabilities — server capability discovery (spec §Ephemeral Templates). Returns {ephemeral_templates: {echo: {allowed_models, default_model, system_prompt_max_bytes}}}. Any authenticated caller may read it (no instantiate scope). Parsed dict verbatim; any non-200 → SessionApiFailed.
PRE: [PRE-001 hard] client is not None -- assert client is not None
POST: [POST-001 return_value] on 200 returns resp.json() unmodified -- assert result == resp.json()
ERROR_ROUTING:
HTTP non-200:
local_handling: raise SessionApiFailed(status=resp.status_code, body=resp.content)
flow_control: abort
state_recovery: none
STEPS:
1. [setup, prescriptive] assert client is not None
2. [sequential, prescriptive] resp = await client.get("/capabilities")
3. [branch, prescriptive] IF resp.status_code == 200: RETURN resp.json(); ELSE RAISE SessionApiFailed
TESTS:
happy [happy]: 200 {ephemeral_templates:{echo:{...}}} → dict returned verbatim
non_200_raises [error]: 500 → SessionApiFailed(status=500)
```
## Amendment 2026-07-01 — session tool introspection (v1 coverage-audit)
Owner-scoped tool-inventory read (spec #183, `GET /sessions/{id}/tools`),
surfaced in the TUI Tools pane on session-attach. Same shape as the other
introspection wrappers: GET, 200 → parsed dict verbatim, non-200 →
`SessionApiFailed`. Reachable with the consumer key (no admin scope), unlike the
admin variant `GET /admin/sessions/{id}/tools`.
```contract
FN get_session_tools(client: httpx.AsyncClient, session_id: str) -> dict[str, Any]
BRIEF: GET /sessions/{session_id}/tools — owner-scoped merged tool inventory (spec #183) the LLM saw at turn-fire: {agent_id, builtin_tools: [...], bifrost_tools: [{name, description, parameters}, ...]}. Owner gate (ctx.user_id == session.user_id); cross-owner → 404 session_not_found (existence-hiding), revoked → 401 auth_revoked. Parsed dict verbatim; any non-200 → SessionApiFailed.
PRE: [PRE-001 hard] client is not None -- assert client is not None
PRE: [PRE-002 hard] session_id is non-empty str -- assert session_id and isinstance(session_id, str)
POST: [POST-001 return_value] on 200 returns resp.json() unmodified -- assert result == resp.json()
ERROR_ROUTING:
HTTP non-200 (incl. 404 session_not_found cross-owner/unknown, 401 auth_revoked):
local_handling: raise SessionApiFailed(status=resp.status_code, body=resp.content)
flow_control: abort
state_recovery: none
STEPS:
1. [setup, prescriptive] assert PRE-001, PRE-002
2. [sequential, prescriptive] resp = await client.get(f"/sessions/{session_id}/tools")
3. [branch, prescriptive] IF resp.status_code == 200: RETURN resp.json(); ELSE RAISE SessionApiFailed
TESTS:
happy [happy,tracer]: 200 {agent_id, builtin_tools:[], bifrost_tools:[{name,...}]} → dict verbatim
cross_owner_404 [error]: 404 session_not_found → SessionApiFailed(status=404)
empty_session_id [adversarial]: "" → AssertionError; no HTTP issued
```
+61
View File
@@ -372,3 +372,64 @@ test layer.
- Issue #7 (mid-stream robustness, `MalformedSseData`) — landed; #6's - Issue #7 (mid-stream robustness, `MalformedSseData`) — landed; #6's
pre/in-alt-screen split is orthogonal to #7's empty-data/malformed pre/in-alt-screen split is orthogonal to #7's empty-data/malformed
distinction (different error layers entirely). distinction (different error layers entirely).
## Amendment 2026-06-30 — startup session picker (v1 coverage-audit, slice b2)
The v1 coverage-audit found `list_sessions` had **no caller** — the startup
session picker (design-brief §4: "single-session-per-launch, with a startup
picker invoked when more than one session exists ... plus flags `--session`/
`--new` to skip it") was never built. Bare TUI mode (neither `--session` nor
`--new`) was a hard usage error. This adds the picker as a pre-alt-screen
resolution step in `_resolve_then_run`, mirroring the existing `AgentPickerApp`.
**Locked design (design-brief §4):** the picker is **resume-only** (§4 negative
clause "no in-app session creation — `--new` flag only"); shown only when **>1**
session exists (exactly 1 auto-resumes; the launch intent is "resume the last
session I was poking at"). `--agent` stays a `--new` companion (forbidden in bare
mode). **bare + 0 sessions → error** `[no_sessions]` directing the operator to
`--new` (honors the "no in-app creation" clause; the friendlier
auto-fall-through-to-new alternative is deferred pending operator confirmation).
### `_parse` validation relaxation (ratatoskr.cli._parse)
- Bare TUI mode (`send is None` AND no `--session` AND no `--new`) is now VALID
→ triggers the picker. (Previously `raise UsageError("pass exactly one of
--session or --new")` unconditionally.)
- `--send` mode still requires exactly one of `--session`/`--new` (non-
interactive: no picker can open) → `UsageError("--send requires --session or
--new")`.
- `--session` + `--new` stays mutually exclusive.
- `--agent` in bare mode → `UsageError` (`--agent` belongs to `--new`).
```contract
FN SessionPickerApp.__init__(self, sessions: list[SessionInfo]) -> None
BRIEF: Textual App[str | None] startup session picker (mirrors AgentPickerApp, issue #8). Opens before RatatoskrApp when bare TUI mode resolves >1 session. `run_async()` returns the chosen session_id (str) or None on Esc/Ctrl-D/Ctrl-C dismissal. Architecturally separate from RatatoskrApp (list_sessions failures + dismissal land before any alt-screen — preserves #6 INV-001).
PRE: [PRE-001 hard] sessions is non-empty -- assert sessions (caller resolves 0-session and 1-session cases BEFORE constructing the picker)
POST: [POST-001 return_value] run_async() returns sessions[i].session_id for the highlighted row on `pick`, or None on dismiss -- assert result in {s.session_id for s in sessions} | {None}
STEPS:
1. [setup, prescriptive] Store sessions; register the Australis theme (mirror AgentPickerApp).
2. [sequential, prescriptive] compose: Header + prompt Static + ListView of one ListItem per session (id-short + agent_id + last_active/name lines) + Footer.
3. [sequential, prescriptive] BINDINGS: enter→action_pick, escape/ctrl+d/ctrl+c→action_dismiss.
4. [branch, prescriptive] action_pick: read ListView.index; if None return (nothing highlighted); else exit(sessions[index].session_id). action_dismiss: exit(None).
TESTS:
pick_returns_session_id [happy,tracer]: SessionPickerApp([s0, s1]); pilot highlights row 1 + press enter → run_async() returns s1.session_id.
dismiss_returns_none [happy]: press escape → run_async() returns None.
ctrl_d_dismisses [adversarial]: press ctrl+d → None.
FN _resolve_then_run(args) — bare-mode extension (session picker)
BRIEF: Before the existing new/resume branches, resolve bare TUI mode (not args.new AND args.session_id is None) via list_sessions + the picker. Sets a local `effective_new` and `resolved_session_id`; the existing branches then run unchanged on those locals.
STEPS (inserted at the top of the `async with client` block):
1. [setup, prescriptive] SET effective_new = args.new; resolved_session_id = args.session_id.
2. [branch, prescriptive] IF (not args.new) AND (args.session_id is None): # bare mode
a. CALL list_sessions(client) → page; ON SessionApiFailed → stderr `[session_api_failed]` + return 20; ON network error → `[network_error]` + return 21.
b. IF not page.items: stderr `[no_sessions] no sessions to resume; launch with --new --agent <id>` + return 14.
c. ELIF len(page.items) == 1: SET resolved_session_id = page.items[0].session_id. # §4: picker only when >1
d. ELSE: SET resolved_session_id = await SessionPickerApp(page.items).run_async(); IF None → return 0 (Esc/Ctrl-D clean exit).
3. [sequential, prescriptive] Replace the two `if args.new` predicates with `if effective_new`; the resume `else` branch asserts + uses `resolved_session_id`.
TESTS (in the `_resolve_then_run` block):
bare_zero_sessions_errors [error]: bare args; list_sessions → 0 items → stderr contains `[no_sessions]`; return 14; NO POST /sessions, NO picker.
bare_one_session_auto_resumes [scenario]: bare args; list_sessions → 1 item (sid="s-solo") → RatatoskrApp constructed with session_id="s-solo"; NO picker shown.
bare_multi_opens_picker [scenario,tracer]: bare args; list_sessions → 2 items; picker returns items[1].session_id → RatatoskrApp constructed with that session_id.
bare_picker_dismiss_exits_zero [scenario]: bare args; 2 items; picker returns None → return 0; RatatoskrApp NOT constructed.
bare_list_sessions_api_failure [error]: bare args; list_sessions raises SessionApiFailed(500) → stderr `[session_api_failed]`; return 20.
```
+15 -13
View File
@@ -48,7 +48,7 @@ resolved (§ Surface 1, scope-resolution table).
| Surface | Points | ✅ covered-live | ⬜ gap (in-scope) | 🚫 excluded-by-design | | Surface | Points | ✅ covered-live | ⬜ gap (in-scope) | 🚫 excluded-by-design |
|---|---|---|---|---| |---|---|---|---|---|
| REST (OpenAPI 2.2.0, path groups) | 40 | 7 | 11 | 22 | | REST (OpenAPI 2.2.0, path groups) | 40 | 10 | 8 | 22 |
| SSE events | 11 | 11 | 0 | 0 | | SSE events | 11 | 11 | 0 | 0 |
| Bifrost provider planes | 8 verbs | 8 | 0 | (10 gated verbs deferred) | | Bifrost provider planes | 8 verbs | 8 | 0 | (10 gated verbs deferred) |
@@ -75,6 +75,9 @@ sub-gap).
| `POST /agents/define` | ✅ | `tier3.py:175``_run_define` | Tier-3 create | | `POST /agents/define` | ✅ | `tier3.py:175``_run_define` | Tier-3 create |
| `PATCH /agents/{id}` | ✅ | `tier3.py:219``_run_patch` | Tier-3 mutate (system_prompt/model) | | `PATCH /agents/{id}` | ✅ | `tier3.py:219``_run_patch` | Tier-3 mutate (system_prompt/model) |
| `DELETE /agents/{id}` | ✅ | `tier3.py:242``_run_delete` | Tier-3 hard-delete | | `DELETE /agents/{id}` | ✅ | `tier3.py:242``_run_delete` | Tier-3 hard-delete |
| `GET /me` | ✅ | `sessions.py:411` `get_me``cli.py` `--whoami` | identity/whoami probe; 401→SessionApiFailed |
| `GET /capabilities` | ✅ | `sessions.py:428` `get_capabilities``cli.py` `--whoami` | Echo ephemeral-template discovery |
| `GET /sessions/{id}/tools` | ✅ | `sessions.py:411` `get_session_tools``tui.py` `_hydrate_session_tools` | owner-scoped tool inventory in the TUI Tools pane (#183) |
**Sub-gaps inside ✅ path groups** (the method we use is live; a sibling method **Sub-gaps inside ✅ path groups** (the method we use is live; a sibling method
on the same path is an unwired frontier item — see frontier Tier 1): on the same path is an unwired frontier item — see frontier Tier 1):
@@ -95,15 +98,12 @@ on the same path is an unwired frontier item — see frontier Tier 1):
| `GET /admin/events` | ⬜ | design-brief §5 v1 **AdminEvents pane**; = issue **#11**, **blocked** on `admin.events.read` scope (infra-ops) | | `GET /admin/events` | ⬜ | design-brief §5 v1 **AdminEvents pane**; = issue **#11**, **blocked** on `admin.events.read` scope (infra-ops) |
| `GET /admin/sessions/{id}/bifrost` | ⬜ | design-brief §5 v1 **BifrostState widget** — never built; admin-key-gated | | `GET /admin/sessions/{id}/bifrost` | ⬜ | design-brief §5 v1 **BifrostState widget** — never built; admin-key-gated |
| `GET /admin/sessions/{id}/tools` | ⬜ | design-brief §5 v1 **Tools widget** — never built; admin-key-gated | | `GET /admin/sessions/{id}/tools` | ⬜ | design-brief §5 v1 **Tools widget** — never built; admin-key-gated |
| `GET /capabilities` | ⬜ | server capability discovery — a turn flows through what's advertised |
| `GET /me` | ⬜ | whoami / key-identity — "which key am I against" is a debug primitive |
| (`GET /sessions` picker · resume) | ⬜ | sub-gaps above — presenter-wiring only, wrappers exist | | (`GET /sessions` picker · resume) | ⬜ | sub-gaps above — presenter-wiring only, wrappers exist |
**Tier 2 — rounds out I/O coverage under A (postdates the design-brief):** **Tier 2 — rounds out I/O coverage under A (postdates the design-brief):**
| Endpoint | Status | Why in-scope | | Endpoint | Status | Why in-scope |
|---|---|---| |---|---|---|
| `GET /sessions/{id}/tools` | ⬜ | Tier-3 owner-scoped tool introspection (#183) — reachable with the **consumer key** (no admin scope), the unblocked tool-introspection path |
| `POST /sessions/{id}/persona_state` (write) | ⬜ | affect-injection is debug-relevant; pairs with our provider affect plane | | `POST /sessions/{id}/persona_state` (write) | ⬜ | affect-injection is debug-relevant; pairs with our provider affect plane |
| `POST /characters` · `DELETE /characters/{id}` · `GET /characters/{id}/state` · `GET /models/available-for-characters` | ⬜ | transient-characters (Echo) is a session-creation **routing path** a debug client should be able to drive a turn through | | `POST /characters` · `DELETE /characters/{id}` · `GET /characters/{id}/state` · `GET /models/available-for-characters` | ⬜ | transient-characters (Echo) is a session-creation **routing path** a debug client should be able to drive a turn through |
@@ -201,21 +201,23 @@ starts exercising them.
## Convergence frontier (the v1 to-do) ## Convergence frontier (the v1 to-do)
**Tier 1 — debug-observability core**, in dependency order: **Tier 1 — debug-observability core:**
1. **Session picker + SSE-resume** — wrappers exist (`list_sessions`, 1. **DONE** — Session picker (`v0.18.7`) + SSE-resume (`v0.18.5`/`.6`).
`reconnect_turn`), need presenter wiring only. **Cheapest; unblocked.** 2.**DONE**`GET /capabilities` + `GET /me` (`v0.18.8`, `--whoami`).
2. **`GET /capabilities` + `GET /me`** — cheap debug primitives. Unblocked. 3. **BifrostState + Tools widgets** (`GET /admin/sessions/{id}/bifrost` — the
3. **BifrostState + Tools widgets** (`GET /admin/sessions/{id}/{bifrost,tools}`) admin session-bifrost inspection) — design-brief'd v1, unbuilt. Admin-key-gated.
— design-brief'd v1, unbuilt. Admin-key-gated reads. *(The Tools half is effectively covered by the owner-scoped `GET /sessions/{id}/tools`
inventory, item 5 — the admin `/admin/sessions/{id}/tools` remains a gap only for
cross-user operator debug.)*
4. **#11 — AdminEvents pane** — **blocked** on an `admin.events.read` scope grant 4. **#11 — AdminEvents pane** — **blocked** on an `admin.events.read` scope grant
(infra-ops). The single externally-blocked item; everything else can ship (infra-ops). The single externally-blocked item; everything else can ship
without it. without it.
**Tier 2 — rounds out coverage** (lower priority): **Tier 2 — rounds out coverage:**
5. **`GET /sessions/{id}/tools`** — owner-scoped tool introspection; consumer-key 5. **DONE**`GET /sessions/{id}/tools` (`v0.18.9`, owner-scoped tool inventory
reachable (no admin scope), so unblocked. in the TUI Tools pane).
6. **Transient-characters routing** (4 endpoints) + **`POST /sessions/{id}/persona_state`**. 6. **Transient-characters routing** (4 endpoints) + **`POST /sessions/{id}/persona_state`**.
--- ---
+7
View File
@@ -151,6 +151,13 @@ decision. Captures rationale that won't be obvious from code alone.
- `[2026-06-30]` **(b) Tier-1 frontier SCOPED, ready for a contract-first TDD cycle (next focused work).** The primitives already exist + are contracted + tested; the gap is PRESENTER-level wiring. Two slices: **(b1) SSE-resume** — contract #1 (`ratatoskr.sse_client`) DELIBERATELY makes resume caller-owned ("on `SseConnectionDropped`, the caller MAY invoke `reconnect_turn`"); `reconnect_turn` (sse_client.py:524) has NO caller. Gap = a SHARED resume-orchestration wrapper (catch `SseConnectionDropped` → track last-seen `sse_id``reconnect_turn` → continue), consumed by all 3 presenters per design-brief §8b "share the consumer, branch the presenter" (NOT per-presenter — that forks the consumer). New function block → **amend contract #1** (additive FN, e.g. `stream_turn_resilient`) then TDD (RED: drop-mid-stream→resume continuity; GREEN: wrapper; wire `cli --send` first as the tracer). Resume design pre-locked: in-process Last-Event-ID only, cross-process deferred to v2 (design-brief §8d). **(b2) session-picker** — `list_sessions` (sessions.py:198) has NO caller; add a Textual DataTable startup picker (>1 session) + `--session <id>`/`--new` CLI flags (design-brief §4, decisions pre-locked). Both pre-locked → heid-contract-review likely skippable as ceremony (small additive amendments to mature specs); heid-code-review still valuable. **#11 AdminEvents stays BLOCKED** on `admin.events.read` scope (infra-ops). - `[2026-06-30]` **(b) Tier-1 frontier SCOPED, ready for a contract-first TDD cycle (next focused work).** The primitives already exist + are contracted + tested; the gap is PRESENTER-level wiring. Two slices: **(b1) SSE-resume** — contract #1 (`ratatoskr.sse_client`) DELIBERATELY makes resume caller-owned ("on `SseConnectionDropped`, the caller MAY invoke `reconnect_turn`"); `reconnect_turn` (sse_client.py:524) has NO caller. Gap = a SHARED resume-orchestration wrapper (catch `SseConnectionDropped` → track last-seen `sse_id``reconnect_turn` → continue), consumed by all 3 presenters per design-brief §8b "share the consumer, branch the presenter" (NOT per-presenter — that forks the consumer). New function block → **amend contract #1** (additive FN, e.g. `stream_turn_resilient`) then TDD (RED: drop-mid-stream→resume continuity; GREEN: wrapper; wire `cli --send` first as the tracer). Resume design pre-locked: in-process Last-Event-ID only, cross-process deferred to v2 (design-brief §8d). **(b2) session-picker** — `list_sessions` (sessions.py:198) has NO caller; add a Textual DataTable startup picker (>1 session) + `--session <id>`/`--new` CLI flags (design-brief §4, decisions pre-locked). Both pre-locked → heid-contract-review likely skippable as ceremony (small additive amendments to mature specs); heid-code-review still valuable. **#11 AdminEvents stays BLOCKED** on `admin.events.read` scope (infra-ops).
- `[2026-06-30]` **(b1) SSE-resume SHIPPED (`v0.18.5`) — `stream_turn_resilient` (sse_client.py).** The shared resume-orchestration surface (design-brief §8b): wraps `stream_turn`+`reconnect_turn`, catches `SseConnectionDropped` (mid-stream drop OR clean-EOF-before-terminal) → resumes from last-seen `sse_id` via `reconnect_turn` (Last-Event-ID), up to `max_reconnects` (default 5); non-drop reconnect failures (412/410/400/TurnIdFlip/SseConnectFailed) PROPAGATE per contract #1's "surface, not recover". `last_seen` persists ACROSS attempts (a zero-event reconnect drop falls back to the prior attempt's id). Direct in-session TDD against a contract-#1 amendment (8 cases incl. two-drops, max-reconnects-exhausted, zero-budget, buffer-expired-propagates, unresumable-zero-event). Wired ALL THREE presenters through it (`v0.18.6`): `cli --send` (`cli.py:396`), TUI (`tui.py:1321`), web (`web/server.py:294`) — each a name-for-name `stream_turn``stream_turn_resilient` swap (the §8b "all presenters share the consumer" promise, fully kept; the TUI is the primary resume beneficiary — long-lived sessions / laptop-suspend). Suite 518 green; ruff+mypy clean on touched code (pre-existing cli.py:400/543 mypy warts left untouched per surgical rule); contract #1 validates OK. **heid-code-review NOT run** (small additive well-TDD'd wrapper; offered to operator). **b2 (session-picker + `--session`/`--new` flags) still pending.** - `[2026-06-30]` **(b1) SSE-resume SHIPPED (`v0.18.5`) — `stream_turn_resilient` (sse_client.py).** The shared resume-orchestration surface (design-brief §8b): wraps `stream_turn`+`reconnect_turn`, catches `SseConnectionDropped` (mid-stream drop OR clean-EOF-before-terminal) → resumes from last-seen `sse_id` via `reconnect_turn` (Last-Event-ID), up to `max_reconnects` (default 5); non-drop reconnect failures (412/410/400/TurnIdFlip/SseConnectFailed) PROPAGATE per contract #1's "surface, not recover". `last_seen` persists ACROSS attempts (a zero-event reconnect drop falls back to the prior attempt's id). Direct in-session TDD against a contract-#1 amendment (8 cases incl. two-drops, max-reconnects-exhausted, zero-budget, buffer-expired-propagates, unresumable-zero-event). Wired ALL THREE presenters through it (`v0.18.6`): `cli --send` (`cli.py:396`), TUI (`tui.py:1321`), web (`web/server.py:294`) — each a name-for-name `stream_turn``stream_turn_resilient` swap (the §8b "all presenters share the consumer" promise, fully kept; the TUI is the primary resume beneficiary — long-lived sessions / laptop-suspend). Suite 518 green; ruff+mypy clean on touched code (pre-existing cli.py:400/543 mypy warts left untouched per surgical rule); contract #1 validates OK. **heid-code-review NOT run** (small additive well-TDD'd wrapper; offered to operator). **b2 (session-picker + `--session`/`--new` flags) still pending.**
- `[2026-06-30]` **(b2) session-picker SHIPPED (`v0.18.7`) — bare TUI mode → startup picker (design-brief §4).** `list_sessions` had NO caller; now bare TUI mode (no `--session`/`--new`) resolves via `list_sessions` in `_resolve_then_run`: **0 sessions → `[no_sessions]` error, exit 14** (resume-only, honors §4 "no in-app session creation — `--new` flag only"); **exactly 1 → auto-resume** (§4 "picker only when >1"); **≥2 → new `SessionPickerApp`** (Textual `App[str|None]`, mirrors `AgentPickerApp`; ListView of sessions) → resume the pick (Esc/Ctrl-D → exit 0). cli `_parse` relaxed: bare TUI now VALID (was "pass exactly one" error); `--send` still requires one flag (non-interactive, no picker); `--agent` forbidden in bare mode; `run_tui` PRE-002 XOR→"not both". Direct in-session TDD (contract #6 amendment, validated OK): 3 widget pilot tests + 5 `_resolve_then_run` resolution tests + 3 cli validation tests. Suite **528 green**; touched code ruff-clean (mypy: only the `BINDINGS` list-invariance warning every App in tui.py already carries — consistent). **DESIGN NOTE — bare+0-sessions → error (clause-consistent). The friendlier auto-fall-through-to-new alternative is DEFERRED pending operator preference (it would create a session without `--new`, against the §4 negative clause).** **Frontier now: `GET /capabilities`+`GET /me` → BifrostState/Tools widgets (`GET /admin/sessions/{id}/{bifrost,tools}`, admin-key) → #11 AdminEvents (BLOCKED on `admin.events.read`).** heid-code-review NOT run on b1 or b2 (offered).
- `[2026-06-30]` **capabilities+me slice SHIPPED (`v0.18.8`) — `GET /me` + `GET /capabilities` consumed via a new `--whoami` one-shot.** `get_me`/`get_capabilities` added to sessions.py (mirror `get_persona_state`: 200→dict verbatim, non-200→`SessionApiFailed`; freeform dicts per the frozen OpenAPI). New `ratatoskr --whoami` CLI mode (mirrors `--send`'s non-interactive shape) fetches both + prints an identity+capabilities report; standalone probe (mutually exclusive with `--send`/`--session`/`--new`/`--agent`, opens no session; new `ParsedArgs.whoami` field + main() dispatch). **`/capabilities` is the Echo EPHEMERAL-TEMPLATE discovery endpoint** (`{ephemeral_templates:{echo:{allowed_models,default_model,system_prompt_max_bytes}}}`), NOT a generic server-caps endpoint (audit finding — the coverage-map's earlier "server capability discovery" framing was imprecise). `/me` = whoami (`{user_id,scopes,tier,key_id?,...}`, optionals omitted-not-null). Contract-skip privilege invoked (low-effort GET wrappers) but contract #2 amended (2 FNs, validated OK) to keep the sessions spec canonical + honest test citations. TDD: 5 wrapper tests + 5 cli tests (validation + mode + error). Suite **538 green**; touched code ruff-clean (mypy: only `no-any-return` on `resp.json()`→dict, identical to the pre-existing `get_persona_state`). **Coverage: REST 9/40 ✅ (up from 7).** TUI-surfacing of /me (footer identity line) + /capabilities DEFERRED — the one-shot is the minimal tracer. **Frontier now: BifrostState + Tools widgets (`GET /admin/sessions/{id}/{bifrost,tools}`, admin-key-gated) → #11 AdminEvents (BLOCKED on `admin.events.read`).**
- `[2026-07-01]` **b1 (SSE-resume) heid-code-review panel: ZERO findings — cross-model-verified clean.** Gróa (Grok) + Hulda (Codex) + Regin (GLM-5.2) each independently reviewed `stream_turn_resilient` vs contract #1's amendment (artifact-only, firewall held) → all three ZERO findings; signature / PRE-001..004 / STEP 1-4 / POST-001..003 / ERROR_ROUTING / all-8-TESTS confirmed, incl. the subtle `seen = last_seen or drop.last_seen_sse_id` zero-event-drop fallback. Convergent meta-note: **TDD + the unusually-prescriptive contract (STEPS `flexibility=prescriptive` + explicit GOTO) left no room for compliant-but-different drift — confirmation, not discovery.** Calibration signal: for a thin wrapper with a tight prescriptive contract + comprehensive TDD, the panel confirms rather than discovers. **b2 (picker) + capabilities+me NOT yet reviewed** (higher-surface b2 is the better candidate if more review is wanted). Dispatch msg `01KWE2K99T…` / thread `01KWE2K99S…`; heid dispatch-log `2026-06.jsonl#01KWE2V3MMY8XS55FCJYXYV14B`.
- `[2026-07-01]` **`GET /sessions/{id}/tools` quick-win SHIPPED (`v0.18.9`) — owner-scoped tool inventory in the TUI Tools pane.** `get_session_tools` wrapper (sessions.py, mirror get_me: 200→dict, non-200→`SessionApiFailed`) + `_format_tool_inventory` helper + `_hydrate_session_tools` best-effort worker (mirror `_hydrate_persona`) wired UNCONDITIONALLY in `on_mount` → writes the merged `{agent_id, builtin_tools, bifrost_tools}` inventory (what the LLM saw at turn-fire) to the Tools pane + audits `session_tools_hydrated`, never crashes on failure. Owner-scoped (`ctx.user_id==session.user_id`) → reachable with the CONSUMER key, NO admin scope — so this **covers the design-brief §5 "Tools widget" via the reachable owner endpoint** (the admin `/admin/sessions/{id}/tools` variant stays a gap only for cross-user operator debug). Contract #2 amended (FN, validated OK) + TDD (3 wrapper respx tests + 1 format-helper unit + 2 hydrate integration tests via `_spy_writes`+pilot). Suite **544 green**; touched code ruff-clean (the tui.py ruff/mypy debt at other lines is pre-existing). **Coverage: REST 10/40 ✅.** **Frontier now: BifrostState widget (`GET /admin/sessions/{id}/bifrost`, admin-key) + #11 AdminEvents (BLOCKED on `admin.events.read`) + Tier-2 (transient-characters routing, `POST /sessions/{id}/persona_state`).**
_41 older entries (2026-05-* — the original debug-TUI/web build era) archived to archival-memory.md._ _41 older entries (2026-05-* — the original debug-TUI/web build era) archived to archival-memory.md._
_For per-issue TDD implementation notes, Volva findings, and contract amendments, see the git log — every per-issue commit carries a structured message capturing the trail._ _For per-issue TDD implementation notes, Volva findings, and contract amendments, see the git log — every per-issue commit carries a structured message capturing the trail._
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "ratatoskr" name = "ratatoskr"
version = "0.18.6" version = "0.18.9"
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard" description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
+77 -3
View File
@@ -12,7 +12,7 @@ import signal
import sys import sys
from dataclasses import dataclass, field from dataclasses import dataclass, field
from importlib.metadata import PackageNotFoundError, version from importlib.metadata import PackageNotFoundError, version
from typing import TextIO from typing import Any, TextIO
import httpx import httpx
@@ -24,6 +24,8 @@ from ratatoskr.sessions import (
SessionApiFailed, SessionApiFailed,
create_session, create_session,
endpoint_for_plane, endpoint_for_plane,
get_capabilities,
get_me,
) )
from ratatoskr.sse_client import ( from ratatoskr.sse_client import (
AffectUpdate, AffectUpdate,
@@ -97,6 +99,9 @@ class ParsedArgs:
bifrost: BifrostBinding | None = None bifrost: BifrostBinding | None = None
bifrost_plane: str | None = None bifrost_plane: str | None = None
consumer_key: str | None = None consumer_key: str | None = None
# Standalone boot-time orientation probe: GET /me + GET /capabilities, print,
# exit. Mutually exclusive with the session/turn flags (opens no session).
whoami: bool = False
class _ArgparseError(Exception): class _ArgparseError(Exception):
@@ -121,6 +126,7 @@ def _parse_args(argv: list[str] | None) -> ParsedArgs:
parser.add_argument("--api-key", dest="api_key") parser.add_argument("--api-key", dest="api_key")
parser.add_argument("--server") parser.add_argument("--server")
parser.add_argument("--raw", action="store_true") parser.add_argument("--raw", action="store_true")
parser.add_argument("--whoami", action="store_true")
# Issue #5: required for per-end-user agents (lofn etc.); optional otherwise (mimir). # Issue #5: required for per-end-user agents (lofn etc.); optional otherwise (mimir).
parser.add_argument("--end-user-id", dest="end_user_id", default=None) parser.add_argument("--end-user-id", dest="end_user_id", default=None)
# Issue #17: bind the created session to our own Bifrost provider plane. # Issue #17: bind the created session to our own Bifrost provider plane.
@@ -141,10 +147,25 @@ def _parse_args(argv: list[str] | None) -> ParsedArgs:
# Issue #5 INV-001: --end-user-id, if passed, MUST be non-empty (mirrors --send). # Issue #5 INV-001: --end-user-id, if passed, MUST be non-empty (mirrors --send).
if ns.end_user_id is not None and not ns.end_user_id: 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") raise UsageError("--end-user-id must be non-empty when passed")
if ns.whoami:
# Standalone boot-time probe (GET /me + /capabilities): opens no session.
if ns.send is not None or ns.session or ns.new or ns.agent:
raise UsageError(
"--whoami is a standalone probe (no --send/--session/--new/--agent)"
)
else:
if ns.session and ns.new: if ns.session and ns.new:
raise UsageError("--session and --new are mutually exclusive; pass exactly one") raise UsageError("--session and --new are mutually exclusive")
if not ns.session and not ns.new: if not ns.session and not ns.new:
raise UsageError("pass exactly one of --session or --new") # Bare TUI mode → startup session picker (design-brief §4). --send is
# non-interactive (no picker can open), so it still requires one flag;
# --agent belongs with --new (bare mode resumes, it doesn't create).
if ns.send is not None:
raise UsageError("--send requires --session or --new (no interactive picker)")
if ns.agent:
raise UsageError(
"--agent belongs with --new; bare TUI mode opens the session picker"
)
if ns.session and ns.agent: if ns.session and ns.agent:
raise UsageError("--agent is required with --new and forbidden with --session") raise UsageError("--agent is required with --new and forbidden with --session")
if ns.new and not ns.agent and ns.send is not None: if ns.new and not ns.agent and ns.send is not None:
@@ -200,6 +221,7 @@ def _parse_args(argv: list[str] | None) -> ParsedArgs:
bifrost=bifrost, bifrost=bifrost,
bifrost_plane=bifrost_plane, bifrost_plane=bifrost_plane,
consumer_key=consumer_key, consumer_key=consumer_key,
whoami=ns.whoami,
) )
@@ -550,6 +572,56 @@ async def _amain(args: ParsedArgs) -> int:
loop.remove_signal_handler(signal.SIGINT) loop.remove_signal_handler(signal.SIGINT)
def _format_whoami(me: dict[str, Any], caps: dict[str, Any]) -> str:
"""Render the --whoami report: identity (GET /me) + server capabilities."""
lines = ["identity:"]
lines.append(f" user_id: {me.get('user_id', '?')}")
lines.append(f" tier: {me.get('tier', '?')}")
lines.append(f" scopes: {', '.join(me.get('scopes', [])) or '(none)'}")
for k in ("display_name", "key_id", "key_label"):
if k in me:
lines.append(f" {k}: {me[k]}")
lines.append("capabilities:")
templates = caps.get("ephemeral_templates", {})
if templates:
for name, spec in templates.items():
models = ", ".join(spec.get("allowed_models", []))
lines.append(
f" ephemeral_template {name}: default={spec.get('default_model', '?')} "
f"max_bytes={spec.get('system_prompt_max_bytes', '?')} models=[{models}]"
)
else:
lines.append(" (no ephemeral templates advertised)")
return "\n".join(lines) + "\n"
async def _whoami(args: ParsedArgs) -> int:
"""--whoami one-shot: GET /me + GET /capabilities, print a compact report, exit.
A boot-time orientation probe (mirrors --send's non-interactive shape):
"who am I against this server, and what does it offer." Opens no session.
Errors land on stderr with the same [session_api_failed] / [network_error]
vocab + exit codes as the other modes.
"""
assert isinstance(args, ParsedArgs)
async with httpx.AsyncClient(
base_url=args.server_url,
headers={"Authorization": f"Bearer {args.api_key}", "User-Agent": USER_AGENT},
timeout=httpx.Timeout(connect=10.0, read=10.0, write=10.0, pool=10.0),
) as client:
try:
me = await get_me(client)
caps = await get_capabilities(client)
except SessionApiFailed as exc:
sys.stderr.write(f"[session_api_failed] status={exc.status} body={exc.body!r}\n")
return 20
except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc:
sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n")
return 21
sys.stdout.write(_format_whoami(me, caps))
return 0
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:
"""Sync entry point. Maps UsageError/_AuthError to exit codes BEFORE the event loop.""" """Sync entry point. Maps UsageError/_AuthError to exit codes BEFORE the event loop."""
assert argv is None or all(isinstance(a, str) for a in argv) assert argv is None or all(isinstance(a, str) for a in argv)
@@ -565,6 +637,8 @@ def main(argv: list[str] | None = None) -> int:
# argparse's --help / --version short-circuit via SystemExit(0). Pass the code # argparse's --help / --version short-circuit via SystemExit(0). Pass the code
# through verbatim — argparse already printed help to stdout. # through verbatim — argparse already printed help to stdout.
return int(exc.code) if exc.code is not None else 0 return int(exc.code) if exc.code is not None else 0
if args.whoami:
return asyncio.run(_whoami(args))
if args.send_content is None: if args.send_content is None:
# TUI mode — lazy import preserves INV-001 (no textual in cli at module scope). # TUI mode — lazy import preserves INV-001 (no textual in cli at module scope).
from ratatoskr.tui import run_tui from ratatoskr.tui import run_tui
+50
View File
@@ -406,3 +406,53 @@ async def get_persona_state(
if resp.status_code == 403 and error_code == "auth_scope_denied": if resp.status_code == 403 and error_code == "auth_scope_denied":
raise AuthScopeDenied(scope="persona.read") raise AuthScopeDenied(scope="persona.read")
raise SessionApiFailed(status=resp.status_code, body=resp.content) raise SessionApiFailed(status=resp.status_code, body=resp.content)
async def get_me(client: httpx.AsyncClient) -> dict[str, Any]:
"""GET /me — the authenticated principal's identity + key metadata (spec §GET /me).
Boot-time whoami: verify the key without agent-config side effects. Returns
the parsed dict verbatim (freeform per the frozen OpenAPI; the spec documents
`{user_id, scopes, tier, display_name?, key_id?, key_label?, ...}`, optional
fields omitted-not-null). 401 (bad/absent key when auth is enabled) — like
every other non-200 — surfaces as SessionApiFailed (get_persona_state
precedent). Read-only, rate-exempt, no audit emission.
"""
assert client is not None
resp = await client.get("/me")
if resp.status_code == 200:
return resp.json()
raise SessionApiFailed(status=resp.status_code, body=resp.content)
async def get_session_tools(client: httpx.AsyncClient, session_id: str) -> dict[str, Any]:
"""GET /sessions/{session_id}/tools — owner-scoped tool inventory (spec #183).
Returns the merged tool list the LLM saw at turn-fire: `{agent_id,
builtin_tools: [...], bifrost_tools: [{name, description, parameters}, ...]}`.
Owner-scoped (`ctx.user_id == session.user_id`) — reachable with the consumer
key, NO admin scope. Cross-owner access returns 404 `session_not_found`
(existence-hiding); a revoked session returns 401 `auth_revoked`. Parsed dict
verbatim; any non-200 → SessionApiFailed (mirrors get_persona_state).
"""
assert client is not None
assert session_id and isinstance(session_id, str)
resp = await client.get(f"/sessions/{session_id}/tools")
if resp.status_code == 200:
return resp.json()
raise SessionApiFailed(status=resp.status_code, body=resp.content)
async def get_capabilities(client: httpx.AsyncClient) -> dict[str, Any]:
"""GET /capabilities — server capability discovery (spec §Ephemeral Templates).
Returns `{ephemeral_templates: {echo: {allowed_models, default_model,
system_prompt_max_bytes}}}` — what the server offers before a client decides
to instantiate. Any authenticated caller may read it (no scope). Parsed dict
verbatim; any non-200 → SessionApiFailed.
"""
assert client is not None
resp = await client.get("/capabilities")
if resp.status_code == 200:
return resp.json()
raise SessionApiFailed(status=resp.status_code, body=resp.content)
+207 -4
View File
@@ -42,9 +42,12 @@ from ratatoskr.sessions import (
BifrostHandshakeFailed, BifrostHandshakeFailed,
PersonaNotConfigured, PersonaNotConfigured,
SessionApiFailed, SessionApiFailed,
SessionInfo,
create_session, create_session,
get_persona_state, get_persona_state,
get_session_tools,
list_agents, list_agents,
list_sessions,
) )
from ratatoskr.sse_client import ( from ratatoskr.sse_client import (
AffectUpdate, AffectUpdate,
@@ -194,6 +197,22 @@ def _ts() -> str:
return now.strftime("%H:%M:%S") + f".{now.microsecond // 1000:03d}" return now.strftime("%H:%M:%S") + f".{now.microsecond // 1000:03d}"
def _format_tool_inventory(tools: dict) -> list[str]:
"""Render GET /sessions/{id}/tools (#183) into Tools-pane inventory lines.
The merged tool list the LLM saw at turn-fire — distinct from the live
tool_start/tool_result events that stream into the same pane during a turn.
"""
builtin = [t.get("name", "?") for t in tools.get("builtin_tools", [])]
bifrost = [t.get("name", "?") for t in tools.get("bifrost_tools", [])]
return [
f"session tool inventory: agent={tools.get('agent_id', '?')} "
f"builtin={len(builtin)} bifrost={len(bifrost)}",
f" builtin: {', '.join(builtin) or '(none)'}",
f" bifrost: {', '.join(bifrost) or '(none)'}",
]
def _format_persona_header(snapshot: dict) -> str: def _format_persona_header(snapshot: dict) -> str:
"""One-line persona summary for the sticky header widget. """One-line persona summary for the sticky header widget.
@@ -798,6 +817,128 @@ class AgentPickerApp(App[str | None]):
self.exit(None) self.exit(None)
def _session_desc(s: SessionInfo) -> str:
"""One-line session summary for the picker's second row."""
tail = f"session {s.session_id} · last active {s.last_active}"
if s.message_count is not None:
tail += f" · {s.message_count} msgs"
return tail
class SessionPickerApp(App[str | None]):
"""Startup session picker (design-brief §4, slice b2). Opens before
RatatoskrApp when bare TUI mode resolves >1 session. `run_async()` returns
the chosen session_id (str) or None on Esc/Ctrl-D/Ctrl-C dismissal.
Resume-only (design-brief §4 negative clause "no in-app session creation —
--new flag only"): the picker chooses among EXISTING sessions; starting a
fresh one is the --new flag's job. Architecturally separate from
RatatoskrApp (mirrors AgentPickerApp): list_sessions failures + dismissal
land before any alt-screen opens (preserves #6 INV-001).
"""
DEFAULT_CSS = """
Header, HeaderIcon, HeaderTitle, HeaderClock {
background: $surface;
color: $au-bright-blue;
}
Footer {
background: $surface;
}
ListView {
scrollbar-background: $background;
scrollbar-background-hover: $background;
scrollbar-background-active: $background;
scrollbar-color: $au-dark-50;
scrollbar-color-hover: $au-dark-60;
scrollbar-color-active: $au-bright-cyan;
}
#picker-prompt {
dock: top;
height: 1;
padding: 0 1;
color: $au-bright-cyan;
background: $surface;
}
#session-list {
height: 1fr;
background: $background;
}
#session-list > ListItem {
height: auto;
padding: 1 1;
background: $background;
}
#session-list:focus ListItem.-highlight {
background: $primary;
}
#session-list:focus ListItem.-highlight .session-id-line {
color: $au-bright-white;
text-style: bold;
}
#session-list:focus ListItem.-highlight .session-desc {
color: $au-bright-80;
}
.session-id-line {
color: $au-bright-blue;
text-style: bold;
}
.session-desc {
color: $au-bright-70;
}
"""
BINDINGS: ClassVar[list[Binding]] = [
Binding("enter", "pick", "Resume", priority=True),
Binding("escape", "dismiss", "Cancel", priority=True),
Binding("ctrl+d", "dismiss", "Cancel", priority=True),
Binding("ctrl+c", "dismiss", "Cancel", priority=True),
]
def __init__(self, sessions: list[SessionInfo]) -> None:
super().__init__()
# PRE-001: caller (_resolve_then_run) resolves the 0-session and
# 1-session cases BEFORE constructing the picker.
assert sessions
self.sessions = sessions
self.register_theme(AUSTRALIS_THEME)
self.theme = "australis"
def compose(self) -> ComposeResult:
yield Header()
yield Static(
"Pick a session to resume (relaunch with --new for a fresh one):",
id="picker-prompt",
)
yield ListView(
*[
ListItem(
Static(
f"{s.name or s.session_id} · {s.agent_id}",
classes="session-id-line",
),
Static(_session_desc(s), classes="session-desc"),
)
for s in self.sessions
],
id="session-list",
)
yield Footer()
async def on_mount(self) -> None:
self.query_one("#session-list", ListView).focus()
def action_pick(self) -> None:
lv = self.query_one("#session-list", ListView)
idx = lv.index
if idx is None:
return # nothing highlighted; ignore
self.exit(self.sessions[idx].session_id)
def action_dismiss(self) -> None:
self.exit(None)
class RatatoskrApp(App[int]): class RatatoskrApp(App[int]):
"""Textual TUI shell — single chat pane.""" """Textual TUI shell — single chat pane."""
@@ -1116,6 +1257,10 @@ class RatatoskrApp(App[int]):
# surface (PersonaNotConfigured) get a placeholder + empty header. # surface (PersonaNotConfigured) get a placeholder + empty header.
if self.agent_id is not None: if self.agent_id is not None:
self.run_worker(self._hydrate_persona()) self.run_worker(self._hydrate_persona())
# #183: hydrate the Tools pane with the session's tool inventory via
# GET /sessions/{id}/tools (owner-scoped — consumer key, no admin scope).
# Unconditional: every session has a tool inventory to introspect.
self.run_worker(self._hydrate_session_tools())
async def _hydrate_persona(self) -> None: async def _hydrate_persona(self) -> None:
"""Hydrate persona-header + Persona pane via GET /agents/{id}/persona_state. """Hydrate persona-header + Persona pane via GET /agents/{id}/persona_state.
@@ -1151,6 +1296,34 @@ class RatatoskrApp(App[int]):
f"err={type(exc).__name__}: {exc!s:.120}" f"err={type(exc).__name__}: {exc!s:.120}"
) )
async def _hydrate_session_tools(self) -> None:
"""Hydrate the Tools pane inventory via GET /sessions/{id}/tools (#183).
Best-effort observability (mirrors _hydrate_persona): on 200, writes the
merged tool inventory (builtin + bifrost) the LLM saw at turn-fire into
the Tools pane + audits; on any failure, audits and moves on — never
crashes the TUI. Owner-scoped, so reachable with the consumer key.
"""
assert self.client is not None and self.session_id is not None
from rich.text import Text as RichText
try:
tools = await get_session_tools(self.client, self.session_id)
except Exception as exc: # best-effort — never crash the TUI on hydrate
self._audit(
f"session_tools_hydration_failed session={self.session_id[-8:]} "
f"err={type(exc).__name__}: {exc!s:.120}"
)
return
log = self.query_one("#tools-log", RichLog)
for line in _format_tool_inventory(tools):
log.write(RichText(line))
self._audit(
f"session_tools_hydrated session={self.session_id[-8:]} "
f"builtin={len(tools.get('builtin_tools', []))} "
f"bifrost={len(tools.get('bifrost_tools', []))}"
)
def _update_persona_surfaces(self, snapshot: dict) -> None: def _update_persona_surfaces(self, snapshot: dict) -> None:
"""Update sticky header + Persona pane from a fresh snapshot. """Update sticky header + Persona pane from a fresh snapshot.
@@ -1430,8 +1603,9 @@ def run_tui(args: ParsedArgs) -> int:
""" """
# PRE-001: TUI-mode marker (issue #4 contract) # PRE-001: TUI-mode marker (issue #4 contract)
assert isinstance(args, ParsedArgs) and args.send_content is None assert isinstance(args, ParsedArgs) and args.send_content is None
# PRE-002: Exactly one of session_id / new must be set (xor) # PRE-002 (slice b2): --session and --new are mutually exclusive, but NEITHER
assert bool(args.session_id) != bool(args.new) # is now valid — bare TUI mode opens the startup session picker (§4).
assert not (args.session_id and args.new)
return asyncio.run(_resolve_then_run(args)) return asyncio.run(_resolve_then_run(args))
@@ -1467,6 +1641,35 @@ async def _resolve_then_run(args: ParsedArgs) -> int:
# agent_id (remote wins on conflict, since a server-listed agent # agent_id (remote wins on conflict, since a server-listed agent
# is the authoritative source). # is the authoritative source).
chosen_agent_id: str | None = args.agent_id chosen_agent_id: str | None = args.agent_id
# slice b2: bare TUI mode (no --session, no --new) → startup session
# picker (design-brief §4). Resolve into a concrete session_id BEFORE
# the new/resume branches. Resume-only: bare + 0 sessions is an error
# (creating a session is the --new flag's job).
resolved_session_id: str | None = args.session_id
if not args.new and args.session_id is None:
try:
page = await list_sessions(client)
except SessionApiFailed as exc:
sys.stderr.write(
f"[session_api_failed] status={exc.status} body={exc.body!r}\n"
)
return 20
except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc:
sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n")
return 21
if not page.items:
sys.stderr.write(
"[no_sessions] no sessions to resume; "
"launch with --new --agent <id>\n"
)
return 14
if len(page.items) == 1:
# §4: picker only when >1 — a single session auto-resumes.
resolved_session_id = page.items[0].session_id
else:
resolved_session_id = await SessionPickerApp(page.items).run_async()
if resolved_session_id is None:
return 0 # Esc / Ctrl-D — clean exit, no session opened
if args.new and args.agent_id is None: if args.new and args.agent_id is None:
try: try:
agents = await list_agents(client) agents = await list_agents(client)
@@ -1551,8 +1754,8 @@ async def _resolve_then_run(args: ParsedArgs) -> int:
session_id = info.session_id session_id = info.session_id
agent_id: str | None = info.agent_id agent_id: str | None = info.agent_id
else: else:
assert args.session_id is not None assert resolved_session_id is not None
session_id = args.session_id session_id = resolved_session_id
agent_id = args.agent_id # may be None — INV-002 carve-out preserved agent_id = args.agent_id # may be None — INV-002 carve-out preserved
app = RatatoskrApp(args, session_id=session_id, agent_id=agent_id, client=client) app = RatatoskrApp(args, session_id=session_id, agent_id=agent_id, client=client)
exit_code = await app.run_async() exit_code = await app.run_async()
+99 -6
View File
@@ -175,10 +175,29 @@ class TestParseArgs:
) )
def test_usage_neither_session_nor_new(self) -> None: def test_usage_neither_session_nor_new(self) -> None:
"""usage_neither_session_nor_new: neither flag → UsageError('pass exactly one').""" """usage_neither_session_nor_new: --send with neither flag → UsageError.
with pytest.raises(UsageError, match="pass exactly one"):
--send is non-interactive (no picker can open), so a session must be
named. Bare TUI mode (no --send) is now valid → session picker (§4).
"""
with pytest.raises(UsageError, match="--send requires"):
_parse_args(["--send", "hi", "--api-key", "k"]) _parse_args(["--send", "hi", "--api-key", "k"])
def test_bare_tui_mode_accepted(self) -> None:
"""bare_tui_mode (slice b2): no --send, no --session, no --new → valid;
_resolve_then_run drives the startup session picker (design-brief §4)."""
args = _parse_args(["--api-key", "k"])
assert args.send_content is None
assert args.session_id is None
assert args.new is False
assert args.agent_id is None
def test_usage_bare_tui_with_agent(self) -> None:
"""bare_tui_with_agent (slice b2): bare TUI + --agent → UsageError
(--agent belongs with --new; bare mode opens the resume picker)."""
with pytest.raises(UsageError, match="belongs with --new"):
_parse_args(["--agent", "mimir", "--api-key", "k"])
def test_usage_send_new_without_agent(self) -> None: def test_usage_send_new_without_agent(self) -> None:
"""send_new_without_agent (issue #8): --send --new without --agent → UsageError. """send_new_without_agent (issue #8): --send --new without --agent → UsageError.
@@ -1319,10 +1338,15 @@ class TestMain:
rc = main(["--send", "hi", "--new", "--agent", "m", "--api-key", "k"]) rc = main(["--send", "hi", "--new", "--agent", "m", "--api-key", "k"])
assert rc == 0 assert rc == 0
def test_usage_error_no_send( def test_empty_argv_fails_on_auth(
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None: ) -> None:
"""usage_error_no_send: empty argv → exit 10; stderr [usage_error]; _amain never called.""" """empty argv → exit 11 [auth_error]; _amain never called.
Since slice b2 bare TUI mode (no --send/--session/--new) is VALID (it
opens the session picker), so empty argv is no longer a usage error —
it now fails on the missing API key instead (still before _amain).
"""
amain_calls: list[int] = [] amain_calls: list[int] = []
async def fake_amain(args: ParsedArgs) -> int: async def fake_amain(args: ParsedArgs) -> int:
@@ -1331,8 +1355,8 @@ class TestMain:
monkeypatch.setattr(cli_mod, "_amain", fake_amain) monkeypatch.setattr(cli_mod, "_amain", fake_amain)
rc = main([]) rc = main([])
assert rc == 10 assert rc == 11
assert "[usage_error]" in capsys.readouterr().err assert "[auth_error]" in capsys.readouterr().err
assert amain_calls == [] assert amain_calls == []
def test_usage_error_both_session_and_new( def test_usage_error_both_session_and_new(
@@ -1540,3 +1564,72 @@ class TestBifrostBindCli:
) )
rc = await _amain(args) rc = await _amain(args)
assert rc == 22 assert rc == 22
class TestWhoami:
"""--whoami one-shot probe (slice: capabilities+me): GET /me + GET /capabilities."""
def test_whoami_standalone_accepted(self) -> None:
"""whoami_standalone_accepted: --whoami alone → valid; whoami=True, no turn flags."""
args = _parse_args(["--whoami", "--api-key", "k"])
assert args.whoami is True
assert args.send_content is None
assert args.session_id is None
assert args.new is False
def test_whoami_with_send_rejected(self) -> None:
"""whoami_with_send_rejected [adversarial]: --whoami + --send → UsageError."""
with pytest.raises(UsageError, match="standalone probe"):
_parse_args(["--whoami", "--send", "hi", "--api-key", "k"])
def test_whoami_with_new_rejected(self) -> None:
"""whoami_with_new_rejected [adversarial]: --whoami + --new → UsageError."""
with pytest.raises(UsageError, match="standalone probe"):
_parse_args(["--whoami", "--new", "--agent", "m", "--api-key", "k"])
@respx.mock
def test_whoami_mode_prints_report(self, capsys: pytest.CaptureFixture[str]) -> None:
"""whoami_mode_prints_report [happy,tracer]: /me + /capabilities → stdout report; exit 0."""
respx.get("https://w.example/me").mock(
return_value=httpx.Response(
200,
json={
"user_id": "alice",
"scopes": ["conversations.read", "conversations.write"],
"tier": "user",
"key_id": "a1b2c3d4",
},
)
)
respx.get("https://w.example/capabilities").mock(
return_value=httpx.Response(
200,
json={
"ephemeral_templates": {
"echo": {
"allowed_models": ["glm5-turbo"],
"default_model": "glm5-turbo",
"system_prompt_max_bytes": 32768,
}
}
},
)
)
rc = main(["--whoami", "--api-key", "k", "--server", "https://w.example"])
assert rc == 0
out = capsys.readouterr().out
assert "user_id: alice" in out
assert "tier: user" in out
assert "key_id: a1b2c3d4" in out
assert "ephemeral_template echo" in out
assert "glm5-turbo" in out
@respx.mock
def test_whoami_me_auth_failure_exits_20(self, capsys: pytest.CaptureFixture[str]) -> None:
"""whoami_me_auth_failure [error]: /me 401 → exit 20 [session_api_failed]."""
respx.get("https://w.example/me").mock(
return_value=httpx.Response(401, json={"detail": "auth_invalid"})
)
rc = main(["--whoami", "--api-key", "k", "--server", "https://w.example"])
assert rc == 20
assert "[session_api_failed]" in capsys.readouterr().err
+143
View File
@@ -18,7 +18,10 @@ from ratatoskr.sessions import (
SessionPage, SessionPage,
create_session, create_session,
endpoint_for_plane, endpoint_for_plane,
get_capabilities,
get_me,
get_persona_state, get_persona_state,
get_session_tools,
list_agents, list_agents,
list_sessions, list_sessions,
) )
@@ -896,3 +899,143 @@ class TestGetPersonaState:
with pytest.raises(PersonaNotConfigured) as exc_info: with pytest.raises(PersonaNotConfigured) as exc_info:
await get_persona_state(client, "domari") await get_persona_state(client, "domari")
assert exc_info.value.agent_id == "domari" assert exc_info.value.agent_id == "domari"
class TestGetMe:
"""docs/contracts/issues/2.contract.md FN get_me (slice: capabilities+me)."""
@respx.mock
async def test_happy_authenticated(self) -> None:
"""happy_authenticated [happy,tracer]: 200 → parsed identity dict verbatim."""
respx.get("https://w.example/me").mock(
return_value=httpx.Response(
200,
json={
"user_id": "alice",
"scopes": ["conversations.read", "conversations.write"],
"tier": "user",
"key_id": "a1b2c3d4",
"key_label": "alice phone",
},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
me = await get_me(client)
assert me["user_id"] == "alice"
assert me["tier"] == "user"
assert me["key_id"] == "a1b2c3d4"
assert me["scopes"] == ["conversations.read", "conversations.write"]
@respx.mock
async def test_anonymous_dev_mode(self) -> None:
"""anonymous_dev_mode: 200 anonymous shape → dict with tier=anonymous."""
respx.get("https://w.example/me").mock(
return_value=httpx.Response(
200,
json={
"user_id": "anonymous",
"scopes": ["conversations.read"],
"tier": "anonymous",
},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
me = await get_me(client)
assert me["tier"] == "anonymous"
assert "key_id" not in me # optional fields omitted, not null
@respx.mock
async def test_401_raises_session_api_failed(self) -> None:
"""401_raises [error]: bad/absent key → SessionApiFailed(status=401)."""
respx.get("https://w.example/me").mock(
return_value=httpx.Response(401, json={"detail": "auth_invalid"})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc:
await get_me(client)
assert exc.value.status == 401
class TestGetCapabilities:
"""docs/contracts/issues/2.contract.md FN get_capabilities (slice: capabilities+me)."""
@respx.mock
async def test_happy(self) -> None:
"""happy [happy]: 200 → ephemeral_templates dict verbatim."""
respx.get("https://w.example/capabilities").mock(
return_value=httpx.Response(
200,
json={
"ephemeral_templates": {
"echo": {
"allowed_models": ["glm5-turbo", "glm4.7"],
"default_model": "glm5-turbo",
"system_prompt_max_bytes": 32768,
}
}
},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
caps = await get_capabilities(client)
echo = caps["ephemeral_templates"]["echo"]
assert echo["default_model"] == "glm5-turbo"
assert echo["system_prompt_max_bytes"] == 32768
@respx.mock
async def test_non_200_raises(self) -> None:
"""non_200_raises [error]: 500 → SessionApiFailed(status=500)."""
respx.get("https://w.example/capabilities").mock(
return_value=httpx.Response(500, content=b"boom")
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc:
await get_capabilities(client)
assert exc.value.status == 500
class TestGetSessionTools:
"""docs/contracts/issues/2.contract.md — get_session_tools (GET /sessions/{id}/tools, #183)."""
@respx.mock
async def test_happy(self) -> None:
"""happy [happy,tracer]: 200 → merged tool inventory dict verbatim."""
respx.get("https://w.example/sessions/s1/tools").mock(
return_value=httpx.Response(
200,
json={
"agent_id": "alice:wizard",
"builtin_tools": [],
"bifrost_tools": [
{"name": "bifrost.alice.set_field", "description": "d", "parameters": {}}
],
},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
tools = await get_session_tools(client, "s1")
assert tools["agent_id"] == "alice:wizard"
assert tools["builtin_tools"] == []
assert tools["bifrost_tools"][0]["name"] == "bifrost.alice.set_field"
@respx.mock
async def test_cross_owner_404_raises(self) -> None:
"""cross_owner_404 [error]: 404 session_not_found → SessionApiFailed(404)."""
respx.get("https://w.example/sessions/s1/tools").mock(
return_value=httpx.Response(404, json={"error_code": "session_not_found"})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc:
await get_session_tools(client, "s1")
assert exc.value.status == 404
@respx.mock
async def test_empty_session_id_asserts(self) -> None:
"""empty_session_id [adversarial]: '' → AssertionError; no HTTP issued."""
route = respx.get("https://w.example/sessions//tools").mock(
return_value=httpx.Response(200, json={})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AssertionError):
await get_session_tools(client, "")
assert route.call_count == 0
+290
View File
@@ -2945,3 +2945,293 @@ class TestTuiBifrostBind:
err = capsys.readouterr().err err = capsys.readouterr().err
assert "bifrost: status=bound" in err assert "bifrost: status=bound" in err
assert "plane=memory" in err assert "plane=memory" in err
class TestSessionPickerApp:
"""docs/contracts/issues/6.contract.md FN SessionPickerApp (amendment slice b2)."""
@staticmethod
def _two():
from ratatoskr.sessions import SessionInfo
return [
SessionInfo(
session_id="s-first-0001", agent_id="mimir", created_at="t0",
last_active="t1", metadata={}, message_count=3, name=None,
archived=False, tags=[],
),
SessionInfo(
session_id="s-second-002", agent_id="echo", created_at="t0",
last_active="t2", metadata={}, message_count=None, name="probe",
archived=False, tags=[],
),
]
def test_pick_returns_session_id(self) -> None:
"""pick_returns_session_id [happy,tracer]: idx 1 + Enter → exit value == that session_id."""
from ratatoskr.tui import SessionPickerApp
app = SessionPickerApp(self._two())
async def drive() -> str | None:
async with app.run_test() as pilot:
from textual.widgets import ListView
lv = app.query_one("#session-list", ListView)
lv.index = 1
await pilot.pause()
await pilot.press("enter")
await pilot.pause()
return app.return_value
import asyncio
assert asyncio.run(drive()) == "s-second-002"
def test_esc_returns_none(self) -> None:
"""esc_returns_none [happy]: Esc → exit value is None (dismiss, resume nothing)."""
from ratatoskr.tui import SessionPickerApp
app = SessionPickerApp(self._two())
async def drive() -> str | None:
async with app.run_test() as pilot:
await pilot.press("escape")
await pilot.pause()
return app.return_value
import asyncio
assert asyncio.run(drive()) is None
def test_ctrl_d_returns_none(self) -> None:
"""ctrl_d_returns_none [adversarial]: Ctrl-D → None."""
from ratatoskr.tui import SessionPickerApp
app = SessionPickerApp(self._two())
async def drive() -> str | None:
async with app.run_test() as pilot:
await pilot.press("ctrl+d")
await pilot.pause()
return app.return_value
import asyncio
assert asyncio.run(drive()) is None
class TestBareSessionPicker:
"""docs/contracts/issues/6.contract.md amendment (slice b2): _resolve_then_run bare mode."""
@staticmethod
def _bare_args() -> ParsedArgs:
return ParsedArgs(
send_content=None, session_id=None, new=False, agent_id=None,
api_key="k", server_url="https://w.example", raw=False,
end_user_id=None, bifrost=None, bifrost_plane=None, consumer_key=None,
)
@staticmethod
def _sess(sid: str, agent: str = "mimir"):
from ratatoskr.sessions import SessionInfo
return SessionInfo(
session_id=sid, agent_id=agent, created_at="t0", last_active="t1",
metadata={}, message_count=1, name=None, archived=False, tags=[],
)
def test_bare_zero_sessions_errors(
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""bare_zero_sessions_errors [error]: 0 sessions → exit 14 [no_sessions]; App not opened."""
import ratatoskr.tui as tui_mod
from ratatoskr.sessions import SessionPage
async def fake_list(client, **kw):
return SessionPage(items=[], next_cursor=None)
monkeypatch.setattr(tui_mod, "list_sessions", fake_list)
opened: list[int] = []
async def spy(self, *a, **k):
opened.append(1)
return 0
monkeypatch.setattr(RatatoskrApp, "run_async", spy)
rc = run_tui(self._bare_args())
assert rc == 14
assert "[no_sessions]" in capsys.readouterr().err
assert not opened
def test_bare_one_session_auto_resumes(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""bare_one_session_auto_resumes: exactly 1 → auto-resume, no picker (§4 >1 rule)."""
import ratatoskr.tui as tui_mod
from ratatoskr.sessions import SessionPage
from ratatoskr.tui import SessionPickerApp
async def fake_list(client, **kw):
return SessionPage(items=[self._sess("s-solo")], next_cursor=None)
monkeypatch.setattr(tui_mod, "list_sessions", fake_list)
picker_used: list[int] = []
async def spy_picker(self, *a, **k):
picker_used.append(1)
return None
monkeypatch.setattr(SessionPickerApp, "run_async", spy_picker)
snap: dict = {}
async def cap(self, *a, **k):
snap["sid"] = self.session_id
return 0
monkeypatch.setattr(RatatoskrApp, "run_async", cap)
rc = run_tui(self._bare_args())
assert rc == 0
assert snap["sid"] == "s-solo"
assert not picker_used
def test_bare_multi_opens_picker(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""bare_multi_opens_picker [scenario,tracer]: >1 → picker; its choice resumes."""
import ratatoskr.tui as tui_mod
from ratatoskr.sessions import SessionPage
from ratatoskr.tui import SessionPickerApp
async def fake_list(client, **kw):
return SessionPage(items=[self._sess("s-a"), self._sess("s-b")], next_cursor=None)
monkeypatch.setattr(tui_mod, "list_sessions", fake_list)
async def pick_b(self, *a, **k):
return "s-b"
monkeypatch.setattr(SessionPickerApp, "run_async", pick_b)
snap: dict = {}
async def cap(self, *a, **k):
snap["sid"] = self.session_id
return 0
monkeypatch.setattr(RatatoskrApp, "run_async", cap)
rc = run_tui(self._bare_args())
assert rc == 0
assert snap["sid"] == "s-b"
def test_bare_picker_dismiss_exits_zero(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""bare_picker_dismiss_exits_zero [scenario]: picker None → exit 0; App not opened."""
import ratatoskr.tui as tui_mod
from ratatoskr.sessions import SessionPage
from ratatoskr.tui import SessionPickerApp
async def fake_list(client, **kw):
return SessionPage(items=[self._sess("s-a"), self._sess("s-b")], next_cursor=None)
monkeypatch.setattr(tui_mod, "list_sessions", fake_list)
async def pick_none(self, *a, **k):
return None
monkeypatch.setattr(SessionPickerApp, "run_async", pick_none)
opened: list[int] = []
async def spy(self, *a, **k):
opened.append(1)
return 0
monkeypatch.setattr(RatatoskrApp, "run_async", spy)
rc = run_tui(self._bare_args())
assert rc == 0
assert not opened
def test_bare_list_sessions_api_failure(
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""bare_list_sessions_api_failure [error]: list_sessions 500 → exit 20; App not opened."""
import ratatoskr.tui as tui_mod
from ratatoskr.sessions import SessionApiFailed
async def fake_list(client, **kw):
raise SessionApiFailed(status=500, body=b"boom")
monkeypatch.setattr(tui_mod, "list_sessions", fake_list)
opened: list[int] = []
async def spy(self, *a, **k):
opened.append(1)
return 0
monkeypatch.setattr(RatatoskrApp, "run_async", spy)
rc = run_tui(self._bare_args())
assert rc == 20
assert "[session_api_failed]" in capsys.readouterr().err
assert not opened
class TestSessionToolsHydration:
"""get_session_tools + the #183 Tools-pane inventory hydrate (GET /sessions/{id}/tools)."""
def test_format_tool_inventory(self) -> None:
"""format_tool_inventory [unit]: header + builtin + bifrost lines."""
from ratatoskr.tui import _format_tool_inventory
lines = _format_tool_inventory(
{
"agent_id": "alice:wizard",
"builtin_tools": [],
"bifrost_tools": [{"name": "bifrost.x"}, {"name": "bifrost.y"}],
}
)
joined = "\n".join(lines)
assert "agent=alice:wizard" in joined
assert "builtin=0 bifrost=2" in joined
assert "builtin: (none)" in joined
assert "bifrost.x, bifrost.y" in joined
async def test_hydrate_writes_inventory_and_audits(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""hydrate_writes_inventory [scenario,tracer]: 200 → inventory in Tools pane + audit."""
import ratatoskr.tui as tui_mod
writes = _spy_writes(monkeypatch)
async def fake_tools(client, session_id):
return {
"agent_id": "alice:wizard",
"builtin_tools": [],
"bifrost_tools": [{"name": "bifrost.set_field"}],
}
monkeypatch.setattr(tui_mod, "get_session_tools", fake_tools)
app = _resolved_app(_args_existing(session_id="s-tools-01"))
async with app.run_test() as pilot:
await pilot.pause()
await app._hydrate_session_tools()
await pilot.pause()
joined = " ".join(_text_of(w) for w in writes)
assert "session tool inventory" in joined
assert "bifrost.set_field" in joined
assert "session_tools_hydrated" in joined # audit line landed
async def test_hydrate_failure_audits_no_crash(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""hydrate_failure [error]: get_session_tools raises → failure audit; no crash."""
import ratatoskr.tui as tui_mod
from ratatoskr.sessions import SessionApiFailed
writes = _spy_writes(monkeypatch)
async def boom(client, session_id):
raise SessionApiFailed(status=404, body=b"session_not_found")
monkeypatch.setattr(tui_mod, "get_session_tools", boom)
app = _resolved_app(_args_existing(session_id="s-tools-02"))
async with app.run_test() as pilot:
await pilot.pause()
await app._hydrate_session_tools()
await pilot.pause()
joined = " ".join(_text_of(w) for w in writes)
assert "session_tools_hydration_failed" in joined
Generated
+1 -1
View File
@@ -1052,7 +1052,7 @@ wheels = [
[[package]] [[package]]
name = "ratatoskr" name = "ratatoskr"
version = "0.18.6" version = "0.18.9"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "httpx" }, { name = "httpx" },