From 92aa05c68801342795e6d4d638aaef85d955041c Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Mon, 25 May 2026 18:48:44 -0700 Subject: [PATCH] feat(sse,tui): bump spec pin to v0.28.0 + AffectUpdate event (v0.11.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- docs/SPEC-PIN.md | 17 +++-- docs/conversation-api-spec.md | 102 ++++++++++++++++++++++++++++++ docs/conversation_api.contract.md | 30 +++++++++ pyproject.toml | 8 +-- src/ratatoskr/sse_client.py | 36 +++++++++++ src/ratatoskr/tui.py | 25 +++++++- tests/test_sse_client.py | 78 +++++++++++++++++++++++ tests/test_tui.py | 61 ++++++++++++++++++ uv.lock | 2 +- 9 files changed, 348 insertions(+), 11 deletions(-) diff --git a/docs/SPEC-PIN.md b/docs/SPEC-PIN.md index 6ab71d0..9980e7d 100644 --- a/docs/SPEC-PIN.md +++ b/docs/SPEC-PIN.md @@ -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 diff --git a/docs/conversation-api-spec.md b/docs/conversation-api-spec.md index 32bcff5..e5a1f56 100644 --- a/docs/conversation-api-spec.md +++ b/docs/conversation-api-spec.md @@ -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..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 `:`, 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:: + ^^^^^^ + 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::` (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). diff --git a/docs/conversation_api.contract.md b/docs/conversation_api.contract.md index 3e8cf2b..5abe1f7 100644 --- a/docs/conversation_api.contract.md +++ b/docs/conversation_api.contract.md @@ -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` diff --git a/pyproject.toml b/pyproject.toml index 29feef0..d74c7d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "ratatoskr" -version = "0.10.0" +version = "0.11.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"] diff --git a/src/ratatoskr/sse_client.py b/src/ratatoskr/sse_client.py index 9f034ed..2ea9cfa 100644 --- a/src/ratatoskr/sse_client.py +++ b/src/ratatoskr/sse_client.py @@ -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}") diff --git a/src/ratatoskr/tui.py b/src/ratatoskr/tui.py index c018f7a..bdae88f 100644 --- a/src/ratatoskr/tui.py +++ b/src/ratatoskr/tui.py @@ -41,6 +41,7 @@ from ratatoskr.sessions import ( list_agents, ) from ratatoskr.sse_client import ( + AffectUpdate, CancelAlreadyCompleted, CancelFailed, Cancelled, @@ -217,6 +218,20 @@ def _audit_line(event: Event) -> str: ) 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() @@ -283,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 @@ -316,6 +331,14 @@ class TuiPresenterState: 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- diff --git a/tests/test_sse_client.py b/tests/test_sse_client.py index 095067a..44a3b69 100644 --- a/tests/test_sse_client.py +++ b/tests/test_sse_client.py @@ -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) diff --git a/tests/test_tui.py b/tests/test_tui.py index b08b344..fc85f2f 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -682,6 +682,67 @@ class TestPresenterAuditLogging: 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 diff --git a/uv.lock b/uv.lock index 13523bc..049da1a 100644 --- a/uv.lock +++ b/uv.lock @@ -968,7 +968,7 @@ wheels = [ [[package]] name = "ratatoskr" -version = "0.10.0" +version = "0.11.0" source = { editable = "." } dependencies = [ { name = "httpx" },