Compare commits

...
4 Commits
Author SHA1 Message Date
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
vh 139771c8d8 feat(tui): live Markdown rendering during text streaming (v0.9.0)
Replaces v0.8.2's drop-Markdown patch with proper in-place Markdown
rendering. The transcript becomes a VerticalScroll container; each
turn's response body lives as a single Static widget whose content
is updated as Text deltas arrive — Markdown is re-rendered in place
rather than re-printed on Done. Eliminates the v0.8.x double-print
without sacrificing rich formatting.

- transcript: RichLog → VerticalScroll (#transcript-scroll)
- Text deltas: mount Static(Markdown(buffer)) on first delta;
  Static.update(Markdown(buffer)) on subsequent deltas
- --raw mode: bypass Markdown, mount Static(plain_str) for the same
  in-place update semantics
- Terminal events (Done/Error/Cancelled) mount styled label Statics
- _cancel_via_sse: write → mount Static on the new container
- _write_turn_headers: transcript gets a styled RichText Static
  ("── turn N ──"); other panes still receive Rule renderables
- Test suite reshape: bulk rename `log` → `transcript` for the
  presenter contract, `_mounted_renderables` helper extracts
  Static.content for assertion, `_spy_writes` captures both
  RichLog.write and VerticalScroll.mount
2026-05-24 22:18:45 -07:00
vh 489cfee1f0 fix(tui): drop post-Done Markdown body re-render (v0.8.2)
Operator: "first turn double prints agent's turn."

Root cause: v0.8.1 wrote both the streamed Text lines AND the post-
Done `Markdown(event.response)` body into the transcript. Same
content rendered twice — once as plain streaming, once as a full
markdown re-render. The v0.8.1 commit message documented this as
"some duplication is acceptable" but the live UX read as a bug.

## Fix

Drop the post-Done `Rule + Markdown(response)` writes in non-raw
mode. The streamed text IS the response; whatever the model emitted
flows into the transcript line-by-line via coalesce-on-newline.
Markdown formatting (bold, lists, code blocks) renders as plain
text — a known regression from v0.8.1's polished output but the
right tradeoff vs the duplication bug.

## What this loses temporarily

Pre-v0.8.2 (after Done):
  [done] turn_id=... ───
  ─── (Rule separator) ───
  **Bold text** rendered bold, `code` highlighted, lists as bullets, etc.

v0.8.2 (after Done):
  [done] turn_id=... ───
  **Bold text** as plain asterisks, `code` as backticks, lists as plain dashes

## v0.9.0 plan

Restore markdown rendering via LIVE rendering during the stream
(not post-Done re-render). Replace `RichLog#transcript` with a
`VerticalScroll` container that mounts a fresh `Markdown` widget
per turn; Text deltas update the widget; markdown renders as
content arrives. No duplication, no snap, full formatting.
Operator-confirmed direction (2026-05-25 AskUserQuestion).

## Tests

287/287 GREEN; ruff clean. Two tests updated for the new shape:
- test_done_renders_markdown_after_label → renamed
  test_done_flushes_tail_and_writes_label; asserts NO Markdown, NO
  Rule (post-Done) in the writes.
- test_happy_text_done_renders_markdown → renamed
  test_happy_text_done_no_double_print; asserts NO Markdown in the
  spy.

Patch bump (v0.8.1 → v0.8.2): bug fix; no public API change.
2026-05-24 21:53:20 -07:00
vh 11ef6830ab fix(tui,sse): inline Text streaming + empty-id keepalive skip (v0.8.1)
Two related fixes for the same user-reported bug pattern from a
running session against ratatoskr:sindra (qwen3.6-35-a3b-heretic):

## 1. Streaming text overlapping the transcript

Operator: "new text comes at the bottom and overwrites the existing
pane information instead of pushing it up naturally."

Root cause: the v0.6.0 `#current-text` Static was `dock: bottom`
with `height: auto`, sitting between the transcript RichLog (1fr)
and the prompt Input (dock: bottom). As text streamed, the Static
grew UPWARD but Textual didn't dynamically resize the 1fr transcript
to accommodate — the growing Static visually OVERLAPPED the
transcript's bottom rows. On Done, `current_text.update("")` snapped
it to height 0 and the transcript re-laid-out — "boom, everything
updates."

Fix: remove `#current-text` Static entirely. Apply the same
coalesce-on-newline pattern v0.7.1 used for thinking — Text deltas
accumulate in `TuiPresenterState.text_chunk_buffer`, flushing whole
lines (each `\n` boundary) directly to `log` (transcript). On Done:
flush remaining tail, then [done] label + Rule + Markdown body.

Trade-off accepted: streamed lines + post-Done Markdown body are
both in the transcript (some content duplication). The Markdown
body re-renders the same content with proper formatting (lists,
bold, code blocks). Acceptable — operator gets both the live-progress
streaming AND the canonical rendered version.

## 2. MalformedSseId raw='' crashing every turn

Operator: "current session is erroring on every turn with
[malformed_sse_id] raw=''"

Worldtree's qwen3.6-35-a3b-heretic provider emits some events
without `id:` lines (observed 2026-05-25 mid-stream). When the FIRST
such event arrives before any prior id has been seen, httpx_sse's
`ServerSentEvent.id` is `""`. `_parse_sse_id('')` raised ValueError
→ MalformedSseId → turn worker bailed → operator saw the label
every turn.

Per SSE RFC, events without `id:` are legitimate (they just don't
update Last-Event-ID). Issue #7 already covered the empty-DATA
keepalive case with skip-silently semantics. Empty-id is the same
shape of wire weirdness; same fix shape:

  if sse.id == "":
      continue  # treat as keepalive

Ordered AFTER the empty-data branch so an empty-data + empty-id
event still gets skipped on the data check.

## Tests + smoke

287/287 GREEN (was 286, +1 for empty-id skip; +1 net Text-flow test
adjustments). Ruff clean.

Verified Worldtree alive when the user hit the empty-id bug
(/healthz returned ok in 18ms) — not a server-down issue, just
wire-format mid-stream.

## Caveats

The fix doesn't recover content from the dropped empty-id event.
If the event happened to carry meaningful data (not a true
keepalive), we silently lose it. Acceptable trade-off: pre-v0.8.1
EVERY turn died on the offending agent; post-v0.8.1 the turn
continues and any single dropped frame is recoverable from logs if
debugging. Worldtree-side fix (always emit ids) is the right
upstream answer; ratatoskr just stops panicking on wire weirdness.

Patch bump (v0.8.0 → v0.8.1) — both fixes are bug fixes; no public
API change. The `TuiPresenterState.render` signature loses the
`current_text` parameter (was added v0.6.0), but presenter is an
internal contract; no external callers.
2026-05-24 21:39:02 -07:00
7 changed files with 843 additions and 296 deletions
+5 -3
View File
@@ -32,9 +32,9 @@ separate dev team rather than an in-tree Worldtree tool.
## Current state / in-flight ## Current state / in-flight
_As of 2026-05-25 (post-v0.8.0 local tier-3 agent index in picker):_ _As of 2026-05-25 (post-v0.8.2 drop double-print; v0.9.0 live-md next):_
**Status: v0.8.0 shipped.** Eleven core features complete (`sse_client` **Status: v0.8.2 shipped.** Eleven core features complete (`sse_client`
#1, `sessions` #2, `cli` #3, `tui` #4, `--end-user-id` #5, TUI #1, `sessions` #2, `cli` #3, `tui` #4, `--end-user-id` #5, TUI
startup error visibility #6, presenter contract semantics amendment startup error visibility #6, presenter contract semantics amendment
#12, startup agent picker #8, §5 layout reshape + Tools pane #13) #12, startup agent picker #8, §5 layout reshape + Tools pane #13)
@@ -51,7 +51,9 @@ Static in the footer (static "Tools" v1; dynamic when more tabs
land). CLI mode (--send) unaffected by design — INV-018. land). CLI mode (--send) unaffected by design — INV-018.
Last commits on `main`: Last commits on `main`:
- v0.8.0 feat(local_agents): JSON-backed local tier-3 index + picker merge - v0.8.2 fix(tui): drop post-Done Markdown body re-render (no double-print)
- `11ef683` fix(tui,sse): inline Text streaming + empty-id keepalive skip (v0.8.1)
- `9fade55` feat(local_agents): JSON-backed local tier-3 index + picker merge (v0.8.0)
- `9918c10` fix(tui): coalesce thinking deltas on `\n` (v0.7.1) - `9918c10` fix(tui): coalesce thinking deltas on `\n` (v0.7.1)
- `c086ae2` feat(tier3): ratatoskr.tier3 module + CLI (v0.7.0) - `c086ae2` feat(tier3): ratatoskr.tier3 module + CLI (v0.7.0)
- `d356990` refactor(tui): thinking streams into thinking-log (v0.6.5) - `d356990` refactor(tui): thinking streams into thinking-log (v0.6.5)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "ratatoskr" name = "ratatoskr"
version = "0.8.0" version = "0.10.0"
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard" description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
+8
View File
@@ -309,6 +309,14 @@ async def _iter_events(
# with a bad id is still a keepalive). Don't reorder. # with a bad id is still a keepalive). Don't reorder.
if sse.data == "": if sse.data == "":
continue 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: try:
sse_id = _parse_sse_id(sse.id) sse_id = _parse_sse_id(sse.id)
except ValueError as exc: except ValueError as exc:
+367 -108
View File
@@ -10,13 +10,15 @@ from __future__ import annotations
import asyncio import asyncio
import sys import sys
from dataclasses import dataclass, field import time as _time
from dataclasses import dataclass
from datetime import datetime as _datetime
from typing import ClassVar, Literal from typing import ClassVar, Literal
import httpx import httpx
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.binding import Binding from textual.binding import Binding
from textual.containers import Horizontal, Vertical from textual.containers import Horizontal, Vertical, VerticalScroll
from textual.theme import Theme from textual.theme import Theme
from textual.widgets import ( from textual.widgets import (
Footer, Footer,
@@ -178,6 +180,48 @@ def _plain_label(event: Event) -> str:
return f"[unknown_event] {type(event).__name__}" 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}"
else: # Text / Thinking handled by counter path; fallback for safety
detail = ""
return f"[{_ts()}] {kind} sse_id={sid_str} {detail}".rstrip()
@dataclass(slots=True) @dataclass(slots=True)
class TuiPresenterState: class TuiPresenterState:
"""Per-turn presenter state for TUI mode (issue #12). """Per-turn presenter state for TUI mode (issue #12).
@@ -186,9 +230,6 @@ class TuiPresenterState:
""" """
thinking_open: bool = False thinking_open: bool = False
# v0.6.0: per-turn streaming text buffer. Text deltas accumulate here
# and update `current_text` Static in place — no per-token RichLog spam.
text_buffer: list[str] = field(default_factory=list)
# Thinking-run counter for turn-scoped start/end markers. # Thinking-run counter for turn-scoped start/end markers.
thinking_run_index: int = 0 thinking_run_index: int = 0
# v0.7.1: thinking-content accumulator. Worldtree emits Thinking deltas # v0.7.1: thinking-content accumulator. Worldtree emits Thinking deltas
@@ -197,13 +238,29 @@ class TuiPresenterState:
# only on `\n` boundaries (one written line per natural paragraph) or # only on `\n` boundaries (one written line per natural paragraph) or
# when the run closes (any leftover tail). # when the run closes (any leftover tail).
thinking_chunk_buffer: str = "" thinking_chunk_buffer: str = ""
# v0.9.0: Text accumulator for live Markdown rendering. Worldtree emits
# Text deltas at token granularity; each delta appends to this buffer
# and the current_response_widget re-renders Markdown(text_chunk_buffer)
# in place. On terminal event the widget is finalized + reference clears.
text_chunk_buffer: str = ""
# 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( def render(
self, self,
event: Event, event: Event,
*, *,
log: RichLog, transcript: "VerticalScroll",
current_text: Static,
tools_log: RichLog, tools_log: RichLog,
debug_log: RichLog, debug_log: RichLog,
thinking_log: RichLog, thinking_log: RichLog,
@@ -211,18 +268,14 @@ class TuiPresenterState:
) -> None: ) -> None:
"""Render one Worldtree SSE event with the TUI hierarchy + coalescing. """Render one Worldtree SSE event with the TUI hierarchy + coalescing.
v0.6.5 routing: v0.9.0 routing:
- `log` (transcript) = content only: user-prompt echo (written - `transcript` (VerticalScroll) = chat content: each turn mounts
outside the presenter), terminal labels, post-Done Markdown body. child widgets (turn-header / prompt-echo / response Markdown /
- `current_text` (Static below transcript) = live-streaming Text done-label). Live Markdown rendering during Text streaming.
deltas accumulated into one growing line; cleared on terminal. - `tools_log` (RichLog) = ToolStart + ToolResult.
- `tools_log` = ToolStart + ToolResult. - `debug_log` (RichLog) = WorkerPhase + TextBoundary.
- `debug_log` = WorkerPhase + TextBoundary. - `thinking_log` (RichLog) = streaming Thinking deltas inline
- `thinking_log` = streaming Thinking deltas inline (each chunk = (coalesced on `\n`); Rule(start)/Rule(end) wrap each run.
one line in the scrollable log). Rule(start)/Rule(end) markers
wrap each run. The whole pane scrolls naturally — no separate
tail-scrolling Static at the bottom (v0.6.5 removed
`thinking-current`).
Exceptions caught at the presenter boundary (INV-009 fallback). Exceptions caught at the presenter boundary (INV-009 fallback).
""" """
@@ -240,6 +293,29 @@ class TuiPresenterState:
return RichText(s, style=_AU_DEMOTED) return RichText(s, style=_AU_DEMOTED)
try: 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.7.1: Thinking deltas coalesce by newline before flushing. # v0.7.1: Thinking deltas coalesce by newline before flushing.
# Worldtree emits Thinking events at token granularity; per-delta # Worldtree emits Thinking events at token granularity; per-delta
# RichLog writes produce one visual line per token (per-token-per- # RichLog writes produce one visual line per token (per-token-per-
@@ -284,51 +360,99 @@ class TuiPresenterState:
self.thinking_open = False self.thinking_open = False
# Now render the non-thinking event itself. # Now render the non-thinking event itself.
if isinstance(event, Text): if isinstance(event, Text):
# v0.6.0: streaming text accumulates into current_text Static # v0.8.1: stream Text deltas into transcript directly,
# — one growing live line, NOT per-delta RichLog entries. # coalesced on `\n`. Same pattern as Thinking (v0.7.1).
self.text_buffer.append(event.content) # The pre-v0.8.1 #current-text Static is gone — its dock-
current_text.update("".join(self.text_buffer)) # bottom growth was overlapping the transcript visually.
#
# v0.9.0: Text deltas accumulate in text_chunk_buffer and
# the current_response_widget renders Markdown(buffer) in
# place. First Text delta of the turn mounts a fresh Static
# holding the Markdown Renderable; subsequent deltas update
# the same widget. Live markdown rendering — no post-Done
# re-render needed.
from rich.markdown import Markdown
self.text_chunk_buffer += event.content
# --raw bypasses Markdown rendering — useful for debugging
# the raw text stream surface, and matches the pre-v0.9.0
# --raw semantics (which dropped the post-Done Markdown re-
# render). In raw mode the response widget holds plain str.
rendered = (
self.text_chunk_buffer if raw else Markdown(self.text_chunk_buffer)
)
if self.current_response_widget is None:
self.current_response_widget = Static(
rendered, classes="response-md"
)
transcript.mount(self.current_response_widget)
else:
self.current_response_widget.update(rendered)
transcript.scroll_end(animate=False)
return return
if isinstance(event, (Done, Error, Cancelled)): if isinstance(event, (Done, Error, Cancelled)):
# Terminal event: clear the streaming Static first so the # v0.10.0: emit turn-summary to debug pane before clearing
# live-preview band collapses. Then write the colored label # counters. Aggregates the per-event totals (Text + Thinking
# + (non-raw) Markdown body / (raw) accumulated plain text # deltas don't get per-event audit lines because they arrive
# to the transcript. # at token rate; the summary surfaces what was elided).
accumulated = "".join(self.text_buffer) elapsed_ms = (
self.text_buffer.clear() int((_time.monotonic() - self.turn_start_ts) * 1000)
current_text.update("") if self.turn_start_ts
# Terminal labels tinted per outcome (Aurora green / Dawn red else 0
# / Dawn yellow) for at-a-glance scanning. )
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
# re-render, no double-print.
self.text_chunk_buffer = ""
self.current_response_widget = None
# Terminal labels mount as styled Statics. Tinted per outcome
# (Aurora green / Dawn red / Dawn yellow) for at-a-glance
# scanning.
if isinstance(event, Done): if isinstance(event, Done):
log.write(RichText( transcript.mount(Static(
f"[done] turn_id={event.sse_id.turn_id} model={event.model} " RichText(
f"duration={_format_duration_ms(event.duration_ms)} " f"[done] turn_id={event.sse_id.turn_id} "
f"usage {_format_usage(event.usage, arrow='→')}", f"model={event.model} "
style=_AU_SUCCESS, f"duration={_format_duration_ms(event.duration_ms)} "
f"usage {_format_usage(event.usage, arrow='→')}",
style=_AU_SUCCESS,
),
classes="done-label",
)) ))
if raw:
# Raw mode: emit the accumulated streamed text verbatim
# so the operator has a record after the Static clears.
if accumulated:
log.write(accumulated)
else:
from rich.markdown import Markdown
from rich.rule import Rule
log.write(Rule(style=_AU_DEMOTED))
log.write(Markdown(event.response))
elif isinstance(event, Error): elif isinstance(event, Error):
log.write(RichText( transcript.mount(Static(
f"[error] turn_id={event.sse_id.turn_id} code={event.error_code} " RichText(
f"message={event.message!r}", f"[error] turn_id={event.sse_id.turn_id} "
style=_AU_ERROR, f"code={event.error_code} message={event.message!r}",
style=_AU_ERROR,
),
classes="error-label",
)) ))
else: # Cancelled else: # Cancelled
log.write(RichText( transcript.mount(Static(
f"[cancelled] turn_id={event.turn_id} reason={event.reason!r} " RichText(
f"partial_message_id={event.partial_message_id}", f"[cancelled] turn_id={event.turn_id} "
style=_AU_WARNING, f"reason={event.reason!r} "
f"partial_message_id={event.partial_message_id}",
style=_AU_WARNING,
),
classes="cancelled-label",
)) ))
transcript.scroll_end(animate=False)
return return
if isinstance(event, WorkerPhase): if isinstance(event, WorkerPhase):
# v0.5.0: telemetry → Debug pane, not transcript. # v0.5.0: telemetry → Debug pane, not transcript.
@@ -360,22 +484,24 @@ class TuiPresenterState:
# the original event AND a render_error line with the class name only # the original event AND a render_error line with the class name only
# (NO exception message — security clause). Volva F1 fix. # (NO exception message — security clause). Volva F1 fix.
# #
# v0.6.0 routing-under-failure preservation — fallback writes go # v0.9.0 routing-under-failure: panes (RichLog) still write Strip
# to the same destination the successful render would have used: # lines; transcript (VerticalScroll) mounts a Static instead.
# - ToolStart/ToolResult → tools_log
# - Thinking → thinking_log
# - WorkerPhase/TextBoundary → debug_log
# - everything else → log
if isinstance(event, (ToolStart, ToolResult)): if isinstance(event, (ToolStart, ToolResult)):
target = tools_log tools_log.write(_plain_label(event))
tools_log.write(f"[render_error] {type(exc).__name__}")
elif isinstance(event, Thinking): elif isinstance(event, Thinking):
target = thinking_log thinking_log.write(_plain_label(event))
thinking_log.write(f"[render_error] {type(exc).__name__}")
elif isinstance(event, (WorkerPhase, TextBoundary)): elif isinstance(event, (WorkerPhase, TextBoundary)):
target = debug_log debug_log.write(_plain_label(event))
debug_log.write(f"[render_error] {type(exc).__name__}")
else: else:
target = log # Transcript-bound event (Text / Done / Error / Cancelled).
target.write(_plain_label(event)) transcript.mount(Static(_plain_label(event), classes="error-label"))
target.write(f"[render_error] {type(exc).__name__}") transcript.mount(
Static(f"[render_error] {type(exc).__name__}", classes="error-label")
)
transcript.scroll_end(animate=False)
class AgentPickerApp(App[str | None]): class AgentPickerApp(App[str | None]):
@@ -564,21 +690,43 @@ class RatatoskrApp(App[int]):
} }
/* v0.6.5: thinking-current Static removed; thinking now streams /* v0.6.5: thinking-current Static removed; thinking now streams
directly into thinking-log so the whole pane scrolls naturally. */ directly into thinking-log so the whole pane scrolls naturally. */
#transcript { /* v0.9.0: transcript is a VerticalScroll container holding dynamically
mounted Statics + Markdown widgets per turn. Live Markdown rendering
replaces the v0.8.x RichLog approach which couldn't render Markdown
in-flight (only on Done as a re-render → double-print bug). */
#transcript-scroll {
height: 1fr; height: 1fr;
background: $background; background: $background;
padding: 0 1; padding: 0 1;
} }
/* v0.6.0: streaming-text Static carries in-flight assistant tokens. /* Per-turn mounted widgets carry id-prefix conventions:
Replaces per-token RichLog spam — one growing line that updates in - .turn-header "── turn N ──" (dim)
place. Cleared on terminal event; final Markdown body lands in the - .prompt-echo "❯ user input" (aurora bright cyan)
transcript. */ - .response-md Markdown(accumulated_text) — updated live
#current-text { - .done-label "[done] turn_id=…" (aurora green)
dock: bottom; - .error-label "[error] …" (dawn red)
- .cancelled-label "[cancelled] …" (dawn yellow)
*/
.turn-header {
height: auto;
padding: 0 1;
color: $au-dark-60;
}
.prompt-echo {
height: auto; height: auto;
background: $background;
padding: 0 1; padding: 0 1;
} }
.response-md {
height: auto;
padding: 0 1;
}
.done-label, .error-label, .cancelled-label {
height: auto;
padding: 0 1;
}
/* v0.8.1: #current-text Static removed. Streaming text now coalesces
on `\n` and writes directly to #transcript (same pattern as v0.7.1
thinking fix). Eliminates the dock-bottom-growth-overlap bug. */
#tools-log, #debug-log, #thinking-log { #tools-log, #debug-log, #thinking-log {
background: $background; background: $background;
padding: 0 1; padding: 0 1;
@@ -677,8 +825,12 @@ class RatatoskrApp(App[int]):
# work without widget-level markup=True. # work without widget-level markup=True.
with Horizontal(id="main-row"): with Horizontal(id="main-row"):
with Vertical(id="left-column"): with Vertical(id="left-column"):
yield RichLog(id="transcript", wrap=True, markup=False, highlight=False) # v0.9.0: transcript is a VerticalScroll holding per-turn
yield Static("", id="current-text") # mounted widgets (turn header, prompt echo, response Markdown,
# done label). Live Markdown rendering happens via Static
# widgets holding `Markdown` Renderables, updated as Text
# deltas arrive.
yield VerticalScroll(id="transcript-scroll")
yield Input(id="prompt", placeholder="Type a message and press Enter") yield Input(id="prompt", placeholder="Type a message and press Enter")
with Vertical(id="right-column"): with Vertical(id="right-column"):
with TabbedContent(id="side-panes"): with TabbedContent(id="side-panes"):
@@ -739,22 +891,40 @@ class RatatoskrApp(App[int]):
) )
self.state = "idle" self.state = "idle"
self._set_hint(self.HINT_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: def _write_turn_headers(self, turn_id: int) -> None:
"""v0.6.0: Write `── turn N ──` Rule headers across every pane so """v0.6.0: turn-ID headers across every pane for cross-pane
operators can visually correlate sections during cross-pane correlation. v0.9.0: transcript is a VerticalScroll; mounts a
debugging. Called from `_stream_turn_worker` on first event of Static with rule-style text instead of writing a Rule Renderable
each new turn (idempotent per turn via active_turn_id guard). to RichLog. Other panes still use RichLog.write(Rule).
""" """
from rich.rule import Rule from rich.rule import Rule
from rich.text import Text as RichText
title = f"turn {turn_id}" title = f"turn {turn_id}"
rule = Rule(title=title, style=_AU_DEMOTED) rule = Rule(title=title, style=_AU_DEMOTED)
try: try:
self.query_one("#transcript", RichLog).write(rule) # Transcript (VerticalScroll): mount a styled Static.
transcript = self.query_one("#transcript-scroll", VerticalScroll)
transcript.mount(
Static(
RichText(f"── turn {turn_id} ──", style=_AU_DEMOTED),
classes="turn-header",
)
)
# Other panes (RichLog): write the Rule Renderable.
self.query_one("#tools-log", RichLog).write(rule) self.query_one("#tools-log", RichLog).write(rule)
self.query_one("#debug-log", RichLog).write(rule) self.query_one("#debug-log", RichLog).write(rule)
self.query_one("#thinking-log", RichLog).write(rule) self.query_one("#thinking-log", RichLog).write(rule)
transcript.scroll_end(animate=False)
except Exception: except Exception:
# Defensive: widget tree may be tearing down — never let a # Defensive: widget tree may be tearing down — never let a
# turn-header write block the SSE consumer. # turn-header write block the SSE consumer.
@@ -769,13 +939,51 @@ class RatatoskrApp(App[int]):
# Widget may be gone during shutdown; ignore. # Widget may be gone during shutdown; ignore.
pass 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 idle→streaming→cancelling→idle 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: async def on_input_submitted(self, event: Input.Submitted) -> None:
"""Echo user prompt, spawn stream worker; busy notice if not idle.""" """Echo user prompt, spawn stream worker; busy notice if not idle.
v0.9.0: prompt echo mounts as a Static in the transcript VerticalScroll
(was log.write to RichLog).
"""
if event.input.id != "prompt": if event.input.id != "prompt":
return return
log = self.query_one("#transcript", RichLog) transcript = self.query_one("#transcript-scroll", VerticalScroll)
if self.state != "idle": if self.state != "idle":
log.write("[busy] turn in flight; input ignored") transcript.mount(
Static("[busy] turn in flight; input ignored", classes="error-label")
)
transcript.scroll_end(animate=False)
event.input.value = "" event.input.value = ""
return return
content = event.input.value.strip() content = event.input.value.strip()
@@ -784,37 +992,53 @@ class RatatoskrApp(App[int]):
# v0.4.1 retheme: operator's voice gets Australis bright cyan so it # v0.4.1 retheme: operator's voice gets Australis bright cyan so it
# stands out against the default-foreground assistant text below it. # stands out against the default-foreground assistant text below it.
from rich.text import Text as RichText from rich.text import Text as RichText
log.write(RichText(f"❯ {content}", style=_AU_USER_ECHO)) # noqa: RUF001 transcript.mount(
Static(
RichText(f"❯ {content}", style=_AU_USER_ECHO), # noqa: RUF001
classes="prompt-echo",
)
)
transcript.scroll_end(animate=False)
event.input.value = "" 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._set_hint(self.HINT_STREAMING)
self.stream_worker = self.run_worker( self.stream_worker = self.run_worker(
self._stream_turn_worker(content), exclusive=True self._stream_turn_worker(content), exclusive=True
) )
async def _stream_turn_worker(self, content: str) -> None: async def _stream_turn_worker(self, content: str) -> None:
"""Drive stream_turn, render events via TuiPresenterState (issue #12).""" """Drive stream_turn, render events via TuiPresenterState.
v0.9.0: transcript is a VerticalScroll; the presenter's `transcript`
argument is the container, and the presenter mounts Static / Markdown-
backed widgets directly. Wire-error labels mount as `error-label`
Statics into the transcript-scroll.
"""
assert self.state == "streaming" assert self.state == "streaming"
assert self.client is not None assert self.client is not None
assert content assert content
log = self.query_one("#transcript", RichLog) transcript = self.query_one("#transcript-scroll", VerticalScroll)
current_text = self.query_one("#current-text", Static)
tools_log = self.query_one("#tools-log", RichLog) tools_log = self.query_one("#tools-log", RichLog)
debug_log = self.query_one("#debug-log", RichLog) debug_log = self.query_one("#debug-log", RichLog)
thinking_log = self.query_one("#thinking-log", RichLog) thinking_log = self.query_one("#thinking-log", RichLog)
presenter = TuiPresenterState() presenter = TuiPresenterState()
def _mount_wire_error(label: str) -> None:
try:
transcript.mount(Static(label, classes="error-label"))
transcript.scroll_end(animate=False)
except Exception:
pass
try: try:
async for event in stream_turn(self.client, self.session_id, content): async for event in stream_turn(self.client, self.session_id, content):
if self.active_turn_id is None: if self.active_turn_id is None:
self.active_turn_id = event.sse_id.turn_id self.active_turn_id = event.sse_id.turn_id
# v0.6.0: turn-ID headers across all panes so the
# operator can visually correlate sections during
# cross-pane debugging.
self._write_turn_headers(self.active_turn_id) self._write_turn_headers(self.active_turn_id)
presenter.render( presenter.render(
event, event,
log=log, transcript=transcript,
current_text=current_text,
tools_log=tools_log, tools_log=tools_log,
debug_log=debug_log, debug_log=debug_log,
thinking_log=thinking_log, thinking_log=thinking_log,
@@ -823,17 +1047,22 @@ class RatatoskrApp(App[int]):
if isinstance(event, (Done, Error, Cancelled)): if isinstance(event, (Done, Error, Cancelled)):
break break
except SseConnectFailed as exc: except SseConnectFailed as exc:
log.write(f"[sse_connect_failed] status={exc.status} body={exc.body!r}") 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: except SseConnectionDropped as exc:
log.write(f"[connection_dropped] last_seen={exc.last_seen_sse_id}") 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: except MalformedSseId as exc:
log.write(f"[malformed_sse_id] raw={exc.raw!r}") 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: except MalformedSseData as exc:
log.write(f"[malformed_sse_data] raw={exc.raw!r}") 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: except TurnIdFlip as exc:
log.write(f"[turn_id_flip] expected={exc.established} got={exc.got}") 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: finally:
self.state = "idle" self._transition("idle", "worker_finally")
self.active_turn_id = None self.active_turn_id = None
self._set_hint(self.HINT_IDLE) self._set_hint(self.HINT_IDLE)
@@ -845,26 +1074,35 @@ class RatatoskrApp(App[int]):
"""Two-stage Ctrl-C state machine per INV-003.""" """Two-stage Ctrl-C state machine per INV-003."""
assert self.state in ("idle", "streaming", "cancelling") assert self.state in ("idle", "streaming", "cancelling")
if self.state == "idle": if self.state == "idle":
self._audit("ctrl_c state=idle action=exit code=0")
self.exit(0) self.exit(0)
elif self.state == "streaming": elif self.state == "streaming":
if self.active_turn_id is None: 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: if self.stream_worker is not None:
self.stream_worker.cancel() self.stream_worker.cancel()
self.exit(3) self.exit(3)
return 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) self._set_hint(self.HINT_CANCELLING)
log = self.query_one("#transcript", RichLog) transcript = self.query_one("#transcript-scroll", VerticalScroll)
self.run_worker( self.run_worker(
_cancel_via_sse(self.client, self.session_id, self.active_turn_id, log=log) _cancel_via_sse(
self.client, self.session_id, self.active_turn_id,
transcript=transcript,
audit=self._audit,
)
) )
elif self.state == "cancelling": elif self.state == "cancelling":
self._audit("ctrl_c state=cancelling action=force_exit code=3")
if self.stream_worker is not None: if self.stream_worker is not None:
self.stream_worker.cancel() self.stream_worker.cancel()
self.exit(3) self.exit(3)
def action_quit(self) -> None: def action_quit(self) -> None:
"""Ctrl-D — immediate exit regardless of state.""" """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: if self.stream_worker is not None and not self.stream_worker.is_finished:
self.stream_worker.cancel() self.stream_worker.cancel()
self.exit(0) self.exit(0)
@@ -1005,12 +1243,33 @@ async def _cancel_via_sse(
session_id: str, session_id: str,
turn_id: int, turn_id: int,
*, *,
log: RichLog, transcript: VerticalScroll,
audit: "Callable[[str], None] | None" = None,
) -> None: ) -> None:
"""Fire-and-forget cancel; never raises (mirrors cli._cancel_and_log; #3 INV-009).""" """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 client is not None
assert isinstance(turn_id, int) and turn_id > 0 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: try:
await cancel_turn(client, session_id, turn_id) 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: except (CancelFailed, CancelTurnNotFound, CancelAlreadyCompleted, httpx.RequestError) as exc:
log.write(f"[cancel_failed] {type(exc).__name__}: {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}",
classes="error-label",
))
transcript.scroll_end(animate=False)
except Exception:
pass
+41
View File
@@ -711,6 +711,47 @@ def _sse_raw_chunk(sse_id: str, raw_data: str) -> bytes:
return f"id: {sse_id}\ndata: {raw_data}\n\n".encode() return f"id: {sse_id}\ndata: {raw_data}\n\n".encode()
def _sse_no_id_chunk(data: str) -> bytes:
"""SSE frame with NO id line + arbitrary data (v0.8.1: keepalive shape)."""
return f"data: {data}\n\n".encode()
class TestEmptyIdSkipped:
@respx.mock
async def test_empty_id_on_first_event_skipped(self) -> None:
"""empty_id_on_first_event_skipped [v0.8.1]: stream starts with an
event carrying NO `id:` line → httpx_sse exposes sse.id == ''
(no prior id to inherit). Pre-v0.8.1: MalformedSseId raw='' crashed
the turn. v0.8.1: treat same as empty-data keepalive — skip silently.
Observed 2026-05-25 on Worldtree's qwen3.6-35-a3b-heretic provider:
the first stream frame had no id line, every turn died with
`[malformed_sse_id] raw=''`.
"""
from ratatoskr.sse_client import Done as _Done
from ratatoskr.sse_client import Text as _Text
# First frame: no id line (httpx_sse → sse.id = ""). Skip it.
# Subsequent frames have ids; normal processing resumes.
stream = (
_sse_no_id_chunk('{"type":"keepalive"}') # ← skipped (sse.id == "")
+ _sse_chunk("42:1", {"type": "text", "content": "first"})
+ _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")]
# 2 events — the no-id frame is invisible (no MalformedSseId crash).
assert len(events) == 2
assert isinstance(events[0], _Text)
assert events[0].content == "first"
assert isinstance(events[1], _Done)
class TestEmptyDataSkipped: class TestEmptyDataSkipped:
@respx.mock @respx.mock
async def test_empty_data_skipped(self) -> None: async def test_empty_data_skipped(self) -> None:
+420 -183
View File
File diff suppressed because it is too large Load Diff
Generated
+1 -1
View File
@@ -968,7 +968,7 @@ wheels = [
[[package]] [[package]]
name = "ratatoskr" name = "ratatoskr"
version = "0.8.0" version = "0.10.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "httpx" }, { name = "httpx" },