diff --git a/docs/contracts/issues/2.contract.md b/docs/contracts/issues/2.contract.md index 9ef04cb..f5a8550 100644 --- a/docs/contracts/issues/2.contract.md +++ b/docs/contracts/issues/2.contract.md @@ -251,3 +251,32 @@ 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 +``` diff --git a/docs/coverage-map.md b/docs/coverage-map.md index 08c307d..036ca31 100644 --- a/docs/coverage-map.md +++ b/docs/coverage-map.md @@ -48,7 +48,7 @@ resolved (§ Surface 1, scope-resolution table). | Surface | Points | ✅ covered-live | ⬜ gap (in-scope) | 🚫 excluded-by-design | |---|---|---|---|---| -| REST (OpenAPI 2.2.0, path groups) | 40 | 9 | 9 | 22 | +| REST (OpenAPI 2.2.0, path groups) | 40 | 10 | 8 | 22 | | SSE events | 11 | 11 | 0 | 0 | | Bifrost provider planes | 8 verbs | 8 | 0 | (10 gated verbs deferred) | @@ -77,6 +77,7 @@ sub-gap). | `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 on the same path is an unwired frontier item — see frontier Tier 1): @@ -103,7 +104,6 @@ on the same path is an unwired frontier item — see frontier Tier 1): | 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 /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) -**Tier 1 — debug-observability core**, in dependency order: +**Tier 1 — debug-observability core:** -1. **Session picker + SSE-resume** — wrappers exist (`list_sessions`, - `reconnect_turn`), need presenter wiring only. **Cheapest; unblocked.** -2. **`GET /capabilities` + `GET /me`** — cheap debug primitives. Unblocked. -3. **BifrostState + Tools widgets** (`GET /admin/sessions/{id}/{bifrost,tools}`) - — design-brief'd v1, unbuilt. Admin-key-gated reads. +1. ✅ **DONE** — Session picker (`v0.18.7`) + SSE-resume (`v0.18.5`/`.6`). +2. ✅ **DONE** — `GET /capabilities` + `GET /me` (`v0.18.8`, `--whoami`). +3. **BifrostState + Tools widgets** (`GET /admin/sessions/{id}/bifrost` — the + admin session-bifrost inspection) — design-brief'd v1, unbuilt. Admin-key-gated. + *(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 (infra-ops). The single externally-blocked item; everything else can ship 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 - reachable (no admin scope), so unblocked. +5. ✅ **DONE** — `GET /sessions/{id}/tools` (`v0.18.9`, owner-scoped tool inventory + in the TUI Tools pane). 6. **Transient-characters routing** (4 endpoints) + **`POST /sessions/{id}/persona_state`**. --- diff --git a/persistent-memory.md b/persistent-memory.md index e67da2f..631a14f 100644 --- a/persistent-memory.md +++ b/persistent-memory.md @@ -156,6 +156,8 @@ decision. Captures rationale that won't be obvious from code alone. - `[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._ _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._ diff --git a/pyproject.toml b/pyproject.toml index 9be7390..493df61 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "ratatoskr" -version = "0.18.8" +version = "0.18.9" description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard" readme = "README.md" requires-python = ">=3.12" diff --git a/src/ratatoskr/sessions.py b/src/ratatoskr/sessions.py index 9fd7310..dee800a 100644 --- a/src/ratatoskr/sessions.py +++ b/src/ratatoskr/sessions.py @@ -425,6 +425,24 @@ async def get_me(client: httpx.AsyncClient) -> dict[str, Any]: 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). diff --git a/src/ratatoskr/tui.py b/src/ratatoskr/tui.py index 4e062ff..d298182 100644 --- a/src/ratatoskr/tui.py +++ b/src/ratatoskr/tui.py @@ -45,6 +45,7 @@ from ratatoskr.sessions import ( SessionInfo, create_session, get_persona_state, + get_session_tools, list_agents, list_sessions, ) @@ -196,6 +197,22 @@ def _ts() -> str: 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: """One-line persona summary for the sticky header widget. @@ -1240,6 +1257,10 @@ class RatatoskrApp(App[int]): # surface (PersonaNotConfigured) get a placeholder + empty header. if self.agent_id is not None: 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: """Hydrate persona-header + Persona pane via GET /agents/{id}/persona_state. @@ -1275,6 +1296,34 @@ class RatatoskrApp(App[int]): 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: """Update sticky header + Persona pane from a fresh snapshot. diff --git a/tests/test_sessions.py b/tests/test_sessions.py index b9fcfbc..2e89a64 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -21,6 +21,7 @@ from ratatoskr.sessions import ( get_capabilities, get_me, get_persona_state, + get_session_tools, list_agents, list_sessions, ) @@ -991,3 +992,50 @@ class TestGetCapabilities: 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 diff --git a/tests/test_tui.py b/tests/test_tui.py index cb126e0..bf4e7dc 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -3167,3 +3167,71 @@ class TestBareSessionPicker: 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 diff --git a/uv.lock b/uv.lock index e9247f8..c36d854 100644 --- a/uv.lock +++ b/uv.lock @@ -1052,7 +1052,7 @@ wheels = [ [[package]] name = "ratatoskr" -version = "0.18.8" +version = "0.18.9" source = { editable = "." } dependencies = [ { name = "httpx" },