Compare commits

...

3 Commits

Author SHA1 Message Date
vh d516537b08 feat(sessions): get_persona_state client + persona error taxonomy (v0.12.0)
Adds the read-side half of Worldtree #204's persona-state observability
surface. Pairs with v0.11.0's AffectUpdate SSE event — together they
let a consumer hydrate a persona pane on session-open (this GET) and
keep it live as turns fire (the SSE event).

Public surface:
- `get_persona_state(client, agent_id) -> dict[str, Any]` — GET
  /agents/{agent_id}/persona_state, returns the same `snapshot` dict
  shape as AffectUpdate.snapshot
- New exception types mapped from the spec's documented 4xx error_codes:
  - `PersonaNotConfigured` (404 persona_not_configured) — agent has
    no persona surface (domari, muninn, all Tier 3 in Phase 2.0)
  - `AgentNotAvailable` (404 agent_not_available) — unknown agent_id
  - `AuthScopeDenied` (403 auth_scope_denied) — key lacks the
    requested scope (persona.read here; reusable for future scoped
    endpoints)
- Other non-2xx falls through to the existing SessionApiFailed
  precedent so novel failure modes aren't silently absorbed

Tests: 6 new cases covering happy snapshot return, each typed 4xx
sub-code, unknown 404 fall-through, and 5xx SessionApiFailed parity.

Not yet consumed: TUI persona surface (Persona TabPane / sticky
header line). UX shape pending operator direction — step 3.
2026-05-25 18:55:12 -07:00
vh 92aa05c688 feat(sse,tui): bump spec pin to v0.28.0 + AffectUpdate event (v0.11.0)
Spec pin moved 55101e9 (v0.19.0) → da93ca7 (v0.28.0); vendored
conversation-api-spec.md + conversation_api.contract.md re-snapshotted
from Worldtree at the new SHA. The only material delta consumed in
this bump is Worldtree #204's affect_update SSE event surface.

Wire layer (sse_client.py):
- New AffectUpdate dataclass: sse_id / status / turn_id / snapshot
  (snapshot is None for status="scheduled")
- Added to Event union + _envelope_for_type dispatch branch
- Without this, ratatoskr would crash on any persona-enabled turn
  from a v0.28.0 server (unknown SSE event type → ValueError)

TUI layer (tui.py):
- AffectUpdate routes through the v0.10.0 audit pipeline only — one
  debug-pane line per arrival with dominant_emotion + PAD for
  status="current", lightweight status+turn_id for status="scheduled"
- No transcript / tools / thinking pane writes — the persona UX shape
  (Persona TabPane vs sticky header line) is deferred to a separate
  bump pending operator direction

Tests: 2 new wire-layer tests for current+scheduled parsing + 2 new
presenter audit tests for routing and audit-line shape.

Not yet consumed: GET /agents/{id}/persona_state endpoint (step 2 of
the integration plan).
2026-05-25 18:48:44 -07:00
vh 209427ab23 feat(tui): debug-pane audit logging surface (v0.10.0)
Adds wire-level visibility appropriate for a debugging TUI. Every
SSE event arrival now lands as one debug-pane line; token-rate Text
and Thinking deltas get aggregated counters surfaced in a per-turn
summary instead of per-delta spam.

Audit surfaces added (all routed to the debug pane):
- per-event arrival: timestamp + event type + sse_id + event-specific
  summary for WorkerPhase / ToolStart / ToolResult / TextBoundary /
  Done / Error / Cancelled
- turn-summary at terminal events: text_deltas / text_bytes /
  thinking_deltas / thinking_bytes / elapsed_ms
- app-level state-machine transitions via new RatatoskrApp._transition
  helper (idle → streaming → cancelling → idle, with reason)
- worker_spawn line at on_input_submitted with content_len
- ctrl_c / ctrl_d audit lines documenting action + exit code
- cancel POST lifecycle: _cancel_via_sse takes an optional audit
  callback and emits issued / ok / failed lines
- app_mounted bootstrap line at on_mount (server + agent + session
  tail + raw + end_user_id)
- wire-error exception class + body audit at _stream_turn_worker

Helpers:
- TuiPresenterState: text_delta_count / text_byte_count /
  thinking_delta_count / thinking_byte_count / turn_start_ts
- module-level _ts() + _audit_line() + RatatoskrApp._audit() /
  _transition()

