Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aba17304bd | |||
| 74d41eb559 | |||
| 59602fe3ff | |||
| 5c595b862d |
@@ -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 `AgentNotAvailable` / `TurnLaunchUnavailable` / `SessionRetired` (stream-open) | ratatoskr `AgentNotAvailable` / `TurnLaunchUnavailable` / (retired → `SessionApiFailed`) — same names, passthrough |
|
||||||
| SDK `ConnectionDropped` (mid-stream) | `SseConnectionDropped` |
|
| SDK `ConnectionDropped` (mid-stream) | `SseConnectionDropped` |
|
||||||
| SDK `ResumeError` subclasses (in resilient stream) | resilient `stream_turn` absorbs; terminal → `SseConnectFailed` |
|
| 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.create` | `AgentNotFound` |
|
||||||
| `ApiError(404)` on `sessions.write_history` | `AuthoredHistoryUnavailable` (hide-existence) |
|
| `ApiError(404)` on `sessions.write_history` | `AuthoredHistoryUnavailable` (hide-existence) |
|
||||||
| `ApiError(422 cursor_invalid)` on `sessions.list` | `InvalidCursor` |
|
| `ApiError(422 cursor_invalid)` on `sessions.list` | `InvalidCursor` (dual-key: status 422 AND error_code; the flat cursor body surfaces the code) |
|
||||||
| `ApiError(502 bifrost_handshake_failed)` on bound `sessions.create` | `BifrostHandshakeFailed` |
|
| `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)` |
|
| **`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
|
The default row is load-bearing: any `ApiError` not matched above surfaces as the
|
||||||
generic `SessionApiFailed` carrying the raw `status`/`error_code`/`body` — 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
|
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
|
leave an `ApiError` un-mapped. **This default holds on EVERY route, including the
|
||||||
the old path is deleted.
|
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)
|
## Slice plan (incremental, DEC-4)
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "ratatoskr"
|
name = "ratatoskr"
|
||||||
version = "0.21.6"
|
version = "0.21.10"
|
||||||
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"
|
||||||
|
|||||||
+40
-21
@@ -62,9 +62,6 @@ from ratatoskr.sessions import (
|
|||||||
# (--whoami / --characters / --set-persona / --seed-first-message) stay on the
|
# (--whoami / --characters / --set-persona / --seed-first-message) stay on the
|
||||||
# `sessions` wrappers until their own slices.
|
# `sessions` wrappers until their own slices.
|
||||||
from ratatoskr.sse_client import (
|
from ratatoskr.sse_client import (
|
||||||
CancelAlreadyCompleted,
|
|
||||||
CancelFailed,
|
|
||||||
CancelTurnNotFound,
|
|
||||||
MalformedSseData,
|
MalformedSseData,
|
||||||
MalformedSseId,
|
MalformedSseId,
|
||||||
SseConnectFailed,
|
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)"
|
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
|
"""Tolerant wrapper over `_format_usage` for the SDK's open-world
|
||||||
`DoneEvent.usage` (typed optional): the canonical four-key usage formats;
|
`DoneEvent.usage`: the canonical four-key mapping formats; anything absent or
|
||||||
anything absent or malformed degrades to `(n/a)` rather than crashing the
|
malformed (None, a non-mapping like `5`, a partial dict) degrades to `(n/a)`
|
||||||
presenter (same posture as `_format_whoami`)."""
|
rather than crashing the presenter (same posture as `_format_whoami`)."""
|
||||||
keys = ("prompt_tokens", "completion_tokens", "total_tokens", "cached_input_tokens")
|
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 _format_usage(dict(usage), arrow="->")
|
||||||
return "(n/a)"
|
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 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
|
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(":")
|
head, _, _ = sse_id.partition(":")
|
||||||
try:
|
try:
|
||||||
turn = int(head)
|
turn = int(head)
|
||||||
@@ -389,14 +399,19 @@ class CliPresenterState:
|
|||||||
malformed/partial event degrades to a placeholder rather than crashing the
|
malformed/partial event degrades to a placeholder rather than crashing the
|
||||||
presenter — the same posture as `_format_whoami`.
|
presenter — the same posture as `_format_whoami`.
|
||||||
"""
|
"""
|
||||||
assert isinstance(
|
if not isinstance(
|
||||||
event,
|
event,
|
||||||
(
|
(
|
||||||
WorkerPhaseEvent, ThinkingEvent, TextEvent, TextBoundaryEvent,
|
WorkerPhaseEvent, ThinkingEvent, TextEvent, TextBoundaryEvent,
|
||||||
ToolStartEvent, ToolResultEvent, DoneEvent, ErrorEvent, CancelledEvent,
|
ToolStartEvent, ToolResultEvent, DoneEvent, ErrorEvent, CancelledEvent,
|
||||||
AffectUpdateEvent, AwaitingLlmFirstTokenEvent,
|
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.
|
# Thinking events accumulate into the open run.
|
||||||
if isinstance(event, ThinkingEvent):
|
if isinstance(event, ThinkingEvent):
|
||||||
content = event.content or ""
|
content = event.content or ""
|
||||||
@@ -430,7 +445,7 @@ class CliPresenterState:
|
|||||||
if isinstance(event, DoneEvent):
|
if isinstance(event, DoneEvent):
|
||||||
stderr.write(
|
stderr.write(
|
||||||
f"[done] turn_id={event.turn_id} model={event.model} "
|
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"
|
f"usage {_format_usage_safe(event.usage)}\n"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -470,7 +485,8 @@ class CliPresenterState:
|
|||||||
if isinstance(event, AffectUpdateEvent):
|
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:
|
# isinstance(Mapping) guards an open-world non-mapping snapshot.
|
||||||
|
if isinstance(event.snapshot, Mapping):
|
||||||
dom = event.snapshot.get("dominant_emotion")
|
dom = event.snapshot.get("dominant_emotion")
|
||||||
stderr.write(
|
stderr.write(
|
||||||
f". affect_update: status={event.status} turn_id={event.turn_id} "
|
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
|
assert isinstance(turn_id, int) and turn_id > 0
|
||||||
try:
|
try:
|
||||||
await wt.cancel_turn(client, session_id, turn_id)
|
await wt.cancel_turn(client, session_id, turn_id)
|
||||||
except (
|
except Exception as exc:
|
||||||
CancelFailed,
|
# Any cancel failure (mapped ratatoskr cancel exceptions, an adapter-defaulted
|
||||||
CancelTurnNotFound,
|
# SessionApiFailed, an SDK ConnectFailed, a transport error, or anything the
|
||||||
CancelAlreadyCompleted,
|
# SDK doesn't normalize) is logged and swallowed — the fire-and-forget cancel
|
||||||
ConnectFailed, # SDK normalizes a transport drop to ConnectFailed(status=0)
|
# must never propagate into _run_turn's finally.
|
||||||
httpx.RequestError,
|
|
||||||
) as exc:
|
|
||||||
stderr.write(f"[cancel_failed] {type(exc).__name__}: {exc}\n")
|
stderr.write(f"[cancel_failed] {type(exc).__name__}: {exc}\n")
|
||||||
|
|
||||||
|
|
||||||
@@ -574,6 +588,11 @@ async def _run_turn(
|
|||||||
except StopAsyncIteration:
|
except StopAsyncIteration:
|
||||||
stderr.write("[connection_dropped] last_seen=<none>\n")
|
stderr.write("[connection_dropped] last_seen=<none>\n")
|
||||||
return 21
|
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:
|
except SseConnectFailed as exc:
|
||||||
stderr.write(f"[sse_connect_failed] status={exc.status} body={exc.body!r}\n")
|
stderr.write(f"[sse_connect_failed] status={exc.status} body={exc.body!r}\n")
|
||||||
return 20
|
return 20
|
||||||
|
|||||||
@@ -38,12 +38,6 @@ class SessionInfo:
|
|||||||
config: dict[str, Any] | None = None
|
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)
|
@dataclass(frozen=True)
|
||||||
@@ -204,53 +198,6 @@ class AuthoredHistoryUnavailable(Exception):
|
|||||||
self.session_id = session_id
|
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:
|
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)
|
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]:
|
async def get_capabilities(client: httpx.AsyncClient) -> dict[str, Any]:
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ Implements docs/contracts/issues/1.contract.md.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import re
|
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, NamedTuple
|
from typing import Any, NamedTuple
|
||||||
@@ -14,8 +13,6 @@ from typing import Any, NamedTuple
|
|||||||
import httpx
|
import httpx
|
||||||
import httpx_sse
|
import httpx_sse
|
||||||
|
|
||||||
_INT_RE = re.compile(r"^-?\d+$")
|
|
||||||
|
|
||||||
|
|
||||||
class SseId(NamedTuple):
|
class SseId(NamedTuple):
|
||||||
"""Parsed composite SSE wire `id:` per spec §SSE id format."""
|
"""Parsed composite SSE wire `id:` per spec §SSE id format."""
|
||||||
@@ -24,155 +21,6 @@ class SseId(NamedTuple):
|
|||||||
seq: int
|
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)
|
@dataclass(frozen=True)
|
||||||
@@ -258,31 +106,6 @@ class TurnLaunchUnavailable(SseConnectFailed):
|
|||||||
self.message = message
|
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):
|
class SseConnectionDropped(Exception):
|
||||||
@@ -352,275 +175,6 @@ class CancelFailed(Exception):
|
|||||||
self.body = body
|
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(
|
async def stream_admin_events(
|
||||||
client: httpx.AsyncClient,
|
client: httpx.AsyncClient,
|
||||||
@@ -667,48 +221,3 @@ async def stream_admin_events(
|
|||||||
raise SseConnectionDropped(last_seen_sse_id=None) from exc
|
raise SseConnectionDropped(last_seen_sse_id=None) from exc
|
||||||
|
|
||||||
|
|
||||||
def _parse_sse_id(raw: str) -> SseId:
|
|
||||||
"""Parse the SSE wire `id:` as composite `{turn_id}:{seq}`. See contract FN _parse_sse_id."""
|
|
||||||
assert isinstance(raw, str)
|
|
||||||
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
@@ -11,7 +11,7 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import itertools
|
import itertools
|
||||||
import json
|
import json
|
||||||
from collections.abc import AsyncIterator, Callable
|
from collections.abc import AsyncIterator, Callable, Mapping
|
||||||
from dataclasses import asdict, dataclass, is_dataclass
|
from dataclasses import asdict, dataclass, is_dataclass
|
||||||
from importlib.metadata import version as _pkg_version
|
from importlib.metadata import version as _pkg_version
|
||||||
|
|
||||||
@@ -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,33 +40,47 @@ 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", "")
|
||||||
|
# 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:
|
def _static_dir() -> str:
|
||||||
"""Locate the bundled static/ directory inside the installed package.
|
"""Locate the bundled static/ directory inside the installed package.
|
||||||
|
|
||||||
@@ -163,16 +179,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 +205,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 +269,22 @@ 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)
|
||||||
browser_type = "".join(
|
# Open-world: degrade a non-mapping `raw` to an empty payload rather than letting
|
||||||
("_" + c.lower() if c.isupper() and i else c.lower())
|
# dict(raw) raise (which would abort the SSE stream mid-response).
|
||||||
for i, c in enumerate(type_name)
|
src = raw if isinstance(raw, Mapping) else {}
|
||||||
)
|
data = {k: v for k, v in src.items() if k != "type"}
|
||||||
data = asdict(event) # type: ignore[arg-type]
|
data["sse_id"] = getattr(event, "sse_id", None)
|
||||||
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 +296,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,24 +331,28 @@ 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 (wt.SessionApiFailed, SseConnectFailed, SseConnectionDropped,
|
||||||
MalformedSseData, TurnIdFlip) as exc:
|
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(
|
yield _format_sse(
|
||||||
"error",
|
"error",
|
||||||
{"exception": type(exc).__name__, "message": str(exc)},
|
{"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.
|
# 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 +410,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 +501,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 +521,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 +645,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]
|
||||||
|
|||||||
+20
-1
@@ -193,10 +193,22 @@ async def create_session(
|
|||||||
body["bifrost"] = {"endpoint_url": bifrost.endpoint_url, "scope": bifrost.scope}
|
body["bifrost"] = {"endpoint_url": bifrost.endpoint_url, "scope": bifrost.scope}
|
||||||
|
|
||||||
try:
|
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:
|
except ApiError as exc:
|
||||||
if exc.status == 404:
|
if exc.status == 404:
|
||||||
raise AgentNotFound(agent_id=agent_id) from exc
|
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:
|
if bifrost is not None and exc.status == 502:
|
||||||
raise BifrostHandshakeFailed(
|
raise BifrostHandshakeFailed(
|
||||||
bifrost_error=_bifrost_error_from_body(exc.body),
|
bifrost_error=_bifrost_error_from_body(exc.body),
|
||||||
@@ -299,6 +311,10 @@ async def stream_turn(
|
|||||||
raise MalformedSseData(raw=exc.raw) from exc
|
raise MalformedSseData(raw=exc.raw) from exc
|
||||||
except wtsdk.TurnIdFlip as exc:
|
except wtsdk.TurnIdFlip as exc:
|
||||||
raise TurnIdFlip(established=exc.established, got=exc.got) from 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(
|
async def cancel_turn(
|
||||||
@@ -323,3 +339,6 @@ async def cancel_turn(
|
|||||||
raise CancelFailed(
|
raise CancelFailed(
|
||||||
status=0, body=(getattr(exc, "message", "") or str(exc)).encode()
|
status=0, body=(getattr(exc, "message", "") or str(exc)).encode()
|
||||||
) from exc
|
) from exc
|
||||||
|
except ApiError as exc:
|
||||||
|
# INV-CUT-2 default: an undiscriminated ApiError on this route → SessionApiFailed.
|
||||||
|
raise translate_error(exc) from exc
|
||||||
|
|||||||
@@ -647,6 +647,39 @@ class TestCliPresenterState:
|
|||||||
state.render(_make_done(duration_ms=72000), stdout=io.StringIO(), stderr=stderr)
|
state.render(_make_done(duration_ms=72000), stdout=io.StringIO(), stderr=stderr)
|
||||||
assert "duration=1.2m" in stderr.getvalue()
|
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:
|
def test_usage_format_ascii_arrow(self) -> None:
|
||||||
"""usage_format_ascii_arrow [trace]: stderr label contains the natural-language
|
"""usage_format_ascii_arrow [trace]: stderr label contains the natural-language
|
||||||
usage shape with ASCII arrow (-> not →) for CLI scriptability.
|
usage shape with ASCII arrow (-> not →) for CLI scriptability.
|
||||||
@@ -940,6 +973,21 @@ class TestRunTurn:
|
|||||||
assert "[sse_connect_failed]" in out
|
assert "[sse_connect_failed]" in out
|
||||||
assert "status=404" 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
|
@respx.mock
|
||||||
async def test_connection_dropped(self) -> None:
|
async def test_connection_dropped(self) -> None:
|
||||||
"""connection_dropped [error]: RemoteProtocolError mid-stream → exit 21."""
|
"""connection_dropped [error]: RemoteProtocolError mid-stream → exit 21."""
|
||||||
|
|||||||
@@ -13,10 +13,8 @@ from ratatoskr.sessions import (
|
|||||||
BifrostBinding,
|
BifrostBinding,
|
||||||
BifrostConsumerKeyMissing,
|
BifrostConsumerKeyMissing,
|
||||||
BifrostHandshakeFailed,
|
BifrostHandshakeFailed,
|
||||||
InvalidCursor,
|
|
||||||
PersonaNotConfigured,
|
PersonaNotConfigured,
|
||||||
SessionApiFailed,
|
SessionApiFailed,
|
||||||
SessionPage,
|
|
||||||
create_character,
|
create_character,
|
||||||
create_session,
|
create_session,
|
||||||
delete_character,
|
delete_character,
|
||||||
@@ -27,10 +25,8 @@ from ratatoskr.sessions import (
|
|||||||
get_persona_state,
|
get_persona_state,
|
||||||
get_session_bifrost,
|
get_session_bifrost,
|
||||||
get_session_messages,
|
get_session_messages,
|
||||||
get_session_tools,
|
|
||||||
list_agents,
|
list_agents,
|
||||||
list_character_models,
|
list_character_models,
|
||||||
list_sessions,
|
|
||||||
set_persona_state,
|
set_persona_state,
|
||||||
write_authored_history,
|
write_authored_history,
|
||||||
)
|
)
|
||||||
@@ -551,223 +547,6 @@ class TestEndpointForPlane:
|
|||||||
endpoint_for_plane("persona", "10.100.10.50")
|
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:
|
class TestListAgents:
|
||||||
@respx.mock
|
@respx.mock
|
||||||
async def test_happy_full_shape(self) -> None:
|
async def test_happy_full_shape(self) -> None:
|
||||||
@@ -1145,53 +924,6 @@ class TestGetCapabilities:
|
|||||||
assert exc.value.status == 500
|
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:
|
class TestGetSessionBifrost:
|
||||||
"""#2 contract — get_session_bifrost (GET /admin/sessions/{id}/bifrost, #176)."""
|
"""#2 contract — get_session_bifrost (GET /admin/sessions/{id}/bifrost, #176)."""
|
||||||
|
|
||||||
|
|||||||
+8
-1249
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -218,6 +218,14 @@ class TestCreateSession:
|
|||||||
# INV-CUT: the consumer key rides the SDK's per-request auth, NOT a header.
|
# INV-CUT: the consumer key rides the SDK's per-request auth, NOT a header.
|
||||||
assert kwargs["consumer_key"] == "ck-real"
|
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:
|
async def test_bifrost_without_consumer_key_rejected_pre_http(self) -> None:
|
||||||
fake = _FakeSessions(result={"session_id": "s"})
|
fake = _FakeSessions(result={"session_id": "s"})
|
||||||
binding = BifrostBinding(endpoint_url="http://h:8391", scope=None)
|
binding = BifrostBinding(endpoint_url="http://h:8391", scope=None)
|
||||||
@@ -299,6 +307,12 @@ class TestReadPassthroughs:
|
|||||||
await get_session_messages(_wt(fake), "s")
|
await get_session_messages(_wt(fake), "s")
|
||||||
assert ei.value.status == 401
|
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]:
|
async def _drain(aiter: Any) -> list[Any]:
|
||||||
out: list[Any] = []
|
out: list[Any] = []
|
||||||
@@ -376,6 +390,14 @@ class TestStreamTurn:
|
|||||||
await _drain(stream_turn(_wt(fake), "s", "hi"))
|
await _drain(stream_turn(_wt(fake), "s", "hi"))
|
||||||
assert (ei.value.established, ei.value.got) == (5, 7)
|
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:
|
class TestCancelTurn:
|
||||||
async def test_happy_returns_cancel_result(self) -> None:
|
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"))
|
fake = _FakeSessions(error=wtsdk.CancelFailed(42, error_code="boom", message="failed"))
|
||||||
with pytest.raises(CancelFailed):
|
with pytest.raises(CancelFailed):
|
||||||
await cancel_turn(_wt(fake), "s", 42)
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user