Compare commits

..

4 Commits

Author SHA1 Message Date
vh aba17304bd fix(#20): heid-bug-hunt fixups — cutover edge-path robustness (slice-2)
Triaged the heid-bug-hunt panel (Gróa 8 / Hulda 6 / Regin 6; Heid source-checked +
refuted 2 Regin FPs). The lens pulled real weight — confirmed bugs the conformance
review structurally could not see.

Confirmed bugs fixed:
- SessionRetired (410) stream-open maps to wt.SessionApiFailed, but neither cli
  _run_turn nor web gen() caught it → crash / dropped SSE stream. Both presenters now
  catch it (cli → exit 20; web → labeled `event: error`). (Gróa#2) + cli regression test.
- cli forwarded consumer_key unconditionally; an UNBOUND create with the env key set
  would auth as the Bifrost consumer, not the default bearer. Guarded in the adapter
  (consumer_key only when bifrost is set). (Gróa#4 + Regin#4) + test.
- cli _turn_id_from_sse_id crashed on a None/non-str sse_id (web guarded, cli didn't)
  → now tolerant. (Gróa#1 + Hulda#2) + test.
- _cancel_and_log broadened to `except Exception` — after the code-review's ApiError
  default, a cancel could raise SessionApiFailed it didn't catch, breaking INV-009
  (never-raise). (Gróa#3, Heid-endorsed over Regin's refuted mechanism).

Open-world degrade-not-crash (contract posture): render hardened — float duration_ms
(_format_duration_safe), non-mapping usage/snapshot guards, unknown event type
degrades instead of asserting (Gróa#5/#6 + Hulda#3); web _event_to_browser_payload
guards a non-mapping `raw` (Hulda#4); web _wt_client bearer extraction is now
case-insensitive + whitespace-robust (Hulda#5 + Regin#5). + render-degrade test.

Rejected (verified): Regin#1 (httpx IS caught), Regin#2 (wtsdk IS worldtree_sdk),
Regin#3 (sse_client.AgentNotAvailable IS caught by SseConnectFailed) — all FPs;
Hulda#1 (deleted funcs "break callers") — grep-verified zero callers pre-deletion.
Accepted-known-risk: lenient sse_id parse, CancelFailed status=0, async-gen aclose
(pre-existing pattern, not a cutover regression).

Suite 497 green; wt/cli/web ruff + wt mypy clean. Patch.
2026-07-19 07:09:26 -07:00
vh 74d41eb559 fix(#20): heid-code-review fixups — INV-CUT-2 completeness on cancel/stream (slice-2)
Triaged the heid-code-review panel (Gróa + Hulda substantive, Regin zero=weak).

Adopted (genuine adds):
- cancel_turn + stream_turn gain a defensive `except ApiError -> SessionApiFailed`
  default after their discriminated branches. INV-CUT-2 ("every ApiError is mapped;
  default SessionApiFailed") now holds STRUCTURALLY on those routes, not by coupling
  to the SDK's internal guarantee that it maps them to discriminated types. + tests.
- get_session_tools error-path test (symmetric with messages).
- Contract § Error map amended: added the stream ProtocolError rows
  (Malformed*/TurnIdFlip -> ratatoskr same-named), clarified the cancel row (the SDK
  RAISES the typed races -> ratatoskr exceptions, only a 200/cancelled=False is a
  CancelResult; caller surface stays exception-based per DEC-2), and noted the
  ApiError default holds on stream+cancel too.

Rejected (category-5, wrong-grounding) — 2/3 arms flagged create's bound-502 as
"should gate on error_code like list's 422+cursor_invalid". Verified against the SDK
parser (not in the arms' file set): the bound-502 body is
{"error_code":"bifrost_handshake_failed","detail":{"bifrost_error":...}}, and the
SDK's envelope parser PREFERS the nested detail (which lacks error_code), so
ApiError.error_code resolves to "unknown" — gating would REGRESS handshake detection
(the cli/web integration tests caught it). INV-002 also makes the handshake the sole
bound-502 cause. Kept the any-bound-502 mapping; documented WHY in code + contract.

Accepted-as-is: create_session -> Mapping annotation (intentional open-world
passthrough, already documented in the route-map note; category 3).

Suite 493 green; wt.py mypy + ruff clean. Patch.
2026-07-19 06:57:52 -07:00
vh 59602fe3ff refactor(#20): delete the orphaned hand-rolled turn-stream paths (slice-2, part 2b-iii)
DEC-4 live smoke PASSED first (personal :8081, b127/b128): create → streamed turn
that rendered (worker_phase/text/text_boundary/done with usage) → SIGINT cancel that
round-tripped to a cancelled terminal. With both CLI + web on the adapter, the
hand-rolled turn-stream family is fully orphaned — deleting it now.

- sse_client.py (714 → 224): removed stream_turn / reconnect_turn /
  stream_turn_resilient / cancel_turn + the Event dataclasses (Text/Done/…/Event
  union) + CancelResult + the SSE parse helpers (_iter_events / _envelope_for_type /
  _parse_sse_id / _eager_failure_fields / _INT_RE). KEPT: the caller-semantic
  exceptions (the adapter raises them, DEC-2), SseId, AdminEvent, stream_admin_events
  (slice-6 admin surface).
- sessions.py (677 → 608): removed list_sessions + get_session_tools (no surface
  users) + SessionPage. KEPT: create_session / get_session_messages (the
  --seed-first-message probe still uses them, slice-3) + all exceptions + SessionInfo.
- tests: test_sse_client pruned to TestStreamAdminEvents; test_sessions dropped the
  list_sessions + get_session_tools classes. The deleted turn-stream behavior is now
  covered by test_wt.py + the CLI/web integration tests + the live smoke.

Suite 490 green (570 − 80 deleted turn-stream tests); ruff clean on all touched
files; no new mypy errors. Patch (internal cleanup; behavior preserved).
2026-07-19 06:35:04 -07:00
vh 5c595b862d feat(#20): rewire the web turn surface onto the wt adapter (slice-2, part 2b-ii)
The Starlette endpoints (create / stream / cancel / tools / messages) now go through
ratatoskr.wt over the worldtree-sdk; the browser contract is preserved. This is the
last consumer of the hand-rolled turn-stream family — after this, stream_turn* /
cancel_turn are orphaned and get deleted in part 2b-iii (with the live smoke).

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

Suite 570 green; web/server.py + presentation test ruff-clean, mypy unchanged
(same pre-existing errors). Patch (internal; browser contract preserved).
2026-07-19 06:22:13 -07:00
14 changed files with 294 additions and 2217 deletions
@@ -158,18 +158,23 @@ others, they get their own row here — the default is NOT a general "any 404
| 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 |
| SDK `MalformedSseId` / `MalformedSseData` / `TurnIdFlip` (stream `ProtocolError`) | ratatoskr same-named types — same-name rewrap of the discriminated stream protocol errors |
| SDK `Cancel*` (cancel_turn) — the SDK RAISES the typed races | 404 `turn_not_found``CancelTurnNotFound`; 409 `turn_finished``CancelAlreadyCompleted`; other `CancelError``CancelFailed`. A 200 (incl. `cancelled=False`, the B-CAN-3 late-cancel no-op) returns a `CancelResult` — never raises. The caller surface stays exception-based (DEC-2; matches the pre-cutover CLI/web handlers). |
| `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(422 cursor_invalid)` on `sessions.list` | `InvalidCursor` (dual-key: status 422 AND error_code; the flat cursor body surfaces the code) |
| `ApiError(502)` on bound `sessions.create` | `BifrostHandshakeFailed` — NOT gated on error_code (unlike list's 422): INV-002, the synchronous handshake is the SOLE bound-502 cause; and the SDK's envelope parser prefers the nested `detail` (which carries `bifrost_error`, not `error_code`), so no distinguishing top-level `error_code` surfaces. The route+status IS the discriminator. |
| **`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.
leave an `ApiError` un-mapped. **This default holds on EVERY route, including the
stream and cancel** (each carries a defensive `except ApiError → SessionApiFailed`
after its discriminated branches — the SDK maps those routes to discriminated types
today, but the default guarantees INV-CUT-2 structurally, not by SDK-internal
coupling). Each slice adds/confirms its route's rows here before the old path is
deleted.
## Slice plan (incremental, DEC-4)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "ratatoskr"
version = "0.21.6"
version = "0.21.10"
description = "Worldtree Conversation API debug console (web + headless CLI) — multi-pane observability"
readme = "README.md"
requires-python = ">=3.12"
+40 -21
View File
@@ -62,9 +62,6 @@ from ratatoskr.sessions import (
# (--whoami / --characters / --set-persona / --seed-first-message) stay on the
# `sessions` wrappers until their own slices.
from ratatoskr.sse_client import (
CancelAlreadyCompleted,
CancelFailed,
CancelTurnNotFound,
MalformedSseData,
MalformedSseId,
SseConnectFailed,
@@ -347,21 +344,34 @@ def _format_usage(usage: dict[str, int], *, arrow: str) -> str:
return f"{p} in {arrow} {c} out ({t} total, {ci} cached)"
def _format_usage_safe(usage: Mapping[str, int] | None) -> str:
def _format_usage_safe(usage: object) -> 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`)."""
`DoneEvent.usage`: the canonical four-key mapping formats; anything absent or
malformed (None, a non-mapping like `5`, a partial dict) 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):
if isinstance(usage, Mapping) 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:
def _format_duration_safe(ms: object) -> str:
"""Tolerant wrapper over `_format_duration_ms` for the open-world
`DoneEvent.duration_ms`: a finite non-negative number formats (a float wire
value is floored to int); anything else degrades to `n/a` rather than tripping
`_format_duration_ms`'s int assertion."""
if isinstance(ms, (int, float)) and not isinstance(ms, bool) and ms >= 0:
return _format_duration_ms(int(ms))
return "n/a"
def _turn_id_from_sse_id(sse_id: object) -> 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)."""
top-level `turn_id`, which is the body field (absent on text/thinking events).
Tolerant of a malformed/absent sse_id (open-world) — mirrors the web helper."""
if not isinstance(sse_id, str):
return None
head, _, _ = sse_id.partition(":")
try:
turn = int(head)
@@ -389,14 +399,19 @@ class CliPresenterState:
malformed/partial event degrades to a placeholder rather than crashing the
presenter — the same posture as `_format_whoami`.
"""
assert isinstance(
if not isinstance(
event,
(
WorkerPhaseEvent, ThinkingEvent, TextEvent, TextBoundaryEvent,
ToolStartEvent, ToolResultEvent, DoneEvent, ErrorEvent, CancelledEvent,
AffectUpdateEvent, AwaitingLlmFirstTokenEvent,
),
)
):
# Open-world: an unknown / future SDK event type degrades to a one-line
# note rather than aborting the presenter. (The SDK skips unknown wire
# types today, so this is belt-and-suspenders for a future SDK event set.)
stderr.write(f". unknown_event: {type(event).__name__}\n")
return
# Thinking events accumulate into the open run.
if isinstance(event, ThinkingEvent):
content = event.content or ""
@@ -430,7 +445,7 @@ class CliPresenterState:
if isinstance(event, DoneEvent):
stderr.write(
f"[done] turn_id={event.turn_id} model={event.model} "
f"duration={_format_duration_ms(event.duration_ms or 0)} "
f"duration={_format_duration_safe(event.duration_ms)} "
f"usage {_format_usage_safe(event.usage)}\n"
)
return
@@ -470,7 +485,8 @@ class CliPresenterState:
if isinstance(event, AffectUpdateEvent):
# Worldtree #204 / v0.28.0. CLI surface is debug telemetry —
# one line to stderr with status + (for current) dominant_emotion.
if event.snapshot is not None:
# isinstance(Mapping) guards an open-world non-mapping snapshot.
if isinstance(event.snapshot, Mapping):
dom = event.snapshot.get("dominant_emotion")
stderr.write(
f". affect_update: status={event.status} turn_id={event.turn_id} "
@@ -505,13 +521,11 @@ async def _cancel_and_log(
assert isinstance(turn_id, int) and turn_id > 0
try:
await wt.cancel_turn(client, session_id, turn_id)
except (
CancelFailed,
CancelTurnNotFound,
CancelAlreadyCompleted,
ConnectFailed, # SDK normalizes a transport drop to ConnectFailed(status=0)
httpx.RequestError,
) as exc:
except Exception as exc:
# Any cancel failure (mapped ratatoskr cancel exceptions, an adapter-defaulted
# SessionApiFailed, an SDK ConnectFailed, a transport error, or anything the
# SDK doesn't normalize) is logged and swallowed — the fire-and-forget cancel
# must never propagate into _run_turn's finally.
stderr.write(f"[cancel_failed] {type(exc).__name__}: {exc}\n")
@@ -574,6 +588,11 @@ async def _run_turn(
except StopAsyncIteration:
stderr.write("[connection_dropped] last_seen=<none>\n")
return 21
except wt.SessionApiFailed as exc:
# The adapter maps a stream-open SessionRetired (410) here; without
# this the retired-session stream would crash out of _run_turn.
stderr.write(f"[session_api_failed] status={exc.status} body={exc.body!r}\n")
return 20
except SseConnectFailed as exc:
stderr.write(f"[sse_connect_failed] status={exc.status} body={exc.body!r}\n")
return 20
-69
View File
@@ -38,12 +38,6 @@ class SessionInfo:
config: dict[str, Any] | None = None
@dataclass(frozen=True)
class SessionPage:
"""One page of GET /sessions results. `next_cursor=None` on the last page."""
items: list[SessionInfo]
next_cursor: str | None
@dataclass(frozen=True)
@@ -204,53 +198,6 @@ class AuthoredHistoryUnavailable(Exception):
self.session_id = session_id
async def list_sessions(
client: httpx.AsyncClient,
*,
include_archived: bool = False,
limit: int = 50,
cursor: str | None = None,
) -> SessionPage:
"""GET /sessions. See contract FN list_sessions."""
assert client is not None
assert 1 <= limit <= 200
assert cursor is None or (isinstance(cursor, str) and cursor)
params: dict[str, str] = {"limit": str(limit)}
if include_archived:
params["include_archived"] = "true"
if cursor is not None:
params["cursor"] = cursor
resp = await client.get("/sessions", params=params)
if resp.status_code == 422:
try:
err = resp.json()
except ValueError:
err = {}
if err.get("error_code") == "cursor_invalid":
raise InvalidCursor(raw=cursor)
raise SessionApiFailed(status=422, body=resp.content)
if resp.status_code != 200:
raise SessionApiFailed(status=resp.status_code, body=resp.content)
body = resp.json()
items = [
SessionInfo(
session_id=item["session_id"],
agent_id=item["agent_id"],
created_at=item["created_at"],
last_active=item["last_active"],
metadata=item.get("metadata", {}),
message_count=None,
name=item.get("name"),
archived=item.get("archived") or False,
tags=item.get("tags") or [],
kind=item.get("kind"), # INV-002 amendment (#161): present on list items
config=item.get("config"), # forward-compat passthrough; None today
)
for item in body["items"]
]
return SessionPage(items=items, next_cursor=body.get("next_cursor"))
def endpoint_for_plane(plane: str, base_host: str) -> str:
@@ -572,22 +519,6 @@ async def get_session_bifrost(
raise SessionApiFailed(status=resp.status_code, body=resp.content)
async def get_session_tools(client: httpx.AsyncClient, session_id: str) -> dict[str, Any]:
"""GET /sessions/{session_id}/tools — owner-scoped tool inventory (spec #183).
Returns the merged tool list the LLM saw at turn-fire: `{agent_id,
builtin_tools: [...], bifrost_tools: [{name, description, parameters}, ...]}`.
Owner-scoped (`ctx.user_id == session.user_id`) — reachable with the consumer
key, NO admin scope. Cross-owner access returns 404 `session_not_found`
(existence-hiding); a revoked session returns 401 `auth_revoked`. Parsed dict
verbatim; any non-200 → SessionApiFailed (mirrors get_persona_state).
"""
assert client is not None
assert session_id and isinstance(session_id, str)
resp = await client.get(f"/sessions/{session_id}/tools")
if resp.status_code == 200:
return resp.json()
raise SessionApiFailed(status=resp.status_code, body=resp.content)
async def get_capabilities(client: httpx.AsyncClient) -> dict[str, Any]:
-491
View File
@@ -6,7 +6,6 @@ Implements docs/contracts/issues/1.contract.md.
from __future__ import annotations
import json
import re
from collections.abc import AsyncIterator
from dataclasses import dataclass
from typing import Any, NamedTuple
@@ -14,8 +13,6 @@ from typing import Any, NamedTuple
import httpx
import httpx_sse
_INT_RE = re.compile(r"^-?\d+$")
class SseId(NamedTuple):
"""Parsed composite SSE wire `id:` per spec §SSE id format."""
@@ -24,155 +21,6 @@ class SseId(NamedTuple):
seq: int
@dataclass(frozen=True)
class WorkerPhase:
"""SSE event `worker_phase`: agent entered a new processing phase."""
sse_id: SseId
phase: str
turn_id: int
@dataclass(frozen=True)
class Thinking:
"""SSE event `thinking`: incremental thinking content from thinking-enabled models."""
sse_id: SseId
content: str
@dataclass(frozen=True)
class Text:
"""SSE event `text`: an incremental response-text delta."""
sse_id: SseId
content: str
@dataclass(frozen=True)
class TextBoundary:
"""SSE event `text_boundary`: speakable breakpoint after a `text` event."""
sse_id: SseId
kind: str
char_offset: int
ts: str
@dataclass(frozen=True)
class ToolStart:
"""SSE event `tool_start`: agent is about to execute a tool."""
sse_id: SseId
name: str
arguments: dict[str, Any]
@dataclass(frozen=True)
class ToolResult:
"""SSE event `tool_result`: a tool call completed."""
sse_id: SseId
name: str
result: Any
duration_ms: int
@dataclass(frozen=True)
class Done:
"""Terminal SSE event `done`: turn succeeded."""
sse_id: SseId
phase: str
response: str
model: str
duration_ms: int
usage: dict[str, int]
@dataclass(frozen=True)
class Error:
"""Terminal SSE event `error`: turn failed."""
sse_id: SseId
phase: str
message: str
error_code: str | None
@dataclass(frozen=True)
class Cancelled:
"""Terminal SSE event `cancelled`: turn was cancelled server-side."""
sse_id: SseId
phase: str
turn_id: int
reason: str | None
partial_message_id: int | None
@dataclass(frozen=True)
class AwaitingLlmFirstToken:
"""SSE event `awaiting_llm_first_token`: heartbeat during slow first-token.
Fires at the configured interval (default 5s) during the gap between
`worker_phase` phase=BuildingPrompt and phase=CallingLLM. Lets clients
render a live "thinking for Ns…" indicator instead of a frozen line
during legitimate-slow first-token latency. Stops the moment CallingLLM
fires (defense-in-depth at three sites); no heartbeat after Cancelled
or stalled terminal events. Tool round-trip re-entries do NOT re-fire
heartbeats — INV-201-5 scopes the mechanism to the FIRST gap only.
`elapsed_ms_since_building_prompt` is server-authoritative
`time.monotonic()`-based — independent of network latency or clock
skew, monotonically increasing across the heartbeat sequence.
See docs/conversation-api-spec.md § awaiting_llm_first_token
(Worldtree #201, v0.29.0).
"""
sse_id: SseId
turn_id: int
elapsed_ms_since_building_prompt: float
@dataclass(frozen=True)
class AffectUpdate:
"""SSE event `affect_update`: persona-state observability snapshot.
Two emissions per qualifying turn (persona-enabled agent on non-
ephemeral session): `status="current"` at turn start carrying the full
snapshot, `status="scheduled"` after post-turn appraisal kicks off
(lightweight — `snapshot` is None). Suppressed entirely for persona-
disabled agents (e.g. `domari`, `muninn`), Tier 3 consumer-defined
agents (Phase 2.0), and ephemeral sessions.
Bootstrap reads available via `GET /agents/{agent_id}/persona_state`
(same `snapshot` shape, requires `persona.read` scope).
See docs/conversation-api-spec.md § affect_update (Worldtree #204,
v0.28.0).
"""
sse_id: SseId
status: str # "current" | "scheduled"
turn_id: int
snapshot: dict[str, Any] | None # None when status="scheduled"
Event = (
WorkerPhase
| Thinking
| Text
| TextBoundary
| ToolStart
| ToolResult
| Done
| Error
| Cancelled
| AffectUpdate
| AwaitingLlmFirstToken
)
@dataclass(frozen=True)
@@ -258,31 +106,6 @@ class TurnLaunchUnavailable(SseConnectFailed):
self.message = message
# Canonical error_codes (Worldtree #331 / v1.0.0b2): 409 -> agent_not_available,
# 503 -> not_ready (retryable; re-pinned from internal_error). Used only as a
# fallback default when the body omits error_code — the real code is surfaced
# verbatim from the {detail:{error_code,message}} envelope.
_EAGER_TURN_FAILURE_CODE = {409: "agent_not_available", 503: "not_ready"}
def _eager_failure_fields(body: bytes, status: int) -> tuple[str, str]:
"""Extract (error_code, message) from an eager turn-launch failure body
(#331). Accepts the Worldtree `{"detail": {...}}` envelope OR a flat
`{error_code, message}`; falls back to a status-derived default code and a
generic message when the body is absent / non-JSON / malformed."""
try:
parsed: Any = json.loads(body)
except (json.JSONDecodeError, ValueError):
parsed = None
src: dict[str, Any] = {}
if isinstance(parsed, dict):
detail = parsed.get("detail")
src = detail if isinstance(detail, dict) else parsed
code = src.get("error_code") or _EAGER_TURN_FAILURE_CODE[status]
message = src.get("message")
if not isinstance(message, str):
message = f"turn launch failed (HTTP {status})"
return str(code), message
class SseConnectionDropped(Exception):
@@ -352,275 +175,6 @@ class CancelFailed(Exception):
self.body = body
@dataclass(frozen=True)
class CancelResult:
"""Response envelope from POST /sessions/{id}/turns/{turn_id}/cancel."""
turn_id: int
cancelled: bool
reason: str | None
partial_message_id: int | None
def _envelope_for_type(body: dict[str, Any], sse_id: SseId) -> Event:
"""Dispatch a parsed JSON body to its typed Event variant."""
t = body["type"]
if t == "text":
return Text(sse_id=sse_id, content=body["content"])
if t == "worker_phase":
return WorkerPhase(sse_id=sse_id, phase=body["phase"], turn_id=body["turn_id"])
if t == "thinking":
return Thinking(sse_id=sse_id, content=body["content"])
if t == "text_boundary":
return TextBoundary(
sse_id=sse_id,
kind=body["kind"],
char_offset=body["char_offset"],
ts=body["ts"],
)
if t == "tool_start":
return ToolStart(sse_id=sse_id, name=body["name"], arguments=body["arguments"])
if t == "tool_result":
return ToolResult(
sse_id=sse_id,
name=body["name"],
result=body["result"],
duration_ms=body["duration_ms"],
)
if t == "done":
return Done(
sse_id=sse_id,
phase=body["phase"],
response=body["response"],
model=body["model"],
duration_ms=body["duration_ms"],
usage=body["usage"],
)
if t == "error":
return Error(
sse_id=sse_id,
phase=body.get("phase", "failed"),
message=body.get("message", ""),
error_code=body.get("error_code"),
)
if t == "cancelled":
return Cancelled(
sse_id=sse_id,
phase=body["phase"],
turn_id=body["turn_id"],
reason=body.get("reason"),
partial_message_id=body.get("partial_message_id"),
)
if t == "awaiting_llm_first_token":
# Worldtree #201 / v0.29.0: top-level heartbeat during BuildingPrompt
# → CallingLLM gap. Lets clients render live elapsed-time indicators
# instead of frozen lines on legitimate-slow first-token latency.
return AwaitingLlmFirstToken(
sse_id=sse_id,
turn_id=body["turn_id"],
elapsed_ms_since_building_prompt=body["elapsed_ms_since_building_prompt"],
)
if t == "affect_update":
# Worldtree #204 / v0.28.0: persona-state observability event.
# status="current" carries full snapshot at turn start;
# status="scheduled" omits snapshot (lightweight post-appraisal-
# kickoff notification).
return AffectUpdate(
sse_id=sse_id,
status=body["status"],
turn_id=body["turn_id"],
snapshot=body.get("snapshot"),
)
raise ValueError(f"unknown SSE event type: {t!r}")
async def _iter_events(
event_source: httpx_sse.EventSource,
*,
expected_turn_id: int | None,
) -> AsyncIterator[Event]:
"""Apply INV-002 (sse_id present + in range) and INV-003 (turn_id stable) per event.
`expected_turn_id=None` means "establish from the first event" (stream_turn semantics).
`expected_turn_id=N` means "every event must match N" (reconnect_turn semantics — the
first event is already a flip-candidate per INV-003).
"""
established = expected_turn_id
last_sse_id: SseId | None = None
terminal_seen = False
try:
async for sse in event_source.aiter_sse():
# Issue #7 INV-001: empty-data frames are keepalives — skip silently.
# ORDERING: this branch fires BEFORE _parse_sse_id; an empty-data event
# with a malformed id is silently swallowed (intentional — a keepalive
# with a bad id is still a keepalive). Don't reorder.
if sse.data == "":
continue
# v0.8.1: empty-id frames are also treated as keepalives. Worldtree
# SOMETIMES emits events without an `id:` line (observed mid-stream
# on the qwen3.6-35-a3b-heretic provider, 2026-05-25). Per the SSE
# RFC, events without ids are legitimate (they just don't update
# Last-Event-ID); the previous strict behavior crashed every turn
# on the offending agent. Treat same as empty-data: skip silently.
if sse.id == "":
continue
try:
sse_id = _parse_sse_id(sse.id)
except ValueError as exc:
raise MalformedSseId(raw=sse.id) from exc
if established is None:
established = sse_id.turn_id
elif sse_id.turn_id != established:
raise TurnIdFlip(established=established, got=sse_id.turn_id)
try:
body = json.loads(sse.data)
except json.JSONDecodeError as exc:
raise MalformedSseData(raw=sse.data) from exc
event = _envelope_for_type(body, sse_id=sse_id)
yield event
last_sse_id = sse_id
if isinstance(event, (Done, Error, Cancelled)):
terminal_seen = True
return
except (httpx.ReadError, httpx.RemoteProtocolError, httpx.ReadTimeout) as exc:
# ReadTimeout covers idle gaps that exceed httpx's read timeout — the SSE
# stream went quiet long enough for httpx to give up. Treat the same as a
# raw read error: surface as SseConnectionDropped so the caller can decide
# whether to reconnect_turn. (Callers SHOULD configure a long-or-disabled
# read timeout on their AsyncClient for SSE; this is defense in depth.)
raise SseConnectionDropped(last_seen_sse_id=last_sse_id) from exc
if not terminal_seen:
# Clean EOF before terminal event — INV-001 says stream MUST NOT end
# without exactly one Done/Error/Cancelled. Surface as connection drop;
# caller may reconnect_turn if it holds last_sse_id.
raise SseConnectionDropped(last_seen_sse_id=last_sse_id)
async def stream_turn(
client: httpx.AsyncClient, session_id: str, content: str
) -> AsyncIterator[Event]:
"""POST a message and yield typed Events. See contract FN stream_turn."""
assert client is not None
assert session_id and isinstance(session_id, str)
assert content and isinstance(content, str)
async with httpx_sse.aconnect_sse(
client,
"POST",
f"/sessions/{session_id}/messages",
json={"content": content},
) as event_source:
# Worldtree v1.0.0b1 (#331): turn-launch failures arrive EAGERLY as a
# status before any stream — 409 agent_not_available (pre-b1 this was a
# 200 + in-stream `error` event), 503 a transient retryable launch
# failure. Surface them as typed SseConnectFailed subclasses carrying
# error_code; request-level non-2xx (404 session_not_found, etc.) stay
# generic SseConnectFailed.
status = event_source.response.status_code
if status in (409, 503):
body = await event_source.response.aread()
code, message = _eager_failure_fields(body, status)
if status == 409:
raise AgentNotAvailable(body=body, error_code=code, message=message)
raise TurnLaunchUnavailable(body=body, error_code=code, message=message)
try:
event_source.response.raise_for_status()
except httpx.HTTPStatusError as exc:
body = await exc.response.aread()
raise SseConnectFailed(status=exc.response.status_code, body=body) from exc
async for event in _iter_events(event_source, expected_turn_id=None):
yield event
async def reconnect_turn(
client: httpx.AsyncClient,
session_id: str,
content: str,
last_event_id: str,
) -> AsyncIterator[Event]:
"""Re-POST with Last-Event-ID to resume. See contract FN reconnect_turn."""
assert client is not None
assert session_id and isinstance(session_id, str)
assert isinstance(content, str)
expected = _parse_sse_id(last_event_id)
async with httpx_sse.aconnect_sse(
client,
"POST",
f"/sessions/{session_id}/messages",
json={"content": content},
headers={"Last-Event-ID": last_event_id},
) as event_source:
status = event_source.response.status_code
if status != 200:
body_bytes = await event_source.response.aread()
try:
body = json.loads(body_bytes)
except json.JSONDecodeError:
body = {}
if status == 400:
raise InvalidLastEventId(raw=last_event_id)
if status == 410:
raise ResumeTurnFinished(turn_id=body.get("turn_id", expected.turn_id))
if status == 412:
raise ResumeBufferExpired(
turn_id=body.get("turn_id", expected.turn_id),
buffered_from_seq=body.get("buffered_from_seq", 0),
)
raise SseConnectFailed(status=status, body=body_bytes)
async for event in _iter_events(event_source, expected_turn_id=expected.turn_id):
yield event
async def stream_turn_resilient(
client: httpx.AsyncClient,
session_id: str,
content: str,
*,
max_reconnects: int = 5,
) -> AsyncIterator[Event]:
"""Resume-orchestration wrapper over stream_turn + reconnect_turn.
Yields ONE continuous Event stream; on `SseConnectionDropped` (mid-stream
drop or clean EOF before a terminal), resumes from the last-seen `sse_id`
via `reconnect_turn`, up to `max_reconnects` times, until a terminal
Done/Error/Cancelled arrives. The single shared surface presenters consume
for resilient streaming (design-brief §8b: "share the consumer, branch the
presenter"). Cross-process resume stays deferred to v2 (§8d): `last_seen`
lives only in this generator's frame. See contract FN stream_turn_resilient
(amendment 2026-06-30).
"""
assert client is not None
assert session_id and isinstance(session_id, str)
assert content and isinstance(content, str)
assert isinstance(max_reconnects, int) and max_reconnects >= 0
last_seen: SseId | None = None
reconnects = 0
gen = stream_turn(client, session_id, content)
while True:
try:
async for event in gen:
last_seen = event.sse_id
yield event
return # generator completed cleanly → terminal event reached (INV-001)
except SseConnectionDropped as drop:
# Prefer the id we tracked from a yielded event; fall back to the one
# the drop carries (covers a drop on the very first frame). Non-drop
# reconnect failures (412/410/400/flip) are NOT caught here — they
# propagate per the contract's "surface, not recover" policy.
seen = last_seen or drop.last_seen_sse_id
if seen is None or reconnects >= max_reconnects:
raise
reconnects += 1
gen = reconnect_turn(
client,
session_id,
content,
# 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}",
)
async def stream_admin_events(
client: httpx.AsyncClient,
@@ -667,48 +221,3 @@ async def stream_admin_events(
raise SseConnectionDropped(last_seen_sse_id=None) from exc
def _parse_sse_id(raw: str) -> SseId:
"""Parse the SSE wire `id:` as composite `{turn_id}:{seq}`. See contract FN _parse_sse_id."""
assert isinstance(raw, str)
parts = raw.split(":")
if len(parts) != 2:
raise ValueError(f"expected '{{turn_id}}:{{seq}}', got: {raw[:64]!r}")
turn_id_str, seq_str = parts
if not _INT_RE.match(turn_id_str) or not _INT_RE.match(seq_str):
raise ValueError(f"expected '{{turn_id}}:{{seq}}' with decimal ints, got: {raw[:64]!r}")
turn_id = int(turn_id_str)
seq = int(seq_str)
if turn_id < 1 or seq < 1:
raise ValueError(f"expected both ints >= 1 per spec, got: {raw[:64]!r}")
return SseId(turn_id=turn_id, seq=seq)
async def cancel_turn(
client: httpx.AsyncClient,
session_id: str,
turn_id: int,
*,
persist_partial: bool = False,
) -> CancelResult:
"""POST /sessions/{id}/turns/{turn_id}/cancel. See contract FN cancel_turn."""
assert client is not None
assert session_id and isinstance(session_id, str)
assert isinstance(turn_id, int) and turn_id > 0
params = {"persist_partial": "true"} if persist_partial else None
resp = await client.post(
f"/sessions/{session_id}/turns/{turn_id}/cancel", params=params
)
if resp.status_code == 404:
raise CancelTurnNotFound(turn_id=turn_id)
if resp.status_code == 409:
raise CancelAlreadyCompleted(turn_id=turn_id)
if resp.status_code != 200:
raise CancelFailed(status=resp.status_code, body=resp.content)
body = resp.json()
return CancelResult(
turn_id=body["turn_id"],
cancelled=body["cancelled"],
reason=body.get("reason"),
partial_message_id=body.get("partial_message_id"),
)
+95 -58
View File
@@ -11,7 +11,7 @@ from __future__ import annotations
import asyncio
import itertools
import json
from collections.abc import AsyncIterator, Callable
from collections.abc import AsyncIterator, Callable, Mapping
from dataclasses import asdict, dataclass, is_dataclass
from importlib.metadata import version as _pkg_version
@@ -26,8 +26,10 @@ from starlette.responses import (
)
from starlette.routing import Mount, Route
from starlette.staticfiles import StaticFiles
from worldtree_sdk import CancelledEvent, DoneEvent, ErrorEvent, WorldtreeClient
from ratatoskr import local_agents as _local_agents
from ratatoskr import wt
from ratatoskr.first_message import seed_preset_first_message
from ratatoskr.sessions import (
AgentNotAvailable,
@@ -38,33 +40,47 @@ from ratatoskr.sessions import (
BifrostHandshakeFailed,
PersonaNotConfigured,
SessionApiFailed,
create_session,
endpoint_for_plane,
get_persona_state,
get_session_bifrost,
get_session_messages,
get_session_tools,
list_agents,
)
# The turn path (create / stream / cancel / tools / messages) is served by the
# worldtree-sdk adapter (`wt.*`), which raises ratatoskr's caller-semantic
# exceptions (DEC-2). The hand-rolled endpoints (persona / agents / admin /
# bifrost) stay on the `sessions` / `sse_client` wrappers until their own slices.
from ratatoskr.sse_client import (
AdminEvent,
CancelAlreadyCompleted,
CancelFailed,
Cancelled,
CancelTurnNotFound,
Done,
Error,
MalformedSseData,
MalformedSseId,
SseConnectFailed,
SseConnectionDropped,
TurnIdFlip,
cancel_turn,
stream_admin_events,
stream_turn_resilient,
)
def _wt_client(client: httpx.AsyncClient, *, max_reconnects: int = 5) -> WorldtreeClient:
"""Wrap a client_factory transport as the adapter's WorldtreeClient (INV-CUT-1:
the SDK never closes it). base_url + bearer are read off the transport (the
factory bakes them in); the SDK re-applies auth per request, so the extracted
key just mirrors the transport's default. A no-auth test transport falls back to
a placeholder key (respx ignores auth)."""
base_url = str(client.base_url) or "http://localhost"
header = client.headers.get("Authorization", "")
# Case-insensitive scheme + tolerant of extra whitespace, so a valid bearer is
# not silently dropped to the placeholder key (which would misauthenticate).
parts = header.split(None, 1)
api_key = parts[1].strip() if len(parts) == 2 and parts[0].lower() == "bearer" else ""
return wt.build_client(
base_url, api_key=api_key or "ratatoskr", transport=client, max_reconnects=max_reconnects
)
def _static_dir() -> str:
"""Locate the bundled static/ directory inside the installed package.
@@ -163,16 +179,17 @@ async def _create_session_endpoint(request: Request) -> JSONResponse:
try:
async with client_factory() as client:
info = await create_session(
client,
info = await wt.create_session(
_wt_client(client),
agent_id,
end_user_id=end_user_id,
bifrost=bifrost,
consumer_key=consumer_key if bifrost else None,
)
# #347 authored first-message: seed the agent's preset opening
# (best-effort; never blocks create — see first_message INV-001).
await seed_preset_first_message(client, info.session_id, agent_id)
# #347 authored first-message: seed the agent's preset opening (best-effort;
# never blocks create). first_message is a slice-3 hand-rolled path — it
# reuses the raw transport (its default bearer), not the adapter client.
await seed_preset_first_message(client, info["session_id"], agent_id)
except AgentNotFound:
return JSONResponse({"error_code": "agent_not_found"}, status_code=404)
except BifrostConsumerKeyMissing:
@@ -188,12 +205,13 @@ async def _create_session_endpoint(request: Request) -> JSONResponse:
},
status_code=502,
)
except SessionApiFailed as exc:
except wt.SessionApiFailed as exc:
return JSONResponse(
{"error_code": "session_api_failed", "status": exc.status},
status_code=exc.status,
)
payload = _as_dict(info)
# The adapter returns the SDK's open-world create dict; the browser reads it as-is.
payload = dict(info)
if bifrost is not None:
# Bound-state for the UI indicator — plane + endpoint only, never the key.
payload["bifrost"] = {
@@ -251,25 +269,22 @@ async def _submit_turn_endpoint(request: Request) -> JSONResponse:
def _event_to_browser_payload(event: object) -> tuple[str, dict]:
"""Serialize an upstream Event dataclass to (browser_event_type, json_dict).
"""Serialize an SDK `TurnEvent` to (browser_event_type, json_dict).
Per INV-008 + FN stream_turn_endpoint STEP 3. The dict shape is
locked by tests/fixtures/presentation_contract.json — one entry per
Event type. Implementation: snake_case class name as event_type;
asdict(event) with sse_id flattened to "T:S" string.
Per INV-008 + FN stream_turn_endpoint STEP 3. The browser contract
(tests/fixtures/presentation_contract.json) is preserved: the SDK's `raw` is
the wire body — the same per-type field set the old dataclasses carried — so the
payload is `raw` minus the redundant `type`, plus the composite `sse_id` string
(already "T:S"). The browser event_type is the wire `type` ("text" / "done" /
…), NOT the SDK class name. Open-world: additive server fields pass through.
"""
type_name = type(event).__name__
# CamelCase → snake_case
browser_type = "".join(
("_" + c.lower() if c.isupper() and i else c.lower())
for i, c in enumerate(type_name)
)
data = asdict(event) # type: ignore[arg-type]
sse_id = data.get("sse_id")
if isinstance(sse_id, (list, tuple)) and len(sse_id) == 2:
data["sse_id"] = f"{sse_id[0]}:{sse_id[1]}"
elif isinstance(sse_id, dict) and "turn_id" in sse_id and "seq" in sse_id:
data["sse_id"] = f"{sse_id['turn_id']}:{sse_id['seq']}"
browser_type = getattr(event, "type", "") or ""
raw = getattr(event, "raw", None)
# Open-world: degrade a non-mapping `raw` to an empty payload rather than letting
# dict(raw) raise (which would abort the SSE stream mid-response).
src = raw if isinstance(raw, Mapping) else {}
data = {k: v for k, v in src.items() if k != "type"}
data["sse_id"] = getattr(event, "sse_id", None)
return browser_type, data
@@ -281,6 +296,20 @@ def _format_sse(event_type: str, data: dict) -> bytes:
return f"event: {event_type}\ndata: {json.dumps(data)}\n\n".encode()
def _turn_id_from_sse_id(sse_id: object) -> int | None:
"""The turn component of the SDK's composite sse_id (`"{turn}:{seq}"`) — the
upstream cancel target, present on every frame (the SDK's top-level `turn_id` is
the body field, absent on text/thinking events)."""
if not isinstance(sse_id, str):
return None
head, _, _ = sse_id.partition(":")
try:
turn = int(head)
except ValueError:
return None
return turn if turn > 0 else None
async def _stream_turn_endpoint(request: Request) -> StreamingResponse:
"""GET /api/turns/{session_id}/stream?turn_id=N → proxy upstream SSE.
@@ -302,24 +331,28 @@ async def _stream_turn_endpoint(request: Request) -> StreamingResponse:
async def gen() -> AsyncIterator[bytes]:
client = client_factory()
wt_client = _wt_client(client)
try:
handle.status = "streaming"
try:
async for event in stream_turn_resilient(client, session_id, handle.content):
# v0.16.0: capture the upstream (Worldtree-assigned)
# turn_id from the first event so cancel paths target
# the real upstream turn, not our local counter.
async for event in wt.stream_turn(wt_client, session_id, handle.content):
# v0.16.0: capture the upstream (Worldtree-assigned) turn_id from
# the first event so cancel paths target the real upstream turn,
# not our local counter — parsed from the composite sse_id.
if handle.upstream_turn_id is None:
sse_id = getattr(event, "sse_id", None)
if sse_id is not None:
handle.upstream_turn_id = sse_id.turn_id
handle.upstream_turn_id = _turn_id_from_sse_id(
getattr(event, "sse_id", None)
)
event_type, data = _event_to_browser_payload(event)
yield _format_sse(event_type, data)
if isinstance(event, (Done, Error, Cancelled)):
handle.status = type(event).__name__.lower()
if isinstance(event, (DoneEvent, ErrorEvent, CancelledEvent)):
handle.status = event.type or "done"
break
except (SseConnectFailed, SseConnectionDropped, MalformedSseId,
MalformedSseData, TurnIdFlip) as exc:
except (wt.SessionApiFailed, SseConnectFailed, SseConnectionDropped,
MalformedSseId, MalformedSseData, TurnIdFlip) as exc:
# wt.SessionApiFailed covers the adapter's SessionRetired (410) mapping;
# without it a retired-session stream would escape gen() after partial
# frames as an uncaught 500, not a labeled `event: error`.
yield _format_sse(
"error",
{"exception": type(exc).__name__, "message": str(exc)},
@@ -330,7 +363,7 @@ async def _stream_turn_endpoint(request: Request) -> StreamingResponse:
# turn (if it started) — never the local turn_id.
if handle.status == "streaming" and handle.upstream_turn_id is not None:
try:
await cancel_turn(client, session_id, handle.upstream_turn_id)
await wt.cancel_turn(wt_client, session_id, handle.upstream_turn_id)
except (CancelAlreadyCompleted, CancelTurnNotFound):
pass # cooperative race — turn already terminal upstream
except Exception as exc:
@@ -377,15 +410,18 @@ async def _cancel_turn_endpoint(request: Request) -> JSONResponse:
client_factory = request.app.state.client_factory
try:
async with client_factory() as client:
await cancel_turn(client, session_id, handle.upstream_turn_id)
body = {"cancelled": True}
result = await wt.cancel_turn(
_wt_client(client), session_id, handle.upstream_turn_id
)
body = {"cancelled": bool(result.cancelled)}
except (CancelAlreadyCompleted, CancelTurnNotFound):
body = {"cancelled": False, "reason": "race_or_completed"}
except CancelFailed as exc:
except CancelFailed:
# The SDK abstracts the upstream cancel HTTP status; surface a generic 502.
registry.pop((session_id, turn_id), None)
return JSONResponse(
{"error_code": "cancel_failed", "status": exc.status},
status_code=exc.status,
{"error_code": "cancel_failed"},
status_code=502,
)
registry.pop((session_id, turn_id), None)
return JSONResponse(body, status_code=200)
@@ -465,13 +501,13 @@ async def _session_tools_endpoint(request: Request) -> JSONResponse:
client_factory = request.app.state.client_factory
try:
async with client_factory() as client:
info = await get_session_tools(client, session_id)
except SessionApiFailed as exc:
info = await wt.get_session_tools(_wt_client(client), session_id)
except wt.SessionApiFailed as exc:
return JSONResponse(
{"error_code": "session_tools_unavailable", "status": exc.status},
status_code=exc.status,
)
return JSONResponse(info, status_code=200)
return JSONResponse(dict(info), status_code=200)
async def _session_messages_endpoint(request: Request) -> JSONResponse:
@@ -485,13 +521,13 @@ async def _session_messages_endpoint(request: Request) -> JSONResponse:
client_factory = request.app.state.client_factory
try:
async with client_factory() as client:
data = await get_session_messages(client, session_id)
except SessionApiFailed as exc:
data = await wt.get_session_messages(_wt_client(client), session_id)
except wt.SessionApiFailed as exc:
return JSONResponse(
{"error_code": "session_messages_unavailable", "status": exc.status},
status_code=exc.status,
)
return JSONResponse(data, status_code=200)
return JSONResponse(dict(data), status_code=200)
async def _session_bifrost_endpoint(request: Request) -> JSONResponse:
@@ -609,14 +645,15 @@ def create_app(
]
if in_flight:
client = client_factory()
wt_client = _wt_client(client)
try:
task_to_handle = {
asyncio.create_task(
cancel_turn(client, h.session_id, h.upstream_turn_id)
wt.cancel_turn(wt_client, h.session_id, h.upstream_turn_id)
): h
for h in in_flight
}
done, pending = await asyncio.wait(task_to_handle, timeout=5.0)
_done, pending = await asyncio.wait(task_to_handle, timeout=5.0)
# Per-pending session/turn detail (INV-006 logging fidelity).
for task in pending:
h = task_to_handle[task]
+20 -1
View File
@@ -193,10 +193,22 @@ async def create_session(
body["bifrost"] = {"endpoint_url": bifrost.endpoint_url, "scope": bifrost.scope}
try:
return await client.sessions.create(body, consumer_key=consumer_key)
# consumer_key is a BOUND-create credential only — never forward it on an
# unbound create, or the SDK's credential precedence (consumer_key > default)
# would authenticate as the Bifrost consumer instead of the default bearer.
# Centralized here so both surfaces are guarded (the web endpoint already is).
return await client.sessions.create(
body, consumer_key=consumer_key if bifrost is not None else None
)
except ApiError as exc:
if exc.status == 404:
raise AgentNotFound(agent_id=agent_id) from exc
# NOT gated on error_code (unlike list's 422+cursor_invalid): INV-002 — a 502
# on a BOUND create IS the synchronous Bifrost handshake failing, the sole
# bound-502 cause; and the SDK does not surface a distinguishing top-level
# error_code here (its envelope parser prefers the nested `detail`, which
# carries `bifrost_error`, not `error_code`). The nested bifrost_error is
# extracted for the exception; the route+status is the discriminator.
if bifrost is not None and exc.status == 502:
raise BifrostHandshakeFailed(
bifrost_error=_bifrost_error_from_body(exc.body),
@@ -299,6 +311,10 @@ async def stream_turn(
raise MalformedSseData(raw=exc.raw) from exc
except wtsdk.TurnIdFlip as exc:
raise TurnIdFlip(established=exc.established, got=exc.got) from exc
except ApiError as exc:
# INV-CUT-2 default: an undiscriminated ApiError surfacing from the stream →
# SessionApiFailed (the discriminated stream errors are handled above).
raise translate_error(exc) from exc
async def cancel_turn(
@@ -323,3 +339,6 @@ async def cancel_turn(
raise CancelFailed(
status=0, body=(getattr(exc, "message", "") or str(exc)).encode()
) from exc
except ApiError as exc:
# INV-CUT-2 default: an undiscriminated ApiError on this route → SessionApiFailed.
raise translate_error(exc) from exc
+48
View File
@@ -647,6 +647,39 @@ class TestCliPresenterState:
state.render(_make_done(duration_ms=72000), stdout=io.StringIO(), stderr=stderr)
assert "duration=1.2m" in stderr.getvalue()
def test_render_degrades_on_malformed_open_world_fields(self) -> None:
"""Open-world hardening (heid-bug-hunt Gróa#5 / Hulda#3): a DoneEvent with a
float duration_ms + a non-mapping usage, and an AffectUpdate with a non-mapping
snapshot, DEGRADE rather than crash the presenter."""
from ratatoskr.cli import CliPresenterState
stderr = io.StringIO()
state = CliPresenterState()
done = build_event(
"done", "42:9", 42,
{"type": "done", "duration_ms": 1234.0, "usage": 5, "model": "m"},
)
state.render(done, stdout=io.StringIO(), stderr=stderr) # must not raise
out = stderr.getvalue()
# float duration floored to int (1234ms → "1.2s"); non-mapping usage → "(n/a)".
assert "[done]" in out and "duration=1.2s" in out and "usage (n/a)" in out
# AffectUpdate with a list snapshot → no AttributeError on .get.
affect = build_event(
"affect_update", "42:1", 42,
{"type": "affect_update", "status": "current", "snapshot": []},
)
CliPresenterState().render(affect, stdout=io.StringIO(), stderr=io.StringIO())
def test_turn_id_from_sse_id_tolerates_non_str(self) -> None:
"""Open-world hardening (heid-bug-hunt Gróa#1 / Hulda#2): a None/non-str sse_id
yields None instead of crashing on .partition."""
from ratatoskr.cli import _turn_id_from_sse_id
assert _turn_id_from_sse_id(None) is None
assert _turn_id_from_sse_id(42) is None
assert _turn_id_from_sse_id("42:1") == 42
assert _turn_id_from_sse_id("0:1") is None
def test_usage_format_ascii_arrow(self) -> None:
"""usage_format_ascii_arrow [trace]: stderr label contains the natural-language
usage shape with ASCII arrow (-> not →) for CLI scriptability.
@@ -940,6 +973,21 @@ class TestRunTurn:
assert "[sse_connect_failed]" in out
assert "status=404" in out
@respx.mock
async def test_session_retired_410_maps_to_session_api_failed(self) -> None:
"""session_retired [error]: 410 stream-open → SessionRetired → SessionApiFailed
→ exit 20. Without the presenter catch this crashed _run_turn (heid-bug-hunt Gróa#2)."""
respx.post("https://w.example/sessions/s-1/messages").mock(
return_value=httpx.Response(410, json={"error_code": "session_retired"})
)
sigint = asyncio.Event()
stdout, stderr = io.StringIO(), io.StringIO()
async with httpx.AsyncClient(base_url="https://w.example") as _tp:
client = _wtc(_tp)
exit_code = await _run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
assert exit_code == 20
assert "[session_api_failed]" in stderr.getvalue()
@respx.mock
async def test_connection_dropped(self) -> None:
"""connection_dropped [error]: RemoteProtocolError mid-stream → exit 21."""
-268
View File
@@ -13,10 +13,8 @@ from ratatoskr.sessions import (
BifrostBinding,
BifrostConsumerKeyMissing,
BifrostHandshakeFailed,
InvalidCursor,
PersonaNotConfigured,
SessionApiFailed,
SessionPage,
create_character,
create_session,
delete_character,
@@ -27,10 +25,8 @@ from ratatoskr.sessions import (
get_persona_state,
get_session_bifrost,
get_session_messages,
get_session_tools,
list_agents,
list_character_models,
list_sessions,
set_persona_state,
write_authored_history,
)
@@ -551,223 +547,6 @@ class TestEndpointForPlane:
endpoint_for_plane("persona", "10.100.10.50")
def _list_item(
*,
session_id: str = "s1",
agent_id: str = "mimir",
created_at: str = "2026-04-15T12:00:00+00:00",
last_active: str = "2026-04-15T12:05:00+00:00",
metadata: dict[str, object] | None = None,
name: str | None = "Research session",
archived: bool = False,
tags: list[str] | None = None,
) -> dict[str, object]:
"""Build a GET /sessions list-item body for tests."""
item: dict[str, object] = {
"session_id": session_id,
"agent_id": agent_id,
"created_at": created_at,
"last_active": last_active,
"metadata": metadata if metadata is not None else {},
"name": name,
"archived": archived,
"tags": tags if tags is not None else ["work"],
}
return item
class TestListSessions:
@respx.mock
async def test_explicit_null_list_defaults(self) -> None:
"""INV-002: explicit-null archived -> False; explicit-null tags -> []."""
raw_item = {
"session_id": "s1",
"agent_id": "mimir",
"created_at": "2026-04-15T12:00:00+00:00",
"last_active": "2026-04-15T12:05:00+00:00",
"metadata": {},
"name": None,
"archived": None,
"tags": None,
}
respx.get("https://w.example/sessions").mock(
return_value=httpx.Response(
200, json={"items": [raw_item], "next_cursor": None}
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
page = await list_sessions(client)
info = page.items[0]
assert info.archived is False, "explicit-null archived must default to False"
assert info.tags == [], "explicit-null tags must default to []"
assert info.name is None
@respx.mock
async def test_happy_first_page(self) -> None:
"""happy_first_page [happy,tracer]: one item + next_cursor -> SessionPage shape."""
respx.get("https://w.example/sessions").mock(
return_value=httpx.Response(
200,
json={
"items": [_list_item()],
"next_cursor": "v1.eyJhYmMifQ",
},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
page = await list_sessions(client)
assert isinstance(page, SessionPage)
assert len(page.items) == 1
assert page.next_cursor == "v1.eyJhYmMifQ"
info = page.items[0]
assert info.session_id == "s1"
assert info.message_count is None # INV-002: not in list response
assert info.name == "Research session"
assert info.archived is False
assert info.tags == ["work"]
@respx.mock
async def test_happy_last_page(self) -> None:
"""happy_last_page: next_cursor=null -> SessionPage(next_cursor=None)."""
respx.get("https://w.example/sessions").mock(
return_value=httpx.Response(
200,
json={"items": [_list_item()], "next_cursor": None},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
page = await list_sessions(client)
assert page.next_cursor is None
@respx.mock
async def test_empty_results(self) -> None:
"""empty_results: {items: [], next_cursor: null} -> SessionPage([], None)."""
respx.get("https://w.example/sessions").mock(
return_value=httpx.Response(200, json={"items": [], "next_cursor": None})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
page = await list_sessions(client)
assert page == SessionPage(items=[], next_cursor=None)
@respx.mock
async def test_include_archived_query(self) -> None:
"""include_archived_query: True -> has param; default -> NO param at all."""
route = respx.get("https://w.example/sessions").mock(
return_value=httpx.Response(
200, json={"items": [], "next_cursor": None}
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
await list_sessions(client, include_archived=True)
await list_sessions(client) # default
url_with = str(route.calls[0].request.url)
url_default = str(route.calls[1].request.url)
assert "include_archived=true" in url_with
assert "include_archived" not in url_default
@respx.mock
async def test_cursor_threaded(self) -> None:
"""cursor_threaded: cursor=opaque -> URL has cursor=opaque."""
route = respx.get("https://w.example/sessions").mock(
return_value=httpx.Response(
200, json={"items": [], "next_cursor": None}
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
await list_sessions(client, cursor="opaque-from-prev-page")
assert "cursor=opaque-from-prev-page" in str(route.calls[0].request.url)
@respx.mock
async def test_limit_query(self) -> None:
"""limit_query: limit=10 -> URL has limit=10."""
route = respx.get("https://w.example/sessions").mock(
return_value=httpx.Response(
200, json={"items": [], "next_cursor": None}
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
await list_sessions(client, limit=10)
assert "limit=10" in str(route.calls[0].request.url)
@respx.mock
async def test_invalid_cursor_server(self) -> None:
"""invalid_cursor_server: 422 cursor_invalid -> InvalidCursor(raw=<passed cursor>)."""
respx.get("https://w.example/sessions").mock(
return_value=httpx.Response(
422,
json={"error_code": "cursor_invalid", "message": "bad cursor"},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(InvalidCursor) as exc_info:
await list_sessions(client, cursor="bogus")
assert exc_info.value.raw == "bogus"
@respx.mock
async def test_other_validation_failed(self) -> None:
"""other_validation_failed: 422 other error_code -> SessionApiFailed(422); truncated."""
respx.get("https://w.example/sessions").mock(
return_value=httpx.Response(
422,
json={"error_code": "validation_failed", "message": "limit out of range"},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc_info:
await list_sessions(client)
assert exc_info.value.status == 422
assert len(exc_info.value.body) <= 1024
@respx.mock
async def test_unexpected_status_truncates(self) -> None:
"""unexpected_status_truncates: 500 + 5000-byte body -> SessionApiFailed; body == 1024."""
big = b"x" * 5000
respx.get("https://w.example/sessions").mock(
return_value=httpx.Response(500, content=big)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc_info:
await list_sessions(client)
assert exc_info.value.status == 500
assert exc_info.value.body == big[:1024]
@respx.mock
async def test_limit_below_one(self) -> None:
"""limit_below_one [adversarial]: limit=0 -> AssertionError; no HTTP."""
route = respx.get("https://w.example/sessions").mock(
return_value=httpx.Response(200, json={"items": [], "next_cursor": None})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AssertionError):
await list_sessions(client, limit=0)
assert route.call_count == 0
@respx.mock
async def test_limit_above_max(self) -> None:
"""limit_above_max [adversarial]: limit=300 -> AssertionError; no HTTP."""
route = respx.get("https://w.example/sessions").mock(
return_value=httpx.Response(200, json={"items": [], "next_cursor": None})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AssertionError):
await list_sessions(client, limit=300)
assert route.call_count == 0
@respx.mock
async def test_empty_cursor(self) -> None:
"""empty_cursor [adversarial]: cursor='' -> AssertionError; no HTTP."""
route = respx.get("https://w.example/sessions").mock(
return_value=httpx.Response(200, json={"items": [], "next_cursor": None})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AssertionError):
await list_sessions(client, cursor="")
assert route.call_count == 0
# ---- Issue #8: list_agents + AgentInfo --------------------------------------
class TestListAgents:
@respx.mock
async def test_happy_full_shape(self) -> None:
@@ -1145,53 +924,6 @@ class TestGetCapabilities:
assert exc.value.status == 500
class TestGetSessionTools:
"""docs/contracts/issues/2.contract.md — get_session_tools (GET /sessions/{id}/tools, #183)."""
@respx.mock
async def test_happy(self) -> None:
"""happy [happy,tracer]: 200 → merged tool inventory dict verbatim."""
respx.get("https://w.example/sessions/s1/tools").mock(
return_value=httpx.Response(
200,
json={
"agent_id": "alice:wizard",
"builtin_tools": [],
"bifrost_tools": [
{"name": "bifrost.alice.set_field", "description": "d", "parameters": {}}
],
},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
tools = await get_session_tools(client, "s1")
assert tools["agent_id"] == "alice:wizard"
assert tools["builtin_tools"] == []
assert tools["bifrost_tools"][0]["name"] == "bifrost.alice.set_field"
@respx.mock
async def test_cross_owner_404_raises(self) -> None:
"""cross_owner_404 [error]: 404 session_not_found → SessionApiFailed(404)."""
respx.get("https://w.example/sessions/s1/tools").mock(
return_value=httpx.Response(404, json={"error_code": "session_not_found"})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc:
await get_session_tools(client, "s1")
assert exc.value.status == 404
@respx.mock
async def test_empty_session_id_asserts(self) -> None:
"""empty_session_id [adversarial]: '' → AssertionError; no HTTP issued."""
route = respx.get("https://w.example/sessions//tools").mock(
return_value=httpx.Response(200, json={})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AssertionError):
await get_session_tools(client, "")
assert route.call_count == 0
class TestGetSessionBifrost:
"""#2 contract — get_session_bifrost (GET /admin/sessions/{id}/bifrost, #176)."""
+8 -1249
View File
File diff suppressed because it is too large Load Diff
+32 -49
View File
@@ -7,6 +7,12 @@ type. Server-side serialization (`_event_to_browser_payload`) is
unit-tested against the fixture. JS-side rendering in
`src/ratatoskr/web/static/index.html` consumes the same shape — if
this fixture changes, both sides update in lockstep.
Post worldtree-sdk cutover (#20): the presenter consumes SDK `TurnEvent`s.
`_event_to_browser_payload` derives the browser payload from the SDK's `raw`
(the wire body) plus the composite `sse_id` string — the SAME shape the old
dataclasses produced, so the fixture is unchanged. These events are built via
the SDK's own `build_event` from the wire body.
"""
from __future__ import annotations
@@ -14,20 +20,8 @@ from __future__ import annotations
import json
from pathlib import Path
from ratatoskr.sse_client import (
AffectUpdate,
AwaitingLlmFirstToken,
Cancelled,
Done,
Error,
SseId,
Text,
TextBoundary,
Thinking,
ToolResult,
ToolStart,
WorkerPhase,
)
from worldtree_sdk.events import build_event
from ratatoskr.web.server import _event_to_browser_payload
@@ -36,6 +30,13 @@ def _load_fixture() -> dict:
return json.loads(path.read_text())
def _ev(ev_type: str, sse_id: str, **fields: object) -> object:
"""Build an SDK TurnEvent from its wire body (raw includes `type`); turn_id is
the turn component of the composite sse_id."""
turn = int(sse_id.split(":", 1)[0])
return build_event(ev_type, sse_id, turn, {"type": ev_type, **fields})
def _check(name: str, event: object) -> None:
"""Assert (event_type, data) for `event` matches the fixture entry."""
fixture = _load_fixture()
@@ -51,58 +52,40 @@ def _check(name: str, event: object) -> None:
def test_worker_phase_matches_fixture() -> None:
_check(
"worker_phase",
WorkerPhase(sse_id=SseId(42, 3), phase="BuildingPrompt", turn_id=42),
)
_check("worker_phase", _ev("worker_phase", "42:3", phase="BuildingPrompt", turn_id=42))
def test_thinking_matches_fixture() -> None:
_check(
"thinking",
Thinking(sse_id=SseId(42, 5), content="Let me think..."),
)
_check("thinking", _ev("thinking", "42:5", content="Let me think..."))
def test_text_matches_fixture() -> None:
_check(
"text",
Text(sse_id=SseId(42, 7), content="Hello there"),
)
_check("text", _ev("text", "42:7", content="Hello there"))
def test_text_boundary_matches_fixture() -> None:
_check(
"text_boundary",
TextBoundary(
sse_id=SseId(42, 8), kind="sentence",
char_offset=11, ts="2026-05-28T00:00:00Z",
),
_ev("text_boundary", "42:8", kind="sentence", char_offset=11, ts="2026-05-28T00:00:00Z"),
)
def test_tool_start_matches_fixture() -> None:
_check(
"tool_start",
ToolStart(sse_id=SseId(42, 9), name="search", arguments={"q": "ratatoskr"}),
)
_check("tool_start", _ev("tool_start", "42:9", name="search", arguments={"q": "ratatoskr"}))
def test_tool_result_matches_fixture() -> None:
_check(
"tool_result",
ToolResult(
sse_id=SseId(42, 10), name="search",
result={"n": 1}, duration_ms=12,
),
_ev("tool_result", "42:10", name="search", result={"n": 1}, duration_ms=12),
)
def test_done_matches_fixture() -> None:
_check(
"done",
Done(
sse_id=SseId(42, 11), phase="succeeded", response="Hello there",
_ev(
"done", "42:11", phase="succeeded", response="Hello there",
model="qwen3.6-35-a3b", duration_ms=1234,
usage={
"prompt_tokens": 100, "completion_tokens": 50,
@@ -115,8 +98,8 @@ def test_done_matches_fixture() -> None:
def test_error_matches_fixture() -> None:
_check(
"error",
Error(
sse_id=SseId(42, 11), phase="failed",
_ev(
"error", "42:11", phase="failed",
message="llm output invalid", error_code="llm_output_invalid",
),
)
@@ -125,8 +108,8 @@ def test_error_matches_fixture() -> None:
def test_cancelled_matches_fixture() -> None:
_check(
"cancelled",
Cancelled(
sse_id=SseId(42, 11), phase="cancelled", turn_id=42,
_ev(
"cancelled", "42:11", phase="cancelled", turn_id=42,
reason="user_cancel", partial_message_id=None,
),
)
@@ -135,8 +118,8 @@ def test_cancelled_matches_fixture() -> None:
def test_affect_update_matches_fixture() -> None:
_check(
"affect_update",
AffectUpdate(
sse_id=SseId(42, 1), status="current", turn_id=42,
_ev(
"affect_update", "42:1", status="current", turn_id=42,
snapshot={
"agent_id": "mimir",
"pad": {"pleasure": 0.52, "arousal": 0.47, "dominance": 0.50},
@@ -155,8 +138,8 @@ def test_affect_update_matches_fixture() -> None:
def test_awaiting_llm_first_token_matches_fixture() -> None:
_check(
"awaiting_llm_first_token",
AwaitingLlmFirstToken(
sse_id=SseId(42, 2), turn_id=42,
elapsed_ms_since_building_prompt=5012.3,
_ev(
"awaiting_llm_first_token", "42:2",
turn_id=42, elapsed_ms_since_building_prompt=5012.3,
),
)
+9 -4
View File
@@ -456,15 +456,16 @@ class TestCancelTurnEndpoint:
@respx.mock
def test_already_completed_race(self) -> None:
"""already_completed [race]: upstream 409 → 200 reason=race_or_completed."""
"""already_completed [race]: upstream 409 turn_finished → 200 reason=race_or_completed."""
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory())
c = TestClient(app)
turn_id = c.post("/api/turns/s-1", json={"content": "hi"}).json()["turn_id"]
app.state.turn_registry[("s-1", turn_id)].status = "streaming"
app.state.turn_registry[("s-1", turn_id)].upstream_turn_id = 42
# SDK gates the race on the (status, error_code) pair (B-CAN-3).
respx.post("https://w.example/sessions/s-1/turns/42/cancel").mock(
return_value=httpx.Response(409)
return_value=httpx.Response(409, json={"error_code": "turn_finished"})
)
resp = c.post(f"/api/turns/s-1/cancel?turn_id={turn_id}")
assert resp.status_code == 200
@@ -473,7 +474,11 @@ class TestCancelTurnEndpoint:
@respx.mock
def test_cancel_failed_500(self) -> None:
"""cancel_failed [error]: upstream 500 → 500 with cancel_failed envelope."""
"""cancel_failed [error]: upstream 500 → 502 cancel_failed envelope.
Post-cutover: the SDK abstracts the upstream cancel HTTP status behind a
typed CancelFailed, so the endpoint surfaces a generic 502 (bad gateway)
rather than echoing the upstream 500."""
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory())
c = TestClient(app)
@@ -484,7 +489,7 @@ class TestCancelTurnEndpoint:
return_value=httpx.Response(500, content=b"boom")
)
resp = c.post(f"/api/turns/s-1/cancel?turn_id={turn_id}")
assert resp.status_code == 500
assert resp.status_code == 502
assert resp.json()["error_code"] == "cancel_failed"
assert ("s-1", turn_id) not in app.state.turn_registry
+30
View File
@@ -218,6 +218,14 @@ class TestCreateSession:
# INV-CUT: the consumer key rides the SDK's per-request auth, NOT a header.
assert kwargs["consumer_key"] == "ck-real"
async def test_unbound_create_drops_consumer_key(self) -> None:
# A consumer_key must NOT reach the SDK on an UNBOUND create — the SDK's
# credential precedence would otherwise auth as the Bifrost consumer instead
# of the default bearer (heid-bug-hunt Gróa#4 / Regin#4).
fake = _FakeSessions(result={"session_id": "s"})
await create_session(_wt(fake), "mimir", consumer_key="ck-should-be-dropped")
assert fake.calls[-1][2]["consumer_key"] is None
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)
@@ -299,6 +307,12 @@ class TestReadPassthroughs:
await get_session_messages(_wt(fake), "s")
assert ei.value.status == 401
async def test_tools_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_tools(_wt(fake), "s")
assert ei.value.status == 401
async def _drain(aiter: Any) -> list[Any]:
out: list[Any] = []
@@ -376,6 +390,14 @@ class TestStreamTurn:
await _drain(stream_turn(_wt(fake), "s", "hi"))
assert (ei.value.established, ei.value.got) == (5, 7)
async def test_undiscriminated_api_error_maps_to_session_api_failed(self) -> None:
# INV-CUT-2 default: an undiscriminated ApiError surfacing from the stream
# (not a discriminated stream error) → SessionApiFailed.
fake = _FakeSessions(stream_error=ApiError("weird", "boom", status=500))
with pytest.raises(SessionApiFailed) as ei:
await _drain(stream_turn(_wt(fake), "s", "hi"))
assert ei.value.status == 500
class TestCancelTurn:
async def test_happy_returns_cancel_result(self) -> None:
@@ -410,3 +432,11 @@ class TestCancelTurn:
fake = _FakeSessions(error=wtsdk.CancelFailed(42, error_code="boom", message="failed"))
with pytest.raises(CancelFailed):
await cancel_turn(_wt(fake), "s", 42)
async def test_undiscriminated_api_error_maps_to_session_api_failed(self) -> None:
# INV-CUT-2 default: an undiscriminated ApiError on the cancel route (not a
# typed Cancel* race) → SessionApiFailed, never leaked as a bare ApiError.
fake = _FakeSessions(error=ApiError("weird", "boom", status=500))
with pytest.raises(SessionApiFailed) as ei:
await cancel_turn(_wt(fake), "s", 42)
assert ei.value.status == 500
Generated
+1 -1
View File
@@ -472,7 +472,7 @@ wheels = [
[[package]]
name = "ratatoskr"
version = "0.21.6"
version = "0.21.10"
source = { editable = "." }
dependencies = [
{ name = "httpx" },