Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c595b862d | |||
| e3a10ad80e | |||
| b907a7b8a5 | |||
| bb158ae47d | |||
| 12cd8642fa | |||
| b7f4bc5e7c | |||
| 29c4fda12e | |||
| 57fdc48e20 | |||
| e45640c4da | |||
| b1fbadde3c |
@@ -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
@@ -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** |
|
| 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) |
|
| 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
|
> **Pin drift (finding P-1) — REMEDIATED 2026-06-30.** Ratatoskr formerly
|
||||||
> vendored only the **prose markdown** (`docs/conversation-api-spec.md`), which
|
> 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
|
> 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 |
|
| 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}/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 |
|
| `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) |
|
| `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) |
|
| `PATCH /agents/{id}` | ✅ | `tier3.py:219` → `_run_patch` | Tier-3 mutate (system_prompt/model) |
|
||||||
| `DELETE /agents/{id}` | ✅ | `tier3.py:242` → `_run_delete` | Tier-3 hard-delete |
|
| `DELETE /agents/{id}` | ✅ | `tier3.py:242` → `_run_delete` | Tier-3 hard-delete |
|
||||||
| `GET /me` | ✅ | `sessions.py:411` `get_me` → `cli.py` `--whoami` | identity/whoami probe; 401→SessionApiFailed |
|
| `GET /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 /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/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 |
|
| `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
@@ -46,6 +46,33 @@ upstream API key stays server-side (INV-003).
|
|||||||
|
|
||||||
_As of 2026-07-18 (evening):_
|
_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).**
|
**✅ 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
|
**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.
|
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
|
(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`).
|
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.
|
**Substrate / environment:** branch `main` at **v0.21.2**. **origin at `b1fbadd`** (pushed 2026-07-18:
|
||||||
tier3 b127-role adaptation `860e0d5`, #368 memory snapshots + silo work, the contract-drift fix
|
canonical syncs `5d06a27`/`80c8d58`, ephemeral-Echo `c7016f2` #19, coverage-map→SDK-surface `b1fbadd`).
|
||||||
`4bd9abd`, and this snapshot); origin `git@gitea.phasefinal.com:vh/ratatoskr.git`. bifrost
|
**UNPUSHED local commits** (cutover work — operator hasn't pushed): cutover contract `e45640c` #20, the
|
||||||
**`==1.1.4`** / wire v0.7; WT openapi vendored 2.3.0; **suite 644 green**. Personal WT on **b127**
|
`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
|
(`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 —
|
**: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
|
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]` **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]` **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._
|
_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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "ratatoskr"
|
name = "ratatoskr"
|
||||||
version = "0.21.2"
|
version = "0.21.7"
|
||||||
description = "Worldtree Conversation API debug console (web + headless CLI) — multi-pane observability"
|
description = "Worldtree Conversation API debug console (web + headless CLI) — multi-pane observability"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
@@ -16,7 +16,8 @@ keywords = ["worldtree", "debug", "sse", "web", "observability"]
|
|||||||
# See docs/design-brief.md §3 (httpx-sse).
|
# See docs/design-brief.md §3 (httpx-sse).
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"httpx>=0.27",
|
"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]
|
[project.optional-dependencies]
|
||||||
@@ -70,6 +71,7 @@ url = "https://gitea.phasefinal.com/api/packages/vh/pypi/simple/"
|
|||||||
|
|
||||||
[tool.uv.sources]
|
[tool.uv.sources]
|
||||||
bifrost = { index = "gitea" }
|
bifrost = { index = "gitea" }
|
||||||
|
worldtree-sdk = { index = "gitea" }
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel]
|
[tool.hatch.build.targets.wheel]
|
||||||
packages = ["src/ratatoskr"]
|
packages = ["src/ratatoskr"]
|
||||||
|
|||||||
+132
-61
@@ -11,12 +11,30 @@ import hashlib
|
|||||||
import os
|
import os
|
||||||
import signal
|
import signal
|
||||||
import sys
|
import sys
|
||||||
|
from collections.abc import Mapping
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from importlib.metadata import PackageNotFoundError, version
|
from importlib.metadata import PackageNotFoundError, version
|
||||||
from typing import Any, TextIO
|
from typing import Any, TextIO
|
||||||
|
|
||||||
import httpx
|
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.first_message import seed_preset_first_message
|
||||||
from ratatoskr.sessions import (
|
from ratatoskr.sessions import (
|
||||||
AgentNotFound,
|
AgentNotFound,
|
||||||
@@ -37,29 +55,21 @@ from ratatoskr.sessions import (
|
|||||||
set_persona_state,
|
set_persona_state,
|
||||||
write_authored_history,
|
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 (
|
from ratatoskr.sse_client import (
|
||||||
AffectUpdate,
|
|
||||||
AwaitingLlmFirstToken,
|
|
||||||
CancelAlreadyCompleted,
|
CancelAlreadyCompleted,
|
||||||
CancelFailed,
|
CancelFailed,
|
||||||
Cancelled,
|
|
||||||
CancelTurnNotFound,
|
CancelTurnNotFound,
|
||||||
Done,
|
|
||||||
Error,
|
|
||||||
Event,
|
|
||||||
MalformedSseData,
|
MalformedSseData,
|
||||||
MalformedSseId,
|
MalformedSseId,
|
||||||
SseConnectFailed,
|
SseConnectFailed,
|
||||||
SseConnectionDropped,
|
SseConnectionDropped,
|
||||||
Text,
|
|
||||||
TextBoundary,
|
|
||||||
Thinking,
|
|
||||||
ToolResult,
|
|
||||||
ToolStart,
|
|
||||||
TurnIdFlip,
|
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)"
|
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)
|
@dataclass(slots=True)
|
||||||
class CliPresenterState:
|
class CliPresenterState:
|
||||||
"""Per-turn presenter state for `--send` mode (issue #12).
|
"""Per-turn presenter state for `--send` mode (issue #12).
|
||||||
@@ -348,24 +381,31 @@ class CliPresenterState:
|
|||||||
thinking_open: bool = False
|
thinking_open: bool = False
|
||||||
text_written_since_newline: bool = False
|
text_written_since_newline: bool = False
|
||||||
|
|
||||||
def render(self, event: Event, *, stdout: TextIO, stderr: TextIO) -> None:
|
def render(self, event: TurnEvent, *, stdout: TextIO, stderr: TextIO) -> None:
|
||||||
"""Render one Worldtree SSE event with editorial hierarchy + coalescing."""
|
"""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(
|
assert isinstance(
|
||||||
event,
|
event,
|
||||||
(
|
(
|
||||||
WorkerPhase, Thinking, Text, TextBoundary,
|
WorkerPhaseEvent, ThinkingEvent, TextEvent, TextBoundaryEvent,
|
||||||
ToolStart, ToolResult, Done, Error, Cancelled,
|
ToolStartEvent, ToolResultEvent, DoneEvent, ErrorEvent, CancelledEvent,
|
||||||
AffectUpdate, AwaitingLlmFirstToken,
|
AffectUpdateEvent, AwaitingLlmFirstTokenEvent,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
# Thinking events accumulate into the open run.
|
# Thinking events accumulate into the open run.
|
||||||
if isinstance(event, Thinking):
|
if isinstance(event, ThinkingEvent):
|
||||||
|
content = event.content or ""
|
||||||
if not self.thinking_open:
|
if not self.thinking_open:
|
||||||
stderr.write(". thinking: ")
|
stderr.write(". thinking: ")
|
||||||
self.thinking_open = True
|
self.thinking_open = True
|
||||||
stderr.write(event.content)
|
stderr.write(content)
|
||||||
stderr.flush()
|
stderr.flush()
|
||||||
self.thinking_buffer.append(event.content)
|
self.thinking_buffer.append(content)
|
||||||
return
|
return
|
||||||
# Non-thinking event: close any open thinking run first.
|
# Non-thinking event: close any open thinking run first.
|
||||||
if self.thinking_open:
|
if self.thinking_open:
|
||||||
@@ -374,59 +414,60 @@ class CliPresenterState:
|
|||||||
self.thinking_open = False
|
self.thinking_open = False
|
||||||
self.thinking_buffer.clear()
|
self.thinking_buffer.clear()
|
||||||
# Now render the new event.
|
# Now render the new event.
|
||||||
if isinstance(event, Text):
|
if isinstance(event, TextEvent):
|
||||||
stdout.write(event.content)
|
content = event.content or ""
|
||||||
|
stdout.write(content)
|
||||||
stdout.flush()
|
stdout.flush()
|
||||||
# POST-003: only set if cursor is mid-line (no trailing newline).
|
# 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
|
return
|
||||||
if isinstance(event, (Done, Error, Cancelled)):
|
if isinstance(event, (DoneEvent, ErrorEvent, CancelledEvent)):
|
||||||
# INV-005: ensure stdout newline boundary before stderr terminal label.
|
# INV-005: ensure stdout newline boundary before stderr terminal label.
|
||||||
if self.text_written_since_newline:
|
if self.text_written_since_newline:
|
||||||
stdout.write("\n")
|
stdout.write("\n")
|
||||||
stdout.flush()
|
stdout.flush()
|
||||||
self.text_written_since_newline = False
|
self.text_written_since_newline = False
|
||||||
if isinstance(event, Done):
|
if isinstance(event, DoneEvent):
|
||||||
stderr.write(
|
stderr.write(
|
||||||
f"[done] turn_id={event.sse_id.turn_id} model={event.model} "
|
f"[done] turn_id={event.turn_id} model={event.model} "
|
||||||
f"duration={_format_duration_ms(event.duration_ms)} "
|
f"duration={_format_duration_ms(event.duration_ms or 0)} "
|
||||||
f"usage {_format_usage(event.usage, arrow='->')}\n"
|
f"usage {_format_usage_safe(event.usage)}\n"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
if isinstance(event, WorkerPhase):
|
if isinstance(event, WorkerPhaseEvent):
|
||||||
stderr.write(
|
stderr.write(
|
||||||
f". worker_phase: phase={event.phase} turn_id={event.turn_id}\n"
|
f". worker_phase: phase={event.phase} turn_id={event.turn_id}\n"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
if isinstance(event, Error):
|
if isinstance(event, ErrorEvent):
|
||||||
stderr.write(
|
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"
|
f"message={event.message!r}\n"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
if isinstance(event, Cancelled):
|
if isinstance(event, CancelledEvent):
|
||||||
stderr.write(
|
stderr.write(
|
||||||
f"[cancelled] turn_id={event.turn_id} reason={event.reason!r} "
|
f"[cancelled] turn_id={event.turn_id} reason={event.reason!r} "
|
||||||
f"partial_message_id={event.partial_message_id}\n"
|
f"partial_message_id={event.partial_message_id}\n"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
if isinstance(event, ToolStart):
|
if isinstance(event, ToolStartEvent):
|
||||||
stderr.write(
|
stderr.write(
|
||||||
f". tool_start: name={event.name} args={event.arguments!r}\n"
|
f". tool_start: name={event.name} args={event.arguments!r}\n"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
if isinstance(event, ToolResult):
|
if isinstance(event, ToolResultEvent):
|
||||||
stderr.write(
|
stderr.write(
|
||||||
f". tool_result: name={event.name} duration_ms={event.duration_ms} "
|
f". tool_result: name={event.name} duration_ms={event.duration_ms} "
|
||||||
f"result={event.result!r:.200}\n"
|
f"result={event.result!r:.200}\n"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
if isinstance(event, TextBoundary):
|
if isinstance(event, TextBoundaryEvent):
|
||||||
stderr.write(
|
stderr.write(
|
||||||
f". text_boundary: kind={event.kind} char_offset={event.char_offset}\n"
|
f". text_boundary: kind={event.kind} char_offset={event.char_offset}\n"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
if isinstance(event, AffectUpdate):
|
if isinstance(event, AffectUpdateEvent):
|
||||||
# Worldtree #204 / v0.28.0. CLI surface is debug telemetry —
|
# Worldtree #204 / v0.28.0. CLI surface is debug telemetry —
|
||||||
# one line to stderr with status + (for current) dominant_emotion.
|
# one line to stderr with status + (for current) dominant_emotion.
|
||||||
if event.snapshot is not None:
|
if event.snapshot is not None:
|
||||||
@@ -440,10 +481,10 @@ class CliPresenterState:
|
|||||||
f". affect_update: status={event.status} turn_id={event.turn_id}\n"
|
f". affect_update: status={event.status} turn_id={event.turn_id}\n"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
if isinstance(event, AwaitingLlmFirstToken):
|
if isinstance(event, AwaitingLlmFirstTokenEvent):
|
||||||
# Worldtree #201 / v0.29.0. Heartbeat during BuildingPrompt →
|
# Worldtree #201 / v0.29.0. Heartbeat during BuildingPrompt →
|
||||||
# CallingLLM gap. Stderr surface, one line per heartbeat.
|
# 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(
|
stderr.write(
|
||||||
f". awaiting_llm_first_token: turn_id={event.turn_id} elapsed={secs:.1f}s\n"
|
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(
|
async def _cancel_and_log(
|
||||||
client: httpx.AsyncClient,
|
client: WorldtreeClient,
|
||||||
session_id: str,
|
session_id: str,
|
||||||
turn_id: int,
|
turn_id: int,
|
||||||
*,
|
*,
|
||||||
stderr: TextIO,
|
stderr: TextIO,
|
||||||
) -> None:
|
) -> 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 client is not None
|
||||||
assert isinstance(turn_id, int) and turn_id > 0
|
assert isinstance(turn_id, int) and turn_id > 0
|
||||||
try:
|
try:
|
||||||
await cancel_turn(client, session_id, turn_id)
|
await wt.cancel_turn(client, session_id, turn_id)
|
||||||
except (CancelFailed, CancelTurnNotFound, CancelAlreadyCompleted, httpx.RequestError) as exc:
|
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")
|
stderr.write(f"[cancel_failed] {type(exc).__name__}: {exc}\n")
|
||||||
|
|
||||||
|
|
||||||
async def _run_turn(
|
async def _run_turn(
|
||||||
client: httpx.AsyncClient,
|
client: WorldtreeClient,
|
||||||
session_id: str,
|
session_id: str,
|
||||||
content: str,
|
content: str,
|
||||||
sigint_event: asyncio.Event,
|
sigint_event: asyncio.Event,
|
||||||
@@ -493,7 +542,10 @@ async def _run_turn(
|
|||||||
cancelling = False
|
cancelling = False
|
||||||
sigint_task: asyncio.Task[bool] | None = None
|
sigint_task: asyncio.Task[bool] | None = None
|
||||||
cancel_task: asyncio.Task[None] | None = None # strong ref to fire-and-forget cancel
|
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:
|
try:
|
||||||
while True:
|
while True:
|
||||||
@@ -537,17 +589,21 @@ async def _run_turn(
|
|||||||
except TurnIdFlip as exc:
|
except TurnIdFlip as exc:
|
||||||
stderr.write(f"[turn_id_flip] expected={exc.established} got={exc.got}\n")
|
stderr.write(f"[turn_id_flip] expected={exc.established} got={exc.got}\n")
|
||||||
return 22
|
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)
|
state.render(event, stdout=stdout, stderr=stderr)
|
||||||
if isinstance(event, Done):
|
if isinstance(event, DoneEvent):
|
||||||
if sigint_task is not None and not cancelling:
|
if sigint_task is not None and not cancelling:
|
||||||
sigint_task.cancel()
|
sigint_task.cancel()
|
||||||
return 0
|
return 0
|
||||||
if isinstance(event, Error):
|
if isinstance(event, ErrorEvent):
|
||||||
if sigint_task is not None and not cancelling:
|
if sigint_task is not None and not cancelling:
|
||||||
sigint_task.cancel()
|
sigint_task.cancel()
|
||||||
return 2
|
return 2
|
||||||
if isinstance(event, Cancelled):
|
if isinstance(event, CancelledEvent):
|
||||||
if sigint_task is not None and not cancelling:
|
if sigint_task is not None and not cancelling:
|
||||||
sigint_task.cancel()
|
sigint_task.cancel()
|
||||||
return 3
|
return 3
|
||||||
@@ -564,6 +620,11 @@ async def _run_turn(
|
|||||||
async def _amain(args: ParsedArgs) -> int:
|
async def _amain(args: ParsedArgs) -> int:
|
||||||
"""Async orchestrator: create-session (if --new) → SIGINT install → _run_turn → cleanup."""
|
"""Async orchestrator: create-session (if --new) → SIGINT install → _run_turn → cleanup."""
|
||||||
assert isinstance(args, ParsedArgs)
|
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(
|
async with httpx.AsyncClient(
|
||||||
base_url=args.server_url,
|
base_url=args.server_url,
|
||||||
headers={
|
headers={
|
||||||
@@ -575,7 +636,8 @@ async def _amain(args: ParsedArgs) -> int:
|
|||||||
# connect/write/pool keep modest timeouts so true network failures
|
# connect/write/pool keep modest timeouts so true network failures
|
||||||
# still surface promptly.
|
# still surface promptly.
|
||||||
timeout=httpx.Timeout(connect=10.0, read=None, write=10.0, pool=10.0),
|
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:
|
if args.new:
|
||||||
assert args.agent_id is not None
|
assert args.agent_id is not None
|
||||||
try:
|
try:
|
||||||
@@ -586,7 +648,7 @@ async def _amain(args: ParsedArgs) -> int:
|
|||||||
if args.system_prompt is not None
|
if args.system_prompt is not None
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
info = await create_session(
|
info = await wt.create_session(
|
||||||
client,
|
client,
|
||||||
args.agent_id,
|
args.agent_id,
|
||||||
end_user_id=args.end_user_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"
|
"(RATATOSKR_BIFROST_CONSUMER_KEY), not WORLDTREE_API_KEY\n"
|
||||||
)
|
)
|
||||||
return 23
|
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")
|
sys.stderr.write(f"[session_api_failed] status={exc.status} body={exc.body!r}\n")
|
||||||
return 20
|
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")
|
sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n")
|
||||||
return 21
|
return 21
|
||||||
# Issue #12: demoted lifecycle line — written directly here (NOT via
|
# Issue #12: demoted lifecycle line — written directly here (NOT via
|
||||||
# state.render, which only accepts SSE Event variants per PRE-001).
|
# state.render, which only accepts SSE Event variants per PRE-001). The
|
||||||
kind_suffix = f" kind={info.kind}" if info.kind else ""
|
# 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(
|
sys.stderr.write(
|
||||||
f". create_session: session_id={info.session_id} "
|
f". create_session: session_id={session_id} "
|
||||||
f"agent_id={info.agent_id}{kind_suffix}\n"
|
f"agent_id={info['agent_id']}{kind_suffix}\n"
|
||||||
)
|
)
|
||||||
# #347 authored first-message: seed the agent's preset opening (best-effort).
|
# #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(
|
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
|
# Issue #17 bound-state indicator: plane + endpoint + status, so the
|
||||||
# operator sees WHICH identity/endpoint bound (not a bare boolean).
|
# 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". bifrost: status=bound plane={plane} "
|
||||||
f"endpoint={args.bifrost.endpoint_url}\n"
|
f"endpoint={args.bifrost.endpoint_url}\n"
|
||||||
)
|
)
|
||||||
session_id = info.session_id
|
# session_id was bound above from the create dict.
|
||||||
else:
|
else:
|
||||||
assert args.session_id is not None
|
assert args.session_id is not None
|
||||||
session_id = args.session_id
|
session_id = args.session_id
|
||||||
|
|||||||
@@ -286,9 +286,15 @@ def _eager_failure_fields(body: bytes, status: int) -> tuple[str, str]:
|
|||||||
|
|
||||||
|
|
||||||
class SseConnectionDropped(Exception):
|
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}")
|
super().__init__(f"SSE connection dropped; last_seen_sse_id={last_seen_sse_id}")
|
||||||
self.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,
|
client,
|
||||||
session_id,
|
session_id,
|
||||||
content,
|
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
@@ -26,8 +26,10 @@ from starlette.responses import (
|
|||||||
)
|
)
|
||||||
from starlette.routing import Mount, Route
|
from starlette.routing import Mount, Route
|
||||||
from starlette.staticfiles import StaticFiles
|
from starlette.staticfiles import StaticFiles
|
||||||
|
from worldtree_sdk import CancelledEvent, DoneEvent, ErrorEvent, WorldtreeClient
|
||||||
|
|
||||||
from ratatoskr import local_agents as _local_agents
|
from ratatoskr import local_agents as _local_agents
|
||||||
|
from ratatoskr import wt
|
||||||
from ratatoskr.first_message import seed_preset_first_message
|
from ratatoskr.first_message import seed_preset_first_message
|
||||||
from ratatoskr.sessions import (
|
from ratatoskr.sessions import (
|
||||||
AgentNotAvailable,
|
AgentNotAvailable,
|
||||||
@@ -38,30 +40,41 @@ from ratatoskr.sessions import (
|
|||||||
BifrostHandshakeFailed,
|
BifrostHandshakeFailed,
|
||||||
PersonaNotConfigured,
|
PersonaNotConfigured,
|
||||||
SessionApiFailed,
|
SessionApiFailed,
|
||||||
create_session,
|
|
||||||
endpoint_for_plane,
|
endpoint_for_plane,
|
||||||
get_persona_state,
|
get_persona_state,
|
||||||
get_session_bifrost,
|
get_session_bifrost,
|
||||||
get_session_messages,
|
|
||||||
get_session_tools,
|
|
||||||
list_agents,
|
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 (
|
from ratatoskr.sse_client import (
|
||||||
AdminEvent,
|
AdminEvent,
|
||||||
CancelAlreadyCompleted,
|
CancelAlreadyCompleted,
|
||||||
CancelFailed,
|
CancelFailed,
|
||||||
Cancelled,
|
|
||||||
CancelTurnNotFound,
|
CancelTurnNotFound,
|
||||||
Done,
|
|
||||||
Error,
|
|
||||||
MalformedSseData,
|
MalformedSseData,
|
||||||
MalformedSseId,
|
MalformedSseId,
|
||||||
SseConnectFailed,
|
SseConnectFailed,
|
||||||
SseConnectionDropped,
|
SseConnectionDropped,
|
||||||
TurnIdFlip,
|
TurnIdFlip,
|
||||||
cancel_turn,
|
|
||||||
stream_admin_events,
|
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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -163,16 +176,17 @@ async def _create_session_endpoint(request: Request) -> JSONResponse:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
async with client_factory() as client:
|
async with client_factory() as client:
|
||||||
info = await create_session(
|
info = await wt.create_session(
|
||||||
client,
|
_wt_client(client),
|
||||||
agent_id,
|
agent_id,
|
||||||
end_user_id=end_user_id,
|
end_user_id=end_user_id,
|
||||||
bifrost=bifrost,
|
bifrost=bifrost,
|
||||||
consumer_key=consumer_key if bifrost else None,
|
consumer_key=consumer_key if bifrost else None,
|
||||||
)
|
)
|
||||||
# #347 authored first-message: seed the agent's preset opening
|
# #347 authored first-message: seed the agent's preset opening (best-effort;
|
||||||
# (best-effort; never blocks create — see first_message INV-001).
|
# never blocks create). first_message is a slice-3 hand-rolled path — it
|
||||||
await seed_preset_first_message(client, info.session_id, agent_id)
|
# reuses the raw transport (its default bearer), not the adapter client.
|
||||||
|
await seed_preset_first_message(client, info["session_id"], agent_id)
|
||||||
except AgentNotFound:
|
except AgentNotFound:
|
||||||
return JSONResponse({"error_code": "agent_not_found"}, status_code=404)
|
return JSONResponse({"error_code": "agent_not_found"}, status_code=404)
|
||||||
except BifrostConsumerKeyMissing:
|
except BifrostConsumerKeyMissing:
|
||||||
@@ -188,12 +202,13 @@ async def _create_session_endpoint(request: Request) -> JSONResponse:
|
|||||||
},
|
},
|
||||||
status_code=502,
|
status_code=502,
|
||||||
)
|
)
|
||||||
except SessionApiFailed as exc:
|
except wt.SessionApiFailed as exc:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
{"error_code": "session_api_failed", "status": exc.status},
|
{"error_code": "session_api_failed", "status": exc.status},
|
||||||
status_code=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:
|
if bifrost is not None:
|
||||||
# Bound-state for the UI indicator — plane + endpoint only, never the key.
|
# Bound-state for the UI indicator — plane + endpoint only, never the key.
|
||||||
payload["bifrost"] = {
|
payload["bifrost"] = {
|
||||||
@@ -251,25 +266,19 @@ async def _submit_turn_endpoint(request: Request) -> JSONResponse:
|
|||||||
|
|
||||||
|
|
||||||
def _event_to_browser_payload(event: object) -> tuple[str, dict]:
|
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
|
Per INV-008 + FN stream_turn_endpoint STEP 3. The browser contract
|
||||||
locked by tests/fixtures/presentation_contract.json — one entry per
|
(tests/fixtures/presentation_contract.json) is preserved: the SDK's `raw` is
|
||||||
Event type. Implementation: snake_case class name as event_type;
|
the wire body — the same per-type field set the old dataclasses carried — so the
|
||||||
asdict(event) with sse_id flattened to "T:S" string.
|
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__
|
browser_type = getattr(event, "type", "") or ""
|
||||||
# CamelCase → snake_case
|
raw = getattr(event, "raw", None) or {}
|
||||||
browser_type = "".join(
|
data = {k: v for k, v in dict(raw).items() if k != "type"}
|
||||||
("_" + c.lower() if c.isupper() and i else c.lower())
|
data["sse_id"] = getattr(event, "sse_id", None)
|
||||||
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']}"
|
|
||||||
return browser_type, data
|
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()
|
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:
|
async def _stream_turn_endpoint(request: Request) -> StreamingResponse:
|
||||||
"""GET /api/turns/{session_id}/stream?turn_id=N → proxy upstream SSE.
|
"""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]:
|
async def gen() -> AsyncIterator[bytes]:
|
||||||
client = client_factory()
|
client = client_factory()
|
||||||
|
wt_client = _wt_client(client)
|
||||||
try:
|
try:
|
||||||
handle.status = "streaming"
|
handle.status = "streaming"
|
||||||
try:
|
try:
|
||||||
async for event in stream_turn_resilient(client, session_id, handle.content):
|
async for event in wt.stream_turn(wt_client, session_id, handle.content):
|
||||||
# v0.16.0: capture the upstream (Worldtree-assigned)
|
# v0.16.0: capture the upstream (Worldtree-assigned) turn_id from
|
||||||
# turn_id from the first event so cancel paths target
|
# the first event so cancel paths target the real upstream turn,
|
||||||
# the real upstream turn, not our local counter.
|
# not our local counter — parsed from the composite sse_id.
|
||||||
if handle.upstream_turn_id is None:
|
if handle.upstream_turn_id is None:
|
||||||
sse_id = getattr(event, "sse_id", None)
|
handle.upstream_turn_id = _turn_id_from_sse_id(
|
||||||
if sse_id is not None:
|
getattr(event, "sse_id", None)
|
||||||
handle.upstream_turn_id = sse_id.turn_id
|
)
|
||||||
event_type, data = _event_to_browser_payload(event)
|
event_type, data = _event_to_browser_payload(event)
|
||||||
yield _format_sse(event_type, data)
|
yield _format_sse(event_type, data)
|
||||||
if isinstance(event, (Done, Error, Cancelled)):
|
if isinstance(event, (DoneEvent, ErrorEvent, CancelledEvent)):
|
||||||
handle.status = type(event).__name__.lower()
|
handle.status = event.type or "done"
|
||||||
break
|
break
|
||||||
except (SseConnectFailed, SseConnectionDropped, MalformedSseId,
|
except (SseConnectFailed, SseConnectionDropped, MalformedSseId,
|
||||||
MalformedSseData, TurnIdFlip) as exc:
|
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.
|
# turn (if it started) — never the local turn_id.
|
||||||
if handle.status == "streaming" and handle.upstream_turn_id is not None:
|
if handle.status == "streaming" and handle.upstream_turn_id is not None:
|
||||||
try:
|
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):
|
except (CancelAlreadyCompleted, CancelTurnNotFound):
|
||||||
pass # cooperative race — turn already terminal upstream
|
pass # cooperative race — turn already terminal upstream
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -377,15 +401,18 @@ async def _cancel_turn_endpoint(request: Request) -> JSONResponse:
|
|||||||
client_factory = request.app.state.client_factory
|
client_factory = request.app.state.client_factory
|
||||||
try:
|
try:
|
||||||
async with client_factory() as client:
|
async with client_factory() as client:
|
||||||
await cancel_turn(client, session_id, handle.upstream_turn_id)
|
result = await wt.cancel_turn(
|
||||||
body = {"cancelled": True}
|
_wt_client(client), session_id, handle.upstream_turn_id
|
||||||
|
)
|
||||||
|
body = {"cancelled": bool(result.cancelled)}
|
||||||
except (CancelAlreadyCompleted, CancelTurnNotFound):
|
except (CancelAlreadyCompleted, CancelTurnNotFound):
|
||||||
body = {"cancelled": False, "reason": "race_or_completed"}
|
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)
|
registry.pop((session_id, turn_id), None)
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
{"error_code": "cancel_failed", "status": exc.status},
|
{"error_code": "cancel_failed"},
|
||||||
status_code=exc.status,
|
status_code=502,
|
||||||
)
|
)
|
||||||
registry.pop((session_id, turn_id), None)
|
registry.pop((session_id, turn_id), None)
|
||||||
return JSONResponse(body, status_code=200)
|
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
|
client_factory = request.app.state.client_factory
|
||||||
try:
|
try:
|
||||||
async with client_factory() as client:
|
async with client_factory() as client:
|
||||||
info = await get_session_tools(client, session_id)
|
info = await wt.get_session_tools(_wt_client(client), session_id)
|
||||||
except SessionApiFailed as exc:
|
except wt.SessionApiFailed as exc:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
{"error_code": "session_tools_unavailable", "status": exc.status},
|
{"error_code": "session_tools_unavailable", "status": exc.status},
|
||||||
status_code=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:
|
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
|
client_factory = request.app.state.client_factory
|
||||||
try:
|
try:
|
||||||
async with client_factory() as client:
|
async with client_factory() as client:
|
||||||
data = await get_session_messages(client, session_id)
|
data = await wt.get_session_messages(_wt_client(client), session_id)
|
||||||
except SessionApiFailed as exc:
|
except wt.SessionApiFailed as exc:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
{"error_code": "session_messages_unavailable", "status": exc.status},
|
{"error_code": "session_messages_unavailable", "status": exc.status},
|
||||||
status_code=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:
|
async def _session_bifrost_endpoint(request: Request) -> JSONResponse:
|
||||||
@@ -609,14 +636,15 @@ def create_app(
|
|||||||
]
|
]
|
||||||
if in_flight:
|
if in_flight:
|
||||||
client = client_factory()
|
client = client_factory()
|
||||||
|
wt_client = _wt_client(client)
|
||||||
try:
|
try:
|
||||||
task_to_handle = {
|
task_to_handle = {
|
||||||
asyncio.create_task(
|
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
|
): h
|
||||||
for h in in_flight
|
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).
|
# Per-pending session/turn detail (INV-006 logging fidelity).
|
||||||
for task in pending:
|
for task in pending:
|
||||||
h = task_to_handle[task]
|
h = task_to_handle[task]
|
||||||
|
|||||||
@@ -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
@@ -7,8 +7,10 @@ import json
|
|||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
import respx
|
import respx
|
||||||
|
from worldtree_sdk.events import build_event
|
||||||
|
|
||||||
from ratatoskr import cli as cli_mod
|
from ratatoskr import cli as cli_mod
|
||||||
|
from ratatoskr import wt
|
||||||
from ratatoskr.cli import (
|
from ratatoskr.cli import (
|
||||||
ParsedArgs,
|
ParsedArgs,
|
||||||
UsageError,
|
UsageError,
|
||||||
@@ -20,18 +22,7 @@ from ratatoskr.cli import (
|
|||||||
main,
|
main,
|
||||||
)
|
)
|
||||||
from ratatoskr.sessions import BifrostBinding
|
from ratatoskr.sessions import BifrostBinding
|
||||||
from ratatoskr.sse_client import (
|
from ratatoskr.sse_client import SseId
|
||||||
Cancelled,
|
|
||||||
Done,
|
|
||||||
Error,
|
|
||||||
SseId,
|
|
||||||
Text,
|
|
||||||
TextBoundary,
|
|
||||||
Thinking,
|
|
||||||
ToolResult,
|
|
||||||
ToolStart,
|
|
||||||
WorkerPhase,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class _FlushCountingIO(io.StringIO):
|
class _FlushCountingIO(io.StringIO):
|
||||||
@@ -333,6 +324,83 @@ SID = SseId(42, 5)
|
|||||||
SID42 = SseId(42, 1)
|
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:
|
class TestCliPresenterState:
|
||||||
"""Tests for the new CliPresenterState — per issue #12 contract."""
|
"""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(
|
return Done(
|
||||||
sse_id=SID42,
|
sse_id=SID42,
|
||||||
phase="succeeded",
|
phase="succeeded",
|
||||||
@@ -709,7 +777,8 @@ class TestCancelAndLog:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
stderr = 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)
|
||||||
result = await _cancel_and_log(client, "s-1", 42, stderr=stderr)
|
result = await _cancel_and_log(client, "s-1", 42, stderr=stderr)
|
||||||
assert result is None
|
assert result is None
|
||||||
assert stderr.getvalue() == ""
|
assert stderr.getvalue() == ""
|
||||||
@@ -721,7 +790,8 @@ class TestCancelAndLog:
|
|||||||
return_value=httpx.Response(500, content=b"boom")
|
return_value=httpx.Response(500, content=b"boom")
|
||||||
)
|
)
|
||||||
stderr = 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)
|
||||||
await _cancel_and_log(client, "s-1", 42, stderr=stderr)
|
await _cancel_and_log(client, "s-1", 42, stderr=stderr)
|
||||||
out = stderr.getvalue()
|
out = stderr.getvalue()
|
||||||
assert "[cancel_failed]" in out
|
assert "[cancel_failed]" in out
|
||||||
@@ -730,11 +800,14 @@ class TestCancelAndLog:
|
|||||||
@respx.mock
|
@respx.mock
|
||||||
async def test_cancel_already_completed(self) -> None:
|
async def test_cancel_already_completed(self) -> None:
|
||||||
"""cancel_already_completed [scenario]: …"""
|
"""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(
|
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()
|
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)
|
await _cancel_and_log(client, "s-1", 42, stderr=stderr)
|
||||||
out = stderr.getvalue()
|
out = stderr.getvalue()
|
||||||
assert "[cancel_failed]" in out
|
assert "[cancel_failed]" in out
|
||||||
@@ -743,11 +816,13 @@ class TestCancelAndLog:
|
|||||||
@respx.mock
|
@respx.mock
|
||||||
async def test_cancel_turn_not_found(self) -> None:
|
async def test_cancel_turn_not_found(self) -> None:
|
||||||
"""cancel_turn_not_found [scenario]: 404 → returns None; stderr CancelTurnNotFound."""
|
"""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(
|
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()
|
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)
|
await _cancel_and_log(client, "s-1", 42, stderr=stderr)
|
||||||
out = stderr.getvalue()
|
out = stderr.getvalue()
|
||||||
assert "[cancel_failed]" in out
|
assert "[cancel_failed]" in out
|
||||||
@@ -760,11 +835,14 @@ class TestCancelAndLog:
|
|||||||
side_effect=httpx.ConnectError("network down")
|
side_effect=httpx.ConnectError("network down")
|
||||||
)
|
)
|
||||||
stderr = 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)
|
||||||
await _cancel_and_log(client, "s-1", 42, stderr=stderr)
|
await _cancel_and_log(client, "s-1", 42, stderr=stderr)
|
||||||
out = stderr.getvalue()
|
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 "[cancel_failed]" in out
|
||||||
assert "ConnectError" in out
|
assert "ConnectFailed" in out
|
||||||
|
|
||||||
|
|
||||||
class _GatedStream(httpx.AsyncByteStream):
|
class _GatedStream(httpx.AsyncByteStream):
|
||||||
@@ -797,7 +875,8 @@ class TestRunTurn:
|
|||||||
sigint = asyncio.Event()
|
sigint = asyncio.Event()
|
||||||
stdout = io.StringIO()
|
stdout = io.StringIO()
|
||||||
stderr = 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)
|
exit_code = await _run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
|
||||||
assert exit_code == 0
|
assert exit_code == 0
|
||||||
assert stdout.getvalue() == "hello\n"
|
assert stdout.getvalue() == "hello\n"
|
||||||
@@ -820,7 +899,8 @@ class TestRunTurn:
|
|||||||
)
|
)
|
||||||
sigint = asyncio.Event()
|
sigint = asyncio.Event()
|
||||||
stdout, stderr = io.StringIO(), io.StringIO()
|
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)
|
exit_code = await _run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
|
||||||
assert exit_code == 2
|
assert exit_code == 2
|
||||||
assert "[error]" in stderr.getvalue()
|
assert "[error]" in stderr.getvalue()
|
||||||
@@ -836,7 +916,8 @@ class TestRunTurn:
|
|||||||
)
|
)
|
||||||
sigint = asyncio.Event()
|
sigint = asyncio.Event()
|
||||||
stdout, stderr = io.StringIO(), io.StringIO()
|
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)
|
exit_code = await _run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
|
||||||
assert exit_code == 3
|
assert exit_code == 3
|
||||||
assert "[cancelled]" in stderr.getvalue()
|
assert "[cancelled]" in stderr.getvalue()
|
||||||
@@ -849,7 +930,8 @@ class TestRunTurn:
|
|||||||
)
|
)
|
||||||
sigint = asyncio.Event()
|
sigint = asyncio.Event()
|
||||||
stdout, stderr = io.StringIO(), io.StringIO()
|
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(
|
exit_code = await _run_turn(
|
||||||
client, "missing", "hi", sigint, stdout=stdout, stderr=stderr
|
client, "missing", "hi", sigint, stdout=stdout, stderr=stderr
|
||||||
)
|
)
|
||||||
@@ -882,7 +964,8 @@ class TestRunTurn:
|
|||||||
)
|
)
|
||||||
sigint = asyncio.Event()
|
sigint = asyncio.Event()
|
||||||
stdout, stderr = io.StringIO(), io.StringIO()
|
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)
|
exit_code = await _run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
|
||||||
assert exit_code == 21
|
assert exit_code == 21
|
||||||
assert "[connection_dropped]" in stderr.getvalue()
|
assert "[connection_dropped]" in stderr.getvalue()
|
||||||
@@ -896,7 +979,8 @@ class TestRunTurn:
|
|||||||
)
|
)
|
||||||
sigint = asyncio.Event()
|
sigint = asyncio.Event()
|
||||||
stdout, stderr = io.StringIO(), io.StringIO()
|
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)
|
exit_code = await _run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
|
||||||
assert exit_code == 22
|
assert exit_code == 22
|
||||||
assert "[malformed_sse_id]" in stderr.getvalue()
|
assert "[malformed_sse_id]" in stderr.getvalue()
|
||||||
@@ -913,7 +997,8 @@ class TestRunTurn:
|
|||||||
)
|
)
|
||||||
sigint = asyncio.Event()
|
sigint = asyncio.Event()
|
||||||
stdout, stderr = io.StringIO(), io.StringIO()
|
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)
|
exit_code = await _run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
|
||||||
assert exit_code == 22
|
assert exit_code == 22
|
||||||
out = stderr.getvalue()
|
out = stderr.getvalue()
|
||||||
@@ -933,7 +1018,8 @@ class TestRunTurn:
|
|||||||
)
|
)
|
||||||
sigint = asyncio.Event()
|
sigint = asyncio.Event()
|
||||||
stdout, stderr = io.StringIO(), io.StringIO()
|
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)
|
exit_code = await _run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
|
||||||
assert exit_code == 22
|
assert exit_code == 22
|
||||||
out = stderr.getvalue()
|
out = stderr.getvalue()
|
||||||
@@ -955,7 +1041,8 @@ class TestRunTurn:
|
|||||||
)
|
)
|
||||||
sigint = asyncio.Event()
|
sigint = asyncio.Event()
|
||||||
stdout, stderr = io.StringIO(), io.StringIO()
|
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)
|
exit_code = await _run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
|
||||||
assert exit_code == 22
|
assert exit_code == 22
|
||||||
out = stderr.getvalue()
|
out = stderr.getvalue()
|
||||||
@@ -978,7 +1065,8 @@ class TestRunTurn:
|
|||||||
sigint = asyncio.Event()
|
sigint = asyncio.Event()
|
||||||
sigint.set() # SIGINT before _run_turn even starts
|
sigint.set() # SIGINT before _run_turn even starts
|
||||||
stdout, stderr = io.StringIO(), io.StringIO()
|
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(
|
exit_code = await asyncio.wait_for(
|
||||||
_run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr), timeout=2.0
|
_run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr), timeout=2.0
|
||||||
)
|
)
|
||||||
@@ -1007,7 +1095,8 @@ class TestRunTurn:
|
|||||||
|
|
||||||
sigint = asyncio.Event()
|
sigint = asyncio.Event()
|
||||||
stdout, stderr = io.StringIO(), io.StringIO()
|
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(
|
task = asyncio.create_task(
|
||||||
_run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
|
_run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
|
||||||
)
|
)
|
||||||
@@ -1045,7 +1134,8 @@ class TestRunTurn:
|
|||||||
|
|
||||||
sigint = asyncio.Event()
|
sigint = asyncio.Event()
|
||||||
stdout, stderr = io.StringIO(), io.StringIO()
|
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(
|
task = asyncio.create_task(
|
||||||
_run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
|
_run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
|
||||||
)
|
)
|
||||||
@@ -1091,7 +1181,8 @@ class TestRunTurn:
|
|||||||
monkeypatch.setattr(sigint, "wait", counting_wait)
|
monkeypatch.setattr(sigint, "wait", counting_wait)
|
||||||
|
|
||||||
stdout, stderr = io.StringIO(), io.StringIO()
|
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(
|
task = asyncio.create_task(
|
||||||
_run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
|
_run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
|
||||||
)
|
)
|
||||||
@@ -1129,7 +1220,8 @@ class TestRunTurn:
|
|||||||
|
|
||||||
sigint = asyncio.Event()
|
sigint = asyncio.Event()
|
||||||
stdout, stderr = io.StringIO(), io.StringIO()
|
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(
|
task = asyncio.create_task(
|
||||||
_run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
|
_run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
|
||||||
)
|
)
|
||||||
@@ -1176,7 +1268,8 @@ class TestRunTurn:
|
|||||||
|
|
||||||
sigint = asyncio.Event()
|
sigint = asyncio.Event()
|
||||||
stdout, stderr = io.StringIO(), io.StringIO()
|
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)
|
exit_code = await _run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
|
||||||
assert exit_code == 0
|
assert exit_code == 0
|
||||||
assert call_count == 3
|
assert call_count == 3
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ type. Server-side serialization (`_event_to_browser_payload`) is
|
|||||||
unit-tested against the fixture. JS-side rendering in
|
unit-tested against the fixture. JS-side rendering in
|
||||||
`src/ratatoskr/web/static/index.html` consumes the same shape — if
|
`src/ratatoskr/web/static/index.html` consumes the same shape — if
|
||||||
this fixture changes, both sides update in lockstep.
|
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
|
from __future__ import annotations
|
||||||
@@ -14,20 +20,8 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from ratatoskr.sse_client import (
|
from worldtree_sdk.events import build_event
|
||||||
AffectUpdate,
|
|
||||||
AwaitingLlmFirstToken,
|
|
||||||
Cancelled,
|
|
||||||
Done,
|
|
||||||
Error,
|
|
||||||
SseId,
|
|
||||||
Text,
|
|
||||||
TextBoundary,
|
|
||||||
Thinking,
|
|
||||||
ToolResult,
|
|
||||||
ToolStart,
|
|
||||||
WorkerPhase,
|
|
||||||
)
|
|
||||||
from ratatoskr.web.server import _event_to_browser_payload
|
from ratatoskr.web.server import _event_to_browser_payload
|
||||||
|
|
||||||
|
|
||||||
@@ -36,6 +30,13 @@ def _load_fixture() -> dict:
|
|||||||
return json.loads(path.read_text())
|
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:
|
def _check(name: str, event: object) -> None:
|
||||||
"""Assert (event_type, data) for `event` matches the fixture entry."""
|
"""Assert (event_type, data) for `event` matches the fixture entry."""
|
||||||
fixture = _load_fixture()
|
fixture = _load_fixture()
|
||||||
@@ -51,58 +52,40 @@ def _check(name: str, event: object) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_worker_phase_matches_fixture() -> None:
|
def test_worker_phase_matches_fixture() -> None:
|
||||||
_check(
|
_check("worker_phase", _ev("worker_phase", "42:3", phase="BuildingPrompt", turn_id=42))
|
||||||
"worker_phase",
|
|
||||||
WorkerPhase(sse_id=SseId(42, 3), phase="BuildingPrompt", turn_id=42),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_thinking_matches_fixture() -> None:
|
def test_thinking_matches_fixture() -> None:
|
||||||
_check(
|
_check("thinking", _ev("thinking", "42:5", content="Let me think..."))
|
||||||
"thinking",
|
|
||||||
Thinking(sse_id=SseId(42, 5), content="Let me think..."),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_text_matches_fixture() -> None:
|
def test_text_matches_fixture() -> None:
|
||||||
_check(
|
_check("text", _ev("text", "42:7", content="Hello there"))
|
||||||
"text",
|
|
||||||
Text(sse_id=SseId(42, 7), content="Hello there"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_text_boundary_matches_fixture() -> None:
|
def test_text_boundary_matches_fixture() -> None:
|
||||||
_check(
|
_check(
|
||||||
"text_boundary",
|
"text_boundary",
|
||||||
TextBoundary(
|
_ev("text_boundary", "42:8", kind="sentence", char_offset=11, ts="2026-05-28T00:00:00Z"),
|
||||||
sse_id=SseId(42, 8), kind="sentence",
|
|
||||||
char_offset=11, ts="2026-05-28T00:00:00Z",
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_tool_start_matches_fixture() -> None:
|
def test_tool_start_matches_fixture() -> None:
|
||||||
_check(
|
_check("tool_start", _ev("tool_start", "42:9", name="search", arguments={"q": "ratatoskr"}))
|
||||||
"tool_start",
|
|
||||||
ToolStart(sse_id=SseId(42, 9), name="search", arguments={"q": "ratatoskr"}),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_tool_result_matches_fixture() -> None:
|
def test_tool_result_matches_fixture() -> None:
|
||||||
_check(
|
_check(
|
||||||
"tool_result",
|
"tool_result",
|
||||||
ToolResult(
|
_ev("tool_result", "42:10", name="search", result={"n": 1}, duration_ms=12),
|
||||||
sse_id=SseId(42, 10), name="search",
|
|
||||||
result={"n": 1}, duration_ms=12,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_done_matches_fixture() -> None:
|
def test_done_matches_fixture() -> None:
|
||||||
_check(
|
_check(
|
||||||
"done",
|
"done",
|
||||||
Done(
|
_ev(
|
||||||
sse_id=SseId(42, 11), phase="succeeded", response="Hello there",
|
"done", "42:11", phase="succeeded", response="Hello there",
|
||||||
model="qwen3.6-35-a3b", duration_ms=1234,
|
model="qwen3.6-35-a3b", duration_ms=1234,
|
||||||
usage={
|
usage={
|
||||||
"prompt_tokens": 100, "completion_tokens": 50,
|
"prompt_tokens": 100, "completion_tokens": 50,
|
||||||
@@ -115,8 +98,8 @@ def test_done_matches_fixture() -> None:
|
|||||||
def test_error_matches_fixture() -> None:
|
def test_error_matches_fixture() -> None:
|
||||||
_check(
|
_check(
|
||||||
"error",
|
"error",
|
||||||
Error(
|
_ev(
|
||||||
sse_id=SseId(42, 11), phase="failed",
|
"error", "42:11", phase="failed",
|
||||||
message="llm output invalid", error_code="llm_output_invalid",
|
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:
|
def test_cancelled_matches_fixture() -> None:
|
||||||
_check(
|
_check(
|
||||||
"cancelled",
|
"cancelled",
|
||||||
Cancelled(
|
_ev(
|
||||||
sse_id=SseId(42, 11), phase="cancelled", turn_id=42,
|
"cancelled", "42:11", phase="cancelled", turn_id=42,
|
||||||
reason="user_cancel", partial_message_id=None,
|
reason="user_cancel", partial_message_id=None,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -135,8 +118,8 @@ def test_cancelled_matches_fixture() -> None:
|
|||||||
def test_affect_update_matches_fixture() -> None:
|
def test_affect_update_matches_fixture() -> None:
|
||||||
_check(
|
_check(
|
||||||
"affect_update",
|
"affect_update",
|
||||||
AffectUpdate(
|
_ev(
|
||||||
sse_id=SseId(42, 1), status="current", turn_id=42,
|
"affect_update", "42:1", status="current", turn_id=42,
|
||||||
snapshot={
|
snapshot={
|
||||||
"agent_id": "mimir",
|
"agent_id": "mimir",
|
||||||
"pad": {"pleasure": 0.52, "arousal": 0.47, "dominance": 0.50},
|
"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:
|
def test_awaiting_llm_first_token_matches_fixture() -> None:
|
||||||
_check(
|
_check(
|
||||||
"awaiting_llm_first_token",
|
"awaiting_llm_first_token",
|
||||||
AwaitingLlmFirstToken(
|
_ev(
|
||||||
sse_id=SseId(42, 2), turn_id=42,
|
"awaiting_llm_first_token", "42:2",
|
||||||
elapsed_ms_since_building_prompt=5012.3,
|
turn_id=42, elapsed_ms_since_building_prompt=5012.3,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -456,15 +456,16 @@ class TestCancelTurnEndpoint:
|
|||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
def test_already_completed_race(self) -> None:
|
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
|
from ratatoskr.web.server import create_app
|
||||||
app = create_app(_mock_client_factory())
|
app = create_app(_mock_client_factory())
|
||||||
c = TestClient(app)
|
c = TestClient(app)
|
||||||
turn_id = c.post("/api/turns/s-1", json={"content": "hi"}).json()["turn_id"]
|
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)].status = "streaming"
|
||||||
app.state.turn_registry[("s-1", turn_id)].upstream_turn_id = 42
|
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(
|
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}")
|
resp = c.post(f"/api/turns/s-1/cancel?turn_id={turn_id}")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
@@ -473,7 +474,11 @@ class TestCancelTurnEndpoint:
|
|||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
def test_cancel_failed_500(self) -> None:
|
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
|
from ratatoskr.web.server import create_app
|
||||||
app = create_app(_mock_client_factory())
|
app = create_app(_mock_client_factory())
|
||||||
c = TestClient(app)
|
c = TestClient(app)
|
||||||
@@ -484,7 +489,7 @@ class TestCancelTurnEndpoint:
|
|||||||
return_value=httpx.Response(500, content=b"boom")
|
return_value=httpx.Response(500, content=b"boom")
|
||||||
)
|
)
|
||||||
resp = c.post(f"/api/turns/s-1/cancel?turn_id={turn_id}")
|
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 resp.json()["error_code"] == "cancel_failed"
|
||||||
assert ("s-1", turn_id) not in app.state.turn_registry
|
assert ("s-1", turn_id) not in app.state.turn_registry
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -472,11 +472,12 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ratatoskr"
|
name = "ratatoskr"
|
||||||
version = "0.21.2"
|
version = "0.21.7"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
{ name = "httpx-sse" },
|
{ name = "httpx-sse" },
|
||||||
|
{ name = "worldtree-sdk" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.optional-dependencies]
|
[package.optional-dependencies]
|
||||||
@@ -519,6 +520,7 @@ requires-dist = [
|
|||||||
{ name = "sqlite-vec", marker = "extra == 'provider'", specifier = ">=0.1.6" },
|
{ name = "sqlite-vec", marker = "extra == 'provider'", specifier = ">=0.1.6" },
|
||||||
{ name = "starlette", marker = "extra == 'web'", specifier = ">=0.40" },
|
{ name = "starlette", marker = "extra == 'web'", specifier = ">=0.40" },
|
||||||
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'web'", specifier = ">=0.30" },
|
{ 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"]
|
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/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 },
|
{ 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" },
|
||||||
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user