Tests: 6 new test cases lock in audit-line shape, turn-summary
aggregation, cancel-POST lifecycle callback, and the silence of
per-Text-delta debug writes.
2026-05-25 01:36:35 -07:00
11 changed files with 858 additions and 14 deletions
+12 -5
View File
@@ -7,11 +7,18 @@ documents the pin, the vendored artifacts, and the bump procedure.
| Field | Value |
|---|---|
| Worldtree git SHA | `55101e909abcd2219833266b6f905c5bc956e0f0` |
| Worldtree HEAD message | `memory: snapshot — #177 Vili v1 + persona async-decouple shipped as v0.19.0` |
| Pinned on | 2026-05-20 |
| Pinned by | brokkr-smithy-dev (initial scaffold) |
| Worldtree version at pin | `v0.19.0` |
| Worldtree git SHA | `da93ca7cf613f1dc229a7a44d07fc1d7efc78e25` |
| Worldtree HEAD message | `feat(#204): v0.28.0 — persona-state observability surface` |
| Pinned on | 2026-05-25 |
| Pinned by | ratatoskr-dev (bump for #204 affect_update SSE) |
| Worldtree version at pin | `v0.28.0` |
## Pin history
| Date | SHA | Version | Notable deltas consumed |
|---|---|---|---|
| 2026-05-25 | `da93ca7` | v0.28.0 | #204 — new SSE event `affect_update` (current/scheduled), new endpoint `GET /agents/{id}/persona_state` (not yet consumed), auth-model doc edits |
| 2026-05-20 | `55101e9` | v0.19.0 | initial scaffold pin |
## Vendored artifacts
+102
View File
@@ -55,6 +55,69 @@ conversation_api:
---
## Authorization model — agent invocation
When you call `POST /sessions` against an agent, the authorization check that fires depends on **which kind of agent** you target. There are two distinct scope namespaces — the spelling differs by one character (`agent` vs `agents`) and the granting mechanism differs entirely. Confusing the two is a common source of bug reports.
### Tier 1 — foundational agents (no `:` in agent_id)
Agents bundled with Worldtree: `mimir`, `lofn`, `soong`, `forseti`, `domari`, `vili`, `actor`, `saga`, `bragi`, `leif`, `troi`, `cara`, `glados`, and any future Asgardian. The agent_id is a simple slug like `mimir` — no colon.
> **About tiers:** Your `tier` is set on the `users` table row your API key resolves to, assigned at key-mint time (see `POST /admin/keys`). Tiers are `anonymous` (dev-mode unauthenticated), `user` (default for newly-issued keys), `free`/`pro` (subscription-shaped, not actively differentiated), and `admin`. The tier you have is visible via `GET /me`'s `tier` field. Tier-derived scopes come from `config/policies.yaml > tiers.<tier>.scopes` — there is no per-key scope override.
**Authorization rule (singular `agent`):**
```yaml
- id: agent-call-baseline-allow
principal:
tiers: ["anonymous", "user", "free", "pro", "admin"]
action: "agent.call:*"
resource: "*"
effect: allow
```
This baseline rule lives at `config/policies.yaml`. Every authenticated tier — including the `user` tier that newly-issued keys default to — already passes this check for every Tier 1 agent. **There is no per-agent scope you can add to "grant" Tier 1 access; it's covered by tier.**
If you get a 422 calling a Tier 1 agent (e.g., `lofn` rejecting with `end_user_id_required`), that's a **request-body validation**, not a scope denial. Check the `error_code` in the response detail — `END_USER_ID_REQUIRED` means the agent requires an `end_user_id` field in the request body; `AUTH_SCOPE_DENIED` (403) would be the actual scope problem. They're not interchangeable.
### Tier 3 — consumer-defined agents (`:` in agent_id)
Agents created at runtime via `POST /agents/define`. The agent_id is `<owner_user_id>:<agent_name>`, e.g., `acme:support-bot`. The `:` in the path is the trigger that switches the auth model.
**Authorization is DB-backed per-resource, NOT policy-driven (plural `agents`):**
```
scope action checked: agents.call:<owner_user_id>:<agent_name>
^^^^^^
PLURAL — different namespace from Tier 1
```
There is **no blanket allow rule** for `agents.call:*` in policy. The grant comes from the live `consumer_agents` table:
- A non-soft-deleted row in `consumer_agents` owned by `ctx.user_id` IS the grant.
- Cascade soft-delete and owner-initiated `DELETE` revoke it.
- Missing row → policy defaults to deny (403 `auth_scope_denied`).
To "add the scope" for a Tier 3 agent, you don't amend any config or call an admin endpoint — you `POST /agents/define` to register it under your `user_id`. Owning the row IS the grant. You cannot call another user's Tier 3 agent; ownership is checked at session-create (`row.user_id == ctx.user_id`).
### Common pitfalls
- **Singular vs plural.** Tier 1 uses `agent.call:*` (singular `agent`). Tier 3 uses `agents.call:<owner>:<name>` (plural `agents`). One character difference, two completely different mechanisms. There is no Tier 1 scope named `agent.call:mimir` or `agents.call:mimir` — Tier 1 is granted by baseline rule, not per-agent name.
- **No scope-mutation API.** `POST /admin/keys` accepts `{user_id, label, tier}` only. There is no per-key scope override mechanism in the storage schema. To change a user's effective scopes, change their `tier`, not their key. Per-resource Tier 3 grants flow through `POST /agents/define` (and its DELETE counterpart), not through admin endpoints.
- **422 vs 403.** A 422 is body-validation (e.g., `end_user_id_required`); a 403 is auth-policy denial (`auth_scope_denied`). Different fix paths. Read the `error_code` in `detail`.
### Quick decision table for consumers
| Target | Auth requirement |
|---|---|
| Tier 1 agent (e.g., `mimir`) | Authenticated tier ≥ `user`. No additional body requirements |
| Tier 1 agent `lofn` (the default welcoming intermediary) | Authenticated tier ≥ `user` + `end_user_id` field required in request body. 422 `END_USER_ID_REQUIRED` if absent |
| Tier 3 agent (any agent_id containing `:`) | `end_user_id` field required in body. AND the row must be owner-matched: `POST /agents/define` first to create a row under your `user_id`, then session-create works against your existing key. Cross-user Tier 3 invocation is rejected with 403 |
> **Programmatic discovery of `end_user_id` requirements:** as of v0.22.x there is no field on `GET /agents` indicating which agents require `end_user_id` — the spec line above (lofn + Tier 3) is the authoritative list, and 422 `END_USER_ID_REQUIRED` is the fallback signal at request time. Adding a discoverable `requires_end_user_id` field on `AgentInfoResponse` is on the table as a small future capability; ping if you want to drive it.
---
## GET /me
Returns the authenticated principal's identity and key metadata. Lets a client verify its key on boot without triggering agent-config-loading side effects.
@@ -1878,6 +1941,45 @@ Tool-using turns cycle through `CallingLLM → ProcessingTools → CallingLLM
Clients that don't need phase events can filter on `event["type"] != "worker_phase"` client-side. Existing SSE consumers that switch on `event["type"]` ignore this event type without code changes.
### affect_update
Persona-state observability event (issue #204). Fires twice per turn for agents with `persona.enabled: true` on non-ephemeral sessions; suppressed entirely for persona-disabled agents (e.g. `domari`, `muninn`), Tier 3 consumer-defined agents (Phase 2.0), and ephemeral sessions.
**Start-of-turn — `status: "current"`:**
Emitted immediately at the start of each qualifying turn, before any `worker_phase` event. Carries the agent's current persona snapshot reflecting all prior turns' completed appraisals.
```json
{
"type": "affect_update",
"status": "current",
"turn_id": 42,
"snapshot": {
"agent_id": "mimir",
"pad": {"pleasure": 0.52, "arousal": 0.47, "dominance": 0.50},
"dominant_emotion": "curiosity",
"emotions_active": [
{"type": "curiosity", "intensity": 0.6, "decay_remaining_s": 202.7}
],
"baseline_pad": {"pleasure": 0.50, "arousal": 0.40, "dominance": 0.50},
"mood_drift": {"valence_delta": 0.02, "arousal_delta": 0.07},
"last_updated_at": "2026-05-25T22:30:18+00:00"
}
}
```
**End-of-turn — `status: "scheduled"`:**
Emitted after the post-turn appraisal task has been scheduled (per #177 Phase A's fire-and-forget discipline) and before `done`. Lightweight notification — no PAD numbers, since the appraisal is still running asynchronously. The result lands in the NEXT turn's `status: "current"` snapshot.
```json
{"type": "affect_update", "status": "scheduled", "turn_id": 42}
```
`scheduled` is skipped on turn failure/cancel paths (the appraisal was never reached); `current` still fires unconditionally for qualifying turns.
Bootstrap reads available via `GET /agents/{agent_id}/persona_state` (same `snapshot` shape, requires `persona.read` scope).
### thinking
Incremental reasoning/thinking content (from thinking-enabled models).
+30
View File
@@ -1851,6 +1851,36 @@ SQLite `consumer_agents` table.
before any other processing; non-slug user_ids return 403
`tier3_user_id_unsupported`.
### Persona-state observability (issue #204)
- **INV-204-1 (affect_update event type)**: `affect_update` is a
top-level SSE event `type` discriminator, sibling to `worker_phase`
/ `tool_*` / `text` / `thinking` / `done`. Not a `worker_phase` sub-
phase. INV-061's "BuildingPrompt is the FIRST event" property is
scoped to `worker_phase` events only — `affect_update status="current"`
may precede BuildingPrompt for persona-enabled agents.
- **INV-204-2 (per-turn emission)**: For agents with persona enabled
on non-ephemeral sessions, `stream_turn` emits `status="current"`
before any other SSE event on a successful or failed turn, and
`status="scheduled"` after `update_after_turn` schedules the
appraisal task (success path only — skipped on cancel / error
before update_after_turn was reached). See contract
`docs/contracts/issues/204.contract.md`.
- **INV-204-3 (emission suppression)**: Persona-disabled agents and
ephemeral sessions emit ZERO `affect_update` events.
- **INV-204-6 / INV-204-7 (persona_state endpoint)**: New
`GET /agents/{agent_id}/persona_state` gated on Heimdall scope
`persona.read`. Route ordering: auth → Tier 3 short-circuit (404
`persona_not_configured`) → Tier 1/2 existence (404
`agent_not_available`) → persona-enabled check (404
`persona_not_configured`) → snapshot (200).
- **INV-204-9 (read-only registry primitive)**: `PersonaRegistry.get_state`
is mutex-free and never mutates `persona.emotions`. Eventual
consistency under concurrent `_appraisal_wrapper` mutations.
- **INV-204-14 (replay participation)**: `affect_update` events flow
through `_publish`, so SSE resume / replay handles them with no
special case.
### Storage extension
The `consumer_agents` table lives in `core/heimdall/storage/sqlite.py`
+4 -4
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "ratatoskr"
version = "0.9.0"
version = "0.12.0"
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
readme = "README.md"
requires-python = ">=3.12"
@@ -42,9 +42,9 @@ Repository = "https://gitea.phasefinal.com/vh/ratatoskr"
# Ratatoskr is built against Worldtree at this commit; the vendored
# spec snapshot in docs/ reflects that SHA.
[tool.ratatoskr.spec-pin]
worldtree-spec-rev = "55101e909abcd2219833266b6f905c5bc956e0f0"
worldtree-version = "v0.19.0"
pinned-on = "2026-05-20"
worldtree-spec-rev = "da93ca7cf613f1dc229a7a44d07fc1d7efc78e25"
worldtree-version = "v0.28.0"
pinned-on = "2026-05-25"
[tool.hatch.build.targets.wheel]
packages = ["src/ratatoskr"]
+82
View File
@@ -89,6 +89,46 @@ class SessionApiFailed(Exception):
self.body = body
# Worldtree #204 / v0.28.0 — persona_state endpoint failure modes.
class PersonaNotConfigured(Exception):
"""Raised on HTTP 404 `persona_not_configured` from GET persona_state.
Agent exists but has no persona surface: persona-disabled Tier 1/2
agents (e.g. `domari`, `muninn`) and all Tier 3 consumer-defined
agents (Phase 2.0). Distinct from `AgentNotAvailable` which means the
agent_id is unknown entirely.
"""
def __init__(self, *, agent_id: str) -> None:
super().__init__(f"persona not configured for agent_id: {agent_id!r}")
self.agent_id = agent_id
class AgentNotAvailable(Exception):
"""Raised on HTTP 404 `agent_not_available` from GET persona_state.
The agent_id is unknown to the server. Distinct from
`PersonaNotConfigured` (agent exists but has no persona).
"""
def __init__(self, *, agent_id: str) -> None:
super().__init__(f"agent not available: {agent_id!r}")
self.agent_id = agent_id
class AuthScopeDenied(Exception):
"""Raised on HTTP 403 `auth_scope_denied` from a Heimdall-scoped endpoint.
The API key lacks the required scope (e.g. `persona.read` for
GET /agents/{id}/persona_state). User-tier keys carry `persona.read`
by default; this surfaces when a narrower key is in use.
"""
def __init__(self, *, scope: str) -> None:
super().__init__(f"auth scope denied: required={scope!r}")
self.scope = scope
async def list_sessions(
client: httpx.AsyncClient,
*,
@@ -200,3 +240,45 @@ async def list_agents(client: httpx.AsyncClient) -> list[AgentInfo]:
)
for item in body
]
async def get_persona_state(
client: httpx.AsyncClient, agent_id: str
) -> dict[str, Any]:
"""GET /agents/{agent_id}/persona_state — fetch current persona snapshot.
Worldtree #204 / v0.28.0. Returns the same `snapshot` dict shape as the
`affect_update` SSE event's `status="current"` emission: pad,
dominant_emotion, emotions_active, baseline_pad, mood_drift,
last_updated_at. Bootstrap read for clients that want to populate a
persona pane on session-open without waiting for turn-1's `affect_update`.
Auth: requires Heimdall `persona.read` scope (user-tier default).
Failure modes (mapped to typed exceptions per the spec error_codes):
- 404 `persona_not_configured` PersonaNotConfigured (persona-disabled
agents: domari / muninn, and all Tier 3 in Phase 2.0)
- 404 `agent_not_available` AgentNotAvailable (unknown agent_id)
- 403 `auth_scope_denied` AuthScopeDenied (key lacks persona.read)
- any other non-2xx SessionApiFailed (preserves the broader-error
precedent from list_agents / list_sessions / create_session)
"""
assert client is not None
assert agent_id and isinstance(agent_id, str)
resp = await client.get(f"/agents/{agent_id}/persona_state")
if resp.status_code == 200:
return resp.json()
# Discriminate the 4xx error_code sub-codes; everything else falls through.
try:
err = resp.json()
error_code = err.get("error_code") if isinstance(err, dict) else None
except ValueError:
error_code = None
if resp.status_code == 404 and error_code == "persona_not_configured":
raise PersonaNotConfigured(agent_id=agent_id)
if resp.status_code == 404 and error_code == "agent_not_available":
raise AgentNotAvailable(agent_id=agent_id)
if resp.status_code == 403 and error_code == "auth_scope_denied":
raise AuthScopeDenied(scope="persona.read")
raise SessionApiFailed(status=resp.status_code, body=resp.content)
+36
View File
@@ -111,6 +111,30 @@ class Cancelled:
partial_message_id: int | None
@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
@@ -121,6 +145,7 @@ Event = (
| Done
| Error
| Cancelled
| AffectUpdate
)
@@ -284,6 +309,17 @@ def _envelope_for_type(body: dict[str, Any], sse_id: SseId) -> Event:
reason=body.get("reason"),
partial_message_id=body.get("partial_message_id"),
)
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}")
+186 -4
View File
@@ -10,7 +10,9 @@ from __future__ import annotations
import asyncio
import sys
import time as _time
from dataclasses import dataclass
from datetime import datetime as _datetime
from typing import ClassVar, Literal
import httpx
@@ -39,6 +41,7 @@ from ratatoskr.sessions import (
list_agents,
)
from ratatoskr.sse_client import (
AffectUpdate,
CancelAlreadyCompleted,
CancelFailed,
Cancelled,
@@ -178,6 +181,62 @@ def _plain_label(event: Event) -> str:
return f"[unknown_event] {type(event).__name__}"
def _ts() -> str:
"""HH:MM:SS.fff wall-clock timestamp for debug-pane log lines."""
now = _datetime.now()
return now.strftime("%H:%M:%S") + f".{now.microsecond // 1000:03d}"
def _audit_line(event: Event) -> str:
"""One-line wire-level audit summary for the debug pane.
v0.10.0: every SSE event arrival lands as one of these in the debug
pane (Text and Thinking deltas are aggregated into the turn summary
instead token-rate per-delta lines would drown the pane). Shape:
`[HH:MM:SS.fff] event_type sse_id=T:S key=val `.
"""
sid = getattr(event, "sse_id", None)
sid_str = f"{sid.turn_id}:{sid.seq}" if sid is not None else "-"
kind = type(event).__name__.lower()
if isinstance(event, WorkerPhase):
detail = f"phase={event.phase} turn_id={event.turn_id}"
elif isinstance(event, ToolStart):
detail = f"name={event.name} args={event.arguments!r:.80}"
elif isinstance(event, ToolResult):
detail = f"name={event.name} duration_ms={event.duration_ms}"
elif isinstance(event, TextBoundary):
detail = f"kind={event.kind} char_offset={event.char_offset}"
elif isinstance(event, Done):
detail = (
f"turn_id={event.sse_id.turn_id} model={event.model} "
f"duration_ms={event.duration_ms}"
)
elif isinstance(event, Error):
detail = (
f"turn_id={event.sse_id.turn_id} code={event.error_code} "
f"message={event.message!r:.80}"
)
elif isinstance(event, Cancelled):
detail = f"turn_id={event.turn_id} reason={event.reason!r}"
elif isinstance(event, AffectUpdate):
# Worldtree #204 / v0.28.0. status="current" carries the full
# snapshot; surface dominant_emotion + PAD inline so the operator
# sees persona drift at a glance. status="scheduled" is
# lightweight — no PAD, just the appraisal-kickoff marker.
if event.snapshot is not None:
pad = event.snapshot.get("pad") or {}
detail = (
f"status={event.status} turn_id={event.turn_id} "
f"dominant_emotion={event.snapshot.get('dominant_emotion')!r} "
f"pad=({pad.get('pleasure')},{pad.get('arousal')},{pad.get('dominance')})"
)
else:
detail = f"status={event.status} turn_id={event.turn_id}"
else: # Text / Thinking handled by counter path; fallback for safety
detail = ""
return f"[{_ts()}] {kind} sse_id={sid_str} {detail}".rstrip()
@dataclass(slots=True)
class TuiPresenterState:
"""Per-turn presenter state for TUI mode (issue #12).
@@ -202,6 +261,15 @@ class TuiPresenterState:
# v0.9.0: reference to the Static widget holding the current turn's
# response Markdown Renderable. None between turns.
current_response_widget: object = None
# v0.10.0: per-turn counters for the debug-pane turn-summary line. Text
# and Thinking events arrive at token rate; emitting per-delta debug
# lines would drown the pane. Instead we count them and surface
# aggregated totals when the turn closes.
text_delta_count: int = 0
text_byte_count: int = 0
thinking_delta_count: int = 0
thinking_byte_count: int = 0
turn_start_ts: float = 0.0
def render(
self,
@@ -230,7 +298,7 @@ class TuiPresenterState:
event,
(
WorkerPhase, Thinking, Text, TextBoundary,
ToolStart, ToolResult, Done, Error, Cancelled,
ToolStart, ToolResult, Done, Error, Cancelled, AffectUpdate,
),
)
from rich.text import Text as RichText
@@ -240,6 +308,37 @@ class TuiPresenterState:
return RichText(s, style=_AU_DEMOTED)
try:
# v0.10.0: per-event audit log line to debug pane. Text and
# Thinking arrive at token rate, so we count them rather than
# emit a line per delta — totals are reported in the turn-
# summary on Done/Error/Cancelled. Everything else gets one
# debug-pane line per arrival with timestamp + sse_id + a short
# event-specific summary, giving the operator a wire-level
# timeline of what the server sent.
if isinstance(event, Text):
if self.text_delta_count == 0:
if self.turn_start_ts == 0.0:
self.turn_start_ts = _time.monotonic()
self.text_delta_count += 1
self.text_byte_count += len(event.content)
elif isinstance(event, Thinking):
if self.thinking_delta_count == 0:
if self.turn_start_ts == 0.0:
self.turn_start_ts = _time.monotonic()
self.thinking_delta_count += 1
self.thinking_byte_count += len(event.content)
else:
if self.turn_start_ts == 0.0:
self.turn_start_ts = _time.monotonic()
debug_log.write(_dim(_audit_line(event)))
# v0.11.0: AffectUpdate is debug-pane-only for now (the audit
# line emitted above is the complete handling). Return early
# so the event doesn't pass through the thinking-close path
# or fall into the unknown-event ValueError branch. A full
# persona surface (Persona TabPane, sticky header line, or
# similar) is deferred to a later bump pending UX direction.
if isinstance(event, AffectUpdate):
return
# v0.7.1: Thinking deltas coalesce by newline before flushing.
# Worldtree emits Thinking events at token granularity; per-delta
# RichLog writes produce one visual line per token (per-token-per-
@@ -315,6 +414,28 @@ class TuiPresenterState:
transcript.scroll_end(animate=False)
return
if isinstance(event, (Done, Error, Cancelled)):
# v0.10.0: emit turn-summary to debug pane before clearing
# counters. Aggregates the per-event totals (Text + Thinking
# deltas don't get per-event audit lines because they arrive
# at token rate; the summary surfaces what was elided).
elapsed_ms = (
int((_time.monotonic() - self.turn_start_ts) * 1000)
if self.turn_start_ts
else 0
)
turn_id = (
event.sse_id.turn_id
if hasattr(event, "sse_id")
else getattr(event, "turn_id", "?")
)
debug_log.write(_dim(
f"[{_ts()}] turn_summary turn_id={turn_id} "
f"text_deltas={self.text_delta_count} "
f"text_bytes={self.text_byte_count} "
f"thinking_deltas={self.thinking_delta_count} "
f"thinking_bytes={self.thinking_byte_count} "
f"elapsed_ms={elapsed_ms}"
))
# Terminal event: finalize the response widget (clear ref so
# the next turn mounts a fresh one). The accumulated text is
# already rendered as Markdown in the widget — no post-Done
@@ -793,6 +914,14 @@ class RatatoskrApp(App[int]):
)
self.state = "idle"
self._set_hint(self.HINT_IDLE)
# v0.10.0: startup audit so the debug pane carries a complete
# session bootstrap line (server URL, agent, end_user_id, raw flag,
# session tail) before the first turn fires.
self._audit(
f"app_mounted server={self.args.server_url} agent_id={self.agent_id!r} "
f"session={self.session_id[-8:]} raw={self.args.raw} "
f"end_user_id={getattr(self.args, 'end_user_id', None)!r}"
)
def _write_turn_headers(self, turn_id: int) -> None:
"""v0.6.0: turn-ID headers across every pane for cross-pane
@@ -833,6 +962,37 @@ class RatatoskrApp(App[int]):
# Widget may be gone during shutdown; ignore.
pass
def _audit(self, line: str) -> None:
"""Write a timestamped audit line to the debug pane.
v0.10.0: shared sink for app-level events that don't pass through
the presenter state transitions, worker spawn/cancel, cancel POST
lifecycle, startup probes. The presenter's per-event audit lives at
`_audit_line()`; this is its app-side counterpart.
"""
try:
from rich.text import Text as RichText
self.query_one("#debug-log", RichLog).write(
RichText(f"[{_ts()}] {line}", style=_AU_DEMOTED)
)
except Exception:
# Widget may not exist yet (pre-mount) or be tearing down.
pass
def _transition(
self, new_state: Literal["idle", "streaming", "cancelling"], reason: str
) -> None:
"""Set self.state with debug-pane audit log.
Every state machine transition flows through here so the debug pane
carries a complete idlestreamingcancellingidle timeline with the
triggering reason. Cheap; safe to call from any context.
"""
old = self.state
self.state = new_state
if old != new_state:
self._audit(f"state {old}{new_state} reason={reason}")
async def on_input_submitted(self, event: Input.Submitted) -> None:
"""Echo user prompt, spawn stream worker; busy notice if not idle.
@@ -863,7 +1023,8 @@ class RatatoskrApp(App[int]):
)
transcript.scroll_end(animate=False)
event.input.value = ""
self.state = "streaming"
self._transition("streaming", "input_submitted")
self._audit(f"worker_spawn content_len={len(content)}")
self._set_hint(self.HINT_STREAMING)
self.stream_worker = self.run_worker(
self._stream_turn_worker(content), exclusive=True
@@ -909,17 +1070,22 @@ class RatatoskrApp(App[int]):
if isinstance(event, (Done, Error, Cancelled)):
break
except SseConnectFailed as exc:
self._audit(f"sse_connect_failed status={exc.status} body={exc.body!r:.120}")
_mount_wire_error(f"[sse_connect_failed] status={exc.status} body={exc.body!r}")
except SseConnectionDropped as exc:
self._audit(f"connection_dropped last_seen={exc.last_seen_sse_id}")
_mount_wire_error(f"[connection_dropped] last_seen={exc.last_seen_sse_id}")
except MalformedSseId as exc:
self._audit(f"malformed_sse_id raw={exc.raw!r}")
_mount_wire_error(f"[malformed_sse_id] raw={exc.raw!r}")
except MalformedSseData as exc:
self._audit(f"malformed_sse_data raw={exc.raw!r:.120}")
_mount_wire_error(f"[malformed_sse_data] raw={exc.raw!r}")
except TurnIdFlip as exc:
self._audit(f"turn_id_flip expected={exc.established} got={exc.got}")
_mount_wire_error(f"[turn_id_flip] expected={exc.established} got={exc.got}")
finally:
self.state = "idle"
self._transition("idle", "worker_finally")
self.active_turn_id = None
self._set_hint(self.HINT_IDLE)
@@ -931,29 +1097,35 @@ class RatatoskrApp(App[int]):
"""Two-stage Ctrl-C state machine per INV-003."""
assert self.state in ("idle", "streaming", "cancelling")
if self.state == "idle":
self._audit("ctrl_c state=idle action=exit code=0")
self.exit(0)
elif self.state == "streaming":
if self.active_turn_id is None:
self._audit("ctrl_c state=streaming active_turn_id=None action=force_exit code=3")
if self.stream_worker is not None:
self.stream_worker.cancel()
self.exit(3)
return
self.state = "cancelling"
self._audit(f"ctrl_c state=streaming turn_id={self.active_turn_id} action=cancel_post")
self._transition("cancelling", "ctrl_c_cancel_post_issued")
self._set_hint(self.HINT_CANCELLING)
transcript = self.query_one("#transcript-scroll", VerticalScroll)
self.run_worker(
_cancel_via_sse(
self.client, self.session_id, self.active_turn_id,
transcript=transcript,
audit=self._audit,
)
)
elif self.state == "cancelling":
self._audit("ctrl_c state=cancelling action=force_exit code=3")
if self.stream_worker is not None:
self.stream_worker.cancel()
self.exit(3)
def action_quit(self) -> None:
"""Ctrl-D — immediate exit regardless of state."""
self._audit(f"ctrl_d state={self.state} action=exit code=0")
if self.stream_worker is not None and not self.stream_worker.is_finished:
self.stream_worker.cancel()
self.exit(0)
@@ -1095,17 +1267,27 @@ async def _cancel_via_sse(
turn_id: int,
*,
transcript: VerticalScroll,
audit: "Callable[[str], None] | None" = None,
) -> None:
"""Fire-and-forget cancel; never raises (mirrors cli._cancel_and_log; #3 INV-009).
v0.9.0: mounts a `[cancel_failed]` Static into the transcript-scroll
container on failure (was log.write to RichLog).
v0.10.0: optional `audit` callback (RatatoskrApp._audit) receives one
line on POST issue + one on POST result, so the debug pane carries the
full cancel lifecycle. Defaults to no-op for legacy callers.
"""
assert client is not None
assert isinstance(turn_id, int) and turn_id > 0
if audit is not None:
audit(f"cancel_post issued session_id={session_id} turn_id={turn_id}")
try:
await cancel_turn(client, session_id, turn_id)
if audit is not None:
audit(f"cancel_post ok turn_id={turn_id}")
except (CancelFailed, CancelTurnNotFound, CancelAlreadyCompleted, httpx.RequestError) as exc:
if audit is not None:
audit(f"cancel_post failed turn_id={turn_id} {type(exc).__name__}: {exc!s:.120}")
try:
transcript.mount(Static(
f"[cancel_failed] {type(exc).__name__}: {exc}",
+109
View File
@@ -6,11 +6,15 @@ import respx
from ratatoskr.sessions import (
AgentInfo,
AgentNotAvailable,
AgentNotFound,
AuthScopeDenied,
InvalidCursor,
PersonaNotConfigured,
SessionApiFailed,
SessionPage,
create_session,
get_persona_state,
list_agents,
list_sessions,
)
@@ -556,3 +560,108 @@ class TestListAgents:
with pytest.raises(SessionApiFailed) as excinfo:
await list_agents(client)
assert excinfo.value.status == 401
class TestGetPersonaState:
"""Worldtree #204 / v0.28.0 — GET /agents/{agent_id}/persona_state.
Bootstrap read for the persona snapshot same shape as `affect_update`'s
`current` snapshot. Auth via `persona.read` scope (user-tier default).
"""
@respx.mock
async def test_happy_full_snapshot(self) -> None:
"""happy_full_snapshot [happy,tracer]: 200 → snapshot dict with pad +
dominant_emotion + emotions_active + baseline_pad + mood_drift.
"""
snapshot = {
"agent_id": "mimir",
"pad": {"pleasure": 0.52, "arousal": 0.47, "dominance": 0.50},
"dominant_emotion": "curiosity",
"emotions_active": [
{"type": "curiosity", "intensity": 0.6, "decay_remaining_s": 202.7}
],
"baseline_pad": {"pleasure": 0.50, "arousal": 0.40, "dominance": 0.50},
"mood_drift": {"valence_delta": 0.02, "arousal_delta": 0.07},
"last_updated_at": "2026-05-25T22:30:18+00:00",
}
respx.get("https://w.example/agents/mimir/persona_state").mock(
return_value=httpx.Response(200, json=snapshot)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
result = await get_persona_state(client, "mimir")
assert result == snapshot
@respx.mock
async def test_persona_not_configured_404(self) -> None:
"""persona_not_configured_404 [error]: 404 with error_code
persona_not_configured PersonaNotConfigured. Agent exists but has
no persona surface (e.g. domari, muninn, Tier 3).
"""
respx.get("https://w.example/agents/domari/persona_state").mock(
return_value=httpx.Response(
404, json={"error_code": "persona_not_configured", "message": "no persona"}
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(PersonaNotConfigured) as exc_info:
await get_persona_state(client, "domari")
assert exc_info.value.agent_id == "domari"
@respx.mock
async def test_agent_not_available_404(self) -> None:
"""agent_not_available_404 [error]: 404 with error_code
agent_not_available AgentNotAvailable. Distinct from
persona_not_configured the agent_id itself is unknown.
"""
respx.get("https://w.example/agents/bogus/persona_state").mock(
return_value=httpx.Response(
404, json={"error_code": "agent_not_available", "message": "unknown agent"}
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AgentNotAvailable) as exc_info:
await get_persona_state(client, "bogus")
assert exc_info.value.agent_id == "bogus"
@respx.mock
async def test_auth_scope_denied_403(self) -> None:
"""auth_scope_denied_403 [error]: 403 with error_code auth_scope_denied
AuthScopeDenied. Key lacks `persona.read` scope.
"""
respx.get("https://w.example/agents/mimir/persona_state").mock(
return_value=httpx.Response(
403,
json={"error_code": "auth_scope_denied", "message": "missing persona.read"},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AuthScopeDenied) as exc_info:
await get_persona_state(client, "mimir")
assert exc_info.value.scope == "persona.read"
@respx.mock
async def test_404_unknown_error_code_falls_through(self) -> None:
"""404_unknown_error_code_falls_through [adversarial]: 404 without the
two known error codes SessionApiFailed (don't swallow novel failure
modes as something more specific than they are).
"""
respx.get("https://w.example/agents/mimir/persona_state").mock(
return_value=httpx.Response(404, json={"error_code": "novel_404"})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc_info:
await get_persona_state(client, "mimir")
assert exc_info.value.status == 404
@respx.mock
async def test_500_unexpected_status(self) -> None:
"""500_unexpected_status [error]: 5xx → SessionApiFailed (matches the
list_agents / list_sessions / create_session precedent)."""
respx.get("https://w.example/agents/mimir/persona_state").mock(
return_value=httpx.Response(500, content=b"boom")
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc_info:
await get_persona_state(client, "mimir")
assert exc_info.value.status == 500
+78
View File
@@ -5,6 +5,7 @@ import pytest
import respx
from ratatoskr.sse_client import (
AffectUpdate,
CancelAlreadyCompleted,
Cancelled,
CancelResult,
@@ -877,3 +878,80 @@ class TestEmptyDataSkipped:
assert exc_info.value.raw == "x" * 200
# Exception message also only contains the truncated form
assert "x" * 5000 not in str(exc_info.value)
class TestAffectUpdate:
"""Worldtree #204 / v0.28.0 — persona-state observability SSE event.
Two emissions per qualifying turn (persona-enabled agent, non-ephemeral
session): `status: "current"` at turn start with full snapshot, then
`status: "scheduled"` near turn end (lightweight, no snapshot).
See docs/conversation-api-spec.md § affect_update.
"""
@respx.mock
async def test_current_status_parsed_with_snapshot(self) -> None:
"""current_status_parsed_with_snapshot [tracer]: status=current carries
the full snapshot dict; AffectUpdate.snapshot is populated with the
nested PAD / dominant_emotion / emotions_active fields.
"""
snapshot = {
"agent_id": "mimir",
"pad": {"pleasure": 0.52, "arousal": 0.47, "dominance": 0.50},
"dominant_emotion": "curiosity",
"emotions_active": [
{"type": "curiosity", "intensity": 0.6, "decay_remaining_s": 202.7}
],
"baseline_pad": {"pleasure": 0.50, "arousal": 0.40, "dominance": 0.50},
"mood_drift": {"valence_delta": 0.02, "arousal_delta": 0.07},
"last_updated_at": "2026-05-25T22:30:18+00:00",
}
stream = _sse_chunk(
"42:1",
{
"type": "affect_update",
"status": "current",
"turn_id": 42,
"snapshot": snapshot,
},
) + _sse_chunk("42:2", _DONE_42_6)
respx.post("https://w.example/sessions/s1/messages").mock(
return_value=httpx.Response(
200, headers={"content-type": "text/event-stream"}, content=stream
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
events = [e async for e in stream_turn(client, "s1", "hi")]
affect = events[0]
assert isinstance(affect, AffectUpdate)
assert affect.status == "current"
assert affect.turn_id == 42
assert affect.snapshot == snapshot
assert affect.sse_id == SseId(42, 1)
@respx.mock
async def test_scheduled_status_parsed_no_snapshot(self) -> None:
"""scheduled_status_parsed_no_snapshot [trace]: status=scheduled carries
no snapshot field; AffectUpdate.snapshot is None.
"""
stream = (
_sse_chunk("42:1", {"type": "text", "content": "x"})
+ _sse_chunk(
"42:2",
{"type": "affect_update", "status": "scheduled", "turn_id": 42},
)
+ _sse_chunk("42:3", _DONE_42_6)
)
respx.post("https://w.example/sessions/s1/messages").mock(
return_value=httpx.Response(
200, headers={"content-type": "text/event-stream"}, content=stream
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
events = [e async for e in stream_turn(client, "s1", "hi")]
affect = next(e for e in events if isinstance(e, AffectUpdate))
assert affect.status == "scheduled"
assert affect.turn_id == 42
assert affect.snapshot is None
assert affect.sse_id == SseId(42, 2)
+218
View File
@@ -605,6 +605,183 @@ class TestTuiPresenterState:
assert "usage 6756 in → 126 out (6882 total, 0 cached)" in done_line
class TestPresenterAuditLogging:
"""v0.10.0 — per-event audit lines + turn-summary in the debug pane.
The presenter emits one debug-pane line per arriving event (Text and
Thinking are aggregated into the turn-summary instead of per-delta to
avoid drowning the pane at token rate).
"""
def test_worker_phase_emits_audit_line(self) -> None:
"""worker_phase_emits_audit_line: WorkerPhase arrival adds an audit
line to debug_log alongside the existing `· worker_phase:` entry.
Audit line shape: `[HH:MM:SS.fff] workerphase sse_id=N:M `.
"""
from ratatoskr.tui import TuiPresenterState
debug_log = MagicMock()
state = TuiPresenterState()
state.render(
WorkerPhase(sse_id=SID, phase="streaming", turn_id=42),
transcript=MagicMock(),
tools_log=MagicMock(),
debug_log=debug_log,
thinking_log=MagicMock(),
raw=False,
)
# Two writes: audit line + worker_phase telemetry.
assert debug_log.write.call_count == 2
audit_line = _text_of(debug_log.write.call_args_list[0][0][0])
assert "workerphase" in audit_line
assert "sse_id=42:5" in audit_line
assert "phase=streaming" in audit_line
def test_tool_start_emits_audit_line(self) -> None:
"""tool_start_emits_audit_line: ToolStart adds one audit line to
debug_log even though the tool event itself routes to tools_log.
"""
from ratatoskr.tui import TuiPresenterState
debug_log = MagicMock()
state = TuiPresenterState()
state.render(
ToolStart(sse_id=SID, name="read_file", arguments={"path": "/x"}),
transcript=MagicMock(),
tools_log=MagicMock(),
debug_log=debug_log,
thinking_log=MagicMock(),
raw=False,
)
assert debug_log.write.call_count == 1
audit_line = _text_of(debug_log.write.call_args[0][0])
assert "toolstart" in audit_line
assert "sse_id=42:5" in audit_line
assert "name=read_file" in audit_line
def test_text_delta_counted_not_per_event_audit_line(self) -> None:
"""text_delta_counted_not_per_event_audit_line: a Text delta does
NOT emit a per-event audit line (token-rate would drown the pane);
instead it bumps text_delta_count / text_byte_count for the turn-
summary at Done.
"""
from ratatoskr.tui import TuiPresenterState
debug_log = MagicMock()
state = TuiPresenterState()
state.render(
Text(sse_id=SID, content="hello world"),
transcript=MagicMock(),
tools_log=MagicMock(),
debug_log=debug_log,
thinking_log=MagicMock(),
raw=False,
)
# No debug-pane writes — text deltas are silent at token rate.
assert not debug_log.write.called
assert state.text_delta_count == 1
assert state.text_byte_count == len("hello world")
def test_affect_update_routes_to_audit_only(self) -> None:
"""affect_update_routes_to_audit_only [v0.11.0]: AffectUpdate emits ONE
debug-pane audit line (with dominant_emotion + PAD for status=current)
and touches NO other pane (no transcript mount, no tools_log, no
thinking_log). UX shape (persona pane / sticky header) is deferred.
"""
from ratatoskr.sse_client import AffectUpdate
from ratatoskr.tui import TuiPresenterState
transcript = MagicMock()
tools_log = MagicMock()
thinking_log = MagicMock()
debug_log = MagicMock()
state = TuiPresenterState()
snapshot = {
"agent_id": "mimir",
"pad": {"pleasure": 0.5, "arousal": 0.4, "dominance": 0.5},
"dominant_emotion": "curiosity",
}
state.render(
AffectUpdate(sse_id=SID, status="current", turn_id=42, snapshot=snapshot),
transcript=transcript,
tools_log=tools_log,
debug_log=debug_log,
thinking_log=thinking_log,
raw=False,
)
assert debug_log.write.call_count == 1
audit = _text_of(debug_log.write.call_args[0][0])
assert "affectupdate" in audit
assert "status=current" in audit
assert "dominant_emotion='curiosity'" in audit
assert "pad=(0.5,0.4,0.5)" in audit
assert not transcript.mount.called
assert not tools_log.write.called
assert not thinking_log.write.called
def test_affect_update_scheduled_has_no_pad_detail(self) -> None:
"""affect_update_scheduled_has_no_pad_detail [v0.11.0]: status=scheduled
carries no snapshot the audit line omits dominant_emotion / pad and
contains only status + turn_id.
"""
from ratatoskr.sse_client import AffectUpdate
from ratatoskr.tui import TuiPresenterState
debug_log = MagicMock()
state = TuiPresenterState()
state.render(
AffectUpdate(sse_id=SID, status="scheduled", turn_id=42, snapshot=None),
transcript=MagicMock(),
tools_log=MagicMock(),
debug_log=debug_log,
thinking_log=MagicMock(),
raw=False,
)
audit = _text_of(debug_log.write.call_args[0][0])
assert "status=scheduled" in audit
assert "turn_id=42" in audit
assert "dominant_emotion" not in audit
assert "pad=" not in audit
def test_done_emits_turn_summary_line(self) -> None:
"""done_emits_turn_summary_line: when Done arrives the presenter
emits a `turn_summary` line aggregating per-delta Text + Thinking
counters. The shape exposes the totals that per-event audit lines
elided.
"""
from ratatoskr.tui import TuiPresenterState
debug_log = MagicMock()
state = TuiPresenterState()
# 3 Text deltas + 2 Thinking deltas, then Done.
state.render(
Text(sse_id=SID, content="a"),
transcript=MagicMock(), tools_log=MagicMock(),
debug_log=debug_log, thinking_log=MagicMock(), raw=False,
)
state.render(
Text(sse_id=SID, content="bc"),
transcript=MagicMock(), tools_log=MagicMock(),
debug_log=debug_log, thinking_log=MagicMock(), raw=False,
)
state.render(
Thinking(sse_id=SID, content="thought\n"),
transcript=MagicMock(), tools_log=MagicMock(),
debug_log=debug_log, thinking_log=MagicMock(), raw=False,
)
state.render(
_make_tui_done(),
transcript=MagicMock(), tools_log=MagicMock(),
debug_log=debug_log, thinking_log=MagicMock(), raw=False,
)
writes = [_text_of(c[0][0]) for c in debug_log.write.call_args_list]
summary = next(w for w in writes if "turn_summary" in w)
assert "text_deltas=2" in summary
assert "text_bytes=3" in summary # "a" + "bc"
assert "thinking_deltas=1" in summary
assert "elapsed_ms=" in summary
def _text_of(write_arg: object) -> str:
"""Extract plain text from a RichLog.write() arg (str or rich.text.Text).
@@ -715,6 +892,47 @@ class TestCancelViaSse:
assert "[cancel_failed]" in line
assert "ConnectError" in line
@respx.mock
async def test_audit_callback_records_lifecycle(self) -> None:
"""audit_callback_records_lifecycle [v0.10.0]: when the caller passes
an `audit` callback, _cancel_via_sse emits two lines on the happy
path (`cancel_post issued ` + `cancel_post ok `) and two lines on
the failure path (`issued` + `failed `). Gives the debug pane a
complete cancel-POST timeline.
"""
respx.post("https://w.example/sessions/s-1/turns/42/cancel").mock(
return_value=httpx.Response(200, json=_CANCEL_OK_RESP)
)
transcript = MagicMock()
audit_lines: list = []
async with httpx.AsyncClient(base_url="https://w.example") as client:
await _cancel_via_sse(
client, "s-1", 42, transcript=transcript, audit=audit_lines.append
)
assert len(audit_lines) == 2
assert audit_lines[0].startswith("cancel_post issued ")
assert "session_id=s-1" in audit_lines[0]
assert "turn_id=42" in audit_lines[0]
assert audit_lines[1] == "cancel_post ok turn_id=42"
@respx.mock
async def test_audit_callback_records_failure(self) -> None:
"""audit_callback_records_failure [v0.10.0]: failure path emits
`cancel_post issued` then `cancel_post failed ` with exception type.
"""
respx.post("https://w.example/sessions/s-1/turns/42/cancel").mock(
return_value=httpx.Response(500, content=b"boom")
)
transcript = MagicMock()
audit_lines: list = []
async with httpx.AsyncClient(base_url="https://w.example") as client:
await _cancel_via_sse(
client, "s-1", 42, transcript=transcript, audit=audit_lines.append
)
assert len(audit_lines) == 2
assert audit_lines[0].startswith("cancel_post issued ")
assert audit_lines[1].startswith("cancel_post failed turn_id=42 CancelFailed")
class TestAppMount:
"""on_mount narrows per issue #6: only identity-widget population.
Generated
+1 -1
View File
@@ -968,7 +968,7 @@ wheels = [
[[package]]
name = "ratatoskr"
version = "0.9.0"
version = "0.12.0"
source = { editable = "." }
dependencies = [
{ name = "httpx" },