Compare commits

...

10 Commits

Author SHA1 Message Date
vh 5c595b862d feat(#20): rewire the web turn surface onto the wt adapter (slice-2, part 2b-ii)
The Starlette endpoints (create / stream / cancel / tools / messages) now go through
ratatoskr.wt over the worldtree-sdk; the browser contract is preserved. This is the
last consumer of the hand-rolled turn-stream family — after this, stream_turn* /
cancel_turn are orphaned and get deleted in part 2b-iii (with the live smoke).

- _wt_client wraps a client_factory transport as the adapter's WorldtreeClient
  (INV-CUT-1), reading base_url + bearer off the transport (a no-auth test transport
  falls back to a placeholder key). The hand-rolled endpoints (persona / agents /
  admin / bifrost) keep using the raw transport until their slices.
- _event_to_browser_payload derives the browser payload from the SDK's `raw` (the
  wire body) minus the redundant `type`, plus the composite `sse_id` string — the
  SAME shape the old dataclasses produced, so the presentation fixture + browser JS
  are unchanged; the browser event_type is the wire `type`, not the SDK class name.
- The stream endpoint captures the upstream cancel target from the composite sse_id
  (the SDK's top-level turn_id is body-derived, absent on text frames); create reads
  the SDK's open create dict; cancel reads CancelResult.cancelled and surfaces a
  generic 502 for CancelFailed (the SDK abstracts the upstream cancel HTTP status).
- test_web_presentation_contract builds SDK events via build_event; two cancel tests
  adopt the SDK's (status, error_code) race pairs + the 502.

Suite 570 green; web/server.py + presentation test ruff-clean, mypy unchanged
(same pre-existing errors). Patch (internal; browser contract preserved).
2026-07-19 06:22:13 -07:00
vh e3a10ad80e feat(#20): rewire the CLI turn path onto the wt adapter (slice-2, part 2b-i)
The --send turn path (_amain create + _run_turn stream + _cancel_and_log) now goes
through ratatoskr.wt over the worldtree-sdk; external CLI behavior (output, exit
codes) is preserved. No hand-rolled path is deleted yet — web/server.py still uses
them (part 2b-ii), so the deletions + live smoke come after web is rewired.

- _amain builds one WorldtreeClient via wt.build_client over a ratatoskr-owned
  transport (INV-CUT-1); create → wt.create_session (reads the SDK's open create
  dict); the transport keeps the default bearer so the not-yet-migrated hand-rolled
  seed_preset_first_message (slice-3) still authenticates.
- _run_turn drives wt.stream_turn and consumes SDK TurnEvents; the mid-stream cancel
  target is parsed from the composite sse_id ("{turn}:{seq}") — the SDK's top-level
  turn_id is the body field and is absent on text/thinking frames.
- CliPresenterState.render consumes the SDK TurnEvent union with None-hardening on
  the now-optional fields (usage degrades to "(n/a)" rather than crashing).
- The SDK normalizes a pre-response transport failure to ConnectFailed(status=0);
  _amain (network → exit 21) and _cancel_and_log (swallow, INV-009) catch it.
- build_client gains max_reconnects (SDK default 5; tests pass 0 to surface drops
  immediately). test_cli: SDK-event factories keep the render-test bodies intact;
  client constructions wrap in build_client; cancel-race mocks carry the SDK's
  (status, error_code) pair.

Suite 570 green; cli.py + wt.py mypy + ruff clean (the pre-existing send_content
arg-type note is unchanged). Patch (internal; external CLI behavior preserved).
2026-07-19 06:07:50 -07:00
vh b907a7b8a5 feat(#20): stream + cancel adapter routes complete the wt surface (slice-2, part 2a)
Completes the adapter's session/turn surface, still additive and non-breaking (no
surface rewired, no hand-rolled path deleted — the cli/web rewire + deletions +
live smoke are part 2b).

- stream_turn: drives the SDK's resilient stream (auto-resume absorbs the old
  reconnect_turn) and yields SDK TurnEvents, re-wrapping the stream's TERMINAL SDK
  errors into ratatoskr's caller-semantic exceptions per DEC-2 (SessionRetired →
  SessionApiFailed; AgentNotAvailable / TurnLaunchUnavailable / MalformedSse* /
  TurnIdFlip → ratatoskr's same-named types; ConnectionDropped → SseConnectionDropped;
  ConnectFailed / terminal ResumeError → SseConnectFailed). The presenter keeps
  catching ratatoskr types (part 2b aligns the except clauses).
- cancel_turn: returns the SDK CancelResult (a 200 cancelled=False is the benign
  late-cancel race, B-CAN-3), mapping the typed cancel races onto ratatoskr's
  CancelTurnNotFound / CancelAlreadyCompleted / CancelFailed.
