Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5fbe353836 | |||
| a3c92b68dc | |||
| 9ce83d5fdc | |||
| e62208d8e3 | |||
| 0205b81319 | |||
| 387ac4ab2c | |||
| 5c1b9816d4 | |||
| 2ba4244e9e | |||
| 0c7660791f | |||
| 1f289098ba | |||
| b798068932 | |||
| 93e4176346 | |||
| a358cc9150 | |||
| e4317f6a73 | |||
| b2e4901264 | |||
| af67ad995c | |||
| 719e4d605b | |||
| c5c8ecf9d5 | |||
| 4f16ba588d | |||
| 359dbb1436 |
@@ -89,3 +89,37 @@ canonical_path = "scripts/contract_drift_check.py"
|
||||
consumer_path = "scripts/contract_drift_check.py"
|
||||
pinned_sha256_16 = "23271287ac488da4"
|
||||
pinned_at = "2026-05-17T05:30:00+00:00"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Worldtree Conversation-API surface (vendored from ~/development/Worldtree).
|
||||
# The v1 wire is FROZEN (Worldtree #326 / v1-schema-freeze-manifest.md). The
|
||||
# machine-readable OpenAPI + SSE schema are the authoritative drift GATES; the
|
||||
# prose markdown is the human reference and is allowed to lag (tolerate_drift).
|
||||
# These are what ratatoskr's v1 coverage map (docs/coverage-map.md) audits
|
||||
# against. Pin target: Worldtree 5810a26 (v1.0.0b2).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
[[pins]]
|
||||
id = "worldtree-conversation-api-openapi-v2"
|
||||
canonical_source = "Worldtree"
|
||||
canonical_path = "docs/conversation-api-openapi.json"
|
||||
consumer_path = "docs/conversation-api-openapi.json"
|
||||
pinned_sha256_16 = "dbdf4e24c8b06c92"
|
||||
pinned_at = "2026-06-30T22:25:56+00:00"
|
||||
|
||||
[[pins]]
|
||||
id = "worldtree-conversation-api-sse-events-v1"
|
||||
canonical_source = "Worldtree"
|
||||
canonical_path = "docs/conversation-api-sse-events.schema.json"
|
||||
consumer_path = "docs/conversation-api-sse-events.schema.json"
|
||||
pinned_sha256_16 = "9deeebf404d72f9a"
|
||||
pinned_at = "2026-06-30T22:25:56+00:00"
|
||||
|
||||
[[pins]]
|
||||
id = "worldtree-conversation-api-spec-v1"
|
||||
canonical_source = "Worldtree"
|
||||
canonical_path = "docs/conversation-api-spec.md"
|
||||
consumer_path = "docs/conversation-api-spec.md"
|
||||
pinned_sha256_16 = "2d111a3b8322b7d1"
|
||||
pinned_at = "2026-06-30T22:25:56+00:00"
|
||||
tolerate_drift = true # prose reference; OpenAPI+SSE are the gates
|
||||
|
||||
+15
-7
@@ -7,24 +7,32 @@ documents the pin, the vendored artifacts, and the bump procedure.
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Worldtree git SHA | `f1b59f8cd6fe41e497d0be9dad9d3110451f0d9a` |
|
||||
| Worldtree HEAD message | `Merge #299: adopt bifrost v0.6 memory scope wire (scope_any/scope_all)` |
|
||||
| Pinned on | 2026-06-17 |
|
||||
| Pinned by | ratatoskr-dev (bump for #297/#298 — cold recall closed end-to-end) |
|
||||
| Worldtree version at pin | `v0.35.16` |
|
||||
| Worldtree git SHA | `5810a26b38a5ea6630892f9a39756f57c5b7b41e` |
|
||||
| Worldtree HEAD message | `memory: snapshot — v1.0.0b2 shipped complete (demo + personal green); consumer loop closed` |
|
||||
| Pinned on | 2026-06-30 |
|
||||
| Pinned by | ratatoskr-dev (v1 coverage-audit — re-pin to the FROZEN OpenAPI 2.2.0 + SSE schema) |
|
||||
| Worldtree version at pin | `v1.0.0b2` |
|
||||
|
||||
## Pin history
|
||||
|
||||
| Date | SHA | Version | Notable deltas consumed |
|
||||
|---|---|---|---|
|
||||
| 2026-06-30 | `5810a26` | v1.0.0b2 | **Re-pin to Worldtree's FROZEN v1 surface (#326), as part of the v1 coverage-audit.** Vendored the machine-readable artifacts — `conversation-api-openapi.json` (OpenAPI **2.2.0**, 40 path-groups) + `conversation-api-sse-events.schema.json` (11 events) — now the **authoritative drift gates** (pinned in `.corviduo-canonicals.toml`, CI-checked by `canonical_drift.py`). The prose `conversation-api-spec.md` is **byte-identical** to the v0.35.16 pin (last WT markdown edit 2026-05-31), kept as the human reference (`tolerate_drift`). b2 deltas already consumed in code: 409/503 eager turn-launch statuses (#331, v0.18.3/.4) + the unified error envelope (#328). 7 endpoints documented only in the OpenAPI, not the prose, all classified in `docs/coverage-map.md`: `admin/keys/bulk`, `admin/persona/{archive,erase}`, `admin/usage`, `embed`, `judgments`, `me/usage`. No client-breaking change — `pin:`-only, no version bump. |
|
||||
| 2026-06-17 | `f1b59f8` | v0.35.16 | **#297 + #298/#299 — Worldtree adopts the bifrost v0.6 scope wire (emits `scope_any`/`scope_all`) + client-side per-scope-value union recall. With our v0.17.6 provider this closes cold cross-session recall end-to-end.** Catch-up bump (v0.29.0→v0.35.16). Intervening client-facing deltas reviewed, none break our consumer: #211 agent rename (`saga`→`echo`, `actor`→`mask` — slugs only); #245 `end_user_id` persistence + memory-scope resolver; #187/#188/#219 Tier-3 define/PATCH policy (additive); `bifrost` binding field + `ephemeral_does_not_accept_bifrost` 422 now documented (the #17 surface). Error codes stable; no ratatoskr code change required. |
|
||||
| 2026-05-25 | `da93ca7` | v0.28.0 | #204 — new SSE event `affect_update` (current/scheduled), new endpoint `GET /agents/{id}/persona_state`, auth-model doc edits |
|
||||
| 2026-05-20 | `55101e9` | v0.19.0 | initial scaffold pin |
|
||||
|
||||
## Vendored artifacts
|
||||
|
||||
- `docs/conversation-api-spec.md` — copy of `Worldtree/docs/conversation-api-spec.md` at the pinned SHA. This is the **client-facing interface contract** Ratatoskr is built against.
|
||||
- `docs/conversation_api.contract.md` — copy of `Worldtree/docs/contracts/conversation_api.contract.md` at the pinned SHA. The **server-side contract** including INV-001..INV-052 and amendments. Useful for understanding load-bearing server invariants (e.g., INV-014 turn-id-public, INV-046 admin-events-envelope-stable, INV-049 admin-events-pii-discipline) when designing client behavior against them.
|
||||
**Authoritative (FROZEN, machine-readable — the drift gates):**
|
||||
|
||||
- `docs/conversation-api-openapi.json` — copy of `Worldtree/docs/conversation-api-openapi.json` (OpenAPI `info.version` **2.2.0**). The frozen v1 REST wire (40 path-groups). Pinned `worldtree-conversation-api-openapi-v2` in `.corviduo-canonicals.toml`; drift gated by `canonical_drift.py`.
|
||||
- `docs/conversation-api-sse-events.schema.json` — copy of `Worldtree/docs/conversation-api-sse-events.schema.json`. The frozen SSE event schema (11 discriminated event types). Pinned `worldtree-conversation-api-sse-events-v1`.
|
||||
|
||||
**Reference (prose; allowed to lag — `tolerate_drift`):**
|
||||
|
||||
- `docs/conversation-api-spec.md` — copy of `Worldtree/docs/conversation-api-spec.md` at the pinned SHA. The **client-facing prose narrative**. Byte-frozen at v0.35.16-era content (last WT edit 2026-05-31); the OpenAPI/SSE JSON above are the source of truth where they diverge. Pinned `worldtree-conversation-api-spec-v1` (tolerate_drift).
|
||||
- `docs/conversation_api.contract.md` — copy of `Worldtree/docs/contracts/conversation_api.contract.md` at the pinned SHA (byte-identical at b2 — server contract unchanged since the v0.35.16 pin). The **server-side contract** including INV-001..INV-052 and amendments. Useful for understanding load-bearing server invariants (e.g., INV-014 turn-id-public, INV-046 admin-events-envelope-stable, INV-049 admin-events-pii-discipline) when designing client behavior against them. Not in the canonical manifest (reference-only).
|
||||
|
||||
Both files are vendored — they reflect Worldtree at the pinned SHA, not
|
||||
the live `~/development/Worldtree` checkout. Update them only when
|
||||
|
||||
@@ -106,7 +106,7 @@ POST: [POST-002 return_value] AsyncIterator yields ≥1 event ending in exactly
|
||||
POST: [POST-003 state_change] every yielded Event has a populated sse_id with both fields >= 1 -- assert all(e.sse_id.turn_id >= 1 and e.sse_id.seq >= 1 for e in events)
|
||||
ERROR_ROUTING:
|
||||
httpx.HTTPStatusError:
|
||||
local_handling: re-raise as SseConnectFailed(status=resp.status_code, body=resp.read()[:1024]) — server returned non-2xx before stream started (e.g., 404 session_not_found)
|
||||
local_handling: re-raise as SseConnectFailed(status=resp.status_code, body=resp.read()[:1024]) — server returned non-2xx before stream started (e.g., 404 session_not_found). EXCEPT the two eager turn-launch failures (Worldtree v1.0.0b1 #331), checked BEFORE raise_for_status and raised as typed SseConnectFailed SUBCLASSES carrying error_code: 409 -> AgentNotAvailable (agent unavailable; pre-b1 this was a 200 + in-stream `error` event), 503 -> TurnLaunchUnavailable (transient turn-launch failure; retryable=True). Subclassing keeps existing `except SseConnectFailed` handlers working with zero changes.
|
||||
flow_control: abort
|
||||
state_recovery: none (no events yielded yet)
|
||||
httpx.ReadError | httpx.RemoteProtocolError | httpx.ReadTimeout:
|
||||
@@ -129,7 +129,8 @@ ERROR_ROUTING:
|
||||
STEPS:
|
||||
1. [setup, flexibility=prescriptive] Validate inputs per PRE-001..PRE-003
|
||||
2. [sequential, flexibility=prescriptive] Open SSE connection via httpx_sse.aconnect_sse with method="POST", url=f"/sessions/{session_id}/messages", json={"content": content}
|
||||
ON httpx.HTTPStatusError before stream opens:
|
||||
2a. [branch, flexibility=prescriptive] IF response.status_code in (409, 503) (b1 #331 eager turn-launch failures): read the body, parse (error_code, message) from the `{"detail": {...}}` envelope OR a flat `{error_code, message}` body (status-derived default code when absent), then RAISE AgentNotAvailable (409) / TurnLaunchUnavailable (503).
|
||||
ON httpx.HTTPStatusError before stream opens (any other non-2xx):
|
||||
RAISE SseConnectFailed
|
||||
3. [loop, flexibility=prescriptive] FOR EACH sse_event in event_source.aiter_sse():
|
||||
0. [branch, flexibility=prescriptive] IF sse_event.data == "":
|
||||
@@ -162,6 +163,9 @@ TESTS:
|
||||
error_terminal [error]: mock emits one `text` then `error` with `error_code: "llm_output_invalid"` → consumer yields Text then Error; iteration ends; Error.message and Error.error_code are populated
|
||||
cancelled_terminal [error]: mock emits `cancelled` with phase=cancelled → consumer yields Cancelled with turn_id; iteration ends
|
||||
session_not_found [error]: mock returns 404 before stream opens → consumer raises SseConnectFailed(status=404)
|
||||
eager_409_agent_not_available [error]: mock returns 409 {detail:{error_code:"agent_not_available", message}} before stream → consumer raises AgentNotAvailable(status=409, error_code="agent_not_available", retryable absent); isinstance SseConnectFailed
|
||||
eager_503_retryable [error]: mock returns 503 before stream → consumer raises TurnLaunchUnavailable(status=503, retryable=True); isinstance SseConnectFailed
|
||||
eager_409_non_json_body [adversarial]: mock returns 409 with a non-JSON body → consumer raises AgentNotAvailable with the status-derived default error_code "agent_not_available"
|
||||
malformed_id_no_seq [adversarial]: mock event has `id: 42` (missing `:seq`) → consumer raises MalformedSseId; no event yielded
|
||||
malformed_id_alpha [adversarial]: mock event has `id: foo:bar` (non-integer parts) → consumer raises MalformedSseId
|
||||
turn_id_flip [adversarial]: mock emits text events with ids `42:1` then `99:2` → consumer raises TurnIdFlip; only the first event was yielded
|
||||
@@ -293,3 +297,51 @@ TESTS:
|
||||
trailing_whitespace [adversarial]: "42:3 " → ValueError (strict; do not strip; the server emits clean ids)
|
||||
truncation [security]: input is 5000-char string with no colon → ValueError message includes only `raw[:64]` (not the full 5000)
|
||||
```
|
||||
|
||||
## Amendment 2026-06-30 — shared resume orchestration (v1 coverage-audit, slice b1)
|
||||
|
||||
The original contract specs resume as **caller-owned** (§Resume semantics: "the
|
||||
caller MAY invoke `reconnect_turn`"). The v1 coverage-audit found `reconnect_turn`
|
||||
had **no caller** — every presenter (cli/tui/web) let a mid-stream drop propagate
|
||||
instead of resuming, so the "reference SSE-resume implementation" (design-brief
|
||||
§3/§8d) was unreachable. Per design-brief §8b ("share the consumer, branch the
|
||||
presenter") the resume loop is a **single shared orchestration surface**, not
|
||||
duplicated per presenter. This adds `stream_turn_resilient` as that surface;
|
||||
presenters call it instead of `stream_turn` when they want transparent reconnect.
|
||||
`stream_turn` and `reconnect_turn` are unchanged (still the primitives); this is
|
||||
purely additive.
|
||||
|
||||
```contract
|
||||
FN stream_turn_resilient(client: httpx.AsyncClient, session_id: str, content: str, *, max_reconnects: int = 5) -> AsyncIterator[Event]
|
||||
BRIEF: The shared resume-orchestration wrapper over stream_turn + reconnect_turn. Yields a SINGLE continuous typed Event stream; on SseConnectionDropped (mid-stream drop OR clean EOF before terminal), transparently resumes via reconnect_turn from the last-seen sse_id, up to max_reconnects times, until a terminal Done/Error/Cancelled arrives. The one surface all presenters consume for resilient streaming (design-brief §8b). Cross-process resume stays deferred to v2 (§8d): last-seen lives only in this generator's frame.
|
||||
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)
|
||||
PRE: [PRE-003 hard] content is non-empty str -- assert content and isinstance(content, str)
|
||||
PRE: [PRE-004 hard] max_reconnects is a non-negative int -- assert isinstance(max_reconnects, int) and max_reconnects >= 0
|
||||
POST: [POST-001 return_value] yielded events are the concatenation of each attempt's events in wire order; the wrapper does NOT re-yield events it already saw (the server replays only seq>last_seen) -- assert seq is non-decreasing within a turn_id across the seam
|
||||
POST: [POST-002 return_value] a fully-consumed stream terminates at exactly one Done/Error/Cancelled (INV-001 holds across reconnects) -- assert isinstance(events[-1], (Done, Error, Cancelled))
|
||||
POST: [POST-003 state_change] reconnect_turn is invoked with last_event_id == f"{last_seen.turn_id}:{last_seen.seq}" of the most recently yielded event -- assert the Last-Event-ID header on attempt N+1 == the last sse_id yielded before the drop
|
||||
ERROR_ROUTING:
|
||||
SseConnectionDropped (from stream_turn or reconnect_turn):
|
||||
local_handling: IF a last-seen sse_id exists AND reconnects < max_reconnects → increment reconnects, resume via reconnect_turn(last_event_id=f"{turn_id}:{seq}"); ELSE re-raise
|
||||
flow_control: continue (resume) | abort (re-raise when no last-seen id, or budget exhausted)
|
||||
state_recovery: server replays buffered events seq>last_seen then streams live (spec §Reconnect flow)
|
||||
ResumeBufferExpired | ResumeTurnFinished | InvalidLastEventId | TurnIdFlip | SseConnectFailed (from reconnect_turn):
|
||||
local_handling: propagate unchanged — NOT a transient drop; caller policy is abandon/restart (§Resume semantics "surface, not recover")
|
||||
flow_control: abort
|
||||
state_recovery: none
|
||||
STEPS:
|
||||
1. [setup, flexibility=prescriptive] Validate PRE-001..PRE-004; SET last_seen=None, reconnects=0, gen=stream_turn(client, session_id, content)
|
||||
2. [loop, flexibility=prescriptive] async-for event in gen: SET last_seen=event.sse_id; YIELD event. On clean generator completion (terminal reached): RETURN.
|
||||
3. [branch, flexibility=prescriptive] ON SseConnectionDropped d: SET seen = last_seen or d.last_seen_sse_id. IF seen is None OR reconnects >= max_reconnects: RE-RAISE. ELSE: reconnects += 1; gen = reconnect_turn(client, session_id, content, last_event_id=f"{seen.turn_id}:{seen.seq}"); GOTO step 2.
|
||||
4. [error_handler, flexibility=prescriptive] Any non-drop exception from gen (ResumeBufferExpired/ResumeTurnFinished/InvalidLastEventId/TurnIdFlip/SseConnectFailed) is NOT caught — it propagates unchanged.
|
||||
TESTS:
|
||||
happy_no_drop [happy]: stream yields text(42:1), done(42:2) cleanly → wrapper yields exactly those 2; endpoint hit ONCE (no reconnect).
|
||||
resume_after_one_drop [scenario,tracer]: attempt 1 yields text(42:1) then RemoteProtocolError; reconnect replays text(42:2)+done(42:3) → wrapper yields 42:1,42:2,42:3 as ONE stream; 2nd request carried Last-Event-ID "42:1".
|
||||
resume_after_clean_eof [scenario]: attempt 1 yields text(42:1) then clean EOF (no terminal); reconnect yields done(42:2) → continuous (resumes on the INV-001 clean-eof drop too).
|
||||
two_drops_then_done [scenario]: drops after 42:1 then after 42:2; third attempt yields done(42:3) → all 3 events; reconnects==2; Last-Event-ID headers "42:1" then "42:2".
|
||||
unresumable_zero_event_drop [adversarial]: attempt 1 drops with ZERO events seen (last_seen None) → SseConnectionDropped propagates; only 1 request issued.
|
||||
max_reconnects_exhausted [adversarial]: every attempt drops after one event; max_reconnects=2 → after initial + 2 reconnects (3 requests), SseConnectionDropped propagates.
|
||||
buffer_expired_propagates [error]: attempt 1 drops after 42:1; reconnect returns 412 → ResumeBufferExpired propagates (not retried as a transient drop).
|
||||
zero_budget_no_resume [adversarial]: max_reconnects=0; attempt 1 drops after 42:1 → SseConnectionDropped propagates immediately (no reconnect attempted).
|
||||
```
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
contract_version: "2.1"
|
||||
target_module: "ratatoskr.sessions + ratatoskr.provider (+ cli/tui/web trigger surfaces)"
|
||||
scope: "Issue #17 v1 — make the canary chat client self-drive AND observe its own Bifrost provider. Two parts. (1) BIND: `create_session` gains an optional single-plane Bifrost binding (`BifrostBinding{endpoint_url, scope}`) authenticated with a DISTINCT consumer Heimdall key; Worldtree runs the handshake synchronously at POST /sessions, so handshake failure is a session-create failure (502), surfaced on the create path. A plane selector (`memory`→:8391 / `affect`→:8390) + the consumer key thread through CLI / TUI / web; bound-state is visible. (2) OBSERVE: a structured op-feed in the provider, instrumented at the DISPATCH/ASGI layer (where the JWT ctx / session_id lives — bifrost passes ctx to upsert_many but NOT to search/get/delete, so the existing store-method stdout shim cannot see session_id), emitting JSONL {session_id, plane, op, req_summary, resp_summary, status, ts}. OPERATOR DECISIONS LOCKED: single-plane-per-session for v1 (composite endpoint fronting both planes is PARKED — vNext); op-feed with session-level correlation for v1 (turn-correlated debug-pane UI is PARKED — needs turn_id, TBD). Provider store scope semantics MUST NOT change (AND-parity with bifrost's reference store is a hard constraint). Direct in-session TDD; live-smoke against personal Worldtree is the load-bearing acceptance gate."
|
||||
scope: "Issue #17 v1 — make the canary chat client self-drive AND observe its own Bifrost provider. Two parts. (1) BIND: `create_session` gains an optional single-plane Bifrost binding (`BifrostBinding{endpoint_url, scope}`) authenticated with a DISTINCT consumer Heimdall key; Worldtree runs the handshake synchronously at POST /sessions, so handshake failure is a session-create failure (502), surfaced on the create path. A plane selector (`memory`→:8391 / `affect`→:8390; `combined`→:8392 added post-#17 — the #18 composite, the web default) + the consumer key thread through CLI / TUI / web; bound-state is visible. (2) OBSERVE: a structured op-feed in the provider, instrumented at the DISPATCH/ASGI layer (where the JWT ctx / session_id lives — bifrost passes ctx to upsert_many but NOT to search/get/delete, so the existing store-method stdout shim cannot see session_id), emitting JSONL {session_id, plane, op, req_summary, resp_summary, status, ts}. OPERATOR DECISIONS LOCKED: single-plane-per-session for v1 (composite endpoint fronting both planes was PARKED at #17 — later shipped as #18 and surfaced in the web bind as the `combined` plane); op-feed with session-level correlation for v1 (turn-correlated debug-pane UI is PARKED — needs turn_id, TBD). Provider store scope semantics MUST NOT change (AND-parity with bifrost's reference store is a hard constraint). Direct in-session TDD; live-smoke against personal Worldtree is the load-bearing acceptance gate."
|
||||
depends_on:
|
||||
- "httpx"
|
||||
- "ratatoskr.sessions"
|
||||
@@ -92,8 +92,8 @@ async def create_session(
|
||||
|
||||
|
||||
def endpoint_for_plane(plane: str, base_host: str) -> str:
|
||||
"""'memory'->:8391, 'affect'->:8390 → f'http://{base_host}:{port}'. The
|
||||
Worldtree-visible base URL. See FN endpoint_for_plane."""
|
||||
"""'memory'->:8391, 'affect'->:8390, 'combined'->:8392 (#18 composite) →
|
||||
f'http://{base_host}:{port}'. The Worldtree-visible base URL. See FN endpoint_for_plane."""
|
||||
```
|
||||
|
||||
```python
|
||||
@@ -218,12 +218,12 @@ STEPS:
|
||||
|
||||
```contract
|
||||
FN endpoint_for_plane(plane: str, base_host: str) -> str
|
||||
BRIEF: Map a plane name to the Worldtree-visible provider base URL (memory->:8391, affect->:8390).
|
||||
BRIEF: Map a plane name to the Worldtree-visible provider base URL (memory->:8391, affect->:8390, combined->:8392 — the #18 composite both-plane endpoint, surfaced post-#17).
|
||||
|
||||
PRE: [PRE-001 hard] plane in {"memory", "affect"} -- else ValueError
|
||||
POST: [POST-001 return_value] returns f"http://{base_host}:{port}", port 8391 (memory) / 8390 (affect) -- assert
|
||||
PRE: [PRE-001 hard] plane in {"memory", "affect", "combined"} -- else ValueError
|
||||
POST: [POST-001 return_value] returns f"http://{base_host}:{port}", port 8391 (memory) / 8390 (affect) / 8392 (combined) -- assert
|
||||
STEPS:
|
||||
1. port = 8391 if plane == "memory" else 8390
|
||||
1. port = {"memory": 8391, "affect": 8390, "combined": 8392}[plane]
|
||||
2. return the Worldtree-VISIBLE base URL (not client loopback); HTTPS relaxation is allowlist-side, not a URL concern
|
||||
```
|
||||
|
||||
|
||||
@@ -206,3 +206,110 @@ TESTS:
|
||||
limit_above_max [adversarial]: limit=300 → 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
|
||||
```
|
||||
|
||||
## Amendment 2026-07-01 — admin BifrostState read (v1 coverage-audit)
|
||||
|
||||
Admin-scoped Bifrost dispatch-state read (spec #176, `GET /admin/sessions/{id}/bifrost`),
|
||||
surfaced in the TUI BifrostState pane on session-attach. The first admin-key
|
||||
consumer in ratatoskr: requires the `admin.sessions.read` scope, so the request
|
||||
OVERRIDES the Authorization header with the caller-supplied `admin_key` (distinct
|
||||
from the client's default consumer key). Same result-shape convention as the
|
||||
other introspection wrappers: 200 → parsed dict verbatim, non-200 → `SessionApiFailed`.
|
||||
|
||||
```contract
|
||||
FN get_session_bifrost(client: httpx.AsyncClient, session_id: str, *, admin_key: str) -> dict[str, Any]
|
||||
BRIEF: GET /admin/sessions/{session_id}/bifrost — admin-scoped live Bifrost binding (spec #176): {endpoint_url, consumer_id, connected, capabilities_granted, tools:[{name, description}]}. Requires admin.sessions.read; the request sets Authorization: Bearer <admin_key> (override), NOT the client's default consumer bearer. Parsed dict verbatim; any non-200 → SessionApiFailed — notably 403 auth_scope_denied and 404 session_not_bifrost_bound.
|
||||
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)
|
||||
PRE: [PRE-003 hard] admin_key is non-empty str -- assert admin_key and isinstance(admin_key, str)
|
||||
POST: [POST-001 return_value] on 200 returns resp.json() unmodified -- assert result == resp.json()
|
||||
POST: [POST-002 state_change] the outbound request Authorization header == f"Bearer {admin_key}" (override) -- assert request.headers["Authorization"] == "Bearer " + admin_key
|
||||
ERROR_ROUTING:
|
||||
HTTP non-200 (incl. 403 auth_scope_denied, 404 session_not_found / session_not_bifrost_bound):
|
||||
local_handling: raise SessionApiFailed(status=resp.status_code, body=resp.content)
|
||||
flow_control: abort
|
||||
state_recovery: none (caller decides: 403 → key lacks scope; 404 not-bound → benign unbound session)
|
||||
STEPS:
|
||||
1. [setup, prescriptive] assert PRE-001..PRE-003
|
||||
2. [sequential, prescriptive] resp = await client.get(f"/admin/sessions/{session_id}/bifrost", headers={"Authorization": f"Bearer {admin_key}"})
|
||||
3. [branch, prescriptive] IF resp.status_code == 200: RETURN resp.json(); ELSE RAISE SessionApiFailed
|
||||
TESTS:
|
||||
happy_uses_admin_bearer [happy,tracer]: 200 {endpoint_url, connected, capabilities_granted, tools} → dict verbatim; request Authorization == "Bearer <admin_key>" (override)
|
||||
scope_denied_403 [error]: 403 → SessionApiFailed(status=403)
|
||||
not_bound_404 [error]: 404 session_not_bifrost_bound → SessionApiFailed(status=404)
|
||||
empty_admin_key [adversarial]: admin_key="" → AssertionError; no HTTP issued
|
||||
```
|
||||
|
||||
@@ -372,3 +372,64 @@ test layer.
|
||||
- Issue #7 (mid-stream robustness, `MalformedSseData`) — landed; #6's
|
||||
pre/in-alt-screen split is orthogonal to #7's empty-data/malformed
|
||||
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.
|
||||
```
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,265 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"description": "Server-Sent Events emitted on POST /sessions/{id}/messages. Each event is an object discriminated on `type`; all carry `turn_id`.",
|
||||
"discriminator": {
|
||||
"propertyName": "type"
|
||||
},
|
||||
"oneOf": [
|
||||
{
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"phase": {
|
||||
"enum": [
|
||||
"BuildingPrompt",
|
||||
"CallingLLM",
|
||||
"ProcessingTools",
|
||||
"Streaming",
|
||||
"Finishing"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"turn_id": {
|
||||
"description": "The turn this event belongs to.",
|
||||
"type": [
|
||||
"integer",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"type": {
|
||||
"const": "worker_phase"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"turn_id"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"turn_id": {
|
||||
"description": "The turn this event belongs to.",
|
||||
"type": [
|
||||
"integer",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"type": {
|
||||
"const": "awaiting_llm_first_token"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"turn_id"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"content": {
|
||||
"type": "string"
|
||||
},
|
||||
"turn_id": {
|
||||
"description": "The turn this event belongs to.",
|
||||
"type": [
|
||||
"integer",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"type": {
|
||||
"const": "thinking"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"turn_id"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"content": {
|
||||
"type": "string"
|
||||
},
|
||||
"turn_id": {
|
||||
"description": "The turn this event belongs to.",
|
||||
"type": [
|
||||
"integer",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"type": {
|
||||
"const": "text"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"turn_id"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"turn_id": {
|
||||
"description": "The turn this event belongs to.",
|
||||
"type": [
|
||||
"integer",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"type": {
|
||||
"const": "text_boundary"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"turn_id"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"turn_id": {
|
||||
"description": "The turn this event belongs to.",
|
||||
"type": [
|
||||
"integer",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"type": {
|
||||
"const": "tool_start"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"turn_id"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"result": {},
|
||||
"turn_id": {
|
||||
"description": "The turn this event belongs to.",
|
||||
"type": [
|
||||
"integer",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"type": {
|
||||
"const": "tool_result"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"turn_id"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"turn_id": {
|
||||
"description": "The turn this event belongs to.",
|
||||
"type": [
|
||||
"integer",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"type": {
|
||||
"const": "affect_update"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"turn_id"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"turn_id": {
|
||||
"description": "The turn this event belongs to.",
|
||||
"type": [
|
||||
"integer",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"type": {
|
||||
"const": "done"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"turn_id"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"phase": {
|
||||
"type": "string"
|
||||
},
|
||||
"reason": {
|
||||
"type": "string"
|
||||
},
|
||||
"turn_id": {
|
||||
"description": "The turn this event belongs to.",
|
||||
"type": [
|
||||
"integer",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"type": {
|
||||
"const": "cancelled"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"turn_id"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"error_code": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"turn_id": {
|
||||
"description": "The turn this event belongs to.",
|
||||
"type": [
|
||||
"integer",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"type": {
|
||||
"const": "error"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"turn_id"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
],
|
||||
"title": "Worldtree Conversation API — SSE turn-stream events"
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
# Ratatoskr v1 coverage map
|
||||
|
||||
_The v1 convergence-target ledger. Ratatoskr has **no self-defined feature
|
||||
roadmap**: v1 = consume all of Worldtree's I/O points, reached when Worldtree
|
||||
hits 1.0 (operator, 2026-06-19; auto-memory
|
||||
`project-ratatoskr-v1-derived-from-worldtree-io-coverage`). This file is that
|
||||
coverage map — every Worldtree v1-frozen I/O point × ratatoskr's coverage
|
||||
status, so "are we at v1?" is a ledger lookup, not a judgement call._
|
||||
|
||||
**First authored:** 2026-06-30 (the v1 coverage-audit kickoff).
|
||||
|
||||
---
|
||||
|
||||
## Frozen target
|
||||
|
||||
Worldtree is at **v1.0.0b2** — approaching 1.0, with its wire surfaces now
|
||||
**FROZEN** (Worldtree `docs/v1-schema-freeze-manifest.md`, #326). The audit
|
||||
anchors against the frozen machine-readable artifacts, NOT the prose markdown:
|
||||
|
||||
| Worldtree v1 surface | Frozen anchor | Ratatoskr role |
|
||||
|---|---|---|
|
||||
| Conversation REST API | OpenAPI `info.version` **2.2.0** (`Worldtree/docs/conversation-api-openapi.json`, sha `dbdf4e24…`) — **40 path×method groups** | **client** (debug TUI / web) |
|
||||
| Conversation SSE events | `conversation-api-sse-events.schema.json` (sha `9deeebf4…`) — **11 discriminated event types** | **client** |
|
||||
| Bifrost wire (consumer protocol) | wire **v0.6** STABLE/FROZEN (`bifrost==1.0.0`) — memory + affect planes | **provider** (Worldtree dispatches into us) |
|
||||
|
||||
> **Pin drift (finding P-1) — REMEDIATED 2026-06-30.** Ratatoskr formerly
|
||||
> vendored only the **prose markdown** (`docs/conversation-api-spec.md`), which
|
||||
> is byte-identical to live Worldtree's but frozen at v0.35.16-era content (last
|
||||
> WT edit 2026-05-31) and does **not** document the b2 surface (7 endpoints
|
||||
> below, the 409/503 on messages-POST #331, the unified error envelope #328, the
|
||||
> SSE schema). Remediated: the **OpenAPI 2.2.0 + SSE-schema JSON are now vendored
|
||||
> and pinned** (`.corviduo-canonicals.toml` → `canonical_drift.py` gate); the
|
||||
> prose markdown is the `tolerate_drift` reference. Spec pin advanced to
|
||||
> 5810a26 (v1.0.0b2). See § Pin remediation.
|
||||
|
||||
**7 endpoints new in b2 OpenAPI, absent from our vendored markdown:**
|
||||
`/admin/keys/bulk`, `/admin/persona/archive`, `/admin/persona/erase`,
|
||||
`/admin/usage`, `/embed`, `/judgments`, `/me/usage`.
|
||||
|
||||
---
|
||||
|
||||
## Scorecard
|
||||
|
||||
**Scope mandate: A (ledger-mandate), locked by operator 2026-06-30.** v1 "done"
|
||||
= every frozen I/O point is **classified** (covered or excluded-with-rationale),
|
||||
zero unaccounted. NOT "feature-complete client." All scope-pending rows are now
|
||||
resolved (§ Surface 1, scope-resolution table).
|
||||
|
||||
| Surface | Points | ✅ covered-live | ⬜ gap (in-scope) | 🚫 excluded-by-design |
|
||||
|---|---|---|---|---|
|
||||
| REST (OpenAPI 2.2.0, path groups) | 40 | 12 | 6 | 22 |
|
||||
| SSE events | 11 | 11 | 0 | 0 |
|
||||
| Bifrost provider planes | 8 verbs | 8 | 0 | (10 gated verbs deferred) |
|
||||
|
||||
**Legend.** ✅ consumed in code AND live-proven against real Worldtree · ⬜ a
|
||||
debug-observability I/O point we should cover but don't yet (the convergence
|
||||
frontier) · 🚫 deliberate non-goal per the design-brief negative clauses + the A
|
||||
mandate. Counts are at the **path-group** level; mixed-method groups are
|
||||
footnoted (e.g. `/sessions` POST is ✅ but its `GET` picker is an unwired
|
||||
sub-gap).
|
||||
|
||||
---
|
||||
|
||||
## Surface 1 — Conversation REST API (OpenAPI 2.2.0)
|
||||
|
||||
### Covered — client path (ratatoskr's core identity)
|
||||
|
||||
| Endpoint | Status | Where consumed | Note |
|
||||
|---|---|---|---|
|
||||
| `POST /sessions` | ✅ | `sessions.py:307` → `cli.py:482`,`tui.py:1508`,`web/server.py:155` | + `end_user_id`, `bifrost` binding; 404→AgentNotFound, 502→BifrostHandshakeFailed |
|
||||
| `POST /sessions/{id}/messages` (turn stream, SSE) | ✅ | `sse_client.py:484` `stream_turn` → cli/tui/web | the primary surface; 409→AgentNotAvailable, 503→TurnLaunchUnavailable (b2 #331) |
|
||||
| `POST /sessions/{id}/turns/{turn_id}/cancel` | ✅ | `sse_client.py:581` → cli/tui/web | two-stage Ctrl-C; 404/409 mapped |
|
||||
| `GET /agents` | ✅ | `sessions.py:341` → `tui.py:1472`,`web/server.py:100` | Tier-1 roster; merged with local index |
|
||||
| `GET /agents/{id}/persona_state` | ✅ | `sessions.py:384` → `tui.py:1132`,`web/server.py:386` | persona hydrate; 404/403 mapped |
|
||||
| `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) |
|
||||
| `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) |
|
||||
| `GET /admin/sessions/{id}/bifrost` | ✅ | `sessions.py:428` `get_session_bifrost` → `tui.py` `_hydrate_bifrost_state` | admin-scoped BifrostState pane (#176); admin key (`RATATOSKR_ADMIN_API_KEY`); live-auth-proven |
|
||||
| `GET /admin/events` (SSE) | ✅ | `sse_client.py` `stream_admin_events` → `tui.py` `_stream_admin_events` | admin lifecycle SSE stream (#11), session-filtered AdminEvents pane; admin key; live-auth-proven |
|
||||
|
||||
**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):
|
||||
- `GET /sessions` — `sessions.py:198` `list_sessions` exists, **no caller**: the
|
||||
startup session-picker (design-brief §4 v1) was never wired.
|
||||
- `POST /sessions/{id}/messages` + `Last-Event-ID` — `sse_client.py:524`
|
||||
`reconnect_turn` exists, **no caller**: the reference SSE-resume impl
|
||||
(design-brief §8d) was never wired.
|
||||
- `GET /agents/{id}` — consumer-agent lookup (`GET /agents/<owner>:<name>` with
|
||||
the owner key) is **manual-curl-only**, not in code.
|
||||
|
||||
### In-scope gaps — the convergence frontier (debug-observability path)
|
||||
|
||||
**Tier 1 — the debug-observability core (design-brief'd for v1, unbuilt):**
|
||||
|
||||
| Endpoint | Status | Why in-scope |
|
||||
|---|---|---|
|
||||
| `GET /admin/sessions/{id}/tools` | ⬜ | admin variant of the Tools inventory — **covered-by-alternative** via the owner-scoped `GET /sessions/{id}/tools` (✅); this admin variant remains a gap only for cross-user operator debug |
|
||||
| (`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):**
|
||||
|
||||
| Endpoint | Status | Why in-scope |
|
||||
|---|---|---|
|
||||
| `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 |
|
||||
|
||||
### Excluded by design — the design-brief negative clauses
|
||||
|
||||
| Endpoint(s) | Status | Rationale (design-brief / memory) |
|
||||
|---|---|---|
|
||||
| `PATCH /sessions/{id}` · `DELETE /sessions/{id}` | 🚫 | §4: rename/delete happen outside the tool (`sessions_cli.py`) |
|
||||
| `GET /sessions/{id}/messages` (history) | 🚫 | §6: single-session live transcript, no history fetch |
|
||||
| `GET /sessions/{id}` | 🚫 | session detail — identity is footer-visible, no detail view |
|
||||
| `GET /sessions/{id}/tool-events` | 🚫 | §5: tool calls observed **inline from SSE** `tool_start`/`tool_result`; persisted-events endpoint is opt-in only |
|
||||
| `GET/POST /admin/keys` · `DELETE/POST /admin/keys/{id}` · `POST /admin/keys/{id}/rotate` · `DELETE/POST /admin/keys/bulk` · `POST /admin/keys/bulk/rotate` | 🚫 | §6: **NOT a Worldtree-admin tool** (key mgmt) |
|
||||
| `POST /admin/sessions/{id}/retire` | 🚫 | admin session mutation |
|
||||
| `POST /admin/persona/{archive,erase}` | 🚫 | admin persona GDPR ops (new in b2) |
|
||||
| `POST /admin/users/{id}/tier` | 🚫 | admin user mgmt |
|
||||
| `GET /me/usage` · `GET /admin/usage` | 🚫 | usage metering — not turn-flow observability (new in b2) |
|
||||
| `GET /healthz` · `GET /readyz` | 🚫 | liveness probes — low debug value (could become a connect preflight; park) |
|
||||
| `GET /search` | 🚫 | §5: consumer-product feature, not turn-flow (was "defer to v2") |
|
||||
| `GET/POST /uploads` · `DELETE/GET /uploads/{id}` | 🚫 | §6: no uploads — consumer-product feature |
|
||||
| `GET /pending` · `GET /sessions/{id}/pending` | 🚫 | §5: poll-only, no turn-flow signal (was "optional, skip") |
|
||||
| `POST /embed` | 🚫 | embedding utility — no turn flows through it |
|
||||
| `POST /judgments` | 🚫 | LLM-as-judge A/B eval (`response_a`/`response_b`/`rubric`) — standalone eval utility, not turn-flow |
|
||||
|
||||
### Scope-resolution record (the 11 ❓ rows, resolved under A)
|
||||
|
||||
The 2026-05-20 design-brief deferred several surfaces; the 2026-06-19 reframe
|
||||
("v1 = full I/O coverage") put them back in tension. **Resolved 2026-06-30 under
|
||||
mandate A** (debug-observability identity intact; classify, don't build-all):
|
||||
|
||||
| Endpoint(s) | Resolution |
|
||||
|---|---|
|
||||
| `GET /search` · `uploads` (×2) · `pending` (×2) · `POST /embed` · `POST /judgments` | 🚫 **excluded** — consumer-product / eval / poll utilities, not turn-flow observability |
|
||||
| `characters` (×4) · `POST /sessions/{id}/persona_state` | ⬜ **in-scope** (frontier Tier 2) — session-routing + affect-injection debug paths |
|
||||
|
||||
Nothing remains ❓. The ⬜-vs-🚫 line follows the debug-observability test: *does
|
||||
a turn flow through it / is it a layer worth watching live?*
|
||||
|
||||
---
|
||||
|
||||
## Surface 2 — SSE events (11/11 ✅)
|
||||
|
||||
Every frozen SSE event type is parsed in `sse_client.py:_envelope_for_type`
|
||||
(342-411) and rendered by all three presenters (cli/tui/web). **Full coverage.**
|
||||
|
||||
`text` · `worker_phase` · `thinking` · `text_boundary` · `tool_start` ·
|
||||
`tool_result` · `done` · `error` · `cancelled` · `awaiting_llm_first_token` ·
|
||||
`affect_update`
|
||||
|
||||
> Caveat (not a gap): `affect_update` is wire-verified to emit **zero** events
|
||||
> for consumer (Tier-3) agents — the persona-strip SSE path never populates for
|
||||
> them (memory 2026-06-18). The handler is correct; the upstream emitter is
|
||||
> silent. PAD for consumer agents is surfaced via our own provider read route
|
||||
> (`GET /affect/state/{id}`, #18 D2), not this event.
|
||||
|
||||
---
|
||||
|
||||
## Surface 3 — Bifrost provider planes (8/8 ✅, live-proven)
|
||||
|
||||
Ratatoskr **implements** the provider side; Worldtree dispatches into it.
|
||||
Live-proven end-to-end through real WT turns (#17/#18 smokes; combined `:8392`
|
||||
WT-driven smoke 2026-06-20).
|
||||
|
||||
**Memory plane** — covers the entire `bifrost.memory.MemoryDataStore` protocol
|
||||
(required: `describe_store`, `get`, `get_many`, `search`, `upsert_many`) **plus**
|
||||
`delete_many`:
|
||||
|
||||
| Verb | Where | |
|
||||
|---|---|---|
|
||||
| `describe_store` | `memory_store.py:140` | advertises caps (sync) |
|
||||
| `search` | `memory_store.py:224` | vector recall; scope_all AND / scope_any OR |
|
||||
| `get` / `get_many` | `memory_store.py:293` / `:305` | point reads |
|
||||
| `upsert_many` | `memory_store.py:150` | idempotent batch write; optimistic lock |
|
||||
| `delete_many` | `memory_store.py:314` | transactional delete |
|
||||
|
||||
**Affect plane** — covers `bifrost…InMemoryAffectStore` (`emit`, `fetch`):
|
||||
|
||||
| Verb | Where | |
|
||||
|---|---|---|
|
||||
| `emit` | `affect_store.py:47` | conduit-opaque snapshot upsert (LWW) |
|
||||
| `fetch` | `affect_store.py:116` | `{found, snapshot}`; mandatory since bifrost 0.10.0 strong-or-absent gate |
|
||||
|
||||
Plus the non-wire PAD read route `GET /affect/state/{agent_id}`
|
||||
(`affect_store.py:189`) and the combined `:8392` endpoint advertising both caps
|
||||
by store-presence (`combined.py:46`).
|
||||
|
||||
**Deferred-gated (advertised-unsupported, correctly out-of-scope for the basic
|
||||
plane):** `scan`, `get_edges_for`, `upsert_edges`, `mark_invalid`,
|
||||
`mark_superseded`, `patch_many`, `commit_checkpoint`, `lease_job`,
|
||||
`read_checkpoint`, `health`. These live only in the bifrost reference
|
||||
*extended* store, not the `MemoryDataStore` protocol; deferred per the #294
|
||||
re-scope (memory 2026-06-15). Re-evaluate only if Worldtree's Tier-3 path
|
||||
starts exercising them.
|
||||
|
||||
---
|
||||
|
||||
## Convergence frontier (the v1 to-do)
|
||||
|
||||
**Tier 1 — debug-observability core:**
|
||||
|
||||
1. ✅ **DONE** — Session picker (`v0.18.7`) + SSE-resume (`v0.18.5`/`.6`).
|
||||
2. ✅ **DONE** — `GET /capabilities` + `GET /me` (`v0.18.8`, `--whoami`).
|
||||
3. ✅ **DONE** — BifrostState pane (`v0.18.10`, `GET /admin/sessions/{id}/bifrost`,
|
||||
admin-key; live-auth-proven). The Tools half was already covered by the
|
||||
owner-scoped `GET /sessions/{id}/tools` (item 5).
|
||||
4. ✅ **DONE** — AdminEvents pane (`v0.18.11`, `GET /admin/events` SSE,
|
||||
session-filtered; admin-key; live-auth-proven). #11's blocker was already
|
||||
satisfied (admin key carries `admin.events.read`). **Tier 1 complete** — the
|
||||
admin/debug-observability core (Persona · Tools · BifrostState · AdminEvents)
|
||||
is fully built.
|
||||
|
||||
**Tier 2 — rounds out coverage (all that remains):**
|
||||
|
||||
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`**
|
||||
— the only remaining in-scope client I/O points.
|
||||
|
||||
---
|
||||
|
||||
## Pin remediation (finding P-1) — DONE 2026-06-30
|
||||
|
||||
Re-pinned to the frozen machine-readable artifacts (the chosen option):
|
||||
`conversation-api-openapi.json` (2.2.0) + `conversation-api-sse-events.schema.json`
|
||||
are vendored under `docs/` and pinned in `.corviduo-canonicals.toml`
|
||||
(`worldtree-conversation-api-openapi-v2`, `-sse-events-v1`), drift-gated by
|
||||
`canonical_drift.py`. The prose markdown stays as a `tolerate_drift` reference
|
||||
(`-spec-v1`). `pyproject.toml` spec pin advanced f1b59f8 → 5810a26 (v1.0.0b2);
|
||||
`docs/SPEC-PIN.md` records the bump. This map now audits a frozen, diffable
|
||||
target — re-running the audit is a `canonical_drift.py` check away.
|
||||
|
||||
---
|
||||
|
||||
## Decisions
|
||||
|
||||
1. **Scope mandate — RESOLVED 2026-06-30: A (ledger-mandate).** v1 "done" = every
|
||||
frozen I/O point classified, zero unaccounted; NOT feature-complete client.
|
||||
The ⬜-vs-🚫 line for the 11 formerly-pending rows is locked above (§
|
||||
Scope-resolution record).
|
||||
2. **Pin remediation (P-1) — RESOLVED 2026-06-30 (re-pin to machine-readable).**
|
||||
Vendored `conversation-api-openapi.json` (2.2.0) + `conversation-api-sse-events.schema.json`
|
||||
as the authoritative drift gates (pinned in `.corviduo-canonicals.toml`,
|
||||
`canonical_drift.py` green); prose markdown kept as `tolerate_drift`
|
||||
reference. Spec pin advanced f1b59f8 (v0.35.16) → 5810a26 (v1.0.0b2). This
|
||||
map now audits against a frozen, diffable target.
|
||||
+83
-39
@@ -1,6 +1,6 @@
|
||||
# Persistent memory — ratatoskr
|
||||
|
||||
_Last updated: 2026-06-18_
|
||||
_Last updated: 2026-06-30_
|
||||
|
||||
This file captures durable intent and supporting evidence (goals, decisions,
|
||||
foot-gun warnings, in-flight state) across context resets. Read it at session
|
||||
@@ -39,39 +39,41 @@ upstream API key stays server-side (INV-003).
|
||||
|
||||
## Current state / in-flight
|
||||
|
||||
_As of 2026-06-19:_
|
||||
_As of 2026-06-20:_
|
||||
|
||||
**#18 DELIVERABLE 2 SHIPPED + PUSHED — the persona-telemetry gap is CLOSED.** The web
|
||||
pane now renders live PAD/valence for Tier-3 agents from OUR `:8390` affect store
|
||||
(`v0.17.14`, `39eebd1`, suite **482 green**, **pushed to origin**). Three pieces:
|
||||
provider read route `GET /affect/state/{agent_id}` (non-bifrost, added to the affect app
|
||||
via `app.add_route` — keeps `/bifrost/*` top-level + op-feed-skipped); web proxy
|
||||
`GET /api/affect/{agent_id}` (server-supplied `end_user_id`, colon-id `quote()`'d,
|
||||
`RATATOSKR_AFFECT_READ_URL` config, default `127.0.0.1:8390`); pane affect-render
|
||||
(`renderAffectPane`/`loadAffect`, honest pad+valence+emitted_at, labelled "affect", NO
|
||||
fabricated Tier-1 fields, explicit empty-state, 2s post-turn poll). Live-smoke + a
|
||||
Playwright DOM check PROVEN against real sindra/vuong PAD. The push also published the
|
||||
previously-held **#17** arc (`v0.17.8`→`v0.17.13`) — origin/main is now fully caught up.
|
||||
**#17 and #18 BOTH CLOSED — the composite both-plane binding is fully proven.** #18 shipped
|
||||
`v0.18.0` (`359dbb1`): D2 (PAD read-endpoint, `v0.17.14`) renders live PAD in the web pane from our
|
||||
`:8390` store; D1 (composite endpoint, `v0.17.16` `7f4ceaa`) — `build_combined_provider_app`
|
||||
(`provider/combined.py`) on `:8392` wraps bifrost's public `build_combined_app` over BOTH stores +
|
||||
the shared affect read route; one bound session drives memory.* AND affect.* through ONE endpoint,
|
||||
op-feed deriving plane per path. Suite **503 green**. **#17 closed in the tracker 2026-06-20**
|
||||
(shipped `v0.17.8`–`.13` + the `v0.17.17` op-feed field fix).
|
||||
|
||||
**#18 DELIVERABLE 1 (composite `:8392` endpoint) — PARKED on bifrost** (tracked Gitea #18).
|
||||
Routed to bifrost-dev for a public `build_combined_app` rather than hand-rolled from
|
||||
bifrost privates (debug-surface-uses-canonical principle). bifrost-dev confirmed it: clean
|
||||
additive minor (~`v0.9.0`), design locked (advertise-by-presence handshake, per-route
|
||||
call-time isolation), slotted AFTER WT #289. FR-1 RESOLVED — composite is bifrost-only,
|
||||
ZERO Worldtree change (single-endpoint caps-routed, worldtree-dev code-verified). NEXT:
|
||||
when bifrost ships `build_combined_app`, **repin + reimplement D1 against it** (per-plane
|
||||
failure status + op-feed plane-per-request derivation already specced in the issue).
|
||||
Nothing blocks on our side.
|
||||
**#18's final leg — the Worldtree-DRIVEN composite turn — RAN and is PROVEN end-to-end + persisted
|
||||
(2026-06-20).** infra-ops added `10.100.10.50:8392` to the personal WT's (`:8081`)
|
||||
`BIFROST_CLIENT_ALLOWED_HOSTS` (thread `01KVHWJGTT…`), unblocking the smoke. A real WT turn through
|
||||
`:8392` (session `b83a66b6`, agent `ratatoskr:sindra`, fresh end_user `resmoke-choco-1`) drove the
|
||||
FULL both-plane lifecycle on ONE endpoint, caps-routed by path: `handshake`
|
||||
(`caps_granted=[memory, affect]`) → `affect.fetch` + `memory.search` (reads) → `affect.emit`
|
||||
(`stored:true`, PAD row in `affect_snapshots`) → `memory.upsert_many` (`upserted:1`, chunk
|
||||
`2df1b79de761b948` in `memory_chunks`). Both writes verified directly in our SQLite. The
|
||||
model-backend outage that blocked the first attempt (both agents' models `model_unavailable`) was
|
||||
operator-fixed mid-session, then the resmoke completed clean. **No open legs remain on the composite.**
|
||||
|
||||
**OPERATOR SESSION STATE — running shells are PRE-#18 code (foot-gun).** web `:8765` +
|
||||
affect `:8390` + memory `:8391` are the prior session's background shells running OLD code
|
||||
(no read route; web has no `RATATOSKR_AFFECT_READ_URL`). To see D2 live in the operator's
|
||||
own session, RESTART `:8390` (affect provider, new code → gains the read route) + `:8765`
|
||||
(web, new code + `RATATOSKR_AFFECT_READ_URL=http://127.0.0.1:8390` + `RATATOSKR_END_USER_ID`).
|
||||
This session's live-smoke used THROWAWAY `:8393`/`:8766` instances vs the same `affect.db` to
|
||||
avoid disrupting them. Consumer/owner key = `wt_live_d81b…`; providers SQLite + sqlite-vec,
|
||||
`memory.db`/`affect.db` at repo root (affect.db has live sindra PAD: vuong pleasure 0.146,
|
||||
familiarity 0.589, interaction_count 8).
|
||||
**bifrost repinned 0.8.0 → 0.10.0** (floor, `provider` extra). 0.10.0 made `affect.fetch`
|
||||
MANDATORY (strong-or-absent: `_supports_affect_plane` requires `affect_supported`+`emit`+`fetch`,
|
||||
gating EVERY affect op incl. emit) — so the repin FORCED `affect.fetch` (`v0.17.15`, conformed
|
||||
to bifrost's reference `InMemoryAffectStore.fetch` → `{found, snapshot?}`) or our shipped affect
|
||||
plane would 400. The composite's affect cap depends on it.
|
||||
|
||||
**OPERATOR SESSION STATE — `:8390`/`:8391`/`:8765` shells are PRE-#18 code (foot-gun).** web `:8765`
|
||||
+ affect `:8390` + memory `:8391` are prior-session background shells on OLD code. The **`:8392`
|
||||
composite provider is RUNNING on NEW code** (`ratatoskr-combined-provider`, pid started Jun19,
|
||||
`RATATOSKR_OPFEED_PATH=/tmp/ratatoskr-combined-opfeed.jsonl`, shared `affect.db`/`memory.db`) — now
|
||||
`:8392`-allowlisted and WT-turn-proven. To see the full web stack on new code, RESTART `:8390`/`:8765`
|
||||
from current code (D2 web needs `RATATOSKR_AFFECT_READ_URL`). Consumer/owner key = `wt_live_d81b…`
|
||||
(`~/.config/ratatoskr/provider.env`, mode 600, rotate via infra-ops); providers SQLite + sqlite-vec,
|
||||
`memory.db`/`affect.db` at repo root (live sindra PAD: vuong + the `resmoke-choco-1` smoke fixture).
|
||||
|
||||
**Tier-3 memory PROVEN end-to-end** (earlier this session): `ratatoskr:terse-probe`
|
||||
cold-recalled a seeded user fact (scope_any → 1 hit @ cosine 0.6994), and the verbose
|
||||
@@ -84,15 +86,17 @@ linguistic layer → Worldtree #305). `:8081` runs v0.36.0.
|
||||
(separate `consumer_agents` table) — invisible to `GET /agents`; check via
|
||||
`GET /agents/<owner>:<name>` with the owner key.
|
||||
|
||||
**Standing:** Worldtree spec pin v0.35.16 (`f1b59f8`); bifrost 0.8.0 / wire v0.6
|
||||
(`scope_all`+`scope_any`); WT handshake now advertises `bifrost_version 0.6.0` (worldtree-dev
|
||||
honesty-fix FYI `858ba58` — we don't pin/assert it, no-op our side). Heimdall key env-only
|
||||
at `~/.config/ratatoskr/provider.env` (mode 600); rotate via infra-ops. `graphify-out/`
|
||||
runs dirty (auto-regen, not chased). Open issues: #10 (subject migration), #11 (AdminEvents
|
||||
pane) — deferred; **#18** (D2 PAD-read SHIPPED `v0.17.14`; D1 composite PARKED on bifrost
|
||||
`build_combined_app`). Codex-first pilot dormant.
|
||||
**Standing:** Worldtree spec pin v0.35.16 (`f1b59f8`); **bifrost 0.10.0 / wire v0.6**
|
||||
(`scope_all`+`scope_any`). Heimdall key env-only at `~/.config/ratatoskr/provider.env` (mode
|
||||
600); rotate via infra-ops. `graphify-out/` runs dirty (auto-regen, not chased). **Open issues:
|
||||
#11** (AdminEvents pane — the next-reachable Worldtree-I/O coverage gap, blocked on an
|
||||
`admin.events.read` scope request) and **#10** (subject-migration watch on Worldtree #196) — both
|
||||
deferred. **#17 + #18 CLOSED.** Codex-first pilot dormant. No in-flight implementation work — repo
|
||||
is at a converged checkpoint; v1 advances when Worldtree does (v1 = full Worldtree I/O coverage).
|
||||
|
||||
Branch: `main` (== `origin/main` @ `39eebd1`). Remote: `origin → git@gitea.phasefinal.com:vh/ratatoskr.git`.
|
||||
Branch: `main` (tag `v0.18.0`, `359dbb1`) — **in sync with `origin/main`** (the full #17+#18 arc is
|
||||
pushed). This `/snapshot` commit will sit one ahead of origin until pushed (push is the operator's
|
||||
call). Remote: `origin → git@gitea.phasefinal.com:vh/ratatoskr.git`.
|
||||
|
||||
## Recent decisions
|
||||
|
||||
@@ -127,6 +131,38 @@ decision. Captures rationale that won't be obvious from code alone.
|
||||
- `[2026-06-18]` **#18 D2 implemented via direct in-session TDD (suite 470→482).** Provider read route `GET /affect/state/{agent_id}` added via `app.add_route` (NOT an outer `Mount` — keeps `/bifrost/*` top-level so the existing route test + the op-feed path-check stay valid); web `GET /api/affect/{agent_id}` proxy (server-supplied `end_user_id`, colon-id `quote()`'d, `RATATOSKR_AFFECT_READ_URL`); pane renders the affect-emit shape honestly. Contract `docs/contracts/issues/18.contract.md` (D2-scoped; D1 deferred). **heid-code-review panel (Gróa 5 / Hulda 3 / Regin 0): 1 real INV-001 drift + 4 test-gaps, all fixed.** No contract amendments (code was wrong, contract was right).
|
||||
- `[2026-06-19]` **#18 D2 SHIPPED (`v0.17.14`, `39eebd1`) and the full #17+#18 arc PUSHED to origin.** Live-smoke PROVEN against real data (throwaway `:8393`/`:8766` vs the real `affect.db` → real sindra/vuong PAD through the full web→provider chain; Playwright DOM check confirmed the pane render + the F1 fix — no fabricated "neutral"). The push carried 9 previously-held commits incl. the deliberately-unpushed #17 (`v0.17.8`→`v0.17.13`); origin/main now == `39eebd1`, tag `v0.17.14`.
|
||||
|
||||
- `[2026-06-19]` **bifrost repinned 0.8.0→0.10.0; `affect.fetch` became MANDATORY (strong-or-absent).** 0.10.0's `_supports_affect_plane` requires `affect_supported`+`emit`+`fetch` and gates EVERY affect op — an emit-only store 400s. Implemented `affect.fetch` (`v0.17.15`, `ca6af6b`) conformed to bifrost's reference `InMemoryAffectStore.fetch` (`{found, snapshot?}`): the forced D1 prerequisite + a new Worldtree I/O point consumed. Flagged the now-stale consumer-guide line to bifrost-dev (fixed `a2e6d62`).
|
||||
- `[2026-06-19]` **#18 D1 SHIPPED — composite `build_combined_app` on `:8392` (`v0.17.16`, `7f4ceaa`); #18 CLOSED; published `v0.18.0` (`359dbb1`).** `build_combined_provider_app` wraps bifrost's public builder over both stores + the shared read route; op-feed `plane='combined'` per-path. Direct in-session TDD; heid-code-review panel (Gróa/Hulda/Regin) returned ZERO drift. Live-proven at wire+dispatch; WT-turn gated on infra-ops `:8392` allowlist.
|
||||
- `[2026-06-19]` **op-feed handshake field-name fix (#17, `v0.17.17` `d60b77d`): `capabilities_requested`→`capabilities`.** The summary read a field that never exists on the wire (bifrost reads `capabilities`, `_protocol.py:181`) → caps_requested was always null. Surfaced by the heid panel (Regin) during the D1 review — a latent #17 bug, not D1 drift.
|
||||
- `[2026-06-19]` **Ratatoskr is a REFERENCE implementation of the Worldtree/Bifrost standard (operator).** Adopt the dep's canonical way (even if ours works); INFORM of drift/gaps; ADVISE a different approach only when ours is genuinely better (dep owner decides), never unilaterally fork. [auto-memory `feedback-ratatoskr-is-a-reference-impl-adopt-canonical`]
|
||||
- `[2026-06-19]` **Ratatoskr v1 is DERIVED from Worldtree I/O coverage (operator) — no self-defined feature ROADMAP.** v1 = consume all of Worldtree's I/O points, reached when Worldtree hits 1.0; the convergence target is a coverage map, not a 3–7 capability list. [auto-memory `project-ratatoskr-v1-derived-from-worldtree-io-coverage`]
|
||||
- `[2026-06-20]` **#18's final leg PROVEN — composite `:8392` WT-driven smoke ran end-to-end + persisted.** infra-ops allowlisted `10.100.10.50:8392` on the personal WT (`01KVHWJGTT…`); a real WT turn (session `b83a66b6`, `ratatoskr:sindra`, fresh end_user `resmoke-choco-1`) dispatched the full both-plane lifecycle through ONE endpoint — handshake (both caps) → `affect.fetch` + `memory.search` → `affect.emit` (`stored:true`) → `memory.upsert_many` (`upserted:1`) — both writes verified in our SQLite (`affect_snapshots` PAD row + `memory_chunks` chunk `2df1b79…`). First attempt blocked by a `model_unavailable` outage on the personal WT (both agents' models down), operator-fixed mid-session, then clean. The composite has no open legs.
|
||||
- `[2026-06-20]` **#17 CLOSED in the tracker.** Shipped end-to-end (`v0.17.8`–`.13` + op-feed field fix `v0.17.17`); the 2026-06-20 composite smoke re-exercised its op-feed live. Closing comment captures the full both-plane proof. Open issues now just #11 (scope-blocked) + #10 (watch).
|
||||
- `[2026-06-20]` **Sindra has real PAD but ~empty memory — the affect/memory persistence asymmetry, confirmed on real sessions.** affect EMITS every turn (persona always accumulates: vuong 8→14 interactions across the session); memory only writes on a PROMOTION trigger (salience / turn_count≥6 / idle-≥10min flush). Two real vuong sessions through the combined bind (`04d6414c`, `433541fe`) drove affect emits + memory SEARCHES but ZERO promotion upserts → `memory.db` holds only the smoke fixture, zero vuong chunks. Operator: acceptable (server-takedown = "Sindra bonked on the head"; transient memory loss WAD). Operational catch: combined-as-default web bind saves persona reliably but silently LOSES memory if a session closes before a promotion trigger fires.
|
||||
- `[2026-06-29]` **Web SPA combined-bind default (`v0.18.1`, `719e4d6`) — operator-caught gap.** #18 shipped the composite `:8392` provider but never exposed it in the web bind dropdown (only memory/affect single-plane). Added `combined (:8392)` as the DEFAULT option (both planes in one session), kept single-plane for isolation diagnostics; wired `endpoint_for_plane` combined→8392 + server validation + the dropdown. Direct TDD; #17 contract updated (the governing spec for the web bind). Restarted `:8765` on current code (env.sh + provider.env + `RATATOSKR_AFFECT_READ_URL=:8392`).
|
||||
- `[2026-06-29]` **bifrost repinned 1.0.0 (`v0.18.2`, `af67ad9`).** bifrost-dev shipped its first stable release; wire v0.6 now STABLE/FROZEN. Non-breaking (byte-identical to 0.10.0); switched the floor pin → exact `==1.0.0` per the stable-substrate posture. Post-1.0 breaking changes ride a bifrost MAJOR + new wire (v0.7+); a v0.6-pinned consumer is stable indefinitely. (Also this session: althing migrated to v0.15.0+ lean-bus / schema v4 — moderation retired, chamber/redis ripped; our tooling auto-updated to 0.17.4.)
|
||||
- `[2026-06-30]` **Worldtree v1.0.0b1→b2 consumer adaptation: eager turn-launch statuses (`v0.18.3` `b2e4901`, `v0.18.4` `e4317f6`).** Worldtree #331 decoupled turn execution from the SSE connection → turn-launch failures now arrive EAGERLY as a status before any stream: 409 `agent_not_available` (pre-b1 a 200 + in-stream error event), 503 retryable. Mapped both in `stream_turn` to typed `SseConnectFailed` subclasses keyed on STATUS, parsing the `{detail:{error_code,message}}` envelope — POST-003 preserved (no synthetic event yielded), existing handlers still catch (the design fork vs yield-an-Error-event was decided by POST-003). **DEFERRED follow-ups** (tracked here; bundle with the v1 coverage-audit): (1) live-prove the 409/503 end-to-end on personal-b2 (now unblocked — personal on b2, my key works there); (2) full `conversation-api-spec.md` markdown re-vendor to the b2 era (ratatoskr vendors the markdown, not the OpenAPI JSON).
|
||||
- `[2026-06-30]` **Verify-against-the-real-spec-before-committing caught a real upstream gap.** Holding the v0.18.3 commit to verify against demo's OpenAPI surfaced that the FROZEN OpenAPI 2.1.0 didn't document the 409/503 the heads-up described (`agent_not_available` was in the ErrorCode enum, but NO 503/turn-launch code). worldtree-dev confirmed it was THEIR gap (#331 added the statuses without extending the #328 `openapi()` override), shipped the fix in **v1.0.0b2 / OpenAPI 2.2.0** (409/503 now enumerated, 503 code finalized as `not_ready`). "The consumer-oracle earning its keep." Lesson: a provider's prose heads-up can diverge from its frozen machine-readable spec — verify the actual spec before committing a consumer adaptation.
|
||||
- `[2026-06-30]` **regard is a DEAD AXIS in Worldtree's emitted affect (caught provider-side; worldtree-dev confirmed + escalated to Vuong).** Across all our affect snapshots, `valence[].regard` is EXACTLY 0.15 regardless of agent/end_user/interaction_count, while familiarity accumulates (vuong 0.18→0.69 over 14 turns). Root cause (worldtree-dev, code-grounded): 0.15 = `base_regard = agreeableness*0.3` (sindra A=0.5); regard's only human-writer `update_regard` early-returns unless an emotion is `about="other"`, but the Vili appraiser's `ViliResponse` schema has NO directedness axis (everything hardcoded `about="situation"`) — producer side lost in the #265 Vili rework; consumer machinery intact. NOT WAD; the fix (reintroduce other-directed classification) is an affect-model change touching every agent + a directedness-classification design call → worldtree-dev filing an issue to Vuong. [the consumer/provider thesis paying off again]
|
||||
- `[2026-06-30]` **v1 coverage-audit kicked off; coverage ledger written (`docs/coverage-map.md`) — the first one.** Every Worldtree v1-FROZEN I/O point × ratatoskr status. Anchored on WT's frozen machine-readable artifacts (OpenAPI **2.2.0** `conversation-api-openapi.json` = 40 REST path-groups + SSE schema = 11 events + bifrost wire v0.6), NOT the stale vendored prose markdown. Result: **SSE 11/11 ✅; Bifrost provider planes 8/8 ✅ live-proven** (covers the full `bifrost.memory.MemoryDataStore` protocol = describe_store/get/get_many/search/upsert_many + delete_many, and affect emit/fetch; **`health` is extended-reference-store-only, NOT in the base protocol → correctly deferred, NOT a gap** — settles the prior "health" ambiguity); **client REST 7/40 ✅ live, 11 ⬜ in-scope, 22 🚫 excluded.**
|
||||
- `[2026-06-30]` **Scope mandate A locked (operator): v1 "done" = every frozen I/O point CLASSIFIED (covered-or-excluded-with-rationale), zero unaccounted — NOT a feature-complete client.** The coverage map is a LEDGER, not a build-everything mandate. Reconciles the 2026-06-19 "consume all I/O" reframe with the 2026-05-20 design-brief's "NOT an admin tool" + deferral negative clauses (which predate both the provider identity and the reframe). Resolved the 11 design-brief-vs-reframe ❓ rows via the debug-observability test (*does a turn flow through it?*): 🚫 search / uploads / pending / embed / judgments (consumer-product + eval utilities); ⬜ transient-characters routing (4) + persona_state-write (Tier-2 frontier). **Frontier Tier 1 (all unblocked except #11):** session-picker + SSE-resume (wrappers `list_sessions`/`reconnect_turn` exist with NO caller — presenter-wiring only) → `GET /capabilities` + `GET /me` → BifrostState/Tools widgets (`GET /admin/sessions/{id}/{bifrost,tools}`, admin-key) → **#11 AdminEvents BLOCKED on `admin.events.read` scope**. The 3 admin-observability widgets + picker + resume were design-brief §5/§4/§8d v1 items that **were never built**.
|
||||
- `[2026-06-30]` **Finding P-1 (pin drift) + pin-remediation PENDING.** We vendor the PROSE markdown (`docs/conversation-api-spec.md`), which is **byte-identical to live WT's** but frozen at v0.35.16-era content (last WT edit 2026-05-31) — it does NOT capture b2: 7 new endpoints (admin/keys/bulk, admin/persona/{archive,erase}, admin/usage, embed, judgments, me/usage), the 409/503 on messages-POST (#331), the unified error envelope (#328), or the SSE schema. **WT's authoritative v1 truth is now the FROZEN OpenAPI 2.2.0 + SSE-schema JSON** (`Worldtree/docs/v1-schema-freeze-manifest.md`). So the previously-deferred "re-vendor markdown to b2" is a **near-no-op** (markdown content identical). **Pending operator nod:** re-pin to the machine-readable artifacts (recommended — drift-checkable via `canonical_drift.py`, makes the coverage map reproducible vs a frozen diffable target) vs markdown-only. Deferred (not auto-applied) because it adds vendored artifacts + a canonical-sync pin = substrate change with CI-gating reach. **→ RESOLVED 2026-06-30 (operator: "a then b").** Vendored `conversation-api-openapi.json` (2.2.0) + `conversation-api-sse-events.schema.json` + re-copied the prose markdown; pinned all three in `.corviduo-canonicals.toml` (OpenAPI+SSE = strict drift gates, markdown = `tolerate_drift` reference); advanced `worldtree-spec-rev` f1b59f8→5810a26 + `worldtree-version` v0.29.0(STALE, never bumped from the v0.35.16 pin)→v1.0.0b2 + `pinned-on`→2026-06-30; SPEC-PIN.md history row added. `canonical_drift.py` green (10/10). `pin:`-only, no version bump (no client-facing code change; the b2 409/503 + error-envelope were already consumed in v0.18.3/.4).
|
||||
|
||||
- `[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]` **(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`).**
|
||||
|
||||
- `[2026-07-01]` **BifrostState pane SHIPPED (`v0.18.10`) — `GET /admin/sessions/{id}/bifrost` in a new TUI "Bifrost" pane; the FIRST admin-key consumer in ratatoskr.** `get_session_bifrost(client, session_id, *, admin_key)` (sessions.py) — admin-scoped (`admin.sessions.read`); the request OVERRIDES Authorization with `admin_key` (distinct from the consumer bearer, asserted in a test); 200→dict, non-200→SessionApiFailed. Admin-key wiring: `--admin-key` flag + `RATATOSKR_ADMIN_API_KEY` env → new `ParsedArgs.admin_key`. New "Bifrost" TabPane + `_format_bifrost_state` + `_hydrate_bifrost_state` best-effort worker (mirror `_hydrate_session_tools`) UNCONDITIONALLY in on_mount → writes {endpoint, connected, caps_granted, tools} + audits; self-labels "not configured" (no admin key) / "not bound" (404) / graceful on 403 + error. Contract #2 amended (FN, validated OK) + TDD (4 wrapper respx tests incl. the admin-bearer-override assertion + 1 format unit + 3 hydrate integration). Suite **552 green**; my code ruff-clean (pre-existing tui.py ruff debt at other lines untouched, incl. a dead `RichText` import in `_hydrate_persona`). **LIVE-AUTH-PROVEN** on personal :8081: admin key authenticated (reached resource-layer 404 session_not_found, NOT 401/403) → `admin.sessions.read` works live; 200 full-state not exercised (no bound session on :8081 now — unit-covered). Patch bump (debug feature, no downstream coordination; consistent with the session's cadence — but the §5-core-completion angle is a possible minor, operator's call).
|
||||
- `[2026-07-01]` **LEDGER CORRECTION: #11 (AdminEvents) is NO LONGER BLOCKED.** Verified via `GET /me` on :8081 that `RATATOSKR_ADMIN_API_KEY` (`ratatoskr-readonly`, tier readonly-admin) carries ALL 7 read scopes INCLUDING **`admin.events.read`** (+ `admin.sessions.read`, admin.keys.read, admin.skuld.read, pending.read, search.read, tool_events.read). The coverage-map + prior memory had #11 "blocked on admin.events.read" — **STALE**; the admin key was minted (post-#11-filing, env.sh) WITH the scope, so the blocker is already satisfied. **Only the AdminEvents SSE pane itself is unbuilt** — the last unbuilt §5 debug pane (a live SSE-consuming admin pane, distinct from the hydrate-at-attach panes). Coverage-map updated. **Coverage: REST 11/40 ✅.** Consider building the AdminEvents pane and/or updating #11's tracker status (its stated blocker is gone).
|
||||
|
||||
- `[2026-07-01]` **AdminEvents pane SHIPPED (`v0.18.11`) — `GET /admin/events` SSE in a new TUI pane; #11 closed-by-build; Tier 1 (debug-observability core) COMPLETE.** `stream_admin_events(client, *, admin_key, last_event_id=None)` (sse_client.py) — a NEW long-lived SSE consumer for the admin lifecycle broadcast (envelope `{id,type,timestamp,data}`, 17-event v0 vocab), admin-scoped (`admin.events.read`, bearer-override), Last-Event-ID resume; non-200→SseConnectFailed, mid-drop→SseConnectionDropped; new `AdminEvent` dataclass (distinct from the turn `Event` union). New "AdminEvents" TabPane + `_format_admin_event` + `_admin_event_matches` (design-brief §6 filter: active-session events + non-heartbeat `system.*`) + `_stream_admin_events` long-lived best-effort worker (unconditional on_mount, cancelled on app exit; self-labels "not configured"/"unavailable"/"stream ended"). Reuses the admin key from the BifrostState slice. **Contract-SKIPPED** for `stream_admin_events` (out of contract #1's turn-SSE scope; spec § Admin Event Stream is the reference; well-TDD'd). TDD: 4 sse_client tests (multi-event+bearer-override, Last-Event-ID header, 403, malformed-skip) + 5 tui (format, filter, worker success/no-key/403). Suite **561 green**; my code ruff-clean (pre-existing tui.py ruff debt untouched, incl. the dead `RichText` import in `_hydrate_persona`). **LIVE-AUTH-PROVEN**: `GET /admin/events` on :8081 → HTTP 200 under the admin key (connected + streamed, idle in the 4s window — no 401/403). **Coverage: REST 12/40 ✅. Tier 1 admin/debug-observability core COMPLETE** (Persona · Tools · BifrostState · AdminEvents). AdminEvents work landed as patch `v0.18.11`; then **`v0.19.0` MINOR cut (operator-approved 2026-07-01)** publishing the milestone: **the debug-observability core is complete** (Persona · Tools · BifrostState · AdminEvents all built + consuming real endpoints — the design-brief's headline deliverable). Pre-1.0 minor = release-note-worthy (no downstream althing push needed pre-1.0); lightweight tag per the SemVer mechanics (annotated reserved for major cuts). Remaining in-scope client I/O: only 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._
|
||||
@@ -156,4 +192,12 @@ defense against re-attempting the same cul-de-sac.
|
||||
- `[2026-06-18]` **Rationalized away a KNOWN contract-invariant deviation during TDD — only the cross-model code-review caught it.** #18 D2's `loadAffect` called `setPersonaStrip(snap)`, which renders `dominant_emotion || "neutral"`; the affect snapshot has no `dominant_emotion`, so it fabricated a "neutral" emotion — violating the very INV-001 ("no synthesized Tier-1 fields") I had WRITTEN. I knew the strip did this and talked myself into it as acceptable. Neither the design panel nor TDD caught it (unit tests don't exercise the JS render); the post-implementation `/heid-code-review` did (Gróa + Hulda both). **Lesson: a known deviation from a contract invariant is drift even when you've rationalized it — flag it, don't argue yourself past it; the post-implementation cross-model review is the backstop for author-rationalized drift, distinct from the design-stage panel.**
|
||||
- `[2026-06-18]` **Latent SQLite thread-safety bug in the affect store, surfaced ONLY by the new HTTP read route.** `open_affect_store` created the connection without `check_same_thread=False`; the bifrost emit path never tripped it (uvicorn's loop ran on the connection's creating thread), but the `TestClient`-driven read route runs handlers off a worker thread → `sqlite3.ProgrammingError`. Fix: `check_same_thread=False` (safe — the event loop serializes access) + explicit `PRAGMA busy_timeout=5000` (don't rely on sqlite3's `timeout=5.0` default). **Lesson: a sqlite-backed ASGI app needs `check_same_thread=False`; the HTTP-layer test exposed what the direct-store-method tests structurally couldn't.**
|
||||
|
||||
- `[2026-06-19]` **The SAME `check_same_thread` sqlite bug recurred in the MEMORY store — exposed by the contract-mandated `search` dispatch test (TestClient = worker thread).** Heid's test-fidelity finding (the D1 dispatch test used `describe_store` where the contract says `search`) → fixing it to `search` tripped `sqlite3.ProgrammingError` because `open_memory_store` also lacked `check_same_thread=False`. Fixed (mirrors affect INV-006). **Lesson: this bug is PER-STORE — every sqlite-backed ASGI store needs `check_same_thread=False`; an HTTP-layer (TestClient) test exposes what direct-store tests can't, and the composite serving memory over HTTP makes it bite.**
|
||||
- `[2026-06-19]` **Full WT-driven `:8392` live-smoke is infra-gated — `:8392` not in WT's `BIFROST_CLIENT_ALLOWED_HOSTS` (bind 422s).** New provider ports are NOT auto-allowlisted (only `:8390/:8391` are). Self-driven dispatch (minted consumer-key JWTs → `:8392`) is the wire-proof; the WT-turn needs infra-ops to add `:8392` (requested `01KVHWJGTT…`).
|
||||
- `[2026-06-19]` **heid-code-review pulled MORE weight than its own "marginal" self-assessment.** The panel returned zero drift, but its single test-fidelity finding CASCADED into 2 real latent-bug fixes when applied (the memory `check_same_thread` bug + Regin's op-feed field-name bug). **Lesson: a contract-fidelity nudge can transitively expose bugs the test never reached — don't dismiss a "marginal" finding by its count.**
|
||||
|
||||
- `[2026-06-20]` **The post-turn-async timing trap bit AGAIN — even a 35s post-`[done]` read missed the promotion `upsert_many` by ~2s** (it landed `19:48:58`; the read was ~`19:48:56`). A 15s-interval background poll caught it on the first tick. Same family as the affect.emit / async-promotion traps already logged — re-confirmed that "wait once then read" is fragile for post-turn writes; **poll a window, don't snapshot once.** (The affect.emit write, by contrast, DID land inside the 35s window — promotion is the slower of the two post-turn writes.)
|
||||
- `[2026-06-30]` **Heimdall keys are PER-INSTANCE — a key minted on one Worldtree 401s on another.** Our Conversation-API key works on personal `:8081` but 401s `auth_invalid` on demo `:8080` (per-instance Heimdall user store + pepper; fresh deploys start with an EMPTY key store). Same as the admin key (personal-only). **To live-drive a given instance you need a key minted FOR that instance** (request via infra-ops). Couldn't live-prove the b2 409 on demo for this reason → deferred to personal-b2 where we have access.
|
||||
- `[2026-06-30]` **`tea comment <N>` hangs on Gitea** (the whole compound bash auto-backgrounded + stuck on the open `tea` call). The #11 prereq comment hung; killed it + posted via the Gitea HTTP API directly (`POST /api/v1/repos/vh/ratatoskr/issues/<N>/comments`, token from `~/.config/tea/config.yml`). **For issue comments, prefer the Gitea API over `tea comment` when `tea` is flaky** (CLAUDE.md already says use HTTP for comment-EDITS; this extends it to ADD when tea hangs). Verify-then-post (check the comment didn't already land) to avoid a double-post after a kill.
|
||||
|
||||
_18 older entries (2026-05-* — the original debug-TUI/web build era) archived to archival-memory.md._
|
||||
|
||||
+5
-5
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "ratatoskr"
|
||||
version = "0.17.17"
|
||||
version = "0.19.0"
|
||||
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
@@ -30,7 +30,7 @@ web = [
|
||||
# from the debug TUI. Recipe: bifrost/docs/implementing-a-consumer.md.
|
||||
provider = [
|
||||
"ratatoskr[web]", # reuse the starlette + uvicorn ASGI stack
|
||||
"bifrost>=0.10.0", # consumer engines + library (0.10.0: build_combined_app (#18) + mandatory affect.fetch, strong-or-absent; 0.8.0/wire-v0.6: scope_all/scope_any split (#11); 0.7.0/v0.5: agent_self)
|
||||
"bifrost==1.0.0", # consumer engines + library. 1.0.0 = first STABLE release, wire v0.6 FROZEN (non-breaking repin from >=0.10.0; build_combined_app #18 + mandatory affect.fetch; 0.8.0/v0.6 scope_all/scope_any #11; 0.7.0/v0.5 agent_self)
|
||||
"jsonschema>=4", # bifrost runtime dep — envelope validation
|
||||
"sqlite-vec>=0.1.6", # vector index for the memory plane (vec0 virtual table)
|
||||
]
|
||||
@@ -60,9 +60,9 @@ Repository = "https://gitea.phasefinal.com/vh/ratatoskr"
|
||||
# Ratatoskr is built against Worldtree at this commit; the vendored
|
||||
# spec snapshot in docs/ reflects that SHA.
|
||||
[tool.ratatoskr.spec-pin]
|
||||
worldtree-spec-rev = "f1b59f8cd6fe41e497d0be9dad9d3110451f0d9a"
|
||||
worldtree-version = "v0.29.0"
|
||||
pinned-on = "2026-05-26"
|
||||
worldtree-spec-rev = "5810a26b38a5ea6630892f9a39756f57c5b7b41e"
|
||||
worldtree-version = "v1.0.0b2"
|
||||
pinned-on = "2026-06-30"
|
||||
|
||||
# Bifrost lives on the auth-gated gitea PyPI index (not public PyPI).
|
||||
# uv reads the credential from UV_INDEX_GITEA_USERNAME / _PASSWORD or ~/.netrc.
|
||||
|
||||
+97
-14
@@ -12,7 +12,7 @@ import signal
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
from typing import TextIO
|
||||
from typing import Any, TextIO
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -24,6 +24,8 @@ from ratatoskr.sessions import (
|
||||
SessionApiFailed,
|
||||
create_session,
|
||||
endpoint_for_plane,
|
||||
get_capabilities,
|
||||
get_me,
|
||||
)
|
||||
from ratatoskr.sse_client import (
|
||||
AffectUpdate,
|
||||
@@ -47,7 +49,7 @@ from ratatoskr.sse_client import (
|
||||
TurnIdFlip,
|
||||
WorkerPhase,
|
||||
cancel_turn,
|
||||
stream_turn,
|
||||
stream_turn_resilient,
|
||||
)
|
||||
|
||||
|
||||
@@ -97,6 +99,13 @@ class ParsedArgs:
|
||||
bifrost: BifrostBinding | None = None
|
||||
bifrost_plane: 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
|
||||
# Optional admin-tier key (RATATOSKR_ADMIN_API_KEY / --admin-key) for the
|
||||
# admin-scoped inspection reads (BifrostState pane, GET /admin/sessions/…).
|
||||
# None when unset — the BifrostState pane then shows "admin key not configured".
|
||||
admin_key: str | None = None
|
||||
|
||||
|
||||
class _ArgparseError(Exception):
|
||||
@@ -121,6 +130,8 @@ def _parse_args(argv: list[str] | None) -> ParsedArgs:
|
||||
parser.add_argument("--api-key", dest="api_key")
|
||||
parser.add_argument("--server")
|
||||
parser.add_argument("--raw", action="store_true")
|
||||
parser.add_argument("--whoami", action="store_true")
|
||||
parser.add_argument("--admin-key", dest="admin_key")
|
||||
# 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)
|
||||
# Issue #17: bind the created session to our own Bifrost provider plane.
|
||||
@@ -141,17 +152,32 @@ def _parse_args(argv: list[str] | None) -> ParsedArgs:
|
||||
# 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:
|
||||
raise UsageError("--end-user-id must be non-empty when passed")
|
||||
if ns.session and ns.new:
|
||||
raise UsageError("--session and --new are mutually exclusive; pass exactly one")
|
||||
if not ns.session and not ns.new:
|
||||
raise UsageError("pass exactly one of --session or --new")
|
||||
if ns.session and ns.agent:
|
||||
raise UsageError("--agent is required with --new and forbidden with --session")
|
||||
if ns.new and not ns.agent and ns.send is not None:
|
||||
# Issue #8: --agent stays required for --send --new (non-interactive,
|
||||
# cannot prompt). Bare --new (TUI mode) accepts None — picker drives
|
||||
# the choice via list_agents in _resolve_then_run.
|
||||
raise UsageError("--agent is required when --new is passed in --send mode")
|
||||
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:
|
||||
raise UsageError("--session and --new are mutually exclusive")
|
||||
if not ns.session and not ns.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:
|
||||
raise UsageError("--agent is required with --new and forbidden with --session")
|
||||
if ns.new and not ns.agent and ns.send is not None:
|
||||
# Issue #8: --agent stays required for --send --new (non-interactive,
|
||||
# cannot prompt). Bare --new (TUI mode) accepts None — picker drives
|
||||
# the choice via list_agents in _resolve_then_run.
|
||||
raise UsageError("--agent is required when --new is passed in --send mode")
|
||||
|
||||
api_key = ns.api_key or os.environ.get("WORLDTREE_API_KEY") or ""
|
||||
if not api_key:
|
||||
@@ -187,6 +213,9 @@ def _parse_args(argv: list[str] | None) -> ParsedArgs:
|
||||
bifrost = BifrostBinding(endpoint_url=endpoint_for_plane(ns.bifrost_plane, host))
|
||||
bifrost_plane = ns.bifrost_plane
|
||||
consumer_key = os.environ.get("RATATOSKR_BIFROST_CONSUMER_KEY") or None
|
||||
# Admin-tier key for the admin-scoped inspection reads (BifrostState pane).
|
||||
# Flag > env > None; None leaves the admin panes showing "not configured".
|
||||
admin_key = ns.admin_key or os.environ.get("RATATOSKR_ADMIN_API_KEY") or None
|
||||
|
||||
return ParsedArgs(
|
||||
send_content=ns.send,
|
||||
@@ -200,6 +229,8 @@ def _parse_args(argv: list[str] | None) -> ParsedArgs:
|
||||
bifrost=bifrost,
|
||||
bifrost_plane=bifrost_plane,
|
||||
consumer_key=consumer_key,
|
||||
whoami=ns.whoami,
|
||||
admin_key=admin_key,
|
||||
)
|
||||
|
||||
|
||||
@@ -393,7 +424,7 @@ async def _run_turn(
|
||||
cancelling = False
|
||||
sigint_task: asyncio.Task[bool] | None = None
|
||||
cancel_task: asyncio.Task[None] | None = None # strong ref to fire-and-forget cancel
|
||||
aiter_obj = stream_turn(client, session_id, content).__aiter__()
|
||||
aiter_obj = stream_turn_resilient(client, session_id, content).__aiter__()
|
||||
|
||||
try:
|
||||
while True:
|
||||
@@ -550,6 +581,56 @@ async def _amain(args: ParsedArgs) -> int:
|
||||
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:
|
||||
"""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)
|
||||
@@ -565,6 +646,8 @@ def main(argv: list[str] | None = None) -> int:
|
||||
# argparse's --help / --version short-circuit via SystemExit(0). Pass the code
|
||||
# through verbatim — argparse already printed help to stdout.
|
||||
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:
|
||||
# TUI mode — lazy import preserves INV-001 (no textual in cli at module scope).
|
||||
from ratatoskr.tui import run_tui
|
||||
|
||||
+87
-10
@@ -227,19 +227,21 @@ async def list_sessions(
|
||||
def endpoint_for_plane(plane: str, base_host: str) -> str:
|
||||
"""Map a provider plane name to its Worldtree-VISIBLE base URL.
|
||||
|
||||
Issue #17 dev helper: `memory` → :8391, `affect` → :8390. Returns the
|
||||
Worldtree-visible base (e.g. `http://10.100.10.50:8391`), NOT the client's
|
||||
loopback — Worldtree must reach the provider over the network. `http://` is
|
||||
deliberate: the HTTPS relaxation is allowlist-side (Worldtree's
|
||||
BIFROST_CLIENT_ALLOWED_HOSTS), not a URL concern. A production HTTPS endpoint
|
||||
is supplied directly, bypassing this helper.
|
||||
Issue #17 dev helper: `memory` → :8391, `affect` → :8390, `combined` → :8392
|
||||
(the #18 composite both-plane endpoint). Returns the Worldtree-visible base
|
||||
(e.g. `http://10.100.10.50:8391`), NOT the client's loopback — Worldtree must
|
||||
reach the provider over the network. `http://` is deliberate: the HTTPS
|
||||
relaxation is allowlist-side (Worldtree's BIFROST_CLIENT_ALLOWED_HOSTS), not a
|
||||
URL concern. A production HTTPS endpoint is supplied directly, bypassing this
|
||||
helper.
|
||||
"""
|
||||
if plane not in ("memory", "affect"):
|
||||
ports = {"memory": 8391, "affect": 8390, "combined": 8392}
|
||||
if plane not in ports:
|
||||
raise ValueError(
|
||||
f"unknown plane: {plane!r} (expected 'memory' or 'affect')"
|
||||
f"unknown plane: {plane!r} "
|
||||
"(expected 'memory', 'affect', or 'combined')"
|
||||
)
|
||||
port = 8391 if plane == "memory" else 8390
|
||||
return f"http://{base_host}:{port}"
|
||||
return f"http://{base_host}:{ports[plane]}"
|
||||
|
||||
|
||||
def _bifrost_error_from(resp: httpx.Response) -> str | None:
|
||||
@@ -404,3 +406,78 @@ async def get_persona_state(
|
||||
if resp.status_code == 403 and error_code == "auth_scope_denied":
|
||||
raise AuthScopeDenied(scope="persona.read")
|
||||
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_bifrost(
|
||||
client: httpx.AsyncClient, session_id: str, *, admin_key: str
|
||||
) -> dict[str, Any]:
|
||||
"""GET /admin/sessions/{session_id}/bifrost — admin-scoped Bifrost dispatch state (#176).
|
||||
|
||||
Returns the live Bifrost binding for a session: `{endpoint_url, consumer_id,
|
||||
connected, capabilities_granted, tools: [{name, description}]}`. Requires the
|
||||
`admin.sessions.read` scope (admin tier), so the request OVERRIDES the
|
||||
Authorization header with `admin_key` (distinct from the client's default
|
||||
consumer key). Read-only (audited server-side). Parsed dict verbatim; any
|
||||
non-200 → SessionApiFailed — notably 403 `auth_scope_denied` (key lacks the
|
||||
scope) and 404 `session_not_bifrost_bound` (session exists, no live client).
|
||||
"""
|
||||
assert client is not None
|
||||
assert session_id and isinstance(session_id, str)
|
||||
assert admin_key and isinstance(admin_key, str)
|
||||
resp = await client.get(
|
||||
f"/admin/sessions/{session_id}/bifrost",
|
||||
headers={"Authorization": f"Bearer {admin_key}"},
|
||||
)
|
||||
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)
|
||||
|
||||
@@ -175,6 +175,23 @@ Event = (
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AdminEvent:
|
||||
"""One `/admin/events` envelope (INV-046) — an admin-tier lifecycle event.
|
||||
|
||||
Distinct from the turn-stream `Event` union: this is the process-wide admin
|
||||
broadcast stream, not a per-turn stream. `id` is a plain monotonic int
|
||||
(resets on restart; heartbeats have id=0). `type` is a dotted namespace
|
||||
(session.* / turn.* / key.* / system.*). `data` is a type-specific dict —
|
||||
most carry `session_id`; per INV-049 it holds IDs + small metadata only.
|
||||
"""
|
||||
|
||||
id: int
|
||||
type: str
|
||||
timestamp: str | None
|
||||
data: dict[str, Any]
|
||||
|
||||
|
||||
class MalformedSseId(Exception):
|
||||
"""Raised when an SSE event's `id:` wire field is missing or non-composite."""
|
||||
|
||||
@@ -215,6 +232,59 @@ class SseConnectFailed(Exception):
|
||||
self.body = body
|
||||
|
||||
|
||||
class AgentNotAvailable(SseConnectFailed):
|
||||
"""Eager 409 from the turn POST (Worldtree v1.0.0b1, #331): the session's
|
||||
agent is unavailable, so the turn never launched. Pre-b1 this arrived as a
|
||||
200 stream + an in-stream `error` event; b1 surfaces it eagerly. Subclass of
|
||||
SseConnectFailed so existing `except SseConnectFailed` handlers still catch
|
||||
it — this type just adds the parsed `error_code` + `message`."""
|
||||
|
||||
def __init__(self, *, body: bytes, error_code: str, message: str) -> None:
|
||||
super().__init__(status=409, body=body)
|
||||
self.error_code = error_code
|
||||
self.message = message
|
||||
|
||||
|
||||
class TurnLaunchUnavailable(SseConnectFailed):
|
||||
"""Eager 503 from the turn POST (Worldtree v1.0.0b1, #331): a transient
|
||||
turn-launch failure (loop shutdown / resource exhaustion). RETRYABLE.
|
||||
Subclass of SseConnectFailed; adds `error_code`, `message`, `retryable`."""
|
||||
|
||||
retryable = True
|
||||
|
||||
def __init__(self, *, body: bytes, error_code: str, message: str) -> None:
|
||||
super().__init__(status=503, body=body)
|
||||
self.error_code = error_code
|
||||
self.message = message
|
||||
|
||||
|
||||
# Canonical error_codes (Worldtree #331 / v1.0.0b2): 409 -> agent_not_available,
|
||||
# 503 -> not_ready (retryable; re-pinned from internal_error). Used only as a
|
||||
# fallback default when the body omits error_code — the real code is surfaced
|
||||
# verbatim from the {detail:{error_code,message}} envelope.
|
||||
_EAGER_TURN_FAILURE_CODE = {409: "agent_not_available", 503: "not_ready"}
|
||||
|
||||
|
||||
def _eager_failure_fields(body: bytes, status: int) -> tuple[str, str]:
|
||||
"""Extract (error_code, message) from an eager turn-launch failure body
|
||||
(#331). Accepts the Worldtree `{"detail": {...}}` envelope OR a flat
|
||||
`{error_code, message}`; falls back to a status-derived default code and a
|
||||
generic message when the body is absent / non-JSON / malformed."""
|
||||
try:
|
||||
parsed: Any = json.loads(body)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
parsed = None
|
||||
src: dict[str, Any] = {}
|
||||
if isinstance(parsed, dict):
|
||||
detail = parsed.get("detail")
|
||||
src = detail if isinstance(detail, dict) else parsed
|
||||
code = src.get("error_code") or _EAGER_TURN_FAILURE_CODE[status]
|
||||
message = src.get("message")
|
||||
if not isinstance(message, str):
|
||||
message = f"turn launch failed (HTTP {status})"
|
||||
return str(code), message
|
||||
|
||||
|
||||
class SseConnectionDropped(Exception):
|
||||
"""Raised when the HTTP/SSE connection dropped mid-stream."""
|
||||
|
||||
@@ -434,6 +504,19 @@ async def stream_turn(
|
||||
f"/sessions/{session_id}/messages",
|
||||
json={"content": content},
|
||||
) as event_source:
|
||||
# Worldtree v1.0.0b1 (#331): turn-launch failures arrive EAGERLY as a
|
||||
# status before any stream — 409 agent_not_available (pre-b1 this was a
|
||||
# 200 + in-stream `error` event), 503 a transient retryable launch
|
||||
# failure. Surface them as typed SseConnectFailed subclasses carrying
|
||||
# error_code; request-level non-2xx (404 session_not_found, etc.) stay
|
||||
# generic SseConnectFailed.
|
||||
status = event_source.response.status_code
|
||||
if status in (409, 503):
|
||||
body = await event_source.response.aread()
|
||||
code, message = _eager_failure_fields(body, status)
|
||||
if status == 409:
|
||||
raise AgentNotAvailable(body=body, error_code=code, message=message)
|
||||
raise TurnLaunchUnavailable(body=body, error_code=code, message=message)
|
||||
try:
|
||||
event_source.response.raise_for_status()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
@@ -483,6 +566,100 @@ async def reconnect_turn(
|
||||
yield event
|
||||
|
||||
|
||||
async def stream_turn_resilient(
|
||||
client: httpx.AsyncClient,
|
||||
session_id: str,
|
||||
content: str,
|
||||
*,
|
||||
max_reconnects: int = 5,
|
||||
) -> AsyncIterator[Event]:
|
||||
"""Resume-orchestration wrapper over stream_turn + reconnect_turn.
|
||||
|
||||
Yields ONE continuous Event stream; on `SseConnectionDropped` (mid-stream
|
||||
drop or clean EOF before a terminal), resumes from the last-seen `sse_id`
|
||||
via `reconnect_turn`, up to `max_reconnects` times, until a terminal
|
||||
Done/Error/Cancelled arrives. The single shared surface presenters consume
|
||||
for resilient streaming (design-brief §8b: "share the consumer, branch the
|
||||
presenter"). Cross-process resume stays deferred to v2 (§8d): `last_seen`
|
||||
lives only in this generator's frame. See contract FN stream_turn_resilient
|
||||
(amendment 2026-06-30).
|
||||
"""
|
||||
assert client is not None
|
||||
assert session_id and isinstance(session_id, str)
|
||||
assert content and isinstance(content, str)
|
||||
assert isinstance(max_reconnects, int) and max_reconnects >= 0
|
||||
|
||||
last_seen: SseId | None = None
|
||||
reconnects = 0
|
||||
gen = stream_turn(client, session_id, content)
|
||||
while True:
|
||||
try:
|
||||
async for event in gen:
|
||||
last_seen = event.sse_id
|
||||
yield event
|
||||
return # generator completed cleanly → terminal event reached (INV-001)
|
||||
except SseConnectionDropped as drop:
|
||||
# Prefer the id we tracked from a yielded event; fall back to the one
|
||||
# the drop carries (covers a drop on the very first frame). Non-drop
|
||||
# reconnect failures (412/410/400/flip) are NOT caught here — they
|
||||
# propagate per the contract's "surface, not recover" policy.
|
||||
seen = last_seen or drop.last_seen_sse_id
|
||||
if seen is None or reconnects >= max_reconnects:
|
||||
raise
|
||||
reconnects += 1
|
||||
gen = reconnect_turn(
|
||||
client,
|
||||
session_id,
|
||||
content,
|
||||
last_event_id=f"{seen.turn_id}:{seen.seq}",
|
||||
)
|
||||
|
||||
|
||||
async def stream_admin_events(
|
||||
client: httpx.AsyncClient,
|
||||
*,
|
||||
admin_key: str,
|
||||
last_event_id: int | None = None,
|
||||
) -> AsyncIterator[AdminEvent]:
|
||||
"""GET /admin/events SSE — the admin-tier lifecycle broadcast stream (INV-046).
|
||||
|
||||
Yields `AdminEvent` envelopes as they arrive. Admin-scoped (admin.events.read):
|
||||
the request OVERRIDES Authorization with `admin_key` (distinct from the
|
||||
client's default consumer bearer). `last_event_id` sets the `Last-Event-ID`
|
||||
header for resume (plain decimal int). Long-lived — iterate until the caller
|
||||
stops or the connection ends. Non-200 → SseConnectFailed; a mid-stream drop
|
||||
→ SseConnectionDropped (caller may reconnect from the last-seen `AdminEvent.id`).
|
||||
Malformed frames are skipped (best-effort stream).
|
||||
"""
|
||||
assert client is not None
|
||||
assert admin_key and isinstance(admin_key, str)
|
||||
headers = {"Authorization": f"Bearer {admin_key}"}
|
||||
if last_event_id is not None:
|
||||
headers["Last-Event-ID"] = str(last_event_id)
|
||||
async with httpx_sse.aconnect_sse(
|
||||
client, "GET", "/admin/events", headers=headers
|
||||
) as event_source:
|
||||
if event_source.response.status_code != 200:
|
||||
body = await event_source.response.aread()
|
||||
raise SseConnectFailed(status=event_source.response.status_code, body=body)
|
||||
try:
|
||||
async for sse in event_source.aiter_sse():
|
||||
if sse.data == "":
|
||||
continue
|
||||
try:
|
||||
env = json.loads(sse.data)
|
||||
except json.JSONDecodeError:
|
||||
continue # skip a malformed admin frame (best-effort)
|
||||
yield AdminEvent(
|
||||
id=env.get("id", 0),
|
||||
type=env["type"],
|
||||
timestamp=env.get("timestamp"),
|
||||
data=env.get("data", {}),
|
||||
)
|
||||
except (httpx.ReadError, httpx.RemoteProtocolError, httpx.ReadTimeout) as exc:
|
||||
raise SseConnectionDropped(last_seen_sse_id=None) from exc
|
||||
|
||||
|
||||
def _parse_sse_id(raw: str) -> SseId:
|
||||
"""Parse the SSE wire `id:` as composite `{turn_id}:{seq}`. See contract FN _parse_sse_id."""
|
||||
assert isinstance(raw, str)
|
||||
|
||||
+356
-7
@@ -42,11 +42,16 @@ from ratatoskr.sessions import (
|
||||
BifrostHandshakeFailed,
|
||||
PersonaNotConfigured,
|
||||
SessionApiFailed,
|
||||
SessionInfo,
|
||||
create_session,
|
||||
get_persona_state,
|
||||
get_session_bifrost,
|
||||
get_session_tools,
|
||||
list_agents,
|
||||
list_sessions,
|
||||
)
|
||||
from ratatoskr.sse_client import (
|
||||
AdminEvent,
|
||||
AffectUpdate,
|
||||
AwaitingLlmFirstToken,
|
||||
CancelAlreadyCompleted,
|
||||
@@ -68,7 +73,8 @@ from ratatoskr.sse_client import (
|
||||
TurnIdFlip,
|
||||
WorkerPhase,
|
||||
cancel_turn,
|
||||
stream_turn,
|
||||
stream_admin_events,
|
||||
stream_turn_resilient,
|
||||
)
|
||||
|
||||
# ---- Australis theme (https://github.com/lkraven/australis) ------------------
|
||||
@@ -194,6 +200,46 @@ def _ts() -> str:
|
||||
return now.strftime("%H:%M:%S") + f".{now.microsecond // 1000:03d}"
|
||||
|
||||
|
||||
def _format_admin_event(ev: AdminEvent) -> str:
|
||||
"""One-line render of an /admin/events envelope for the AdminEvents pane.
|
||||
|
||||
Drops `session_id` from the detail (the pane is already session-scoped) and
|
||||
shows HH:MM:SS from the ISO timestamp + the remaining small metadata fields.
|
||||
"""
|
||||
ts = (ev.timestamp or "")[11:19]
|
||||
extras = " ".join(f"{k}={v}" for k, v in ev.data.items() if k != "session_id")
|
||||
return f"[{ts}] {ev.type} {extras}".rstrip()
|
||||
|
||||
|
||||
def _format_bifrost_state(state: dict) -> list[str]:
|
||||
"""Render GET /admin/sessions/{id}/bifrost (#176) into BifrostState-pane lines."""
|
||||
tools = [t.get("name", "?") for t in state.get("tools", [])]
|
||||
caps = state.get("capabilities_granted", [])
|
||||
return [
|
||||
f"bifrost binding: connected={state.get('connected')} "
|
||||
f"consumer={state.get('consumer_id', '?')}",
|
||||
f" endpoint: {state.get('endpoint_url', '?')}",
|
||||
f" caps_granted: {', '.join(caps) or '(none)'}",
|
||||
f" tools ({len(tools)}): {', '.join(tools) or '(none)'}",
|
||||
]
|
||||
|
||||
|
||||
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.
|
||||
|
||||
@@ -798,6 +844,128 @@ class AgentPickerApp(App[str | 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]):
|
||||
"""Textual TUI shell — single chat pane."""
|
||||
|
||||
@@ -898,7 +1066,7 @@ class RatatoskrApp(App[int]):
|
||||
/* v0.8.1: #current-text Static removed. Streaming text now coalesces
|
||||
on `\n` and writes directly to #transcript (same pattern as v0.7.1
|
||||
thinking fix). Eliminates the dock-bottom-growth-overlap bug. */
|
||||
#tools-log, #debug-log, #thinking-log {
|
||||
#tools-log, #debug-log, #thinking-log, #bifrost-log, #admin-events-log {
|
||||
background: $background;
|
||||
padding: 0 1;
|
||||
}
|
||||
@@ -1063,6 +1231,24 @@ class RatatoskrApp(App[int]):
|
||||
id="persona-log", wrap=True, markup=False,
|
||||
highlight=False, min_width=0,
|
||||
)
|
||||
with TabPane("Bifrost", id="bifrost-tab"):
|
||||
# #176: admin-scoped Bifrost dispatch state (endpoint,
|
||||
# connected, granted caps, tools) via
|
||||
# GET /admin/sessions/{id}/bifrost. Hydrated on mount
|
||||
# with the admin key; "not configured" when absent.
|
||||
yield RichLog(
|
||||
id="bifrost-log", wrap=True, markup=False,
|
||||
highlight=False, min_width=0,
|
||||
)
|
||||
with TabPane("AdminEvents", id="admin-events-tab"):
|
||||
# #11: live GET /admin/events SSE stream, admin-scoped,
|
||||
# FILTERED to the active session (design-brief §6). A
|
||||
# long-lived worker appends matching lifecycle events;
|
||||
# "not configured" when no admin key is set.
|
||||
yield RichLog(
|
||||
id="admin-events-log", wrap=True, markup=False,
|
||||
highlight=False, min_width=0,
|
||||
)
|
||||
# INV-002 + INV-003: visible identity + hint widgets (Footer-area).
|
||||
# pane-name widget displays current side-pane name.
|
||||
yield Static("", id="identity")
|
||||
@@ -1116,6 +1302,17 @@ 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())
|
||||
# #176: hydrate the BifrostState pane via GET /admin/sessions/{id}/bifrost
|
||||
# (admin-scoped). Self-labels "not configured" when no admin key is set,
|
||||
# "not bound" for the common unbound-session 404 — always writes at mount.
|
||||
self.run_worker(self._hydrate_bifrost_state())
|
||||
# #11: long-lived worker streaming GET /admin/events into the AdminEvents
|
||||
# pane, filtered to this session. Admin-key-gated; cancelled on app exit.
|
||||
self.run_worker(self._stream_admin_events())
|
||||
|
||||
async def _hydrate_persona(self) -> None:
|
||||
"""Hydrate persona-header + Persona pane via GET /agents/{id}/persona_state.
|
||||
@@ -1151,6 +1348,128 @@ 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', []))}"
|
||||
)
|
||||
|
||||
async def _hydrate_bifrost_state(self) -> None:
|
||||
"""Hydrate the BifrostState pane via GET /admin/sessions/{id}/bifrost (#176).
|
||||
|
||||
Admin-scoped (admin.sessions.read) — uses `self.args.admin_key`. Best-effort
|
||||
(mirrors _hydrate_session_tools): on 200 writes the live binding (endpoint,
|
||||
connected, granted caps, tools) + audits; on failure a labeled line + audit,
|
||||
never crashes. No admin key → "not configured". 404 session_not_bifrost_bound
|
||||
is the routine unbound-session case; 403 means the key lacks the scope.
|
||||
"""
|
||||
assert self.client is not None and self.session_id is not None
|
||||
from rich.text import Text as RichText
|
||||
|
||||
log = self.query_one("#bifrost-log", RichLog)
|
||||
admin_key = getattr(self.args, "admin_key", None)
|
||||
if not admin_key:
|
||||
log.write(
|
||||
RichText("(admin key not configured — set RATATOSKR_ADMIN_API_KEY)")
|
||||
)
|
||||
self._audit(
|
||||
f"bifrost_state_skipped session={self.session_id[-8:]} reason=no_admin_key"
|
||||
)
|
||||
return
|
||||
try:
|
||||
state = await get_session_bifrost(
|
||||
self.client, self.session_id, admin_key=admin_key
|
||||
)
|
||||
except SessionApiFailed as exc:
|
||||
label = (
|
||||
"(session not bound to Bifrost)"
|
||||
if exc.status == 404
|
||||
else f"(bifrost state unavailable: HTTP {exc.status})"
|
||||
)
|
||||
log.write(RichText(label))
|
||||
self._audit(
|
||||
f"bifrost_state_unavailable session={self.session_id[-8:]} status={exc.status}"
|
||||
)
|
||||
return
|
||||
except Exception as exc: # best-effort — never crash the TUI on hydrate
|
||||
log.write(RichText(f"(bifrost state hydration failed: {type(exc).__name__})"))
|
||||
self._audit(
|
||||
f"bifrost_state_hydration_failed session={self.session_id[-8:]} "
|
||||
f"err={type(exc).__name__}: {exc!s:.120}"
|
||||
)
|
||||
return
|
||||
for line in _format_bifrost_state(state):
|
||||
log.write(RichText(line))
|
||||
self._audit(
|
||||
f"bifrost_state_hydrated session={self.session_id[-8:]} "
|
||||
f"connected={state.get('connected')} tools={len(state.get('tools', []))}"
|
||||
)
|
||||
|
||||
def _admin_event_matches(self, ev: AdminEvent) -> bool:
|
||||
"""AdminEvents filter (design-brief §6): active-session events + non-heartbeat
|
||||
system.* (stream-integrity signals). Heartbeats are keepalive noise."""
|
||||
if ev.type == "system.heartbeat":
|
||||
return False
|
||||
if ev.type.startswith("system."):
|
||||
return True
|
||||
return ev.data.get("session_id") == self.session_id
|
||||
|
||||
async def _stream_admin_events(self) -> None:
|
||||
"""Stream GET /admin/events (admin-scoped) into the AdminEvents pane (#11).
|
||||
|
||||
Long-lived + best-effort (never crashes the TUI). Filtered to the active
|
||||
session (design-brief §6): appends matching lifecycle events as they
|
||||
arrive. No admin key → "not configured". On connect failure (e.g. 403
|
||||
scope-denied) or a mid-stream drop, writes a labeled line and stops.
|
||||
"""
|
||||
assert self.client is not None and self.session_id is not None
|
||||
from rich.text import Text as RichText
|
||||
|
||||
log = self.query_one("#admin-events-log", RichLog)
|
||||
admin_key = getattr(self.args, "admin_key", None)
|
||||
if not admin_key:
|
||||
log.write(RichText("(admin key not configured — set RATATOSKR_ADMIN_API_KEY)"))
|
||||
self._audit(
|
||||
f"admin_events_skipped session={self.session_id[-8:]} reason=no_admin_key"
|
||||
)
|
||||
return
|
||||
try:
|
||||
async for ev in stream_admin_events(self.client, admin_key=admin_key):
|
||||
if self._admin_event_matches(ev):
|
||||
log.write(RichText(_format_admin_event(ev)))
|
||||
except SseConnectFailed as exc:
|
||||
log.write(RichText(f"(admin events unavailable: HTTP {exc.status})"))
|
||||
self._audit(
|
||||
f"admin_events_unavailable session={self.session_id[-8:]} status={exc.status}"
|
||||
)
|
||||
except Exception as exc: # drop / best-effort — never crash the TUI
|
||||
log.write(RichText(f"(admin events stream ended: {type(exc).__name__})"))
|
||||
self._audit(
|
||||
f"admin_events_ended session={self.session_id[-8:]} err={type(exc).__name__}"
|
||||
)
|
||||
|
||||
def _update_persona_surfaces(self, snapshot: dict) -> None:
|
||||
"""Update sticky header + Persona pane from a fresh snapshot.
|
||||
|
||||
@@ -1318,7 +1637,7 @@ class RatatoskrApp(App[int]):
|
||||
pass
|
||||
|
||||
try:
|
||||
async for event in stream_turn(self.client, self.session_id, content):
|
||||
async for event in stream_turn_resilient(self.client, self.session_id, content):
|
||||
if self.active_turn_id is None:
|
||||
self.active_turn_id = event.sse_id.turn_id
|
||||
self._write_turn_headers(self.active_turn_id)
|
||||
@@ -1430,8 +1749,9 @@ def run_tui(args: ParsedArgs) -> int:
|
||||
"""
|
||||
# PRE-001: TUI-mode marker (issue #4 contract)
|
||||
assert isinstance(args, ParsedArgs) and args.send_content is None
|
||||
# PRE-002: Exactly one of session_id / new must be set (xor)
|
||||
assert bool(args.session_id) != bool(args.new)
|
||||
# PRE-002 (slice b2): --session and --new are mutually exclusive, but NEITHER
|
||||
# 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))
|
||||
|
||||
|
||||
@@ -1467,6 +1787,35 @@ async def _resolve_then_run(args: ParsedArgs) -> int:
|
||||
# agent_id (remote wins on conflict, since a server-listed agent
|
||||
# is the authoritative source).
|
||||
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:
|
||||
try:
|
||||
agents = await list_agents(client)
|
||||
@@ -1551,8 +1900,8 @@ async def _resolve_then_run(args: ParsedArgs) -> int:
|
||||
session_id = info.session_id
|
||||
agent_id: str | None = info.agent_id
|
||||
else:
|
||||
assert args.session_id is not None
|
||||
session_id = args.session_id
|
||||
assert resolved_session_id is not None
|
||||
session_id = resolved_session_id
|
||||
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)
|
||||
exit_code = await app.run_async()
|
||||
|
||||
@@ -50,7 +50,7 @@ from ratatoskr.sse_client import (
|
||||
SseConnectionDropped,
|
||||
TurnIdFlip,
|
||||
cancel_turn,
|
||||
stream_turn,
|
||||
stream_turn_resilient,
|
||||
)
|
||||
|
||||
|
||||
@@ -138,7 +138,7 @@ async def _create_session_endpoint(request: Request) -> JSONResponse:
|
||||
consumer_key = request.app.state.bifrost_consumer_key
|
||||
visible_host = request.app.state.bifrost_visible_host
|
||||
if bifrost_plane:
|
||||
if bifrost_plane not in ("memory", "affect"):
|
||||
if bifrost_plane not in ("memory", "affect", "combined"):
|
||||
return JSONResponse(
|
||||
{"error_code": "invalid_bifrost_plane"}, status_code=400
|
||||
)
|
||||
@@ -291,7 +291,7 @@ async def _stream_turn_endpoint(request: Request) -> StreamingResponse:
|
||||
try:
|
||||
handle.status = "streaming"
|
||||
try:
|
||||
async for event in stream_turn(client, session_id, handle.content):
|
||||
async for event in stream_turn_resilient(client, session_id, handle.content):
|
||||
# v0.16.0: capture the upstream (Worldtree-assigned)
|
||||
# turn_id from the first event so cancel paths target
|
||||
# the real upstream turn, not our local counter.
|
||||
|
||||
@@ -513,6 +513,7 @@ body {
|
||||
<label class="field-label" for="bifrost-plane">Bifrost binding (Tier-3 provider)</label>
|
||||
<div class="select-wrap">
|
||||
<select id="bifrost-plane">
|
||||
<option value="combined" selected>combined (:8392) — PAD + memory in one session</option>
|
||||
<option value="">none — observe only</option>
|
||||
<option value="memory">memory (:8391) — durable recall</option>
|
||||
<option value="affect">affect (:8390) — persona / PAD</option>
|
||||
|
||||
+99
-6
@@ -175,10 +175,29 @@ class TestParseArgs:
|
||||
)
|
||||
|
||||
def test_usage_neither_session_nor_new(self) -> None:
|
||||
"""usage_neither_session_nor_new: neither flag → UsageError('pass exactly one')."""
|
||||
with pytest.raises(UsageError, match="pass exactly one"):
|
||||
"""usage_neither_session_nor_new: --send with neither flag → UsageError.
|
||||
|
||||
--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"])
|
||||
|
||||
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:
|
||||
"""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"])
|
||||
assert rc == 0
|
||||
|
||||
def test_usage_error_no_send(
|
||||
def test_empty_argv_fails_on_auth(
|
||||
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> 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] = []
|
||||
|
||||
async def fake_amain(args: ParsedArgs) -> int:
|
||||
@@ -1331,8 +1355,8 @@ class TestMain:
|
||||
|
||||
monkeypatch.setattr(cli_mod, "_amain", fake_amain)
|
||||
rc = main([])
|
||||
assert rc == 10
|
||||
assert "[usage_error]" in capsys.readouterr().err
|
||||
assert rc == 11
|
||||
assert "[auth_error]" in capsys.readouterr().err
|
||||
assert amain_calls == []
|
||||
|
||||
def test_usage_error_both_session_and_new(
|
||||
@@ -1540,3 +1564,72 @@ class TestBifrostBindCli:
|
||||
)
|
||||
rc = await _amain(args)
|
||||
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
|
||||
|
||||
@@ -18,7 +18,11 @@ from ratatoskr.sessions import (
|
||||
SessionPage,
|
||||
create_session,
|
||||
endpoint_for_plane,
|
||||
get_capabilities,
|
||||
get_me,
|
||||
get_persona_state,
|
||||
get_session_bifrost,
|
||||
get_session_tools,
|
||||
list_agents,
|
||||
list_sessions,
|
||||
)
|
||||
@@ -384,6 +388,13 @@ class TestEndpointForPlane:
|
||||
== "http://10.100.10.50:8390"
|
||||
)
|
||||
|
||||
def test_combined_plane_maps_to_8392(self) -> None:
|
||||
"""combined [#18 composite]: 'combined' → http://<host>:8392 (POST-001)."""
|
||||
assert (
|
||||
endpoint_for_plane("combined", "10.100.10.50")
|
||||
== "http://10.100.10.50:8392"
|
||||
)
|
||||
|
||||
def test_unknown_plane_raises_value_error(self) -> None:
|
||||
"""unknown_plane [adversarial]: any other plane → ValueError (PRE-001)."""
|
||||
with pytest.raises(ValueError):
|
||||
@@ -889,3 +900,205 @@ class TestGetPersonaState:
|
||||
with pytest.raises(PersonaNotConfigured) as exc_info:
|
||||
await get_persona_state(client, "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
|
||||
|
||||
|
||||
class TestGetSessionBifrost:
|
||||
"""#2 contract — get_session_bifrost (GET /admin/sessions/{id}/bifrost, #176)."""
|
||||
|
||||
@respx.mock
|
||||
async def test_happy_uses_admin_bearer(self) -> None:
|
||||
"""happy [happy,tracer]: 200 → binding dict; request carries the ADMIN bearer (override)."""
|
||||
route = respx.get("https://w.example/admin/sessions/s1/bifrost").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"endpoint_url": "https://bifrost.example/mcp",
|
||||
"consumer_id": "alice",
|
||||
"connected": True,
|
||||
"capabilities_granted": ["tools:call", "tools:read"],
|
||||
"tools": [{"name": "bifrost.alice.echo", "description": "echo"}],
|
||||
},
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(
|
||||
base_url="https://w.example",
|
||||
headers={"Authorization": "Bearer consumer-key"},
|
||||
) as client:
|
||||
state = await get_session_bifrost(client, "s1", admin_key="admin-xyz")
|
||||
assert state["connected"] is True
|
||||
assert state["tools"][0]["name"] == "bifrost.alice.echo"
|
||||
# the request overrode the client's default consumer bearer with the admin key
|
||||
assert route.calls[0].request.headers["Authorization"] == "Bearer admin-xyz"
|
||||
|
||||
@respx.mock
|
||||
async def test_403_scope_denied(self) -> None:
|
||||
"""403 [error]: admin key lacks admin.sessions.read → SessionApiFailed(403)."""
|
||||
respx.get("https://w.example/admin/sessions/s1/bifrost").mock(
|
||||
return_value=httpx.Response(403, json={"error_code": "auth_scope_denied"})
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(SessionApiFailed) as exc:
|
||||
await get_session_bifrost(client, "s1", admin_key="k")
|
||||
assert exc.value.status == 403
|
||||
|
||||
@respx.mock
|
||||
async def test_404_not_bound(self) -> None:
|
||||
"""404 [error]: session_not_bifrost_bound → SessionApiFailed(404)."""
|
||||
respx.get("https://w.example/admin/sessions/s1/bifrost").mock(
|
||||
return_value=httpx.Response(404, json={"error_code": "session_not_bifrost_bound"})
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(SessionApiFailed) as exc:
|
||||
await get_session_bifrost(client, "s1", admin_key="k")
|
||||
assert exc.value.status == 404
|
||||
|
||||
@respx.mock
|
||||
async def test_empty_admin_key_asserts(self) -> None:
|
||||
"""empty_admin_key [adversarial]: '' → AssertionError; no HTTP issued."""
|
||||
route = respx.get("https://w.example/admin/sessions/s1/bifrost").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_bifrost(client, "s1", admin_key="")
|
||||
assert route.call_count == 0
|
||||
|
||||
+294
-1
@@ -5,7 +5,9 @@ import pytest
|
||||
import respx
|
||||
|
||||
from ratatoskr.sse_client import (
|
||||
AdminEvent,
|
||||
AffectUpdate,
|
||||
AgentNotAvailable,
|
||||
AwaitingLlmFirstToken,
|
||||
CancelAlreadyCompleted,
|
||||
Cancelled,
|
||||
@@ -18,13 +20,17 @@ from ratatoskr.sse_client import (
|
||||
ResumeBufferExpired,
|
||||
ResumeTurnFinished,
|
||||
SseConnectFailed,
|
||||
SseConnectionDropped,
|
||||
SseId,
|
||||
Text,
|
||||
TurnIdFlip,
|
||||
TurnLaunchUnavailable,
|
||||
_parse_sse_id,
|
||||
cancel_turn,
|
||||
reconnect_turn,
|
||||
stream_admin_events,
|
||||
stream_turn,
|
||||
stream_turn_resilient,
|
||||
)
|
||||
|
||||
_DONE_42_6 = {
|
||||
@@ -49,6 +55,36 @@ def _sse_chunk(sse_id: str, body: dict[str, object]) -> bytes:
|
||||
return f"id: {sse_id}\ndata: {json.dumps(body)}\n\n".encode()
|
||||
|
||||
|
||||
_EVENT_STREAM = {"content-type": "text/event-stream"}
|
||||
|
||||
|
||||
class _DropStream(httpx.AsyncByteStream):
|
||||
"""Yield the given chunks, then raise a mid-stream drop (RemoteProtocolError).
|
||||
|
||||
Mirrors the inline `_DropAfter` used by TestStreamTurn.test_connection_drop;
|
||||
hoisted to module scope because the resilient-wrapper tests reuse it.
|
||||
"""
|
||||
|
||||
def __init__(self, chunks: list[bytes]) -> None:
|
||||
self._chunks = chunks
|
||||
|
||||
async def __aiter__(self): # type: ignore[no-untyped-def]
|
||||
for c in self._chunks:
|
||||
yield c
|
||||
raise httpx.RemoteProtocolError("simulated mid-stream drop")
|
||||
|
||||
async def aclose(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def _drop_response(chunks: list[bytes]) -> httpx.Response:
|
||||
return httpx.Response(200, headers=_EVENT_STREAM, stream=_DropStream(chunks))
|
||||
|
||||
|
||||
def _stream_response(content: bytes) -> httpx.Response:
|
||||
return httpx.Response(200, headers=_EVENT_STREAM, content=content)
|
||||
|
||||
|
||||
class TestParseSseId:
|
||||
def test_happy_simple(self) -> None:
|
||||
"""happy_simple [happy,tracer]: '42:3' -> SseId(turn_id=42, seq=3)."""
|
||||
@@ -415,8 +451,9 @@ class TestStreamTurn:
|
||||
async def test_connect_failed_body_truncated(self) -> None:
|
||||
"""ERROR_ROUTING: SseConnectFailed.body is truncated to <= 1024 bytes."""
|
||||
big_body = b"x" * 5000
|
||||
# 500 (not 409/503 — those are now eager turn-launch carve-outs, #331).
|
||||
respx.post("https://w.example/sessions/s1/messages").mock(
|
||||
return_value=httpx.Response(503, content=big_body)
|
||||
return_value=httpx.Response(500, content=big_body)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(SseConnectFailed) as exc_info:
|
||||
@@ -424,6 +461,71 @@ class TestStreamTurn:
|
||||
assert len(exc_info.value.body) <= 1024
|
||||
assert exc_info.value.body == big_body[:1024]
|
||||
|
||||
@respx.mock
|
||||
async def test_eager_409_agent_not_available(self) -> None:
|
||||
"""b1 #331: eager 409 -> AgentNotAvailable (SseConnectFailed subclass) with
|
||||
typed error_code; the turn never streams."""
|
||||
respx.post("https://w.example/sessions/s1/messages").mock(
|
||||
return_value=httpx.Response(
|
||||
409,
|
||||
json={
|
||||
"detail": {
|
||||
"error_code": "agent_not_available",
|
||||
"message": "agent ratatoskr:sindra is unavailable",
|
||||
}
|
||||
},
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(AgentNotAvailable) as exc:
|
||||
_ = [e async for e in stream_turn(client, "s1", "hi")]
|
||||
assert exc.value.status == 409
|
||||
assert exc.value.error_code == "agent_not_available"
|
||||
assert "unavailable" in exc.value.message
|
||||
assert isinstance(exc.value, SseConnectFailed) # existing handlers still catch
|
||||
|
||||
@respx.mock
|
||||
async def test_eager_503_turn_launch_unavailable_retryable(self) -> None:
|
||||
"""b1 #331: eager 503 -> TurnLaunchUnavailable (retryable, SseConnectFailed subclass)."""
|
||||
respx.post("https://w.example/sessions/s1/messages").mock(
|
||||
return_value=httpx.Response(
|
||||
503,
|
||||
json={"error_code": "turn_launch_failed", "message": "resource exhausted"},
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(TurnLaunchUnavailable) as exc:
|
||||
_ = [e async for e in stream_turn(client, "s1", "hi")]
|
||||
assert exc.value.status == 503
|
||||
assert exc.value.retryable is True
|
||||
assert exc.value.error_code == "turn_launch_failed"
|
||||
assert isinstance(exc.value, SseConnectFailed)
|
||||
|
||||
@respx.mock
|
||||
async def test_eager_409_non_json_body_defaults(self) -> None:
|
||||
"""b1 #331: eager 409 with a non-JSON body -> AgentNotAvailable with the
|
||||
status-derived default error_code."""
|
||||
respx.post("https://w.example/sessions/s1/messages").mock(
|
||||
return_value=httpx.Response(409, content=b"<html>nope</html>")
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(AgentNotAvailable) as exc:
|
||||
_ = [e async for e in stream_turn(client, "s1", "hi")]
|
||||
assert exc.value.error_code == "agent_not_available"
|
||||
|
||||
@respx.mock
|
||||
async def test_eager_503_non_json_body_defaults_not_ready(self) -> None:
|
||||
"""b2: eager 503 with a non-JSON body -> TurnLaunchUnavailable with the
|
||||
canonical default error_code `not_ready`."""
|
||||
respx.post("https://w.example/sessions/s1/messages").mock(
|
||||
return_value=httpx.Response(503, content=b"<html>nope</html>")
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(TurnLaunchUnavailable) as exc:
|
||||
_ = [e async for e in stream_turn(client, "s1", "hi")]
|
||||
assert exc.value.error_code == "not_ready"
|
||||
assert exc.value.retryable is True
|
||||
|
||||
@respx.mock
|
||||
async def test_no_text_aggregation(self) -> None:
|
||||
"""no_text_aggregation: consumer yields each text event separately; no concat."""
|
||||
@@ -1040,3 +1142,194 @@ class TestAwaitingLlmFirstToken:
|
||||
elapsed = [b.elapsed_ms_since_building_prompt for b in beats]
|
||||
assert elapsed == sorted(elapsed) # monotonically increasing
|
||||
assert all(b.turn_id == 42 for b in beats)
|
||||
|
||||
|
||||
_URL = "https://w.example/sessions/s1/messages"
|
||||
|
||||
|
||||
class TestStreamTurnResilient:
|
||||
"""docs/contracts/issues/1.contract.md FN stream_turn_resilient (amendment 2026-06-30)."""
|
||||
|
||||
@respx.mock
|
||||
async def test_happy_no_drop(self) -> None:
|
||||
"""happy_no_drop [happy]: clean stream passes through; no reconnect issued."""
|
||||
stream = _sse_chunk("42:1", {"type": "text", "content": "a"}) + _sse_chunk(
|
||||
"42:2", _DONE_42_6
|
||||
)
|
||||
route = respx.post(_URL).mock(return_value=_stream_response(stream))
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
events = [e async for e in stream_turn_resilient(client, "s1", "hi")]
|
||||
assert [e.sse_id for e in events] == [SseId(42, 1), SseId(42, 2)]
|
||||
assert isinstance(events[-1], Done)
|
||||
assert route.call_count == 1 # POST-001: no reconnect on a clean stream
|
||||
|
||||
@respx.mock
|
||||
async def test_resume_after_one_drop(self) -> None:
|
||||
"""resume_after_one_drop [tracer]: a mid-stream drop resumes via reconnect; one stream."""
|
||||
first = _drop_response([_sse_chunk("42:1", {"type": "text", "content": "a"})])
|
||||
resume = _stream_response(
|
||||
_sse_chunk("42:2", {"type": "text", "content": "b"})
|
||||
+ _sse_chunk("42:3", _DONE_42_6)
|
||||
)
|
||||
route = respx.post(_URL).mock(side_effect=[first, resume])
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
events = [e async for e in stream_turn_resilient(client, "s1", "hi")]
|
||||
assert [e.sse_id for e in events] == [SseId(42, 1), SseId(42, 2), SseId(42, 3)]
|
||||
assert isinstance(events[-1], Done)
|
||||
assert route.call_count == 2
|
||||
# POST-003: reconnect carries the last yielded pre-drop event's id.
|
||||
assert route.calls[1].request.headers.get("Last-Event-ID") == "42:1"
|
||||
# PRE/wire: first attempt does NOT carry a Last-Event-ID.
|
||||
assert route.calls[0].request.headers.get("Last-Event-ID") is None
|
||||
|
||||
@respx.mock
|
||||
async def test_resume_after_clean_eof(self) -> None:
|
||||
"""resume_after_clean_eof: a clean EOF before terminal also triggers resume (INV-001)."""
|
||||
first = _stream_response(_sse_chunk("42:1", {"type": "text", "content": "a"}))
|
||||
resume = _stream_response(_sse_chunk("42:2", _DONE_42_6))
|
||||
route = respx.post(_URL).mock(side_effect=[first, resume])
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
events = [e async for e in stream_turn_resilient(client, "s1", "hi")]
|
||||
assert [e.sse_id for e in events] == [SseId(42, 1), SseId(42, 2)]
|
||||
assert isinstance(events[-1], Done)
|
||||
assert route.calls[1].request.headers.get("Last-Event-ID") == "42:1"
|
||||
|
||||
@respx.mock
|
||||
async def test_two_drops_then_done(self) -> None:
|
||||
"""two_drops_then_done: two transient drops, third attempt completes; ids thread through."""
|
||||
a1 = _drop_response([_sse_chunk("42:1", {"type": "text", "content": "a"})])
|
||||
a2 = _drop_response([_sse_chunk("42:2", {"type": "text", "content": "b"})])
|
||||
a3 = _stream_response(_sse_chunk("42:3", _DONE_42_6))
|
||||
route = respx.post(_URL).mock(side_effect=[a1, a2, a3])
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
events = [e async for e in stream_turn_resilient(client, "s1", "hi")]
|
||||
assert [e.sse_id for e in events] == [SseId(42, 1), SseId(42, 2), SseId(42, 3)]
|
||||
assert route.call_count == 3
|
||||
assert route.calls[1].request.headers.get("Last-Event-ID") == "42:1"
|
||||
assert route.calls[2].request.headers.get("Last-Event-ID") == "42:2"
|
||||
|
||||
@respx.mock
|
||||
async def test_unresumable_zero_event_drop(self) -> None:
|
||||
"""unresumable_zero_event_drop [adversarial]: drop before any event → propagate."""
|
||||
route = respx.post(_URL).mock(side_effect=[_drop_response([])])
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(SseConnectionDropped):
|
||||
_ = [e async for e in stream_turn_resilient(client, "s1", "hi")]
|
||||
assert route.call_count == 1 # no id to resume from → no reconnect
|
||||
|
||||
@respx.mock
|
||||
async def test_max_reconnects_exhausted(self) -> None:
|
||||
"""max_reconnects_exhausted [adversarial]: every attempt drops; budget caps reconnects."""
|
||||
side = [
|
||||
_drop_response([_sse_chunk(f"42:{n}", {"type": "text", "content": "x"})])
|
||||
for n in (1, 2, 3)
|
||||
]
|
||||
route = respx.post(_URL).mock(side_effect=side)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(SseConnectionDropped):
|
||||
_ = [
|
||||
e
|
||||
async for e in stream_turn_resilient(
|
||||
client, "s1", "hi", max_reconnects=2
|
||||
)
|
||||
]
|
||||
assert route.call_count == 3 # initial + 2 reconnects, then give up
|
||||
|
||||
@respx.mock
|
||||
async def test_zero_budget_no_resume(self) -> None:
|
||||
"""zero_budget_no_resume [adversarial]: max_reconnects=0 → first drop propagates."""
|
||||
first = _drop_response([_sse_chunk("42:1", {"type": "text", "content": "a"})])
|
||||
route = respx.post(_URL).mock(side_effect=[first])
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(SseConnectionDropped):
|
||||
_ = [
|
||||
e
|
||||
async for e in stream_turn_resilient(
|
||||
client, "s1", "hi", max_reconnects=0
|
||||
)
|
||||
]
|
||||
assert route.call_count == 1
|
||||
|
||||
@respx.mock
|
||||
async def test_buffer_expired_propagates(self) -> None:
|
||||
"""buffer_expired_propagates [error]: a 412 on reconnect surfaces, not retried."""
|
||||
first = _drop_response([_sse_chunk("42:1", {"type": "text", "content": "a"})])
|
||||
resume = httpx.Response(412, json={"turn_id": 42, "buffered_from_seq": 7})
|
||||
route = respx.post(_URL).mock(side_effect=[first, resume])
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
collected: list[object] = []
|
||||
with pytest.raises(ResumeBufferExpired):
|
||||
async for e in stream_turn_resilient(client, "s1", "hi"):
|
||||
collected.append(e)
|
||||
assert [e.sse_id for e in collected] == [SseId(42, 1)] # type: ignore[attr-defined]
|
||||
assert route.call_count == 2
|
||||
|
||||
|
||||
class TestStreamAdminEvents:
|
||||
"""docs/conversation-api-spec.md § Admin Event Stream — stream_admin_events (#11)."""
|
||||
|
||||
@respx.mock
|
||||
async def test_happy_multi_event_admin_bearer(self) -> None:
|
||||
"""happy [happy,tracer]: yields AdminEvent envelopes; request uses the ADMIN bearer."""
|
||||
env1 = {
|
||||
"id": 41, "type": "session.created", "timestamp": "2026-05-06T10:00:00.000Z",
|
||||
"data": {"session_id": "s1", "agent_id": "mimir", "user_id": None},
|
||||
}
|
||||
env2 = {
|
||||
"id": 42, "type": "turn.started", "timestamp": "2026-05-06T10:00:01.000Z",
|
||||
"data": {"session_id": "s1", "turn_id": 7, "agent_id": "mimir", "user_id": None},
|
||||
}
|
||||
stream = _sse_chunk("41", env1) + _sse_chunk("42", env2)
|
||||
route = respx.get("https://w.example/admin/events").mock(
|
||||
return_value=httpx.Response(
|
||||
200, headers={"content-type": "text/event-stream"}, content=stream
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(
|
||||
base_url="https://w.example", headers={"Authorization": "Bearer consumer"}
|
||||
) as client:
|
||||
events = [e async for e in stream_admin_events(client, admin_key="admin-xyz")]
|
||||
assert [e.type for e in events] == ["session.created", "turn.started"]
|
||||
assert isinstance(events[0], AdminEvent)
|
||||
assert events[0].id == 41
|
||||
assert events[1].data["turn_id"] == 7
|
||||
assert route.calls[0].request.headers["Authorization"] == "Bearer admin-xyz"
|
||||
|
||||
@respx.mock
|
||||
async def test_last_event_id_header(self) -> None:
|
||||
"""last_event_id_header [trace]: empty stream → []; Last-Event-ID header sent."""
|
||||
route = respx.get("https://w.example/admin/events").mock(
|
||||
return_value=httpx.Response(
|
||||
200, headers={"content-type": "text/event-stream"}, content=b""
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
events = [e async for e in stream_admin_events(client, admin_key="k", last_event_id=99)]
|
||||
assert events == []
|
||||
assert route.calls[0].request.headers["Last-Event-ID"] == "99"
|
||||
|
||||
@respx.mock
|
||||
async def test_403_scope_denied(self) -> None:
|
||||
"""403 [error]: key lacks admin.events.read → SseConnectFailed(403)."""
|
||||
respx.get("https://w.example/admin/events").mock(
|
||||
return_value=httpx.Response(403, json={"error_code": "auth_scope_denied"})
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(SseConnectFailed) as exc:
|
||||
_ = [e async for e in stream_admin_events(client, admin_key="k")]
|
||||
assert exc.value.status == 403
|
||||
|
||||
@respx.mock
|
||||
async def test_skips_malformed_frame(self) -> None:
|
||||
"""skips_malformed [adversarial]: a bad-JSON frame is skipped, not fatal."""
|
||||
good = _sse_chunk("41", {"id": 41, "type": "session.created", "data": {"session_id": "s1"}})
|
||||
bad = b"id: 42\ndata: not-json\n\n"
|
||||
good2 = _sse_chunk("43", {"id": 43, "type": "session.deleted", "data": {"session_id": "s1"}})
|
||||
respx.get("https://w.example/admin/events").mock(
|
||||
return_value=httpx.Response(
|
||||
200, headers={"content-type": "text/event-stream"}, content=good + bad + good2
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
events = [e async for e in stream_admin_events(client, admin_key="k")]
|
||||
assert [e.type for e in events] == ["session.created", "session.deleted"]
|
||||
|
||||
@@ -2945,3 +2945,495 @@ class TestTuiBifrostBind:
|
||||
err = capsys.readouterr().err
|
||||
assert "bifrost: status=bound" 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
|
||||
|
||||
|
||||
class TestBifrostStateHydration:
|
||||
"""get_session_bifrost + the #176 BifrostState pane (GET /admin/sessions/{id}/bifrost)."""
|
||||
|
||||
@staticmethod
|
||||
def _mute_tools(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Neutralize the on_mount Tools-pane worker so it makes no real call."""
|
||||
import ratatoskr.tui as tui_mod
|
||||
|
||||
async def noop(client, session_id):
|
||||
return {"agent_id": "x", "builtin_tools": [], "bifrost_tools": []}
|
||||
|
||||
monkeypatch.setattr(tui_mod, "get_session_tools", noop)
|
||||
|
||||
def test_format_bifrost_state(self) -> None:
|
||||
"""format_bifrost_state [unit]: connected / endpoint / caps / tools lines."""
|
||||
from ratatoskr.tui import _format_bifrost_state
|
||||
|
||||
lines = _format_bifrost_state(
|
||||
{
|
||||
"endpoint_url": "https://b/mcp",
|
||||
"consumer_id": "alice",
|
||||
"connected": True,
|
||||
"capabilities_granted": ["tools:call", "tools:read"],
|
||||
"tools": [{"name": "bifrost.echo"}],
|
||||
}
|
||||
)
|
||||
joined = "\n".join(lines)
|
||||
assert "connected=True" in joined
|
||||
assert "consumer=alice" in joined
|
||||
assert "https://b/mcp" in joined
|
||||
assert "tools:call, tools:read" in joined
|
||||
assert "bifrost.echo" in joined
|
||||
|
||||
async def test_hydrate_no_admin_key(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""hydrate_no_admin_key [scenario]: admin_key None → 'not configured' + skip audit."""
|
||||
self._mute_tools(monkeypatch)
|
||||
writes = _spy_writes(monkeypatch)
|
||||
app = _resolved_app(_args_existing(session_id="s-bf-1")) # admin_key defaults None
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
await app._hydrate_bifrost_state()
|
||||
await pilot.pause()
|
||||
joined = " ".join(_text_of(w) for w in writes)
|
||||
assert "admin key not configured" in joined
|
||||
assert "bifrost_state_skipped" in joined
|
||||
|
||||
async def test_hydrate_success(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""hydrate_success [scenario,tracer]: 200 → binding in BifrostState pane + audit."""
|
||||
import ratatoskr.tui as tui_mod
|
||||
|
||||
self._mute_tools(monkeypatch)
|
||||
writes = _spy_writes(monkeypatch)
|
||||
|
||||
async def fake_bifrost(client, session_id, *, admin_key):
|
||||
return {
|
||||
"endpoint_url": "https://b/mcp",
|
||||
"consumer_id": "alice",
|
||||
"connected": True,
|
||||
"capabilities_granted": ["tools:call"],
|
||||
"tools": [{"name": "bifrost.echo"}],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(tui_mod, "get_session_bifrost", fake_bifrost)
|
||||
app = _resolved_app(_args_existing(session_id="s-bf-2", admin_key="ak"))
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
await app._hydrate_bifrost_state()
|
||||
await pilot.pause()
|
||||
joined = " ".join(_text_of(w) for w in writes)
|
||||
assert "bifrost binding" in joined
|
||||
assert "bifrost.echo" in joined
|
||||
assert "bifrost_state_hydrated" in joined
|
||||
|
||||
async def test_hydrate_404_not_bound(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""hydrate_404_not_bound [error]: 404 → 'not bound to Bifrost' + audit; no crash."""
|
||||
import ratatoskr.tui as tui_mod
|
||||
from ratatoskr.sessions import SessionApiFailed
|
||||
|
||||
self._mute_tools(monkeypatch)
|
||||
writes = _spy_writes(monkeypatch)
|
||||
|
||||
async def not_bound(client, session_id, *, admin_key):
|
||||
raise SessionApiFailed(status=404, body=b"session_not_bifrost_bound")
|
||||
|
||||
monkeypatch.setattr(tui_mod, "get_session_bifrost", not_bound)
|
||||
app = _resolved_app(_args_existing(session_id="s-bf-3", admin_key="ak"))
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
await app._hydrate_bifrost_state()
|
||||
await pilot.pause()
|
||||
joined = " ".join(_text_of(w) for w in writes)
|
||||
assert "not bound to Bifrost" in joined
|
||||
assert "bifrost_state_unavailable" in joined
|
||||
|
||||
|
||||
class TestAdminEventsStream:
|
||||
"""stream_admin_events + the #11 AdminEvents pane (GET /admin/events, session-filtered)."""
|
||||
|
||||
@staticmethod
|
||||
def _mute_hydrates(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Neutralize the other on_mount workers (tools + bifrost) — no real calls."""
|
||||
import ratatoskr.tui as tui_mod
|
||||
from ratatoskr.sessions import SessionApiFailed
|
||||
|
||||
async def noop_tools(client, session_id):
|
||||
return {"agent_id": "x", "builtin_tools": [], "bifrost_tools": []}
|
||||
|
||||
async def noop_bifrost(client, session_id, *, admin_key):
|
||||
raise SessionApiFailed(status=404, body=b"nb")
|
||||
|
||||
monkeypatch.setattr(tui_mod, "get_session_tools", noop_tools)
|
||||
monkeypatch.setattr(tui_mod, "get_session_bifrost", noop_bifrost)
|
||||
|
||||
def test_format_admin_event(self) -> None:
|
||||
"""format_admin_event [unit]: HH:MM:SS + type + fields; session_id dropped."""
|
||||
from ratatoskr.sse_client import AdminEvent
|
||||
from ratatoskr.tui import _format_admin_event
|
||||
|
||||
line = _format_admin_event(
|
||||
AdminEvent(
|
||||
42, "turn.completed", "2026-05-06T10:00:05.000Z",
|
||||
{"session_id": "s1", "turn_id": 7, "duration_ms": 1200, "phase": "succeeded"},
|
||||
)
|
||||
)
|
||||
assert "turn.completed" in line
|
||||
assert "[10:00:05]" in line
|
||||
assert "turn_id=7" in line
|
||||
assert "session_id" not in line # dropped — pane is already session-scoped
|
||||
|
||||
def test_admin_event_matches_filter(self) -> None:
|
||||
"""admin_event_matches [unit]: active-session + non-heartbeat system.* pass (§6)."""
|
||||
from ratatoskr.sse_client import AdminEvent
|
||||
|
||||
E = AdminEvent
|
||||
app = _resolved_app(_args_existing(session_id="s-match"))
|
||||
assert app._admin_event_matches(E(1, "session.created", "t", {"session_id": "s-match"}))
|
||||
assert not app._admin_event_matches(E(2, "turn.started", "t", {"session_id": "other"}))
|
||||
assert not app._admin_event_matches(E(0, "system.heartbeat", "t", {}))
|
||||
assert app._admin_event_matches(E(3, "system.events_dropped", "t", {"count": 5}))
|
||||
|
||||
async def test_stream_writes_filtered_events(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""stream_filtered [scenario,tracer]: only active-session + non-heartbeat lines land."""
|
||||
import ratatoskr.tui as tui_mod
|
||||
from ratatoskr.sse_client import AdminEvent
|
||||
|
||||
self._mute_hydrates(monkeypatch)
|
||||
writes = _spy_writes(monkeypatch)
|
||||
|
||||
async def fake_stream(client, *, admin_key, last_event_id=None):
|
||||
yield AdminEvent(41, "session.created", "t", {"session_id": "s-ae-2"})
|
||||
yield AdminEvent(0, "system.heartbeat", "t", {}) # filtered (noise)
|
||||
yield AdminEvent(42, "turn.started", "t", {"session_id": "other"}) # diff session
|
||||
yield AdminEvent(43, "session.deleted", "t", {"session_id": "s-ae-2"})
|
||||
|
||||
monkeypatch.setattr(tui_mod, "stream_admin_events", fake_stream)
|
||||
app = _resolved_app(_args_existing(session_id="s-ae-2", admin_key="ak"))
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
await app._stream_admin_events()
|
||||
await pilot.pause()
|
||||
joined = " ".join(_text_of(w) for w in writes)
|
||||
assert "session.created" in joined
|
||||
assert "session.deleted" in joined
|
||||
assert "system.heartbeat" not in joined
|
||||
assert "turn.started" not in joined # different session → filtered
|
||||
|
||||
async def test_stream_no_admin_key(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""stream_no_admin_key [scenario]: admin_key None → 'not configured' + skip audit."""
|
||||
self._mute_hydrates(monkeypatch)
|
||||
writes = _spy_writes(monkeypatch)
|
||||
app = _resolved_app(_args_existing(session_id="s-ae-3")) # admin_key None
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
await app._stream_admin_events()
|
||||
await pilot.pause()
|
||||
joined = " ".join(_text_of(w) for w in writes)
|
||||
assert "admin key not configured" in joined
|
||||
assert "admin_events_skipped" in joined
|
||||
|
||||
async def test_stream_403_unavailable(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""stream_403 [error]: 403 scope-denied → 'unavailable' + audit; no crash."""
|
||||
import ratatoskr.tui as tui_mod
|
||||
from ratatoskr.sse_client import SseConnectFailed
|
||||
|
||||
self._mute_hydrates(monkeypatch)
|
||||
writes = _spy_writes(monkeypatch)
|
||||
|
||||
async def denied(client, *, admin_key, last_event_id=None):
|
||||
raise SseConnectFailed(status=403, body=b"auth_scope_denied")
|
||||
yield # unreachable — makes this an async generator
|
||||
|
||||
monkeypatch.setattr(tui_mod, "stream_admin_events", denied)
|
||||
app = _resolved_app(_args_existing(session_id="s-ae-4", admin_key="ak"))
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
await app._stream_admin_events()
|
||||
await pilot.pause()
|
||||
joined = " ".join(_text_of(w) for w in writes)
|
||||
assert "admin events unavailable: HTTP 403" in joined
|
||||
assert "admin_events_unavailable" in joined
|
||||
|
||||
@@ -837,6 +837,50 @@ class TestWebBifrostBind:
|
||||
}
|
||||
assert upstream.headers["Authorization"] == "Bearer server-ck"
|
||||
|
||||
@respx.mock
|
||||
def test_combined_plane_binds_to_8392(self) -> None:
|
||||
"""combined [#18 composite]: a 'combined' plane from the browser → the server
|
||||
binds the :8392 both-plane endpoint; bound-state echoes plane='combined'."""
|
||||
import json as _json
|
||||
|
||||
from ratatoskr.web.server import create_app
|
||||
|
||||
route = respx.post("https://w.example/sessions").mock(
|
||||
return_value=httpx.Response(201, json=_CREATE_OK)
|
||||
)
|
||||
app = create_app(
|
||||
_mock_client_factory(),
|
||||
bifrost_consumer_key="server-ck",
|
||||
bifrost_visible_host="10.100.10.50",
|
||||
)
|
||||
resp = TestClient(app).post(
|
||||
"/api/sessions",
|
||||
json={"agent_id": "ratatoskr:sindra", "bifrost_plane": "combined"},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["bifrost"] == {
|
||||
"plane": "combined",
|
||||
"endpoint": "http://10.100.10.50:8392",
|
||||
"status": "bound",
|
||||
}
|
||||
upstream = route.calls[0].request
|
||||
body = _json.loads(upstream.content)
|
||||
assert body["bifrost"] == {
|
||||
"endpoint_url": "http://10.100.10.50:8392",
|
||||
"scope": None,
|
||||
}
|
||||
|
||||
def test_dropdown_offers_combined_as_default(self) -> None:
|
||||
"""(a)+default: the SPA plane dropdown offers a 'combined' (:8392) option,
|
||||
it is the DEFAULT-selected one, and single-plane memory/affect remain."""
|
||||
from pathlib import Path
|
||||
|
||||
import ratatoskr.web as web_pkg
|
||||
|
||||
html = (Path(web_pkg.__file__).parent / "static" / "index.html").read_text()
|
||||
assert '<option value="combined" selected>' in html
|
||||
assert 'value="memory"' in html and 'value="affect"' in html
|
||||
|
||||
@respx.mock
|
||||
def test_plane_without_server_config_is_400(self) -> None:
|
||||
"""A plane requested but no server-held key/host → bifrost_not_configured."""
|
||||
|
||||
@@ -190,14 +190,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "bifrost"
|
||||
version = "0.10.0"
|
||||
version = "1.0.0"
|
||||
source = { registry = "https://gitea.phasefinal.com/api/packages/vh/pypi/simple/" }
|
||||
dependencies = [
|
||||
{ name = "jsonschema" },
|
||||
]
|
||||
sdist = { url = "https://gitea.phasefinal.com/api/packages/vh/pypi/files/bifrost/0.10.0/bifrost-0.10.0.tar.gz", hash = "sha256:aba1869dba68d921f2e0be8fb560277073da09ec2ad5f410e226cacd5e84fe1a" }
|
||||
sdist = { url = "https://gitea.phasefinal.com/api/packages/vh/pypi/files/bifrost/1.0.0/bifrost-1.0.0.tar.gz", hash = "sha256:93130d68dfd9868580a4514277996ba176837972b9e42129eda8bb03ad3b18b9" }
|
||||
wheels = [
|
||||
{ url = "https://gitea.phasefinal.com/api/packages/vh/pypi/files/bifrost/0.10.0/bifrost-0.10.0-py3-none-any.whl", hash = "sha256:88adbce23fa8840a14f9493e4f0cf6f9320f4950845f7a6080387defe574a5cc" },
|
||||
{ url = "https://gitea.phasefinal.com/api/packages/vh/pypi/files/bifrost/1.0.0/bifrost-1.0.0-py3-none-any.whl", hash = "sha256:1a53baa2b0596b7c418e2d82e3eeee0f13054604d78b592609ee1aff90dccac2" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1052,7 +1052,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ratatoskr"
|
||||
version = "0.17.17"
|
||||
version = "0.19.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
@@ -1086,7 +1086,7 @@ web = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "bifrost", marker = "extra == 'provider'", specifier = ">=0.10.0", index = "https://gitea.phasefinal.com/api/packages/vh/pypi/simple/" },
|
||||
{ name = "bifrost", marker = "extra == 'provider'", specifier = "==1.0.0", index = "https://gitea.phasefinal.com/api/packages/vh/pypi/simple/" },
|
||||
{ name = "httpx", specifier = ">=0.27" },
|
||||
{ name = "httpx-sse", specifier = ">=0.4" },
|
||||
{ name = "jsonschema", marker = "extra == 'provider'", specifier = ">=4" },
|
||||
|
||||
Reference in New Issue
Block a user