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