- SseConnectionDropped.last_seen_sse_id widened to SseId | str | None: the SDK's
  resume cursor is a raw composite-id str (the cutover's target form); the
  hand-rolled path's SseId stays accepted until it is deleted. The one live reader
  (stream_turn_resilient) generalizes cleanly — a str cursor is already the id.

Suite 570 green (555 + 15); wt.py + sse_client.py mypy + ruff clean. Patch.
2026-07-19 00:31:16 -07:00
vh bb158ae47d feat(#20): sessions read/create adapter routes — ratatoskr.wt (slice-2, part 1)
First slice-2 increment: the presenter-independent sessions routes, additive and
non-breaking (no surface rewired, no hand-rolled path deleted yet — the cli/web
rewire + deletions + live smoke land in part 2).

- create_session / list_sessions / get_session_messages / get_session_tools over
  WorldtreeClient.sessions.*, each building the request from ratatoskr's domain
  params and mapping the SDK's ApiError floor by ROUTE (INV-CUT-2): create 404 →
  AgentNotFound, bound 502 → BifrostHandshakeFailed, list 422 cursor_invalid →
  InvalidCursor, else the SessionApiFailed default.
- Open-world reads returned VERBATIM (parity-pass posture): the routes return the
  SDK's open dicts, not ratatoskr's typed SessionInfo/SessionPage — those typed
  result shapes retire when the presenters are rewired to read mappings (adopt the
  dep's canonical open-world way, reference-impl doctrine).
- Transitional: wt imports the caller-semantic exceptions + BifrostBinding from the
  retiring sessions module (one-way, no cycle); they relocate into the adapter as
  their call-sites are rewired.
- Cancel + the resilient turn STREAM are deferred to part 2, where they wire into
  the async presenter loop and are validated by the live smoke.

Suite 555 green (541 + 14); mypy strict + ruff clean. Patch (internal, additive).
2026-07-19 00:19:33 -07:00
vh 12cd8642fa feat(#20): worldtree-sdk adapter foundation — ratatoskr.wt (slice-1)
Slice-1 of the SDK cutover (docs/contracts/worldtree_sdk_cutover.contract.md):
the adapter chokepoint onto worldtree-sdk 1.0.0, unit-tested but not yet wired
to any surface (that is slice-2).

- build_client(base_url, *, api_key, admin_key=None, transport) constructs the
  single WorldtreeClient over a ratatoskr-owned injected httpx.AsyncClient.
  INV-CUT-1: the SDK is given the transport (_owns_client=False) and never closes
  it — proven by a test asserting aclose() leaves ratatoskr's transport open.
- translate_error implements the § Error map DEFAULT: SDK ApiError → the adapter's
  SessionApiFailed (carrying the SDK's parsed status/error_code/body); every
  discriminated WorldtreeError subclass passes through by identity. Route-specific
  rows land at their call-sites in later slices (the route is the discriminator).
- SessionApiFailed gains error_code vs the retiring sessions.py copy (extends it
  per the contract error-map row); the two coexist transiently and reconcile in
  slice-2 (DEC-4 incremental cutover — nothing wires the adapter this slice, so
  they never meet at runtime).

Deletes no hand-rolled path, so DEC-4's live-smoke bar does not apply yet.
Suite 541 green (534 + 7 new); mypy + ruff clean. Patch (internal foundation;
the cutover's minor bump is DEC-6 at slice-7 ship).
2026-07-18 23:59:33 -07:00
vh b7f4bc5e7c memory: /snapshot — slice-1 dep integrated + DEC-5 verified (29c4fda), adapter next
Re-snapshot for fresh context. Cutover slice-1 half-landed: worldtree-sdk==1.0.0
integrated + DEC-5 install-verified + committed (29c4fda), suite 534 green; the
ratatoskr.wt adapter (auth/transport split, error-map default) is the next step.
Substrate: unpushed cutover chain (e45640c contract, snapshot, 29c4fda dep) noted;
origin still at b1fbadd. Handoff aimed at the adapter. Index ~19 over soft cap;
archival deferred (guard-protected recents leave little to move — next run.)
2026-07-18 23:27:40 -07:00
vh 29c4fda12e build(deps): integrate worldtree-sdk==1.0.0 (DEC-5 verified) — #20 slice-1
Add the worldtree-sdk (Python) 1.0.0 consumer client to core deps + the gitea uv
source (reuses the existing bifrost gitea-index auth). DEC-5 precondition met: uv
resolves + installs it from the registry; WorldtreeClient constructs with an
injected httpx.AsyncClient and _owns_client=False (INV-CUT-1 transport ownership
confirmed live). httpx-sse retained until slice-7 teardown. Full suite 534 green —
the dep is inert until ratatoskr.wt wires it (next).

No version bump (dependency add, no runtime code consumes it yet). Refs #20.
2026-07-18 23:22:24 -07:00
vh 57fdc48e20 memory: /snapshot — worldtree-sdk cutover decided (#20), slice-1 next
Captures the SDK cutover as the active migration: contract done + heid-reviewed,
DEC-5 registry gate cleared, slice-1 foundation the next step. Plus ephemeral-Echo
(v0.21.2 #19) + reference-consumer parity arc in Recent decisions; tier3 model→role
deferred to slice-4 / worldtree-dev deploy flag. Handoff written for slice-1.
2026-07-18 23:19:21 -07:00
vh e45640c4da docs(contract): worldtree-sdk cutover — SDK-adapter contract (refs #20)
Consumer-layer cutover to worldtree-sdk (Python) 1.0.0: retire the hand-rolled
httpx wrappers (sessions/sse_client/tier3) behind a thin ratatoskr.wt adapter over
the SDK. Carries the 6 locked DECs (vor-cross'd with worldtree-codex), the route
map (21 wrappers -> SDK methods), the Error map table (heid-panel find:
route-as-discriminator, default -> SessionApiFailed), INV-CUT-1..5, and the 7-slice
plan. Adapter design: caller-injected transport (never closed), thin semantic error
adapter, Bifrost provider planes untouched.

heid-contract-review clean after fixups (3/3 error-map convergence + 6
clarifications folded in). No version bump (.contract.md, no code).

Refs #20.
2026-07-18 23:15:17 -07:00
vh b1fbadde3c docs(coverage): track v1 coverage against worldtree-sdk's 41-op ratified surface
worldtree-sdk v1.0.0 (wtsdk-dev, althing 01KXVF24WQD2T5ZCS49KKFCCMH) ratifies the
same 41-op surface from the identical OpenAPI 2.3.0 (sha 36148179601453a0) this
ledger already tracks — record it as the external parity authority. Ratatoskr is
the parallel Python/httpx reference-consumer (no TS adoption); the forthcoming
worldtree-sdk Python spine is the future consumable, noted as a repin candidate.

Fold in v0.21.2: POST /sessions row now notes ephemeral-Echo config passthrough
(role not model, W-4 cross-validated by the SDK); GET /capabilities row notes the
--whoami allowed_roles/default_role fix + spec v1.1. REST count unchanged (19/41 —
ephemeral is a depth enhancement to an already-covered route).

No version bump (docs-only coverage-ledger update).
2026-07-18 13:44:04 -07:00
13 changed files with 1425 additions and 217 deletions
@@ -0,0 +1,218 @@
---
contract_version: "2.1"
module: "ratatoskr.wt"
purpose: "SDK-adapter cutover — replace ratatoskr's hand-rolled httpx consumer wrappers (sessions/sse_client/tier3) with a thin adapter over worldtree-sdk (Python) 1.0.0, deleting the old wrappers; CLI/TUI/web surfaces and caller-semantic exceptions preserved."
touches:
- src/ratatoskr/wt.py
- src/ratatoskr/cli.py
- src/ratatoskr/tui.py
- src/ratatoskr/web/server.py
scope: "Cut ratatoskr's CONSUMER client layer over from its hand-rolled httpx wrappers (sessions.py / sse_client.py / tier3.py / first_message.py) to consume worldtree-sdk (Python) 1.0.0. Replace the wrappers with a thin ratatoskr-owned adapter over the SDK; delete the old wrappers; keep the CLI / TUI / Starlette web surfaces and ratatoskr's caller-semantic exception surface. The Bifrost PROVIDER planes (memory/affect — where Worldtree dispatches INTO ratatoskr, ADR-0009) are consumer-orthogonal and untouched. Cutover decided by operator ruling 2026-07-18; design locked via vor-cross with worldtree-codex (thread 01KXWCWV3VKNR0HP74D2RCY0DC)."
depends_on:
- "worldtree-sdk (Python) == 1.0.0 # git tag python-v1.0.0; pin to the internal Gitea registry once published (DEC-5)"
used_by:
- "ratatoskr.cli"
- "ratatoskr.tui"
- "ratatoskr.web"
language: "python"
complexity: "high"
estimated_loc: 400 # net, rough — deletes (old wrappers) offset adds (adapter + call-site edits); sized per slice, not up front
confidence: 0.8
assumptions:
- "worldtree-sdk 1.0.0 is the GA Python consumer SDK ratatoskr's parity pass shaped; its resource surface (WorldtreeClient.sessions/agents/characters/me/capabilities/models/admin) covers all 21 of ratatoskr's covered routes. Verified against ~/development/worldtree-sdk @ python-v1.0.0."
- "Caller-injected transport is a first-class tested path (PY-INV-012): WorldtreeClient(base_url, auth=..., transport=my_httpx_async_client) uses ratatoskr's AsyncClient and NEVER closes it. Ratatoskr keeps base_url / bearer / User-Agent / timeout / web-app lifecycle ownership."
- "The SDK REST floor is an undiscriminated ApiError(error_code, message, status, body) (B-ERR-1/B-OPEN-3); hide-existence 404s are NOT distinguished by the SDK (B-ERR-4). Ratatoskr's caller-semantic mapping lives one layer up (DEC-2)."
---
# worldtree-sdk cutover — consumer-layer adapter contract
Single SDK-adapter contract per operator DEC-1. This is a migration contract: its
spine is a route-family slice plan, not a dense FN-block spec — each slice's
adapter FN detail is filled in at that slice's TDD (incremental migration; the
per-route SDK signatures are already mapped below).
## Locked design (operator-accepted 2026-07-18, post vor-cross)
- **DEC-1 — retire, don't repurpose (two-phase).** Normative authority for the
consumer wire transfers to THIS contract immediately (as of this file, #2 sessions
and #15 tier3 are superseded — no longer the spec of record); the contract FILES
are DELETED at slice-7 teardown, only after the code they specified is gone
(avoids both orphaning live code and leaving obsolete norms active mid-migration).
`first_message` is NOT retired — it stays a ratatoskr-owned usage contract at the
spec level (its preset / best-effort-timeout / swallow policy is local, not
SDK-owned). Repurposing the wire contracts would risk a shadow-normative copy of
SDK-owned semantics — the exact thing the cutover eliminates.
- **DEC-2 — thin semantic error adapter.** CLI/web/TUI keep ratatoskr's typed
exceptions; the adapter maps the SDK's `ApiError` floor → ratatoskr's
caller-semantic types (the SDK stays undiscriminated; hide-existence maps here).
- **DEC-3 — no shims (house rule).** Delete the old wrapper modules and update all
call-sites to the new adapter. No compatibility facade preserving old
module/function APIs (pre-1.0 no-backwards-compat rule).
- **DEC-4 — incremental + live-smoke, no soak.** Migrate one route-family per
slice, TDD each slice, and run a targeted LIVE smoke of that family against the
personal endpoint before deleting the replaced hand-rolled path. (The SDK's
fixture corpus proves the WIRE; the live smoke proves ratatoskr's
presenter/adapter/env-wiring survived the dependency inversion.)
**Smoke acceptance bar (the minimum that lets an old path be deleted):** drive
each route in the family's primary flow END-TO-END through the ratatoskr surface
(CLI/web) — not the wrapper directly — against the personal endpoint under the
real consumer key, and OBSERVE the expected success (a created session, a streamed
turn that renders, a persona write that reads back), not merely a non-crash. A
smoke that only asserts "no exception" does NOT clear the bar. **Smoke vs. soak
boundary:** a smoke is a single pass of the family's flows at deletion time; a
(forbidden) soak is a sustained dual-path parallel-run — do the former, not the
latter.
- **DEC-5 — wait for the registry.** Pin `worldtree-sdk == 1.0.0` from the internal
Gitea registry once published (verify the uv install first); use the
`python-v1.0.0` git tag only if the registry lags. Contract-drafting is NOT
gated (SDK source is inspectable); IMPLEMENTATION pin is.
- **DEC-6 — minor bump at ship.** The cutover ships as a minor (architecture-cutover
milestone; pre-1.0 breaking-internal) — requires explicit operator approval at
ship time, not now.
## Adapter design
- **`ratatoskr.wt`** — a thin adapter module owning ONE `WorldtreeClient`, built
with ratatoskr's own `httpx.AsyncClient` injected (`transport=`), ratatoskr's
`auth`/`admin_auth` providers, and the ratatoskr User-Agent. It exposes
ratatoskr-semantic call surfaces the presenters already expect, and translates
SDK errors → ratatoskr exceptions (DEC-2).
- **Transport ownership (INV-CUT-1 [hard]):** the injected `AsyncClient` is
ratatoskr-owned; `WorldtreeClient.aclose()` / `async with` exit must NOT close
it (SDK `_owns_client=False` on injection). Ratatoskr owns the client lifecycle
exactly as today.
- **Error boundary (INV-CUT-2 [hard]):** ratatoskr's caller-semantic exceptions
are raised by the adapter, mapping from the SDK where the SDK already
discriminates (`AgentNotAvailable`, `TurnLaunchUnavailable`, `SessionRetired`,
`Cancel*`, `Resume*`, `Malformed*`) and from `ApiError(status=..., error_code)`
on a KNOWN route otherwise — including the hide-existence 404 →
`AuthoredHistoryUnavailable` (labeling a call we made, NOT capability-probing).
- **Provider boundary (INV-CUT-3 [hard]):** the Bifrost provider planes stay
hand-rolled and are never routed through the SDK. The Bifrost-BOUND
`sessions.create` (consumer-side, `bifrost=` + consumer-key auth) DOES go through
the SDK and stays consumer-layer.
- **Import boundary (INV-CUT-4 [hard]):** `tests/test_no_worldtree_imports.py`
stays; the boundary becomes "no Worldtree SOURCE imports", with `worldtree_sdk`
the allowed client dependency.
## Invariants
- **INV-CUT-1 [hard]** — Ratatoskr owns the injected `httpx.AsyncClient` lifecycle;
the SDK never closes it (`_owns_client=False` on transport injection).
- **INV-CUT-2 [hard]** — The adapter raises ratatoskr's caller-semantic exceptions
per the explicit **§ Error map** table below — mapping from the SDK's discriminated
errors (passthrough) and from `ApiError` by the ROUTE that raised it (the route is
the discriminator, never the body). Every `ApiError` is mapped; the default is
`SessionApiFailed`. No route invents semantics the table doesn't list.
- **INV-CUT-3 [hard]** — The Bifrost PROVIDER planes never route through the SDK;
the Bifrost-BOUND `sessions.create` does (consumer-side).
- **INV-CUT-4 [hard]** — The no-Worldtree-SOURCE-import boundary holds
(`worldtree_sdk` allowed); `tests/test_no_worldtree_imports.py` enforces it.
- **INV-CUT-5 [hard]** — Each slice runs a LIVE smoke of its route family before the
replaced hand-rolled path is deleted (DEC-4); no old path is removed unproven.
## Route map — ratatoskr wrapper → SDK method (grounded @ python-v1.0.0)
| Ratatoskr wrapper (retiring) | SDK method | SDK return |
|---|---|---|
| `create_session` | `sessions.create(...)` | `SessionInfo` |
| `list_sessions` | `sessions.list(limit, cursor)` | page |
| `stream_turn` | `sessions.stream_turn(...)` (resilient) / `stream_turn_raw` (single) | `AsyncIterator[Event]` |
| `reconnect_turn` | folded into `sessions.stream_turn` auto-resume | — |
| `cancel_turn` | `sessions.cancel_turn(...)` | `CancelResult` |
| `get_session_messages` | `sessions.messages(id)` | open-world |
| `get_session_tools` | `sessions.tools(id)` | `SessionTools` |
| `set_persona_state` | `sessions.set_persona_state(id, PadState)` | None |
| `write_authored_history` | `sessions.write_history(...)` | authored-turn |
| `get_persona_state` | `agents.persona_state(agent_id)` | `AgentPersonaState` |
| `list_agents` | `agents.list()` | `AgentList` |
| `define_agent` | `agents.define(...)` | `DefinedAgent` |
| `patch_agent` | `agents.patch(id, changes)` | `PatchedAgent` |
| `delete_agent` | `agents.delete(id)` | None |
| `get_me` | `me.get()` | `MeInfo` |
| `get_capabilities` | `capabilities.get()` | `Capabilities` |
| `list_character_models` | `models.available_for_characters()` | `ModelsAvailable` |
| `create_character` | `characters.create(...)` | `CreatedCharacter` |
| `get_character_state` | `characters.state(id)` | `CharacterState` |
| `delete_character` | `characters.delete(id)` | `CharacterDeleteResult` |
| `get_session_bifrost` | `admin.sessions.bifrost(id)` | `BifrostInspection` |
| `stream_admin_events` | `admin.stream_events(...)` | `AsyncIterator[AdminEvent]` |
Note: SDK returns are typed `shapes.*`; per the parity pass the freeform reads
(`SessionTools`, `BifrostInspection`, capabilities) are deliberately open-world in
the Python spine. The adapter PASSES THESE THROUGH unmodified (does not normalize);
presenters read them as mappings, tolerant of wire drift. If an open-world read is
malformed the presenter degrades (never crashes) — same posture as the existing
`_format_whoami` hardening.
## Error map (INV-CUT-2 — the explicit table)
**The ROUTE is the discriminator, not the error body** (labeling a call we made, NOT
capability-probing — B-ERR-4). A 404 is mapped by which method raised it; the adapter
NEVER sniffs the body to decide whether something exists. Authored-history is
currently the ONLY hide-existence route (per Worldtree B-ERR-4); if Worldtree adds
others, they get their own row here — the default is NOT a general "any 404 → hidden".
| SDK raises (from) | Adapter maps to |
|---|---|
| SDK `AgentNotAvailable` / `TurnLaunchUnavailable` / `SessionRetired` (stream-open) | ratatoskr `AgentNotAvailable` / `TurnLaunchUnavailable` / (retired → `SessionApiFailed`) — same names, passthrough |
| SDK `ConnectionDropped` (mid-stream) | `SseConnectionDropped` |
| SDK `ResumeError` subclasses (in resilient stream) | resilient `stream_turn` absorbs; terminal → `SseConnectFailed` |
| SDK `Cancel*` (cancel_turn) | folded into `CancelResult`; late-cancel race (B-CAN-3) returns `cancelled=False`, never raises |
| `ApiError(404)` on `sessions.create` | `AgentNotFound` |
| `ApiError(404)` on `sessions.write_history` | `AuthoredHistoryUnavailable` (hide-existence) |
| `ApiError(422 cursor_invalid)` on `sessions.list` | `InvalidCursor` |
| `ApiError(502 bifrost_handshake_failed)` on bound `sessions.create` | `BifrostHandshakeFailed` |
| **`ApiError` (any other status/route) — the default** | `SessionApiFailed(status, error_code, body)` |
The default row is load-bearing: any `ApiError` not matched above surfaces as the
generic `SessionApiFailed` carrying the raw `status`/`error_code`/`body` — the
adapter does NOT invent per-route semantics the contract doesn't list, and does NOT
leave an `ApiError` un-mapped. Each slice adds/confirms its route's rows here before
the old path is deleted.
## Slice plan (incremental, DEC-4)
Each slice: wire the family through `ratatoskr.wt` → update call-sites → TDD →
LIVE smoke against the personal endpoint → delete the replaced hand-rolled path →
re-anchor its coverage-map rows.
1. **Foundation**`ratatoskr.wt` module + injected `WorldtreeClient` + auth
providers + the error-adapter skeleton (the § Error map's default `ApiError`
`SessionApiFailed` rule + the discriminated-error passthrough). No USER-VISIBLE
behavior change (imports/construction change; the skeleton is unit-tested but
unexercised by any surface until slice 2). Done = `ratatoskr.wt` importable, the
client constructs with an injected transport, and the error-map default is unit-
covered.
2. **Sessions/turn** — create/list/messages/tools/cancel + the turn STREAM + the
Bifrost-bound create. The turn presenter wires the resilient `sessions.stream_turn`
(auto-resume — this ABSORBS the old `reconnect_turn`, which is deleted this slice
with its call-sites); `stream_turn_raw` is available for a single-attempt caller
but is NOT the presenter default. (Highest-traffic; the SSE presenter is the
riskiest wiring.)
3. **Persona + authored-history** — set_persona_state (PadState) + write_history
(hide-existence mapping) + first_message presets (stay ratatoskr-owned OVER the
SDK method).
4. **Agents (Tier-3)** — list/get/define/patch/delete/persona_state. (Folds in the
pending `model``role` cutover — see [[project-tier3-agents-model-to-role-pending]].)
5. **Characters + me/capabilities/models** — the remaining consumer reads.
6. **Admin** — admin.sessions.bifrost + admin.stream_events (admin_auth).
7. **Teardown** — delete residual hand-rolled modules; drop `httpx-sse` (SDK owns
SSE parsing); retire contracts #2/#15; final coverage-map re-anchor; minor bump
(DEC-6, operator approval).
## Out of scope
- Bifrost PROVIDER planes (memory/affect) — hand-rolled, ADR-0009, untouched.
- CLI / TUI / Starlette web surfaces — their EXTERNAL BEHAVIOR is preserved (their
call-sites DO change — they're updated to the new adapter per `touches:`; what's
invariant is the observable behavior, not the code).
- The four parked post-v1 SDK ergonomics items (replayed:bool, raw-dict persona
escape hatch, list_all iterator, split session types) — coordinated post-v1 lane
with wtsdk-dev, not this cutover.
## Coverage-map re-anchor
`docs/coverage-map.md` shifts from "hand-rolled route exists + live-proven" to
"SDK method wired and exercised by a ratatoskr surface"; excluded-by-design rows
stay explicit. Re-anchored per slice as each family lands.
+19 -2
View File
@@ -23,6 +23,23 @@ anchors against the frozen machine-readable artifacts, NOT the prose markdown:
| 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) |
> **External ratification — worldtree-sdk v1.0.0 (wtsdk-dev, 2026-07-18, althing
> thread `01KXVF24WQD2T5ZCS49KKFCCMH`).** The TypeScript v0 consumer SDK wires all
> **41 ops as typed methods** from the **same OpenAPI 2.3.0** (sha
> `36148179601453a0` — matches our vendored pin exactly), so it is the independent
> parity authority for the surface this ledger tracks. Ratatoskr does **not** adopt
> the SDK — we are the parallel **Python/httpx** reference-consumer of the identical
> surface (no TS/npm surface exists to pin it into). Cross-check corroboration: the
> SDK's **W-4** (send `config.role`, response echoes `model` unremapped) matches what
> ratatoskr shipped in **v0.21.2** for ephemeral-Echo create.
>
> **Future consumable — worldtree-sdk Python spine (wtsdk-dev, next milestone).** The
> TS v1 surface is our parity *checklist*; the forthcoming **Python spine** (same
> behavioral-spec + fixture corpus, TS decisions kept Python-portable per their
> surface-design §6) is what ratatoskr would actually *consume* when it lands.
> wtsdk-dev slots ratatoskr as **THE reference consumer** whose coverage column
> drives that spine — a future repin candidate, not yet available.
> **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
@@ -67,7 +84,7 @@ sub-gap).
| 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` | ✅ | `sessions.py` `create_session``cli.py`,`tui.py`,`web/server.py` | + `end_user_id`, `bifrost` binding; 404→AgentNotFound, 502→BifrostHandshakeFailed. **v0.21.2 (#19): ephemeral-template (Echo) create**`config` passthrough (`--system-prompt`), `role` not `model` (W-4), `kind`/`config` captured; 422 ephemeral_requires_config now reachable-and-handled. Depth enhancement to an already-covered route — count unchanged |
| `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}/history` (authored-history-write, #347) | ✅ | `sessions.py:583` `write_authored_history``cli.py:758` `--seed-first-message` | v1: author=assistant, effects=none, per-session idempotency; 404→AuthoredHistoryUnavailable (hide-existence: feature-absent, never probe); 409/422 mapped. **LIVE-PROVEN 2026-07-06** on personal :8081 (grant applied via a rule-based Heimdall allow, worldtree-dev): create mimir session → seed → **201** (seq=0, phase=seeded, turn_id=1798) → GET /messages reads it back as a plain role=assistant turn (model-invisible provenance confirmed). Hide-404 for ungranted is unit+probe covered |
| `GET /sessions/{id}/messages` (history) | ✅ | `sessions.py:635` `get_session_messages``cli.py:758` `--seed-first-message` read-back | un-deferred as the #347 seed read-back — confirms model-invisible provenance (a seed reads back as a normal `role=assistant` turn) |
@@ -78,7 +95,7 @@ sub-gap).
| `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 /capabilities` | ✅ | `sessions.py` `get_capabilities``cli.py` `--whoami` | Echo ephemeral-template discovery. **v0.21.2: `--whoami` renderer reads `allowed_roles`/`default_role`** (was the dead `allowed_models`/`default_model`) + tolerates malformed caps; matches conversation-api-spec **v1.1** (`b4a278c`) |
| `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 |
+37 -4
View File
@@ -46,6 +46,33 @@ upstream API key stays server-side (INV-003).
_As of 2026-07-18 (evening):_
**🔨 ACTIVE MIGRATION — worldtree-sdk cutover (issue #20), starting slice-1.** Operator ruled ADOPT
(2026-07-18): ratatoskr cuts its CONSUMER client layer over to consume **worldtree-sdk (Python) 1.0.0**
retire the hand-rolled httpx wrappers (`sessions`/`sse_client`/`tier3`) behind a thin `ratatoskr.wt`
adapter over the SDK. Both TS + Python SDK 1.0.0 are GA (**Python live + pip-installable on the gitea
PyPI** — DEC-5 gate cleared). Ratatoskr's own **parity pass shaped the Python spine** (open-world reads,
caller-injected transport, per-wire role/model). Design locked (6 DECs, vor-cross'd with worldtree-codex,
heid-panel-reviewed → error-map table added); contract `docs/contracts/worldtree_sdk_cutover.contract.md`
(committed `e45640c`). **SLICE-1 IN PROGRESS:** ✅ dep integrated + DEC-5 verified + committed (`29c4fda`) — `worldtree-sdk==1.0.0`
installs from the gitea registry (reuses bifrost's index auth, NO new token; core dep + `[tool.uv.sources]`),
`WorldtreeClient` constructs with an injected transport (`_owns_client=False`, INV-CUT-1 confirmed live),
suite 534 green. **NEXT = the adapter** `src/ratatoskr/wt.py` (TDD): `build_client(base_url, *, api_key,
admin_key, transport)``WorldtreeClient(auth=, admin_auth=, transport=)` (DESIGN CARE: SDK does per-request
auth via the providers; our injected `httpx.AsyncClient` carries base_url/UA/timeout, NOT the Authorization
header — read the SDK `client.py` @ `~/development/worldtree-sdk` for the split, INV-CUT-1) + `translate_error`
DEFAULT (SDK `ApiError``SessionApiFailed`, discriminated `WorldtreeError` subclasses passthrough; route-specific
rows come in later slices). Unit-test; NO surface wiring (slice-2). Then slices 2-7 (route-family + deletions,
live-smoke per slice). Scope: consumer layer ONLY;
Bifrost provider planes untouched. Multi-session grind. Full design → auto-memory
`project_worldtree_sdk_cutover`. This SUPERSEDES the #371 "repin rides the later Python milestone" framing
below (that milestone shipped; we're adopting, not just repinning).
**⏸️ DEFERRED — tier3 agents `model``role` (scope B), on worldtree-dev's deploy flag.** WT renames the
agents-RESPONSE selector `model``role` (spec 1.2, commit `387c67b`, NOT yet deployed). `_parse_tier3_agent_info`
reads `body["model"]` → KeyErrors post-deploy. Operator chose scope B (full tier3 `model``role` incl.
contract #15 + CLI `--model``--role`). Implement ON the deploy flag, not before (breaks the current demo);
folds into cutover slice-4. Auto-memory `project_tier3_agents_model_to_role_pending`.
**✅ RESOLVED — the "app product" workstreams leave Rata entirely (operator 2026-07-18).**
**No arbo fork, no SillyTavern-on-Rata** — a NEW repo (template-dev standing up) takes over BOTH
app-product ideas. Rata does NOT fork into anything and is NOT the base for any chat / image-gen app.
@@ -88,10 +115,13 @@ Full record → `persistent-memory.d/2026-07-18-368-silo-test-passed.md`. Siblin
(2) R39 Phase-2 **matched-quartets rebuild** (confirmatory, "whenever"); (3) bifrost **snapshot-cursor
adoption** (ruled normative, not blocking → `persistent-memory.d/2026-07-16-bifrost-cursor-conformance.md`).
**Substrate / environment:** branch `main` at **v0.21.1**, **PUSHED to origin 2026-07-18** (incl.
tier3 b127-role adaptation `860e0d5`, #368 memory snapshots + silo work, the contract-drift fix
`4bd9abd`, and this snapshot); origin `git@gitea.phasefinal.com:vh/ratatoskr.git`. bifrost
**`==1.1.4`** / wire v0.7; WT openapi vendored 2.3.0; **suite 644 green**. Personal WT on **b127**
**Substrate / environment:** branch `main` at **v0.21.2**. **origin at `b1fbadd`** (pushed 2026-07-18:
canonical syncs `5d06a27`/`80c8d58`, ephemeral-Echo `c7016f2` #19, coverage-map→SDK-surface `b1fbadd`).
**UNPUSHED local commits** (cutover work — operator hasn't pushed): cutover contract `e45640c` #20, the
`memory:` snapshot, dep-integration `29c4fda` (worldtree-sdk==1.0.0), + this snapshot — **push is the
operator's call**. origin `git@gitea.phasefinal.com:vh/ratatoskr.git`. **NEW core dep: `worldtree-sdk==1.0.0`**
(gitea PyPI, `[tool.uv.sources]`). bifrost **`==1.1.4`** / wire v0.7; WT openapi vendored 2.3.0,
**conversation-api-spec re-synced to v1.1** (`b4a278c`); **suite 534 green**. Personal WT on **b127**
(`http://10.250.50.152:8081`; #368 silo + #364 promotion-hygiene live both instances). The combined
**:8392** provider (memory+affect) + **:8765** web are THE surfaces, dev-box BACKGROUND SHELLS —
restart via `scratchpad/relaunch_by_pid.py <pid>` (pid via `ss -ltnp | grep <port>`). `env.sh` sets
@@ -238,6 +268,9 @@ decision. Captures rationale that won't be obvious from code alone.
- `[2026-07-18]` **issue #1 contract drift fixed (`4bd9abd`) — SSE event vocab re-canonicalized: added `awaiting_llm_first_token` (#201) + `affect_update` (#204) to the Event union + TESTS (both were parsed by `_envelope_for_type` and tested in `test_sse_client.py`, but never in #1's contract; surfaced by the #371 parity pass). Contract-only, no bump.**
- `[2026-07-18]` **Both app products (arbo image-gen + SillyTavern chat) leave Rata to a NEW repo (template-dev standing up); Rata does NOT fork and is NOT the app base.** Reverts Rata to its core — Worldtree debug-observability surface + Bifrost reference impl + Conversation-API SDK seed/future-consumer. Supersedes the 2026-07-17 'fork Rata for arbo' NEXT-MAJOR plan.
- `[2026-07-18]` **ephemeral-template (Echo) create SHIPPED (`v0.21.2`, `c7016f2`, #19)**`config` passthrough + `--system-prompt` + `SessionInfo.kind/config` + `--whoami` roles fix. Diagnosed from the ignored `session_api_failed` startup line; role/model drift resolved w/ worldtree-dev (spec re-synced v1.1). TDD + heid contract-review + bug-hunt.
- `[2026-07-18]` **Rata = THE reference consumer of the worldtree-sdk Python spine** — parity pass (12 grounded findings) shaped its contract BEFORE build (open-world reads, caller-injected transport, per-wire role/model — adopted); coverage-map re-anchored to the SDK's 41-op ratified surface (`b1fbadd`); 4 ergonomics items parked post-v1 with wtsdk-dev.
- `[2026-07-18]` **worldtree-sdk cutover DECIDED — adopt the Python SDK for the consumer client layer** (operator, overriding "stay hand-rolled"). Issue #20; contract `docs/contracts/worldtree_sdk_cutover.contract.md` (`e45640c`, vor-cross'd + heid-reviewed); auto-memory `project_worldtree_sdk_cutover`. Consumer layer only, Bifrost provider untouched; slice-1 foundation next. See in-flight.
_65 older entries (2026-05-* debug-TUI/web era + the 2026-06-14 → 06-18 Bifrost-provider build / #17+#18 / #295-296 era) archived to archival-memory.md._
+4 -2
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "ratatoskr"
version = "0.21.2"
version = "0.21.7"
description = "Worldtree Conversation API debug console (web + headless CLI) — multi-pane observability"
readme = "README.md"
requires-python = ">=3.12"
@@ -16,7 +16,8 @@ keywords = ["worldtree", "debug", "sse", "web", "observability"]
# See docs/design-brief.md §3 (httpx-sse).
dependencies = [
"httpx>=0.27",
"httpx-sse>=0.4",
"httpx-sse>=0.4", # #20 slice-7 teardown drops this once the SDK owns SSE parsing
"worldtree-sdk==1.0.0", # #20 cutover: the consumer client layer (gitea PyPI); slices retire the hand-rolled wrappers behind ratatoskr.wt
]
[project.optional-dependencies]
@@ -70,6 +71,7 @@ url = "https://gitea.phasefinal.com/api/packages/vh/pypi/simple/"
[tool.uv.sources]
bifrost = { index = "gitea" }
worldtree-sdk = { index = "gitea" }
[tool.hatch.build.targets.wheel]
packages = ["src/ratatoskr"]
+132 -61
View File
@@ -11,12 +11,30 @@ import hashlib
import os
import signal
import sys
from collections.abc import Mapping
from dataclasses import dataclass, field
from importlib.metadata import PackageNotFoundError, version
from typing import Any, TextIO
import httpx
from worldtree_sdk import (
AffectUpdateEvent,
AwaitingLlmFirstTokenEvent,
CancelledEvent,
ConnectFailed,
DoneEvent,
ErrorEvent,
TextBoundaryEvent,
TextEvent,
ThinkingEvent,
ToolResultEvent,
ToolStartEvent,
TurnEvent,
WorkerPhaseEvent,
WorldtreeClient,
)
from ratatoskr import wt
from ratatoskr.first_message import seed_preset_first_message
from ratatoskr.sessions import (
AgentNotFound,
@@ -37,29 +55,21 @@ from ratatoskr.sessions import (
set_persona_state,
write_authored_history,
)
# The turn path (create / stream / cancel) is served by the worldtree-sdk adapter
# (`wt.*`); these caller-semantic exceptions are what the adapter raises, so the
# presenter keeps catching ratatoskr's own types (DEC-2). The hand-rolled probes
# (--whoami / --characters / --set-persona / --seed-first-message) stay on the
# `sessions` wrappers until their own slices.
from ratatoskr.sse_client import (
AffectUpdate,
AwaitingLlmFirstToken,
CancelAlreadyCompleted,
CancelFailed,
Cancelled,
CancelTurnNotFound,
Done,
Error,
Event,
MalformedSseData,
MalformedSseId,
SseConnectFailed,
SseConnectionDropped,
Text,
TextBoundary,
Thinking,
ToolResult,
ToolStart,
TurnIdFlip,
WorkerPhase,
cancel_turn,
stream_turn_resilient,
)
@@ -337,6 +347,29 @@ def _format_usage(usage: dict[str, int], *, arrow: str) -> str:
return f"{p} in {arrow} {c} out ({t} total, {ci} cached)"
def _format_usage_safe(usage: Mapping[str, int] | None) -> str:
"""Tolerant wrapper over `_format_usage` for the SDK's open-world
`DoneEvent.usage` (typed optional): the canonical four-key usage formats;
anything absent or malformed degrades to `(n/a)` rather than crashing the
presenter (same posture as `_format_whoami`)."""
keys = ("prompt_tokens", "completion_tokens", "total_tokens", "cached_input_tokens")
if usage is not None and all(k in usage for k in keys):
return _format_usage(dict(usage), arrow="->")
return "(n/a)"
def _turn_id_from_sse_id(sse_id: str) -> int | None:
"""The turn component of the SDK's composite sse_id (`"{turn}:{seq}"`). This is
the mid-stream cancel target: it is present on EVERY frame, unlike the SDK's
top-level `turn_id`, which is the body field (absent on text/thinking events)."""
head, _, _ = sse_id.partition(":")
try:
turn = int(head)
except ValueError:
return None
return turn if turn > 0 else None
@dataclass(slots=True)
class CliPresenterState:
"""Per-turn presenter state for `--send` mode (issue #12).
@@ -348,24 +381,31 @@ class CliPresenterState:
thinking_open: bool = False
text_written_since_newline: bool = False
def render(self, event: Event, *, stdout: TextIO, stderr: TextIO) -> None:
"""Render one Worldtree SSE event with editorial hierarchy + coalescing."""
def render(self, event: TurnEvent, *, stdout: TextIO, stderr: TextIO) -> None:
"""Render one Worldtree SSE event with editorial hierarchy + coalescing.
Consumes the worldtree-sdk `TurnEvent` union. The SDK types the de-facto
fields as OPTIONAL (open-world), so every read is hardened: a
malformed/partial event degrades to a placeholder rather than crashing the
presenter — the same posture as `_format_whoami`.
"""
assert isinstance(
event,
(
WorkerPhase, Thinking, Text, TextBoundary,
ToolStart, ToolResult, Done, Error, Cancelled,
AffectUpdate, AwaitingLlmFirstToken,
WorkerPhaseEvent, ThinkingEvent, TextEvent, TextBoundaryEvent,
ToolStartEvent, ToolResultEvent, DoneEvent, ErrorEvent, CancelledEvent,
AffectUpdateEvent, AwaitingLlmFirstTokenEvent,
),
)
# Thinking events accumulate into the open run.
if isinstance(event, Thinking):
if isinstance(event, ThinkingEvent):
content = event.content or ""
if not self.thinking_open:
stderr.write(". thinking: ")
self.thinking_open = True
stderr.write(event.content)
stderr.write(content)
stderr.flush()
self.thinking_buffer.append(event.content)
self.thinking_buffer.append(content)
return
# Non-thinking event: close any open thinking run first.
if self.thinking_open:
@@ -374,59 +414,60 @@ class CliPresenterState:
self.thinking_open = False
self.thinking_buffer.clear()
# Now render the new event.
if isinstance(event, Text):
stdout.write(event.content)
if isinstance(event, TextEvent):
content = event.content or ""
stdout.write(content)
stdout.flush()
# POST-003: only set if cursor is mid-line (no trailing newline).
self.text_written_since_newline = not event.content.endswith("\n")
self.text_written_since_newline = not content.endswith("\n")
return
if isinstance(event, (Done, Error, Cancelled)):
if isinstance(event, (DoneEvent, ErrorEvent, CancelledEvent)):
# INV-005: ensure stdout newline boundary before stderr terminal label.
if self.text_written_since_newline:
stdout.write("\n")
stdout.flush()
self.text_written_since_newline = False
if isinstance(event, Done):
if isinstance(event, DoneEvent):
stderr.write(
f"[done] turn_id={event.sse_id.turn_id} model={event.model} "
f"duration={_format_duration_ms(event.duration_ms)} "
f"usage {_format_usage(event.usage, arrow='->')}\n"
f"[done] turn_id={event.turn_id} model={event.model} "
f"duration={_format_duration_ms(event.duration_ms or 0)} "
f"usage {_format_usage_safe(event.usage)}\n"
)
return
if isinstance(event, WorkerPhase):
if isinstance(event, WorkerPhaseEvent):
stderr.write(
f". worker_phase: phase={event.phase} turn_id={event.turn_id}\n"
)
return
if isinstance(event, Error):
if isinstance(event, ErrorEvent):
stderr.write(
f"[error] turn_id={event.sse_id.turn_id} code={event.error_code} "
f"[error] turn_id={event.turn_id} code={event.error_code} "
f"message={event.message!r}\n"
)
return
if isinstance(event, Cancelled):
if isinstance(event, CancelledEvent):
stderr.write(
f"[cancelled] turn_id={event.turn_id} reason={event.reason!r} "
f"partial_message_id={event.partial_message_id}\n"
)
return
if isinstance(event, ToolStart):
if isinstance(event, ToolStartEvent):
stderr.write(
f". tool_start: name={event.name} args={event.arguments!r}\n"
)
return
if isinstance(event, ToolResult):
if isinstance(event, ToolResultEvent):
stderr.write(
f". tool_result: name={event.name} duration_ms={event.duration_ms} "
f"result={event.result!r:.200}\n"
)
return
if isinstance(event, TextBoundary):
if isinstance(event, TextBoundaryEvent):
stderr.write(
f". text_boundary: kind={event.kind} char_offset={event.char_offset}\n"
)
return
if isinstance(event, AffectUpdate):
if isinstance(event, AffectUpdateEvent):
# Worldtree #204 / v0.28.0. CLI surface is debug telemetry —
# one line to stderr with status + (for current) dominant_emotion.
if event.snapshot is not None:
@@ -440,10 +481,10 @@ class CliPresenterState:
f". affect_update: status={event.status} turn_id={event.turn_id}\n"
)
return
if isinstance(event, AwaitingLlmFirstToken):
if isinstance(event, AwaitingLlmFirstTokenEvent):
# Worldtree #201 / v0.29.0. Heartbeat during BuildingPrompt →
# CallingLLM gap. Stderr surface, one line per heartbeat.
secs = event.elapsed_ms_since_building_prompt / 1000.0
secs = (event.elapsed_ms_since_building_prompt or 0) / 1000.0
stderr.write(
f". awaiting_llm_first_token: turn_id={event.turn_id} elapsed={secs:.1f}s\n"
)
@@ -451,23 +492,31 @@ class CliPresenterState:
async def _cancel_and_log(
client: httpx.AsyncClient,
client: WorldtreeClient,
session_id: str,
turn_id: int,
*,
stderr: TextIO,
) -> None:
"""Spawn-and-forget cancel that never raises (INV-009)."""
"""Spawn-and-forget cancel that never raises (INV-009). The adapter maps the
cancel races onto ratatoskr's typed exceptions; a benign late-cancel (200,
cancelled=False) returns a result and logs nothing."""
assert client is not None
assert isinstance(turn_id, int) and turn_id > 0
try:
await cancel_turn(client, session_id, turn_id)
except (CancelFailed, CancelTurnNotFound, CancelAlreadyCompleted, httpx.RequestError) as exc:
await wt.cancel_turn(client, session_id, turn_id)
except (
CancelFailed,
CancelTurnNotFound,
CancelAlreadyCompleted,
ConnectFailed, # SDK normalizes a transport drop to ConnectFailed(status=0)
httpx.RequestError,
) as exc:
stderr.write(f"[cancel_failed] {type(exc).__name__}: {exc}\n")
async def _run_turn(
client: httpx.AsyncClient,
client: WorldtreeClient,
session_id: str,
content: str,
sigint_event: asyncio.Event,
@@ -493,7 +542,10 @@ 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_resilient(client, session_id, content).__aiter__()
# wt.stream_turn is an async generator — it is already its own iterator, so no
# explicit __aiter__(); keeping the concrete type lets __anext__() type as a
# coroutine for asyncio.create_task.
aiter_obj = wt.stream_turn(client, session_id, content)
try:
while True:
@@ -537,17 +589,21 @@ async def _run_turn(
except TurnIdFlip as exc:
stderr.write(f"[turn_id_flip] expected={exc.established} got={exc.got}\n")
return 22
last_turn_id = event.sse_id.turn_id
# The cancel target is the turn from the composite sse_id (present on
# every frame); the body's turn_id is absent on text/thinking events.
tid = _turn_id_from_sse_id(event.sse_id)
if tid is not None:
last_turn_id = tid
state.render(event, stdout=stdout, stderr=stderr)
if isinstance(event, Done):
if isinstance(event, DoneEvent):
if sigint_task is not None and not cancelling:
sigint_task.cancel()
return 0
if isinstance(event, Error):
if isinstance(event, ErrorEvent):
if sigint_task is not None and not cancelling:
sigint_task.cancel()
return 2
if isinstance(event, Cancelled):
if isinstance(event, CancelledEvent):
if sigint_task is not None and not cancelling:
sigint_task.cancel()
return 3
@@ -564,6 +620,11 @@ async def _run_turn(
async def _amain(args: ParsedArgs) -> int:
"""Async orchestrator: create-session (if --new) → SIGINT install → _run_turn → cleanup."""
assert isinstance(args, ParsedArgs)
# ratatoskr owns the transport (INV-CUT-1): the SDK is injected with it and
# never closes it. The transport carries base_url / User-Agent / timeout AND the
# default bearer — the SDK overrides Authorization per request (so a bound create
# still uses its consumer_key), while the not-yet-migrated hand-rolled
# `seed_preset_first_message` reuses the transport's default bearer directly.
async with httpx.AsyncClient(
base_url=args.server_url,
headers={
@@ -575,7 +636,8 @@ async def _amain(args: ParsedArgs) -> int:
# connect/write/pool keep modest timeouts so true network failures
# still surface promptly.
timeout=httpx.Timeout(connect=10.0, read=None, write=10.0, pool=10.0),
) as client:
) as transport:
client = wt.build_client(args.server_url, api_key=args.api_key, transport=transport)
if args.new:
assert args.agent_id is not None
try:
@@ -586,7 +648,7 @@ async def _amain(args: ParsedArgs) -> int:
if args.system_prompt is not None
else None
)
info = await create_session(
info = await wt.create_session(
client,
args.agent_id,
end_user_id=args.end_user_id,
@@ -616,23 +678,32 @@ async def _amain(args: ParsedArgs) -> int:
"(RATATOSKR_BIFROST_CONSUMER_KEY), not WORLDTREE_API_KEY\n"
)
return 23
except SessionApiFailed as exc:
except wt.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:
except (
httpx.ConnectError,
httpx.ReadTimeout,
httpx.TransportError,
ConnectFailed, # SDK normalizes a pre-response transport failure here
) as exc:
sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n")
return 21
# Issue #12: demoted lifecycle line — written directly here (NOT via
# state.render, which only accepts SSE Event variants per PRE-001).
kind_suffix = f" kind={info.kind}" if info.kind else ""
# state.render, which only accepts SSE Event variants per PRE-001). The
# adapter returns the SDK's open-world create dict; read it as a mapping.
session_id = info["session_id"]
kind = info.get("kind")
kind_suffix = f" kind={kind}" if kind else ""
sys.stderr.write(
f". create_session: session_id={info.session_id} "
f"agent_id={info.agent_id}{kind_suffix}\n"
f". create_session: session_id={session_id} "
f"agent_id={info['agent_id']}{kind_suffix}\n"
)
# #347 authored first-message: seed the agent's preset opening (best-effort).
if await seed_preset_first_message(client, info.session_id, args.agent_id):
# Uses the transport directly — first_message is a slice-3 hand-rolled path.
if await seed_preset_first_message(transport, session_id, args.agent_id):
sys.stderr.write(
f". first_message: seeded preset opening for {info.agent_id}\n"
f". first_message: seeded preset opening for {args.agent_id}\n"
)
# Issue #17 bound-state indicator: plane + endpoint + status, so the
# operator sees WHICH identity/endpoint bound (not a bare boolean).
@@ -642,7 +713,7 @@ async def _amain(args: ParsedArgs) -> int:
f". bifrost: status=bound plane={plane} "
f"endpoint={args.bifrost.endpoint_url}\n"
)
session_id = info.session_id
# session_id was bound above from the create dict.
else:
assert args.session_id is not None
session_id = args.session_id
+10 -3
View File
@@ -286,9 +286,15 @@ def _eager_failure_fields(body: bytes, status: int) -> tuple[str, str]:
class SseConnectionDropped(Exception):
"""Raised when the HTTP/SSE connection dropped mid-stream."""
"""Raised when the HTTP/SSE connection dropped mid-stream.
def __init__(self, *, last_seen_sse_id: SseId | None) -> None:
`last_seen_sse_id` is the resume cursor of the last frame seen. The
hand-rolled path carries a parsed `SseId`; the worldtree-sdk cutover carries
the SDK's raw composite-id `str` (the cutover's target form) — both accepted
during the migration.
"""
def __init__(self, *, last_seen_sse_id: SseId | str | None) -> None:
super().__init__(f"SSE connection dropped; last_seen_sse_id={last_seen_sse_id}")
self.last_seen_sse_id = last_seen_sse_id
@@ -611,7 +617,8 @@ async def stream_turn_resilient(
client,
session_id,
content,
last_event_id=f"{seen.turn_id}:{seen.seq}",
# A str cursor is already the composite id; an SseId is formatted.
last_event_id=seen if isinstance(seen, str) else f"{seen.turn_id}:{seen.seq}",
)
+83 -55
View File
@@ -26,8 +26,10 @@ from starlette.responses import (
)
from starlette.routing import Mount, Route
from starlette.staticfiles import StaticFiles
from worldtree_sdk import CancelledEvent, DoneEvent, ErrorEvent, WorldtreeClient
from ratatoskr import local_agents as _local_agents
from ratatoskr import wt
from ratatoskr.first_message import seed_preset_first_message
from ratatoskr.sessions import (
AgentNotAvailable,
@@ -38,33 +40,44 @@ from ratatoskr.sessions import (
BifrostHandshakeFailed,
PersonaNotConfigured,
SessionApiFailed,
create_session,
endpoint_for_plane,
get_persona_state,
get_session_bifrost,
get_session_messages,
get_session_tools,
list_agents,
)
# The turn path (create / stream / cancel / tools / messages) is served by the
# worldtree-sdk adapter (`wt.*`), which raises ratatoskr's caller-semantic
# exceptions (DEC-2). The hand-rolled endpoints (persona / agents / admin /
# bifrost) stay on the `sessions` / `sse_client` wrappers until their own slices.
from ratatoskr.sse_client import (
AdminEvent,
CancelAlreadyCompleted,
CancelFailed,
Cancelled,
CancelTurnNotFound,
Done,
Error,
MalformedSseData,
MalformedSseId,
SseConnectFailed,
SseConnectionDropped,
TurnIdFlip,
cancel_turn,
stream_admin_events,
stream_turn_resilient,
)
def _wt_client(client: httpx.AsyncClient, *, max_reconnects: int = 5) -> WorldtreeClient:
"""Wrap a client_factory transport as the adapter's WorldtreeClient (INV-CUT-1:
the SDK never closes it). base_url + bearer are read off the transport (the
factory bakes them in); the SDK re-applies auth per request, so the extracted
key just mirrors the transport's default. A no-auth test transport falls back to
a placeholder key (respx ignores auth)."""
base_url = str(client.base_url) or "http://localhost"
header = client.headers.get("Authorization", "")
api_key = header[len("Bearer "):].strip() if header.startswith("Bearer ") else ""
return wt.build_client(
base_url, api_key=api_key or "ratatoskr", transport=client, max_reconnects=max_reconnects
)
def _static_dir() -> str:
"""Locate the bundled static/ directory inside the installed package.
@@ -163,16 +176,17 @@ async def _create_session_endpoint(request: Request) -> JSONResponse:
try:
async with client_factory() as client:
info = await create_session(
client,
info = await wt.create_session(
_wt_client(client),
agent_id,
end_user_id=end_user_id,
bifrost=bifrost,
consumer_key=consumer_key if bifrost else None,
)
# #347 authored first-message: seed the agent's preset opening
# (best-effort; never blocks create — see first_message INV-001).
await seed_preset_first_message(client, info.session_id, agent_id)
# #347 authored first-message: seed the agent's preset opening (best-effort;
# never blocks create). first_message is a slice-3 hand-rolled path — it
# reuses the raw transport (its default bearer), not the adapter client.
await seed_preset_first_message(client, info["session_id"], agent_id)
except AgentNotFound:
return JSONResponse({"error_code": "agent_not_found"}, status_code=404)
except BifrostConsumerKeyMissing:
@@ -188,12 +202,13 @@ async def _create_session_endpoint(request: Request) -> JSONResponse:
},
status_code=502,
)
except SessionApiFailed as exc:
except wt.SessionApiFailed as exc:
return JSONResponse(
{"error_code": "session_api_failed", "status": exc.status},
status_code=exc.status,
)
payload = _as_dict(info)
# The adapter returns the SDK's open-world create dict; the browser reads it as-is.
payload = dict(info)
if bifrost is not None:
# Bound-state for the UI indicator — plane + endpoint only, never the key.
payload["bifrost"] = {
@@ -251,25 +266,19 @@ async def _submit_turn_endpoint(request: Request) -> JSONResponse:
def _event_to_browser_payload(event: object) -> tuple[str, dict]:
"""Serialize an upstream Event dataclass to (browser_event_type, json_dict).
"""Serialize an SDK `TurnEvent` to (browser_event_type, json_dict).
Per INV-008 + FN stream_turn_endpoint STEP 3. The dict shape is
locked by tests/fixtures/presentation_contract.json — one entry per
Event type. Implementation: snake_case class name as event_type;
asdict(event) with sse_id flattened to "T:S" string.
Per INV-008 + FN stream_turn_endpoint STEP 3. The browser contract
(tests/fixtures/presentation_contract.json) is preserved: the SDK's `raw` is
the wire body — the same per-type field set the old dataclasses carried — so the
payload is `raw` minus the redundant `type`, plus the composite `sse_id` string
(already "T:S"). The browser event_type is the wire `type` ("text" / "done" /
…), NOT the SDK class name. Open-world: additive server fields pass through.
"""
type_name = type(event).__name__
# CamelCase → snake_case
browser_type = "".join(
("_" + c.lower() if c.isupper() and i else c.lower())
for i, c in enumerate(type_name)
)
data = asdict(event) # type: ignore[arg-type]
sse_id = data.get("sse_id")
if isinstance(sse_id, (list, tuple)) and len(sse_id) == 2:
data["sse_id"] = f"{sse_id[0]}:{sse_id[1]}"
elif isinstance(sse_id, dict) and "turn_id" in sse_id and "seq" in sse_id:
data["sse_id"] = f"{sse_id['turn_id']}:{sse_id['seq']}"
browser_type = getattr(event, "type", "") or ""
raw = getattr(event, "raw", None) or {}
data = {k: v for k, v in dict(raw).items() if k != "type"}
data["sse_id"] = getattr(event, "sse_id", None)
return browser_type, data
@@ -281,6 +290,20 @@ def _format_sse(event_type: str, data: dict) -> bytes:
return f"event: {event_type}\ndata: {json.dumps(data)}\n\n".encode()
def _turn_id_from_sse_id(sse_id: object) -> int | None:
"""The turn component of the SDK's composite sse_id (`"{turn}:{seq}"`) — the
upstream cancel target, present on every frame (the SDK's top-level `turn_id` is
the body field, absent on text/thinking events)."""
if not isinstance(sse_id, str):
return None
head, _, _ = sse_id.partition(":")
try:
turn = int(head)
except ValueError:
return None
return turn if turn > 0 else None
async def _stream_turn_endpoint(request: Request) -> StreamingResponse:
"""GET /api/turns/{session_id}/stream?turn_id=N → proxy upstream SSE.
@@ -302,21 +325,22 @@ async def _stream_turn_endpoint(request: Request) -> StreamingResponse:
async def gen() -> AsyncIterator[bytes]:
client = client_factory()
wt_client = _wt_client(client)
try:
handle.status = "streaming"
try:
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.
async for event in wt.stream_turn(wt_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 — parsed from the composite sse_id.
if handle.upstream_turn_id is None:
sse_id = getattr(event, "sse_id", None)
if sse_id is not None:
handle.upstream_turn_id = sse_id.turn_id
handle.upstream_turn_id = _turn_id_from_sse_id(
getattr(event, "sse_id", None)
)
event_type, data = _event_to_browser_payload(event)
yield _format_sse(event_type, data)
if isinstance(event, (Done, Error, Cancelled)):
handle.status = type(event).__name__.lower()
if isinstance(event, (DoneEvent, ErrorEvent, CancelledEvent)):
handle.status = event.type or "done"
break
except (SseConnectFailed, SseConnectionDropped, MalformedSseId,
MalformedSseData, TurnIdFlip) as exc:
@@ -330,7 +354,7 @@ async def _stream_turn_endpoint(request: Request) -> StreamingResponse:
# turn (if it started) — never the local turn_id.
if handle.status == "streaming" and handle.upstream_turn_id is not None:
try:
await cancel_turn(client, session_id, handle.upstream_turn_id)
await wt.cancel_turn(wt_client, session_id, handle.upstream_turn_id)
except (CancelAlreadyCompleted, CancelTurnNotFound):
pass # cooperative race — turn already terminal upstream
except Exception as exc:
@@ -377,15 +401,18 @@ async def _cancel_turn_endpoint(request: Request) -> JSONResponse:
client_factory = request.app.state.client_factory
try:
async with client_factory() as client:
await cancel_turn(client, session_id, handle.upstream_turn_id)
body = {"cancelled": True}
result = await wt.cancel_turn(
_wt_client(client), session_id, handle.upstream_turn_id
)
body = {"cancelled": bool(result.cancelled)}
except (CancelAlreadyCompleted, CancelTurnNotFound):
body = {"cancelled": False, "reason": "race_or_completed"}
except CancelFailed as exc:
except CancelFailed:
# The SDK abstracts the upstream cancel HTTP status; surface a generic 502.
registry.pop((session_id, turn_id), None)
return JSONResponse(
{"error_code": "cancel_failed", "status": exc.status},
status_code=exc.status,
{"error_code": "cancel_failed"},
status_code=502,
)
registry.pop((session_id, turn_id), None)
return JSONResponse(body, status_code=200)
@@ -465,13 +492,13 @@ async def _session_tools_endpoint(request: Request) -> JSONResponse:
client_factory = request.app.state.client_factory
try:
async with client_factory() as client:
info = await get_session_tools(client, session_id)
except SessionApiFailed as exc:
info = await wt.get_session_tools(_wt_client(client), session_id)
except wt.SessionApiFailed as exc:
return JSONResponse(
{"error_code": "session_tools_unavailable", "status": exc.status},
status_code=exc.status,
)
return JSONResponse(info, status_code=200)
return JSONResponse(dict(info), status_code=200)
async def _session_messages_endpoint(request: Request) -> JSONResponse:
@@ -485,13 +512,13 @@ async def _session_messages_endpoint(request: Request) -> JSONResponse:
client_factory = request.app.state.client_factory
try:
async with client_factory() as client:
data = await get_session_messages(client, session_id)
except SessionApiFailed as exc:
data = await wt.get_session_messages(_wt_client(client), session_id)
except wt.SessionApiFailed as exc:
return JSONResponse(
{"error_code": "session_messages_unavailable", "status": exc.status},
status_code=exc.status,
)
return JSONResponse(data, status_code=200)
return JSONResponse(dict(data), status_code=200)
async def _session_bifrost_endpoint(request: Request) -> JSONResponse:
@@ -609,14 +636,15 @@ def create_app(
]
if in_flight:
client = client_factory()
wt_client = _wt_client(client)
try:
task_to_handle = {
asyncio.create_task(
cancel_turn(client, h.session_id, h.upstream_turn_id)
wt.cancel_turn(wt_client, h.session_id, h.upstream_turn_id)
): h
for h in in_flight
}
done, pending = await asyncio.wait(task_to_handle, timeout=5.0)
_done, pending = await asyncio.wait(task_to_handle, timeout=5.0)
# Per-pending session/turn detail (INV-006 logging fidelity).
for task in pending:
h = task_to_handle[task]
+325
View File
@@ -0,0 +1,325 @@
"""worldtree-sdk adapter — ratatoskr's single chokepoint onto the Conversation-API SDK.
Slice-1 foundation of the SDK cutover (issue #20;
`docs/contracts/worldtree_sdk_cutover.contract.md`). A thin adapter that owns ONE
`WorldtreeClient`, built over ratatoskr's own injected `httpx.AsyncClient`
transport, and translates the SDK's error floor into ratatoskr's caller-semantic
exceptions. Two hard invariants anchor it:
* **INV-CUT-1** — ratatoskr owns the injected transport's lifecycle; the SDK is
given it as `transport=` (so `_owns_client=False`) and MUST NOT close it.
* **INV-CUT-2** — the adapter raises ratatoskr's caller-semantic exceptions; the
ROUTE is the discriminator (never the error body).
This slice ships only the construction chokepoint (`build_client`) and the error
adapter's DEFAULT rule (`translate_error`: `ApiError` → `SessionApiFailed`, every
discriminated `WorldtreeError` subclass passing through unchanged). Route-specific
error rows (`AgentNotFound`, `InvalidCursor`, `AuthoredHistoryUnavailable`, ...)
and the CLI / web / TUI surface wiring land in later slices.
Transient-migration note (DEC-4, incremental cutover): a same-named
`SessionApiFailed` still lives in the retiring `ratatoskr.sessions` wrapper and
serves the old hand-rolled path until slice-2 deletes it. No surface wires the
adapter in this slice, so the two never meet at runtime; slice-2 reconciles them
(deletes the `sessions.py` copy, points presenters at this one, which additionally
carries the SDK's parsed `error_code`).
"""
from __future__ import annotations
import json
from collections.abc import AsyncGenerator, Mapping
from typing import Any
import httpx
import worldtree_sdk as wtsdk
from worldtree_sdk import ApiError, AuthProvider, CancelResult, WorldtreeClient
# Transitional (slice-2): the caller-semantic exceptions + the BifrostBinding input
# type still live in the retiring `sessions` / `sse_client` modules; they relocate
# into this adapter as their call-sites are rewired in later slice-2 commits. wt →
# sessions / sse_client is one-way (neither imports wt), so there is no cycle.
from .sessions import (
AgentNotFound,
BifrostBinding,
BifrostConsumerKeyMissing,
BifrostHandshakeFailed,
InvalidCursor,
)
from .sse_client import (
AgentNotAvailable,
CancelAlreadyCompleted,
CancelFailed,
CancelTurnNotFound,
MalformedSseData,
MalformedSseId,
SseConnectFailed,
SseConnectionDropped,
TurnIdFlip,
TurnLaunchUnavailable,
)
class SessionApiFailed(Exception):
"""The adapter's DEFAULT caller-semantic error (INV-CUT-2 default row): any SDK
`ApiError` not mapped to a more specific ratatoskr exception surfaces here,
carrying the SDK's cleanly-parsed `status` / `error_code` / `body` verbatim.
The `body` is already UTF-8-byte-bounded and bearer-scrubbed by the SDK."""
def __init__(self, *, status: int, error_code: str, body: str | None = None) -> None:
super().__init__(
f"worldtree API failed: status={status}, error_code={error_code!r}"
)
self.status = status
self.error_code = error_code
self.body = body
def build_client(
base_url: str,
*,
api_key: AuthProvider,
admin_key: AuthProvider | None = None,
transport: httpx.AsyncClient,
max_reconnects: int = 5,
) -> WorldtreeClient:
"""Construct the adapter's `WorldtreeClient` over a ratatoskr-owned transport.
`transport` is REQUIRED and ratatoskr-owned: injecting it sets the SDK's
`_owns_client=False`, so `WorldtreeClient.aclose()` never closes it — ratatoskr
owns the lifecycle exactly as today (INV-CUT-1). ratatoskr's `api_key` /
`admin_key` map to the SDK's per-request `auth` / `admin_auth` providers; the
injected transport carries ratatoskr's User-Agent / timeout, and (transitionally)
the default bearer — the SDK adds auth per request, overriding it.
`max_reconnects` is the resilient turn-stream's reconnect budget (SDK default 5);
pass 0 to surface a transport drop immediately without auto-resume.
"""
return WorldtreeClient(
base_url,
auth=api_key,
admin_auth=admin_key,
transport=transport,
max_reconnects=max_reconnects,
)
def translate_error(exc: BaseException) -> BaseException:
"""Map an SDK exception to ratatoskr's caller-semantic exception (INV-CUT-2).
Foundation scope — the § Error map DEFAULT plus discriminated passthrough:
* SDK `ApiError` (the undiscriminated REST floor) → `SessionApiFailed`
carrying `status` / `error_code` / `body`.
* Every other exception — the SDK's discriminated `WorldtreeError` subclasses
(`AgentNotAvailable`, `SessionRetired`, `ResumeError`, `Cancel*`, ...) and
any non-SDK error — passes through by IDENTITY, unchanged.
Route-specific rows (a 404 on `sessions.create` → `AgentNotFound`, a 404 on
`sessions.write_history` → `AuthoredHistoryUnavailable`, ...) are the ROUTE's
to add at its call-site in later slices, never inferred from the body here.
"""
if isinstance(exc, ApiError):
return SessionApiFailed(
status=exc.status, error_code=exc.error_code, body=exc.body
)
return exc
# ── slice-2: sessions/turn adapter routes ────────────────────────────────────
# Ratatoskr-semantic call surfaces over `WorldtreeClient.sessions.*`. Each builds
# the request from ratatoskr's domain params, delegates the HTTP to the SDK, and
# maps the SDK's `ApiError` floor by ROUTE (INV-CUT-2) — route-specific rows first,
# `translate_error`'s `SessionApiFailed` default otherwise. Open-world reads are
# returned verbatim (the parity-pass posture: presenters read them as mappings,
# tolerant of wire drift). The turn STREAM + cancel land alongside the presenter
# rewire in the next slice-2 commit.
def _bifrost_error_from_body(body: str | None) -> str | None:
"""Pull the spec-level `bifrost_error` from a bound-create 502 body string.
Tolerates both the FastAPI-nested `{"detail": {"bifrost_error": …}}` shape (the
real wire form) and a flat top-level `bifrost_error` — the same both-shape
unwrap the hand-rolled path used, adapted to the SDK's already-parsed str body.
"""
if not body:
return None
try:
err = json.loads(body)
except (json.JSONDecodeError, ValueError):
return None
if not isinstance(err, dict):
return None
bifrost_error = err.get("bifrost_error")
if bifrost_error is None and isinstance(err.get("detail"), dict):
bifrost_error = err["detail"].get("bifrost_error")
return bifrost_error
async def create_session(
client: WorldtreeClient,
agent_id: str,
*,
end_user_id: str | None = None,
bifrost: BifrostBinding | None = None,
consumer_key: str | None = None,
config: Mapping[str, Any] | None = None,
) -> Mapping[str, Any]:
"""Create a session (POST /sessions), returning the open-world create result.
Body-building mirrors the hand-rolled path: `{agent_id}` plus `end_user_id` /
`config` / `bifrost` when set. A bound create authenticates with `consumer_key`
via the SDK's per-request auth (never a header, never the canary fallback —
INV-001); the key is required pre-HTTP. Error map (INV-CUT-2): 404 →
`AgentNotFound`; a bound 502 → `BifrostHandshakeFailed`; otherwise the
`SessionApiFailed` default.
"""
assert agent_id and isinstance(agent_id, str)
assert end_user_id is None or (isinstance(end_user_id, str) and end_user_id)
assert config is None or isinstance(config, Mapping)
# Ephemeral config + Bifrost binding are mutually exclusive (server 422s).
assert not (config is not None and bifrost is not None)
# INV-001: a bound create REQUIRES a non-empty consumer key — enforced pre-HTTP
# so it never falls back to the canary bearer.
if bifrost is not None and not (isinstance(consumer_key, str) and consumer_key):
raise BifrostConsumerKeyMissing()
body: dict[str, Any] = {"agent_id": agent_id}
if end_user_id is not None:
body["end_user_id"] = end_user_id
if config is not None:
body["config"] = dict(config)
if bifrost is not None:
body["bifrost"] = {"endpoint_url": bifrost.endpoint_url, "scope": bifrost.scope}
try:
return await client.sessions.create(body, consumer_key=consumer_key)
except ApiError as exc:
if exc.status == 404:
raise AgentNotFound(agent_id=agent_id) from exc
if bifrost is not None and exc.status == 502:
raise BifrostHandshakeFailed(
bifrost_error=_bifrost_error_from_body(exc.body),
body=(exc.body or "").encode(),
) from exc
raise translate_error(exc) from exc
async def list_sessions(
client: WorldtreeClient,
*,
include_archived: bool = False,
limit: int = 50,
cursor: str | None = None,
) -> Mapping[str, Any]:
"""List sessions (GET /sessions), returning the open-world page verbatim. A 422
`cursor_invalid` → `InvalidCursor`; otherwise the `SessionApiFailed` default."""
assert 1 <= limit <= 200
assert cursor is None or (isinstance(cursor, str) and cursor)
try:
return await client.sessions.list(
limit=limit, cursor=cursor, include_archived=include_archived or None
)
except ApiError as exc:
if exc.status == 422 and exc.error_code == "cursor_invalid":
raise InvalidCursor(raw=cursor) from exc
raise translate_error(exc) from exc
async def get_session_messages(
client: WorldtreeClient, session_id: str
) -> Mapping[str, Any]:
"""The session's message history (GET /sessions/{id}/messages), verbatim. Any
error → the `SessionApiFailed` default (owner-scoped; 404 hide-existence stays
generic here — messages is not a hide-existence-mapped route)."""
assert session_id and isinstance(session_id, str)
try:
return await client.sessions.messages(session_id)
except ApiError as exc:
raise translate_error(exc) from exc
async def get_session_tools(
client: WorldtreeClient, session_id: str
) -> Mapping[str, Any]:
"""The owner-scoped tool inventory (GET /sessions/{id}/tools), verbatim. Any
error → the `SessionApiFailed` default."""
assert session_id and isinstance(session_id, str)
try:
return await client.sessions.tools(session_id)
except ApiError as exc:
raise translate_error(exc) from exc
async def stream_turn(
client: WorldtreeClient, session_id: str, content: str
) -> AsyncGenerator[wtsdk.TurnEvent, None]:
"""Drive the resilient turn stream (auto-resume; absorbs the old `reconnect_turn`)
and yield the SDK's `TurnEvent`s, re-wrapping the stream's TERMINAL SDK errors
into ratatoskr's caller-semantic exceptions (INV-CUT-2 / DEC-2 — the presenter
keeps catching ratatoskr's types).
The SDK's `stream_turn` retries only the transport-drop class internally; a
resume failure / protocol violation / connect failure surfaces unchanged
(B-RES-6), and a drop that exhausts the reconnect budget surfaces as
`ConnectionDropped`. The eager launch failures (`AgentNotAvailable` 409,
`TurnLaunchUnavailable` 503) and `SessionRetired` 410 are subclasses of the
SDK's `ConnectFailed`, so they are caught before the generic `ConnectFailed`.
"""
try:
async for event in client.sessions.stream_turn(session_id, content):
yield event
except wtsdk.SessionRetired as exc:
# Fresh-mode 410 → the session is gone server-side; a generic API failure.
raise SessionApiFailed(
status=exc.status, error_code=exc.error_code, body=exc.message
) from exc
except wtsdk.AgentNotAvailable as exc:
raise AgentNotAvailable(
body=(exc.message or "").encode(),
error_code=exc.error_code,
message=exc.message or "",
) from exc
except wtsdk.TurnLaunchUnavailable as exc:
raise TurnLaunchUnavailable(
body=(exc.message or "").encode(),
error_code=exc.error_code,
message=exc.message or "",
) from exc
except wtsdk.ConnectFailed as exc:
raise SseConnectFailed(status=exc.status, body=(exc.message or "").encode()) from exc
except wtsdk.ResumeError as exc:
# A terminal resume failure (the resilient stream absorbs the retryable ones).
raise SseConnectFailed(status=exc.status, body=(exc.message or "").encode()) from exc
except wtsdk.ConnectionDropped as exc:
raise SseConnectionDropped(last_seen_sse_id=exc.last_seen_sse_id) from exc
except wtsdk.MalformedSseId as exc:
raise MalformedSseId(raw=exc.raw) from exc
except wtsdk.MalformedSseData as exc:
raise MalformedSseData(raw=exc.raw) from exc
except wtsdk.TurnIdFlip as exc:
raise TurnIdFlip(established=exc.established, got=exc.got) from exc
async def cancel_turn(
client: WorldtreeClient, session_id: str, turn_id: int, *, persist_partial: bool = False
) -> CancelResult:
"""Cancel a running turn (POST /sessions/{id}/turns/{turn_id}/cancel). Returns the
SDK `CancelResult` (a 200 with `cancelled=False` is the benign late-cancel race,
not an error). The typed cancel races map onto ratatoskr's same-named exceptions
(DEC-2): 404 `turn_not_found` → `CancelTurnNotFound`, 409 `turn_finished` →
`CancelAlreadyCompleted`, any other cancel failure → `CancelFailed`."""
assert session_id and isinstance(session_id, str)
assert isinstance(turn_id, int) and turn_id > 0
try:
return await client.sessions.cancel_turn(
session_id, turn_id, persist_partial=persist_partial
)
except wtsdk.CancelTurnNotFound as exc:
raise CancelTurnNotFound(turn_id=turn_id) from exc
except wtsdk.CancelAlreadyCompleted as exc:
raise CancelAlreadyCompleted(turn_id=turn_id) from exc
except wtsdk.CancelError as exc:
raise CancelFailed(
status=0, body=(getattr(exc, "message", "") or str(exc)).encode()
) from exc
+129 -36
View File
@@ -7,8 +7,10 @@ import json
import httpx
import pytest
import respx
from worldtree_sdk.events import build_event
from ratatoskr import cli as cli_mod
from ratatoskr import wt
from ratatoskr.cli import (
ParsedArgs,
UsageError,
@@ -20,18 +22,7 @@ from ratatoskr.cli import (
main,
)
from ratatoskr.sessions import BifrostBinding
from ratatoskr.sse_client import (
Cancelled,
Done,
Error,
SseId,
Text,
TextBoundary,
Thinking,
ToolResult,
ToolStart,
WorkerPhase,
)
from ratatoskr.sse_client import SseId
class _FlushCountingIO(io.StringIO):
@@ -333,6 +324,83 @@ SID = SseId(42, 5)
SID42 = SseId(42, 1)
# ── SDK-event factories ──────────────────────────────────────────────────────
# The presenter now consumes worldtree-sdk `TurnEvent`s. These build them exactly
# as the SDK's parser does (via `build_event` from the raw envelope), preserving
# the old dataclass call shapes so the render-test bodies stay unchanged. `sse_id`
# is a parsed `SseId` here purely to keep the terse SID42 idiom; the SDK carries the
# composite id as a string and turn_id top-level.
def _sid_str(sse_id: SseId) -> str:
return f"{sse_id.turn_id}:{sse_id.seq}"
def Thinking(*, sse_id: SseId, content: str) -> object:
return build_event("thinking", _sid_str(sse_id), sse_id.turn_id, {"content": content})
def Text(*, sse_id: SseId, content: str) -> object:
return build_event("text", _sid_str(sse_id), sse_id.turn_id, {"content": content})
def WorkerPhase(*, sse_id: SseId, phase: str, turn_id: int) -> object:
return build_event("worker_phase", _sid_str(sse_id), turn_id, {"phase": phase})
def TextBoundary(*, sse_id: SseId, kind: str, char_offset: int, ts: str) -> object:
return build_event(
"text_boundary", _sid_str(sse_id), sse_id.turn_id,
{"kind": kind, "char_offset": char_offset, "ts": ts},
)
def ToolStart(*, sse_id: SseId, name: str, arguments: object) -> object:
return build_event(
"tool_start", _sid_str(sse_id), sse_id.turn_id, {"name": name, "arguments": arguments}
)
def ToolResult(*, sse_id: SseId, name: str, result: object, duration_ms: int) -> object:
return build_event(
"tool_result", _sid_str(sse_id), sse_id.turn_id,
{"name": name, "result": result, "duration_ms": duration_ms},
)
def Done(
*, sse_id: SseId, phase: str, response: str, model: str, duration_ms: int, usage: object
) -> object:
return build_event(
"done", _sid_str(sse_id), sse_id.turn_id,
{"phase": phase, "response": response, "model": model,
"duration_ms": duration_ms, "usage": usage},
)
def Error(*, sse_id: SseId, phase: str, message: str, error_code: str) -> object:
return build_event(
"error", _sid_str(sse_id), sse_id.turn_id,
{"phase": phase, "message": message, "error_code": error_code},
)
def Cancelled(
*, sse_id: SseId, phase: str, turn_id: int, reason: object, partial_message_id: object
) -> object:
return build_event(
"cancelled", _sid_str(sse_id), turn_id,
{"phase": phase, "reason": reason, "partial_message_id": partial_message_id},
)
def _wtc(transport: httpx.AsyncClient) -> object:
"""The adapter's WorldtreeClient over a respx-mocked transport. Reconnects are
disabled (max_reconnects=0) so a transport drop surfaces immediately instead of
burning the resilient retry budget with real backoff sleeps."""
return wt.build_client(
"https://w.example", api_key="k", transport=transport, max_reconnects=0
)
class TestCliPresenterState:
"""Tests for the new CliPresenterState — per issue #12 contract."""
@@ -687,7 +755,7 @@ _USAGE_ZERO: dict[str, int] = {
}
def _make_done(*, duration_ms: int = 1, usage: dict[str, int] | None = None) -> Done:
def _make_done(*, duration_ms: int = 1, usage: dict[str, int] | None = None) -> object:
return Done(
sse_id=SID42,
phase="succeeded",
@@ -709,7 +777,8 @@ class TestCancelAndLog:
)
)
stderr = io.StringIO()
async with httpx.AsyncClient(base_url="https://w.example") as client:
async with httpx.AsyncClient(base_url="https://w.example") as _tp:
client = _wtc(_tp)
result = await _cancel_and_log(client, "s-1", 42, stderr=stderr)
assert result is None
assert stderr.getvalue() == ""
@@ -721,7 +790,8 @@ class TestCancelAndLog:
return_value=httpx.Response(500, content=b"boom")
)
stderr = io.StringIO()
async with httpx.AsyncClient(base_url="https://w.example") as client:
async with httpx.AsyncClient(base_url="https://w.example") as _tp:
client = _wtc(_tp)
await _cancel_and_log(client, "s-1", 42, stderr=stderr)
out = stderr.getvalue()
assert "[cancel_failed]" in out
@@ -730,11 +800,14 @@ class TestCancelAndLog:
@respx.mock
async def test_cancel_already_completed(self) -> None:
"""cancel_already_completed [scenario]: …"""
# SDK gates the race on the (status, error_code) PAIR (B-CAN-3): 409 alone is
# a generic CancelFailed; 409 + turn_finished is the double-cancel race.
respx.post("https://w.example/sessions/s-1/turns/42/cancel").mock(
return_value=httpx.Response(409)
return_value=httpx.Response(409, json={"error_code": "turn_finished"})
)
stderr = io.StringIO()
async with httpx.AsyncClient(base_url="https://w.example") as client:
async with httpx.AsyncClient(base_url="https://w.example") as _tp:
client = _wtc(_tp)
await _cancel_and_log(client, "s-1", 42, stderr=stderr)
out = stderr.getvalue()
assert "[cancel_failed]" in out
@@ -743,11 +816,13 @@ class TestCancelAndLog:
@respx.mock
async def test_cancel_turn_not_found(self) -> None:
"""cancel_turn_not_found [scenario]: 404 → returns None; stderr CancelTurnNotFound."""
# 404 + turn_not_found is the benign "finished before cancel arrived" race.
respx.post("https://w.example/sessions/s-1/turns/42/cancel").mock(
return_value=httpx.Response(404)
return_value=httpx.Response(404, json={"error_code": "turn_not_found"})
)
stderr = io.StringIO()
async with httpx.AsyncClient(base_url="https://w.example") as client:
async with httpx.AsyncClient(base_url="https://w.example") as _tp:
client = _wtc(_tp)
await _cancel_and_log(client, "s-1", 42, stderr=stderr)
out = stderr.getvalue()
assert "[cancel_failed]" in out
@@ -760,11 +835,14 @@ class TestCancelAndLog:
side_effect=httpx.ConnectError("network down")
)
stderr = io.StringIO()
async with httpx.AsyncClient(base_url="https://w.example") as client:
async with httpx.AsyncClient(base_url="https://w.example") as _tp:
client = _wtc(_tp)
await _cancel_and_log(client, "s-1", 42, stderr=stderr)
out = stderr.getvalue()
# The SDK normalizes a transport drop to ConnectFailed(status=0); _cancel_and_log
# swallows it (INV-009) and logs the normalized type.
assert "[cancel_failed]" in out
assert "ConnectError" in out
assert "ConnectFailed" in out
class _GatedStream(httpx.AsyncByteStream):
@@ -797,7 +875,8 @@ class TestRunTurn:
sigint = asyncio.Event()
stdout = io.StringIO()
stderr = io.StringIO()
async with httpx.AsyncClient(base_url="https://w.example") as client:
async with httpx.AsyncClient(base_url="https://w.example") as _tp:
client = _wtc(_tp)
exit_code = await _run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
assert exit_code == 0
assert stdout.getvalue() == "hello\n"
@@ -820,7 +899,8 @@ class TestRunTurn:
)
sigint = asyncio.Event()
stdout, stderr = io.StringIO(), io.StringIO()
async with httpx.AsyncClient(base_url="https://w.example") as client:
async with httpx.AsyncClient(base_url="https://w.example") as _tp:
client = _wtc(_tp)
exit_code = await _run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
assert exit_code == 2
assert "[error]" in stderr.getvalue()
@@ -836,7 +916,8 @@ class TestRunTurn:
)
sigint = asyncio.Event()
stdout, stderr = io.StringIO(), io.StringIO()
async with httpx.AsyncClient(base_url="https://w.example") as client:
async with httpx.AsyncClient(base_url="https://w.example") as _tp:
client = _wtc(_tp)
exit_code = await _run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
assert exit_code == 3
assert "[cancelled]" in stderr.getvalue()
@@ -849,7 +930,8 @@ class TestRunTurn:
)
sigint = asyncio.Event()
stdout, stderr = io.StringIO(), io.StringIO()
async with httpx.AsyncClient(base_url="https://w.example") as client:
async with httpx.AsyncClient(base_url="https://w.example") as _tp:
client = _wtc(_tp)
exit_code = await _run_turn(
client, "missing", "hi", sigint, stdout=stdout, stderr=stderr
)
@@ -882,7 +964,8 @@ class TestRunTurn:
)
sigint = asyncio.Event()
stdout, stderr = io.StringIO(), io.StringIO()
async with httpx.AsyncClient(base_url="https://w.example") as client:
async with httpx.AsyncClient(base_url="https://w.example") as _tp:
client = _wtc(_tp)
exit_code = await _run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
assert exit_code == 21
assert "[connection_dropped]" in stderr.getvalue()
@@ -896,7 +979,8 @@ class TestRunTurn:
)
sigint = asyncio.Event()
stdout, stderr = io.StringIO(), io.StringIO()
async with httpx.AsyncClient(base_url="https://w.example") as client:
async with httpx.AsyncClient(base_url="https://w.example") as _tp:
client = _wtc(_tp)
exit_code = await _run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
assert exit_code == 22
assert "[malformed_sse_id]" in stderr.getvalue()
@@ -913,7 +997,8 @@ class TestRunTurn:
)
sigint = asyncio.Event()
stdout, stderr = io.StringIO(), io.StringIO()
async with httpx.AsyncClient(base_url="https://w.example") as client:
async with httpx.AsyncClient(base_url="https://w.example") as _tp:
client = _wtc(_tp)
exit_code = await _run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
assert exit_code == 22
out = stderr.getvalue()
@@ -933,7 +1018,8 @@ class TestRunTurn:
)
sigint = asyncio.Event()
stdout, stderr = io.StringIO(), io.StringIO()
async with httpx.AsyncClient(base_url="https://w.example") as client:
async with httpx.AsyncClient(base_url="https://w.example") as _tp:
client = _wtc(_tp)
exit_code = await _run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
assert exit_code == 22
out = stderr.getvalue()
@@ -955,7 +1041,8 @@ class TestRunTurn:
)
sigint = asyncio.Event()
stdout, stderr = io.StringIO(), io.StringIO()
async with httpx.AsyncClient(base_url="https://w.example") as client:
async with httpx.AsyncClient(base_url="https://w.example") as _tp:
client = _wtc(_tp)
exit_code = await _run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
assert exit_code == 22
out = stderr.getvalue()
@@ -978,7 +1065,8 @@ class TestRunTurn:
sigint = asyncio.Event()
sigint.set() # SIGINT before _run_turn even starts
stdout, stderr = io.StringIO(), io.StringIO()
async with httpx.AsyncClient(base_url="https://w.example") as client:
async with httpx.AsyncClient(base_url="https://w.example") as _tp:
client = _wtc(_tp)
exit_code = await asyncio.wait_for(
_run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr), timeout=2.0
)
@@ -1007,7 +1095,8 @@ class TestRunTurn:
sigint = asyncio.Event()
stdout, stderr = io.StringIO(), io.StringIO()
async with httpx.AsyncClient(base_url="https://w.example") as client:
async with httpx.AsyncClient(base_url="https://w.example") as _tp:
client = _wtc(_tp)
task = asyncio.create_task(
_run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
)
@@ -1045,7 +1134,8 @@ class TestRunTurn:
sigint = asyncio.Event()
stdout, stderr = io.StringIO(), io.StringIO()
async with httpx.AsyncClient(base_url="https://w.example") as client:
async with httpx.AsyncClient(base_url="https://w.example") as _tp:
client = _wtc(_tp)
task = asyncio.create_task(
_run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
)
@@ -1091,7 +1181,8 @@ class TestRunTurn:
monkeypatch.setattr(sigint, "wait", counting_wait)
stdout, stderr = io.StringIO(), io.StringIO()
async with httpx.AsyncClient(base_url="https://w.example") as client:
async with httpx.AsyncClient(base_url="https://w.example") as _tp:
client = _wtc(_tp)
task = asyncio.create_task(
_run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
)
@@ -1129,7 +1220,8 @@ class TestRunTurn:
sigint = asyncio.Event()
stdout, stderr = io.StringIO(), io.StringIO()
async with httpx.AsyncClient(base_url="https://w.example") as client:
async with httpx.AsyncClient(base_url="https://w.example") as _tp:
client = _wtc(_tp)
task = asyncio.create_task(
_run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
)
@@ -1176,7 +1268,8 @@ class TestRunTurn:
sigint = asyncio.Event()
stdout, stderr = io.StringIO(), io.StringIO()
async with httpx.AsyncClient(base_url="https://w.example") as client:
async with httpx.AsyncClient(base_url="https://w.example") as _tp:
client = _wtc(_tp)
exit_code = await _run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
assert exit_code == 0
assert call_count == 3
+32 -49
View File
@@ -7,6 +7,12 @@ type. Server-side serialization (`_event_to_browser_payload`) is
unit-tested against the fixture. JS-side rendering in
`src/ratatoskr/web/static/index.html` consumes the same shape if
this fixture changes, both sides update in lockstep.
Post worldtree-sdk cutover (#20): the presenter consumes SDK `TurnEvent`s.
`_event_to_browser_payload` derives the browser payload from the SDK's `raw`
(the wire body) plus the composite `sse_id` string the SAME shape the old
dataclasses produced, so the fixture is unchanged. These events are built via
the SDK's own `build_event` from the wire body.
"""
from __future__ import annotations
@@ -14,20 +20,8 @@ from __future__ import annotations
import json
from pathlib import Path
from ratatoskr.sse_client import (
AffectUpdate,
AwaitingLlmFirstToken,
Cancelled,
Done,
Error,
SseId,
Text,
TextBoundary,
Thinking,
ToolResult,
ToolStart,
WorkerPhase,
)
from worldtree_sdk.events import build_event
from ratatoskr.web.server import _event_to_browser_payload
@@ -36,6 +30,13 @@ def _load_fixture() -> dict:
return json.loads(path.read_text())
def _ev(ev_type: str, sse_id: str, **fields: object) -> object:
"""Build an SDK TurnEvent from its wire body (raw includes `type`); turn_id is
the turn component of the composite sse_id."""
turn = int(sse_id.split(":", 1)[0])
return build_event(ev_type, sse_id, turn, {"type": ev_type, **fields})
def _check(name: str, event: object) -> None:
"""Assert (event_type, data) for `event` matches the fixture entry."""
fixture = _load_fixture()
@@ -51,58 +52,40 @@ def _check(name: str, event: object) -> None:
def test_worker_phase_matches_fixture() -> None:
_check(
"worker_phase",
WorkerPhase(sse_id=SseId(42, 3), phase="BuildingPrompt", turn_id=42),
)
_check("worker_phase", _ev("worker_phase", "42:3", phase="BuildingPrompt", turn_id=42))
def test_thinking_matches_fixture() -> None:
_check(
"thinking",
Thinking(sse_id=SseId(42, 5), content="Let me think..."),
)
_check("thinking", _ev("thinking", "42:5", content="Let me think..."))
def test_text_matches_fixture() -> None:
_check(
"text",
Text(sse_id=SseId(42, 7), content="Hello there"),
)
_check("text", _ev("text", "42:7", content="Hello there"))
def test_text_boundary_matches_fixture() -> None:
_check(
"text_boundary",
TextBoundary(
sse_id=SseId(42, 8), kind="sentence",
char_offset=11, ts="2026-05-28T00:00:00Z",
),
_ev("text_boundary", "42:8", kind="sentence", char_offset=11, ts="2026-05-28T00:00:00Z"),
)
def test_tool_start_matches_fixture() -> None:
_check(
"tool_start",
ToolStart(sse_id=SseId(42, 9), name="search", arguments={"q": "ratatoskr"}),
)
_check("tool_start", _ev("tool_start", "42:9", name="search", arguments={"q": "ratatoskr"}))
def test_tool_result_matches_fixture() -> None:
_check(
"tool_result",
ToolResult(
sse_id=SseId(42, 10), name="search",
result={"n": 1}, duration_ms=12,
),
_ev("tool_result", "42:10", name="search", result={"n": 1}, duration_ms=12),
)
def test_done_matches_fixture() -> None:
_check(
"done",
Done(
sse_id=SseId(42, 11), phase="succeeded", response="Hello there",
_ev(
"done", "42:11", phase="succeeded", response="Hello there",
model="qwen3.6-35-a3b", duration_ms=1234,
usage={
"prompt_tokens": 100, "completion_tokens": 50,
@@ -115,8 +98,8 @@ def test_done_matches_fixture() -> None:
def test_error_matches_fixture() -> None:
_check(
"error",
Error(
sse_id=SseId(42, 11), phase="failed",
_ev(
"error", "42:11", phase="failed",
message="llm output invalid", error_code="llm_output_invalid",
),
)
@@ -125,8 +108,8 @@ def test_error_matches_fixture() -> None:
def test_cancelled_matches_fixture() -> None:
_check(
"cancelled",
Cancelled(
sse_id=SseId(42, 11), phase="cancelled", turn_id=42,
_ev(
"cancelled", "42:11", phase="cancelled", turn_id=42,
reason="user_cancel", partial_message_id=None,
),
)
@@ -135,8 +118,8 @@ def test_cancelled_matches_fixture() -> None:
def test_affect_update_matches_fixture() -> None:
_check(
"affect_update",
AffectUpdate(
sse_id=SseId(42, 1), status="current", turn_id=42,
_ev(
"affect_update", "42:1", status="current", turn_id=42,
snapshot={
"agent_id": "mimir",
"pad": {"pleasure": 0.52, "arousal": 0.47, "dominance": 0.50},
@@ -155,8 +138,8 @@ def test_affect_update_matches_fixture() -> None:
def test_awaiting_llm_first_token_matches_fixture() -> None:
_check(
"awaiting_llm_first_token",
AwaitingLlmFirstToken(
sse_id=SseId(42, 2), turn_id=42,
elapsed_ms_since_building_prompt=5012.3,
_ev(
"awaiting_llm_first_token", "42:2",
turn_id=42, elapsed_ms_since_building_prompt=5012.3,
),
)
+9 -4
View File
@@ -456,15 +456,16 @@ class TestCancelTurnEndpoint:
@respx.mock
def test_already_completed_race(self) -> None:
"""already_completed [race]: upstream 409 → 200 reason=race_or_completed."""
"""already_completed [race]: upstream 409 turn_finished → 200 reason=race_or_completed."""
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory())
c = TestClient(app)
turn_id = c.post("/api/turns/s-1", json={"content": "hi"}).json()["turn_id"]
app.state.turn_registry[("s-1", turn_id)].status = "streaming"
app.state.turn_registry[("s-1", turn_id)].upstream_turn_id = 42
# SDK gates the race on the (status, error_code) pair (B-CAN-3).
respx.post("https://w.example/sessions/s-1/turns/42/cancel").mock(
return_value=httpx.Response(409)
return_value=httpx.Response(409, json={"error_code": "turn_finished"})
)
resp = c.post(f"/api/turns/s-1/cancel?turn_id={turn_id}")
assert resp.status_code == 200
@@ -473,7 +474,11 @@ class TestCancelTurnEndpoint:
@respx.mock
def test_cancel_failed_500(self) -> None:
"""cancel_failed [error]: upstream 500 → 500 with cancel_failed envelope."""
"""cancel_failed [error]: upstream 500 → 502 cancel_failed envelope.
Post-cutover: the SDK abstracts the upstream cancel HTTP status behind a
typed CancelFailed, so the endpoint surfaces a generic 502 (bad gateway)
rather than echoing the upstream 500."""
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory())
c = TestClient(app)
@@ -484,7 +489,7 @@ class TestCancelTurnEndpoint:
return_value=httpx.Response(500, content=b"boom")
)
resp = c.post(f"/api/turns/s-1/cancel?turn_id={turn_id}")
assert resp.status_code == 500
assert resp.status_code == 502
assert resp.json()["error_code"] == "cancel_failed"
assert ("s-1", turn_id) not in app.state.turn_registry
+412
View File
@@ -0,0 +1,412 @@
"""Unit tests for the worldtree-sdk adapter (`ratatoskr.wt`) — slice-1 foundation.
Covers the two foundation surfaces (issue #20 cutover contract, slice 1):
* `build_client` construction wiring + injected-transport ownership (INV-CUT-1:
the SDK must never close ratatoskr's transport).
* `translate_error` the § Error map DEFAULT (`ApiError` `SessionApiFailed`)
plus discriminated-`WorldtreeError` passthrough (INV-CUT-2).
No ratatoskr surface (CLI / web / TUI) is exercised here that wiring lands in
slice 2. These tests hit no network (WorldtreeClient does no I/O at construction).
"""
from __future__ import annotations
from typing import Any, cast
import httpx
import pytest
import worldtree_sdk as wtsdk
from worldtree_sdk import ApiError, CancelResult, WorldtreeClient
from ratatoskr.sessions import (
AgentNotFound,
BifrostBinding,
BifrostConsumerKeyMissing,
BifrostHandshakeFailed,
InvalidCursor,
)
from ratatoskr.sse_client import (
AgentNotAvailable,
CancelAlreadyCompleted,
CancelFailed,
CancelTurnNotFound,
MalformedSseData,
MalformedSseId,
SseConnectFailed,
SseConnectionDropped,
TurnIdFlip,
TurnLaunchUnavailable,
)
from ratatoskr.wt import (
SessionApiFailed,
build_client,
cancel_turn,
create_session,
get_session_messages,
get_session_tools,
list_sessions,
stream_turn,
translate_error,
)
class _FakeSessions:
"""A stand-in for `WorldtreeClient.sessions` — records the last call and
returns a canned result or raises a canned error. Lets the adapter's
body-building + error-mapping be unit-tested without any SDK HTTP."""
def __init__(
self,
*,
result: Any = None,
error: BaseException | None = None,
events: list[Any] | None = None,
stream_error: BaseException | None = None,
) -> None:
self._result = result
self._error = error
self._events = events or []
self._stream_error = stream_error
self.calls: list[tuple[str, tuple[Any, ...], dict[str, Any]]] = []
async def _dispatch(self, name: str, *args: Any, **kwargs: Any) -> Any:
self.calls.append((name, args, kwargs))
if self._error is not None:
raise self._error
return self._result
async def create(self, *args: Any, **kwargs: Any) -> Any:
return await self._dispatch("create", *args, **kwargs)
async def list(self, *args: Any, **kwargs: Any) -> Any:
return await self._dispatch("list", *args, **kwargs)
async def messages(self, *args: Any, **kwargs: Any) -> Any:
return await self._dispatch("messages", *args, **kwargs)
async def tools(self, *args: Any, **kwargs: Any) -> Any:
return await self._dispatch("tools", *args, **kwargs)
def stream_turn(self, *args: Any, **kwargs: Any) -> Any:
self.calls.append(("stream_turn", args, kwargs))
return self._astream()
async def _astream(self) -> Any:
for event in self._events:
yield event
if self._stream_error is not None:
raise self._stream_error
async def cancel_turn(self, *args: Any, **kwargs: Any) -> Any:
return await self._dispatch("cancel_turn", *args, **kwargs)
class _FakeClient:
def __init__(self, sessions: _FakeSessions) -> None:
self.sessions = sessions
def _wt(sessions: _FakeSessions) -> WorldtreeClient:
"""Cast the structural fake to the nominal client type the adapter is typed
against the route functions only touch `client.sessions.*`, which the fake
provides. (No network; construction does no I/O.)"""
return cast(WorldtreeClient, _FakeClient(sessions))
class TestBuildClient:
async def test_constructs_worldtree_client(self) -> None:
transport = httpx.AsyncClient()
try:
client = build_client(
"https://wt.example:8081", api_key="ck-test", transport=transport
)
assert isinstance(client, WorldtreeClient)
assert client.base_url == "https://wt.example:8081"
finally:
await transport.aclose()
async def test_injected_transport_is_ratatoskr_owned(self) -> None:
# INV-CUT-1 [hard]: aclose() on the SDK client must NOT close ratatoskr's
# transport — ratatoskr owns the lifecycle exactly as it does today.
transport = httpx.AsyncClient()
client = build_client("https://wt.example", api_key="ck", transport=transport)
await client.aclose()
assert client.closed is True
assert transport.is_closed is False
await transport.aclose()
async def test_admin_key_optional(self) -> None:
transport = httpx.AsyncClient()
try:
# Absent admin_key → admin_auth=None; still constructs.
without_admin = build_client(
"https://wt.example", api_key="ck", transport=transport
)
assert isinstance(without_admin, WorldtreeClient)
# Present admin_key → constructs (admin surface available in later slices).
with_admin = build_client(
"https://wt.example", api_key="ck", admin_key="ak", transport=transport
)
assert isinstance(with_admin, WorldtreeClient)
finally:
await transport.aclose()
class TestTranslateError:
def test_apierror_maps_to_session_api_failed_default(self) -> None:
exc = ApiError("some_code", "boom", status=500, body="raw-body")
mapped = translate_error(exc)
assert isinstance(mapped, SessionApiFailed)
assert mapped.status == 500
assert mapped.error_code == "some_code"
assert mapped.body == "raw-body"
def test_apierror_with_no_body_maps_cleanly(self) -> None:
exc = ApiError("nope", "no body", status=404)
mapped = translate_error(exc)
assert isinstance(mapped, SessionApiFailed)
assert mapped.status == 404
assert mapped.error_code == "nope"
assert mapped.body is None
def test_discriminated_subclass_passes_through_unchanged(self) -> None:
# Discriminated WorldtreeError subclasses are already the right semantic
# type at the REST layer — translate_error passes them through by identity
# (the stream routes re-wrap them; that is stream_turn's job, not this one).
exc = wtsdk.AgentNotAvailable("agent_not_available", "gone", status=409)
assert translate_error(exc) is exc
def test_non_worldtree_error_passes_through_unchanged(self) -> None:
exc = ValueError("unrelated")
assert translate_error(exc) is exc
class TestCreateSession:
async def test_happy_returns_sdk_dict_and_builds_body(self) -> None:
info = {"session_id": "s-1", "agent_id": "mimir", "created_at": "t", "last_active": "t"}
fake = _FakeSessions(result=info)
client = _wt(fake)
out = await create_session(client, "mimir", end_user_id="u-9")
assert out is info # open-world passthrough — no re-shaping
name, args, kwargs = fake.calls[-1]
assert name == "create"
assert args[0] == {"agent_id": "mimir", "end_user_id": "u-9"}
assert kwargs["consumer_key"] is None
async def test_config_passthrough(self) -> None:
fake = _FakeSessions(result={"session_id": "s"})
await create_session(
_wt(fake), "echo", config={"system_prompt": "be terse"}
)
assert fake.calls[-1][1][0] == {
"agent_id": "echo",
"config": {"system_prompt": "be terse"},
}
async def test_bifrost_bound_body_and_consumer_key(self) -> None:
fake = _FakeSessions(result={"session_id": "s"})
binding = BifrostBinding(endpoint_url="http://h:8391", scope=None)
await create_session(
_wt(fake), "sindra", bifrost=binding, consumer_key="ck-real"
)
_name, args, kwargs = fake.calls[-1]
assert args[0] == {
"agent_id": "sindra",
"bifrost": {"endpoint_url": "http://h:8391", "scope": None},
}
# INV-CUT: the consumer key rides the SDK's per-request auth, NOT a header.
assert kwargs["consumer_key"] == "ck-real"
async def test_bifrost_without_consumer_key_rejected_pre_http(self) -> None:
fake = _FakeSessions(result={"session_id": "s"})
binding = BifrostBinding(endpoint_url="http://h:8391", scope=None)
with pytest.raises(BifrostConsumerKeyMissing):
await create_session(_wt(fake), "sindra", bifrost=binding)
assert fake.calls == [] # never reached the SDK
async def test_404_maps_to_agent_not_found(self) -> None:
fake = _FakeSessions(error=ApiError("agent_not_found", "no", status=404))
with pytest.raises(AgentNotFound) as ei:
await create_session(_wt(fake), "ghost")
assert ei.value.agent_id == "ghost"
async def test_bound_502_maps_to_bifrost_handshake_failed(self) -> None:
body = '{"detail": {"bifrost_error": "bifrost.auth_rejected"}}'
fake = _FakeSessions(
error=ApiError("bifrost_handshake_failed", "boom", status=502, body=body)
)
binding = BifrostBinding(endpoint_url="http://h:8391", scope=None)
with pytest.raises(BifrostHandshakeFailed) as ei:
await create_session(
_wt(fake), "sindra", bifrost=binding, consumer_key="ck"
)
assert ei.value.bifrost_error == "bifrost.auth_rejected"
async def test_unbound_502_stays_session_api_failed(self) -> None:
fake = _FakeSessions(error=ApiError("upstream", "boom", status=502, body="x"))
with pytest.raises(SessionApiFailed) as ei:
await create_session(_wt(fake), "mimir")
assert ei.value.status == 502
async def test_default_error_maps_to_session_api_failed(self) -> None:
fake = _FakeSessions(error=ApiError("weird", "boom", status=418, body="teapot"))
with pytest.raises(SessionApiFailed) as ei:
await create_session(_wt(fake), "mimir")
assert ei.value.status == 418
assert ei.value.error_code == "weird"
class TestListSessions:
async def test_passes_params_and_returns_dict(self) -> None:
page: dict[str, Any] = {"items": [], "next_cursor": None}
fake = _FakeSessions(result=page)
out = await list_sessions(_wt(fake), limit=10, cursor="c1", include_archived=True)
assert out is page
kwargs = fake.calls[-1][2]
assert kwargs["limit"] == 10
assert kwargs["cursor"] == "c1"
assert kwargs["include_archived"] is True
async def test_422_cursor_invalid_maps_to_invalid_cursor(self) -> None:
fake = _FakeSessions(error=ApiError("cursor_invalid", "bad", status=422))
with pytest.raises(InvalidCursor) as ei:
await list_sessions(_wt(fake), cursor="bogus")
assert ei.value.raw == "bogus"
async def test_other_422_stays_session_api_failed(self) -> None:
fake = _FakeSessions(error=ApiError("validation_failed", "x", status=422))
with pytest.raises(SessionApiFailed):
await list_sessions(_wt(fake))
class TestReadPassthroughs:
async def test_messages_returns_dict(self) -> None:
data = {"session_id": "s", "items": []}
fake = _FakeSessions(result=data)
assert await get_session_messages(_wt(fake), "s") is data
assert fake.calls[-1][0] == "messages"
async def test_tools_returns_dict(self) -> None:
data = {"agent_id": "mimir", "builtin_tools": []}
fake = _FakeSessions(result=data)
assert await get_session_tools(_wt(fake), "s") is data
assert fake.calls[-1][0] == "tools"
async def test_messages_error_maps_to_session_api_failed(self) -> None:
fake = _FakeSessions(error=ApiError("auth_revoked", "no", status=401))
with pytest.raises(SessionApiFailed) as ei:
await get_session_messages(_wt(fake), "s")
assert ei.value.status == 401
async def _drain(aiter: Any) -> list[Any]:
out: list[Any] = []
async for ev in aiter:
out.append(ev)
return out
class TestStreamTurn:
async def test_yields_events_verbatim(self) -> None:
e1, e2 = object(), object()
fake = _FakeSessions(events=[e1, e2])
got = await _drain(stream_turn(_wt(fake), "s-1", "hello"))
assert got == [e1, e2]
assert fake.calls[-1] == ("stream_turn", ("s-1", "hello"), {})
async def test_session_retired_maps_to_session_api_failed(self) -> None:
fake = _FakeSessions(
stream_error=wtsdk.SessionRetired("session_retired", "gone", status=410)
)
with pytest.raises(SessionApiFailed) as ei:
await _drain(stream_turn(_wt(fake), "s", "hi"))
assert ei.value.status == 410
assert ei.value.error_code == "session_retired"
async def test_agent_not_available_rewraps_to_ratatoskr(self) -> None:
fake = _FakeSessions(
stream_error=wtsdk.AgentNotAvailable("agent_not_available", "no agent", status=409)
)
with pytest.raises(AgentNotAvailable) as ei:
await _drain(stream_turn(_wt(fake), "s", "hi"))
assert ei.value.error_code == "agent_not_available"
assert ei.value.status == 409
async def test_turn_launch_unavailable_rewraps_to_ratatoskr(self) -> None:
fake = _FakeSessions(
stream_error=wtsdk.TurnLaunchUnavailable("not_ready", "busy", status=503)
)
with pytest.raises(TurnLaunchUnavailable) as ei:
await _drain(stream_turn(_wt(fake), "s", "hi"))
assert ei.value.retryable is True
async def test_generic_connect_failed_maps_to_sse_connect_failed(self) -> None:
fake = _FakeSessions(stream_error=wtsdk.ConnectFailed("connect_failed", "boom", status=500))
with pytest.raises(SseConnectFailed) as ei:
await _drain(stream_turn(_wt(fake), "s", "hi"))
assert ei.value.status == 500
async def test_connection_dropped_maps_and_carries_cursor(self) -> None:
fake = _FakeSessions(stream_error=wtsdk.ConnectionDropped("12:3"))
with pytest.raises(SseConnectionDropped) as ei:
await _drain(stream_turn(_wt(fake), "s", "hi"))
assert ei.value.last_seen_sse_id == "12:3"
async def test_resume_error_maps_to_sse_connect_failed(self) -> None:
fake = _FakeSessions(stream_error=wtsdk.ResumeError("resume_failed", "boom", status=412))
with pytest.raises(SseConnectFailed) as ei:
await _drain(stream_turn(_wt(fake), "s", "hi"))
assert ei.value.status == 412
async def test_malformed_sse_id_passes_through_as_ratatoskr(self) -> None:
fake = _FakeSessions(stream_error=wtsdk.MalformedSseId("bad-id"))
with pytest.raises(MalformedSseId) as ei:
await _drain(stream_turn(_wt(fake), "s", "hi"))
assert ei.value.raw == "bad-id"
async def test_malformed_sse_data_passes_through_as_ratatoskr(self) -> None:
fake = _FakeSessions(stream_error=wtsdk.MalformedSseData("not json"))
with pytest.raises(MalformedSseData):
await _drain(stream_turn(_wt(fake), "s", "hi"))
async def test_turn_id_flip_carries_established_and_got(self) -> None:
fake = _FakeSessions(stream_error=wtsdk.TurnIdFlip(5, 7))
with pytest.raises(TurnIdFlip) as ei:
await _drain(stream_turn(_wt(fake), "s", "hi"))
assert (ei.value.established, ei.value.got) == (5, 7)
class TestCancelTurn:
async def test_happy_returns_cancel_result(self) -> None:
res = CancelResult(turn_id=42, cancelled=True, reason=None, partial_message_id=None)
fake = _FakeSessions(result=res)
out = await cancel_turn(_wt(fake), "s-1", 42, persist_partial=True)
assert out is res
assert fake.calls[-1] == ("cancel_turn", ("s-1", 42), {"persist_partial": True})
async def test_late_cancel_race_is_a_result_not_an_error(self) -> None:
# B-CAN-3: a 200 with cancelled=False is the benign late-cancel no-op.
res = CancelResult(turn_id=42, cancelled=False, reason=None, partial_message_id=None)
out = await cancel_turn(_wt(_FakeSessions(result=res)), "s", 42)
assert out.cancelled is False
async def test_turn_not_found_maps_to_ratatoskr(self) -> None:
fake = _FakeSessions(
error=wtsdk.CancelTurnNotFound(42, error_code="turn_not_found", message="gone")
)
with pytest.raises(CancelTurnNotFound) as ei:
await cancel_turn(_wt(fake), "s", 42)
assert ei.value.turn_id == 42
async def test_turn_finished_maps_to_ratatoskr(self) -> None:
fake = _FakeSessions(
error=wtsdk.CancelAlreadyCompleted(42, error_code="turn_finished", message="done")
)
with pytest.raises(CancelAlreadyCompleted):
await cancel_turn(_wt(fake), "s", 42)
async def test_other_cancel_failure_maps_to_cancel_failed(self) -> None:
fake = _FakeSessions(error=wtsdk.CancelFailed(42, error_code="boom", message="failed"))
with pytest.raises(CancelFailed):
await cancel_turn(_wt(fake), "s", 42)
Generated
+15 -1
View File
@@ -472,11 +472,12 @@ wheels = [
[[package]]
name = "ratatoskr"
version = "0.21.2"
version = "0.21.7"
source = { editable = "." }
dependencies = [
{ name = "httpx" },
{ name = "httpx-sse" },
{ name = "worldtree-sdk" },
]
[package.optional-dependencies]
@@ -519,6 +520,7 @@ requires-dist = [
{ name = "sqlite-vec", marker = "extra == 'provider'", specifier = ">=0.1.6" },
{ name = "starlette", marker = "extra == 'web'", specifier = ">=0.40" },
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'web'", specifier = ">=0.30" },
{ name = "worldtree-sdk", specifier = "==1.0.0", index = "https://gitea.phasefinal.com/api/packages/vh/pypi/simple/" },
]
provides-extras = ["web", "provider", "dev"]
@@ -903,3 +905,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531 },
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598 },
]
[[package]]
name = "worldtree-sdk"
version = "1.0.0"
source = { registry = "https://gitea.phasefinal.com/api/packages/vh/pypi/simple/" }
dependencies = [
{ name = "httpx" },
]
sdist = { url = "https://gitea.phasefinal.com/api/packages/vh/pypi/files/worldtree-sdk/1.0.0/worldtree_sdk-1.0.0.tar.gz", hash = "sha256:0b87001fd90b304befe5e76d21a41dd093057a9d59f86a25189fab80defcd043" }
wheels = [
{ url = "https://gitea.phasefinal.com/api/packages/vh/pypi/files/worldtree-sdk/1.0.0/worldtree_sdk-1.0.0-py3-none-any.whl", hash = "sha256:ec9cb03ecdb4662bf1280a64316ee4545bb1330db42e3135f34f56e62c07f6c5" },
]