Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44138590ad |
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "ratatoskr"
|
name = "ratatoskr"
|
||||||
version = "0.12.0"
|
version = "0.13.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"
|
||||||
|
|||||||
+206
-6
@@ -35,9 +35,13 @@ from textual.widgets import (
|
|||||||
from ratatoskr.cli import USER_AGENT, ParsedArgs, _format_duration_ms, _format_usage
|
from ratatoskr.cli import USER_AGENT, ParsedArgs, _format_duration_ms, _format_usage
|
||||||
from ratatoskr.sessions import (
|
from ratatoskr.sessions import (
|
||||||
AgentInfo,
|
AgentInfo,
|
||||||
|
AgentNotAvailable,
|
||||||
AgentNotFound,
|
AgentNotFound,
|
||||||
|
AuthScopeDenied,
|
||||||
|
PersonaNotConfigured,
|
||||||
SessionApiFailed,
|
SessionApiFailed,
|
||||||
create_session,
|
create_session,
|
||||||
|
get_persona_state,
|
||||||
list_agents,
|
list_agents,
|
||||||
)
|
)
|
||||||
from ratatoskr.sse_client import (
|
from ratatoskr.sse_client import (
|
||||||
@@ -187,6 +191,79 @@ def _ts() -> str:
|
|||||||
return now.strftime("%H:%M:%S") + f".{now.microsecond // 1000:03d}"
|
return now.strftime("%H:%M:%S") + f".{now.microsecond // 1000:03d}"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_persona_header(snapshot: dict) -> str:
|
||||||
|
"""One-line persona summary for the sticky header widget.
|
||||||
|
|
||||||
|
Shape: `agent_id · dominant_emotion · pad(P, A, D) · N emotions active`.
|
||||||
|
Built for at-a-glance scanning above the chat area — concise enough to
|
||||||
|
fit one terminal row. Full detail lives in the Persona TabPane.
|
||||||
|
"""
|
||||||
|
pad = snapshot.get("pad") or {}
|
||||||
|
emotions = snapshot.get("emotions_active") or []
|
||||||
|
dom = snapshot.get("dominant_emotion") or "?"
|
||||||
|
pieces = [
|
||||||
|
f"{snapshot.get('agent_id', '?')}",
|
||||||
|
f"{dom}",
|
||||||
|
f"pad({pad.get('pleasure', '?')}, {pad.get('arousal', '?')}, "
|
||||||
|
f"{pad.get('dominance', '?')})",
|
||||||
|
]
|
||||||
|
if emotions:
|
||||||
|
pieces.append(f"{len(emotions)} emotion{'s' if len(emotions) != 1 else ''} active")
|
||||||
|
return " · ".join(pieces)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_persona_detail(snapshot: dict) -> str:
|
||||||
|
"""Multi-line persona detail for the Persona TabPane.
|
||||||
|
|
||||||
|
Renders the full v0.28.0 snapshot shape: dominant_emotion, PAD with
|
||||||
|
baseline comparison, mood_drift deltas, active emotions list with
|
||||||
|
intensity + decay, last_updated_at footer.
|
||||||
|
"""
|
||||||
|
pad = snapshot.get("pad") or {}
|
||||||
|
baseline = snapshot.get("baseline_pad") or {}
|
||||||
|
drift = snapshot.get("mood_drift") or {}
|
||||||
|
emotions = snapshot.get("emotions_active") or []
|
||||||
|
lines: list[str] = []
|
||||||
|
agent = snapshot.get("agent_id", "?")
|
||||||
|
lines.append(f"Persona snapshot · {agent}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"Dominant emotion: {snapshot.get('dominant_emotion', '?')}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("PAD")
|
||||||
|
for axis in ("pleasure", "arousal", "dominance"):
|
||||||
|
v = pad.get(axis, "?")
|
||||||
|
b = baseline.get(axis, "?")
|
||||||
|
delta = ""
|
||||||
|
if isinstance(v, (int, float)) and isinstance(b, (int, float)):
|
||||||
|
delta = f" (Δ {v - b:+.2f})"
|
||||||
|
lines.append(f" {axis:<10} {v} baseline {b}{delta}")
|
||||||
|
lines.append("")
|
||||||
|
if drift:
|
||||||
|
lines.append("Mood drift")
|
||||||
|
for key in ("valence_delta", "arousal_delta"):
|
||||||
|
if key in drift:
|
||||||
|
v = drift[key]
|
||||||
|
lines.append(f" {key:<16} {v:+}" if isinstance(v, (int, float))
|
||||||
|
else f" {key:<16} {v}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"Active emotions ({len(emotions)})")
|
||||||
|
for e in emotions:
|
||||||
|
et = e.get("type", "?")
|
||||||
|
ei = e.get("intensity", "?")
|
||||||
|
decay = e.get("decay_remaining_s")
|
||||||
|
decay_str = (
|
||||||
|
f" decay {decay / 60:.1f}m" if isinstance(decay, (int, float)) else ""
|
||||||
|
)
|
||||||
|
lines.append(f" {et:<20} intensity {ei}{decay_str}")
|
||||||
|
if not emotions:
|
||||||
|
lines.append(" (none)")
|
||||||
|
last = snapshot.get("last_updated_at")
|
||||||
|
if last:
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"Last updated: {last}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
def _audit_line(event: Event) -> str:
|
def _audit_line(event: Event) -> str:
|
||||||
"""One-line wire-level audit summary for the debug pane.
|
"""One-line wire-level audit summary for the debug pane.
|
||||||
|
|
||||||
@@ -280,6 +357,7 @@ class TuiPresenterState:
|
|||||||
debug_log: RichLog,
|
debug_log: RichLog,
|
||||||
thinking_log: RichLog,
|
thinking_log: RichLog,
|
||||||
raw: bool,
|
raw: bool,
|
||||||
|
on_persona_snapshot: object = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Render one Worldtree SSE event with the TUI hierarchy + coalescing.
|
"""Render one Worldtree SSE event with the TUI hierarchy + coalescing.
|
||||||
|
|
||||||
@@ -292,6 +370,12 @@ class TuiPresenterState:
|
|||||||
- `thinking_log` (RichLog) = streaming Thinking deltas inline
|
- `thinking_log` (RichLog) = streaming Thinking deltas inline
|
||||||
(coalesced on `\n`); Rule(start)/Rule(end) wrap each run.
|
(coalesced on `\n`); Rule(start)/Rule(end) wrap each run.
|
||||||
|
|
||||||
|
v0.13.0: optional `on_persona_snapshot` callback receives the
|
||||||
|
snapshot dict whenever AffectUpdate(status="current") arrives.
|
||||||
|
Lets the App surface the snapshot to the persona-header + Persona
|
||||||
|
pane without the presenter needing direct widget access. Default
|
||||||
|
None — presenter falls back to audit-only routing.
|
||||||
|
|
||||||
Exceptions caught at the presenter boundary (INV-009 fallback).
|
Exceptions caught at the presenter boundary (INV-009 fallback).
|
||||||
"""
|
"""
|
||||||
assert isinstance(
|
assert isinstance(
|
||||||
@@ -331,13 +415,20 @@ class TuiPresenterState:
|
|||||||
if self.turn_start_ts == 0.0:
|
if self.turn_start_ts == 0.0:
|
||||||
self.turn_start_ts = _time.monotonic()
|
self.turn_start_ts = _time.monotonic()
|
||||||
debug_log.write(_dim(_audit_line(event)))
|
debug_log.write(_dim(_audit_line(event)))
|
||||||
# v0.11.0: AffectUpdate is debug-pane-only for now (the audit
|
# v0.11.0 → v0.13.0: AffectUpdate gets the audit line (above)
|
||||||
# line emitted above is the complete handling). Return early
|
# plus a callback to the App so the persona-header + Persona
|
||||||
# so the event doesn't pass through the thinking-close path
|
# pane refresh from the snapshot. status="scheduled" carries
|
||||||
# or fall into the unknown-event ValueError branch. A full
|
# no snapshot — the callback is skipped and the next turn's
|
||||||
# persona surface (Persona TabPane, sticky header line, or
|
# status="current" lands the actual update.
|
||||||
# similar) is deferred to a later bump pending UX direction.
|
|
||||||
if isinstance(event, AffectUpdate):
|
if isinstance(event, AffectUpdate):
|
||||||
|
if event.snapshot is not None and on_persona_snapshot is not None:
|
||||||
|
try:
|
||||||
|
on_persona_snapshot(event.snapshot)
|
||||||
|
except Exception:
|
||||||
|
# Persona surface failure must not break the SSE
|
||||||
|
# stream — the audit line above already records
|
||||||
|
# the event regardless.
|
||||||
|
pass
|
||||||
return
|
return
|
||||||
# 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
|
||||||
@@ -796,6 +887,20 @@ class RatatoskrApp(App[int]):
|
|||||||
color: $au-dark-60;
|
color: $au-dark-60;
|
||||||
padding: 0 1;
|
padding: 0 1;
|
||||||
}
|
}
|
||||||
|
/* v0.13.0: sticky persona-header — one-line agent persona summary
|
||||||
|
above the main row. Empty (height: 0) when the agent has no
|
||||||
|
persona surface (PersonaNotConfigured) so the chat layout
|
||||||
|
collapses cleanly. */
|
||||||
|
#persona-header {
|
||||||
|
dock: top;
|
||||||
|
height: 1;
|
||||||
|
color: $au-bright-80;
|
||||||
|
background: $surface;
|
||||||
|
padding: 0 1;
|
||||||
|
}
|
||||||
|
#persona-header.empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
BINDINGS: ClassVar[list[Binding]] = [
|
BINDINGS: ClassVar[list[Binding]] = [
|
||||||
@@ -806,6 +911,7 @@ class RatatoskrApp(App[int]):
|
|||||||
Binding("ctrl+1", "focus_tools", "Tools tab", priority=False),
|
Binding("ctrl+1", "focus_tools", "Tools tab", priority=False),
|
||||||
Binding("ctrl+2", "focus_debug", "Debug tab", priority=False),
|
Binding("ctrl+2", "focus_debug", "Debug tab", priority=False),
|
||||||
Binding("ctrl+3", "focus_thinking", "Thinking tab", priority=False),
|
Binding("ctrl+3", "focus_thinking", "Thinking tab", priority=False),
|
||||||
|
Binding("ctrl+4", "focus_persona", "Persona tab", priority=False),
|
||||||
]
|
]
|
||||||
|
|
||||||
HINT_IDLE = "Ctrl-C twice to exit"
|
HINT_IDLE = "Ctrl-C twice to exit"
|
||||||
@@ -834,6 +940,11 @@ class RatatoskrApp(App[int]):
|
|||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
yield Header()
|
yield Header()
|
||||||
|
# v0.13.0: sticky persona-header docks at the top, above main-row.
|
||||||
|
# One-line summary refreshed on each AffectUpdate(status=current).
|
||||||
|
# Starts in the .empty CSS class (height collapses to 0) until
|
||||||
|
# on_mount's get_persona_state hydration succeeds.
|
||||||
|
yield Static("", id="persona-header", classes="empty")
|
||||||
# v0.6.0 layout: left column is content-only (transcript + streaming
|
# v0.6.0 layout: left column is content-only (transcript + streaming
|
||||||
# text Static + prompt). Right column hosts thinking-current live
|
# text Static + prompt). Right column hosts thinking-current live
|
||||||
# preview above TabbedContent cycling Tools / Debug / Thinking.
|
# preview above TabbedContent cycling Tools / Debug / Thinking.
|
||||||
@@ -875,6 +986,14 @@ class RatatoskrApp(App[int]):
|
|||||||
yield RichLog(
|
yield RichLog(
|
||||||
id="thinking-log", wrap=True, markup=False, highlight=False
|
id="thinking-log", wrap=True, markup=False, highlight=False
|
||||||
)
|
)
|
||||||
|
with TabPane("Persona", id="persona-tab"):
|
||||||
|
# v0.13.0: full persona-snapshot detail (PAD,
|
||||||
|
# mood drift, active emotions). Replaced (not
|
||||||
|
# appended) on each AffectUpdate(current) — the
|
||||||
|
# snapshot is absolute state, not incremental.
|
||||||
|
yield RichLog(
|
||||||
|
id="persona-log", wrap=True, markup=False, highlight=False
|
||||||
|
)
|
||||||
# INV-002 + INV-003: visible identity + hint widgets (Footer-area).
|
# INV-002 + INV-003: visible identity + hint widgets (Footer-area).
|
||||||
# pane-name widget displays current side-pane name.
|
# pane-name widget displays current side-pane name.
|
||||||
yield Static("", id="identity")
|
yield Static("", id="identity")
|
||||||
@@ -922,6 +1041,81 @@ class RatatoskrApp(App[int]):
|
|||||||
f"session={self.session_id[-8:]} raw={self.args.raw} "
|
f"session={self.session_id[-8:]} raw={self.args.raw} "
|
||||||
f"end_user_id={getattr(self.args, 'end_user_id', None)!r}"
|
f"end_user_id={getattr(self.args, 'end_user_id', None)!r}"
|
||||||
)
|
)
|
||||||
|
# v0.13.0: hydrate persona surface on mount via
|
||||||
|
# GET /agents/{id}/persona_state. Spawns as a Textual worker so the
|
||||||
|
# network call doesn't block mount. Agents without a persona
|
||||||
|
# surface (PersonaNotConfigured) get a placeholder + empty header.
|
||||||
|
if self.agent_id is not None:
|
||||||
|
self.run_worker(self._hydrate_persona())
|
||||||
|
|
||||||
|
async def _hydrate_persona(self) -> None:
|
||||||
|
"""Hydrate persona-header + Persona pane via GET /agents/{id}/persona_state.
|
||||||
|
|
||||||
|
Failure modes are absorbed (this is best-effort observability):
|
||||||
|
- PersonaNotConfigured: pane shows placeholder, header stays empty
|
||||||
|
- AgentNotAvailable / AuthScopeDenied: error placeholder; header empty
|
||||||
|
- Network error: error placeholder; header empty
|
||||||
|
On 200: header populated, pane shows full detail, audit logged.
|
||||||
|
"""
|
||||||
|
assert self.client is not None and self.agent_id is not None
|
||||||
|
from rich.text import Text as RichText
|
||||||
|
try:
|
||||||
|
snapshot = await get_persona_state(self.client, self.agent_id)
|
||||||
|
self._update_persona_surfaces(snapshot)
|
||||||
|
self._audit(
|
||||||
|
f"persona_hydrated agent_id={self.agent_id!r} "
|
||||||
|
f"dominant_emotion={snapshot.get('dominant_emotion')!r}"
|
||||||
|
)
|
||||||
|
except PersonaNotConfigured:
|
||||||
|
self._set_persona_placeholder(
|
||||||
|
f"(persona not configured for {self.agent_id})"
|
||||||
|
)
|
||||||
|
self._audit(f"persona_not_configured agent_id={self.agent_id!r}")
|
||||||
|
except (AgentNotAvailable, AuthScopeDenied, SessionApiFailed, Exception) as exc:
|
||||||
|
# Best-effort — never let a persona hydration failure crash the
|
||||||
|
# TUI. Surface the error in the persona pane and audit log.
|
||||||
|
self._set_persona_placeholder(
|
||||||
|
f"(persona hydration failed: {type(exc).__name__})"
|
||||||
|
)
|
||||||
|
self._audit(
|
||||||
|
f"persona_hydration_failed agent_id={self.agent_id!r} "
|
||||||
|
f"err={type(exc).__name__}: {exc!s:.120}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _update_persona_surfaces(self, snapshot: dict) -> None:
|
||||||
|
"""Update sticky header + Persona pane from a fresh snapshot.
|
||||||
|
|
||||||
|
Called on bootstrap (on_mount) and on each AffectUpdate(current).
|
||||||
|
Header gets the compact one-liner; pane gets the full detail.
|
||||||
|
"""
|
||||||
|
from rich.text import Text as RichText
|
||||||
|
try:
|
||||||
|
header = self.query_one("#persona-header", Static)
|
||||||
|
header.update(RichText(_format_persona_header(snapshot)))
|
||||||
|
header.remove_class("empty")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
log = self.query_one("#persona-log", RichLog)
|
||||||
|
log.clear()
|
||||||
|
log.write(_format_persona_detail(snapshot))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _set_persona_placeholder(self, text: str) -> None:
|
||||||
|
"""Render an italic-dim placeholder in the Persona pane; keep header empty.
|
||||||
|
|
||||||
|
Used when persona hydration returns PersonaNotConfigured or fails —
|
||||||
|
the pane stays usable as documentation of *why* it's empty without
|
||||||
|
the sticky header consuming a row for nothing.
|
||||||
|
"""
|
||||||
|
from rich.text import Text as RichText
|
||||||
|
try:
|
||||||
|
log = self.query_one("#persona-log", RichLog)
|
||||||
|
log.clear()
|
||||||
|
log.write(RichText(text, style=f"{_AU_DEMOTED_FAINT} italic"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
def _write_turn_headers(self, turn_id: int) -> None:
|
def _write_turn_headers(self, turn_id: int) -> None:
|
||||||
"""v0.6.0: turn-ID headers across every pane for cross-pane
|
"""v0.6.0: turn-ID headers across every pane for cross-pane
|
||||||
@@ -1066,6 +1260,7 @@ class RatatoskrApp(App[int]):
|
|||||||
debug_log=debug_log,
|
debug_log=debug_log,
|
||||||
thinking_log=thinking_log,
|
thinking_log=thinking_log,
|
||||||
raw=self.args.raw,
|
raw=self.args.raw,
|
||||||
|
on_persona_snapshot=self._update_persona_surfaces,
|
||||||
)
|
)
|
||||||
if isinstance(event, (Done, Error, Cancelled)):
|
if isinstance(event, (Done, Error, Cancelled)):
|
||||||
break
|
break
|
||||||
@@ -1151,6 +1346,11 @@ class RatatoskrApp(App[int]):
|
|||||||
self.query_one("#side-panes", TabbedContent).active = "thinking-tab"
|
self.query_one("#side-panes", TabbedContent).active = "thinking-tab"
|
||||||
self.query_one("#pane-name", Static).update("Thinking")
|
self.query_one("#pane-name", Static).update("Thinking")
|
||||||
|
|
||||||
|
def action_focus_persona(self) -> None:
|
||||||
|
"""v0.13.0: Ctrl+4 activates the Persona tab. INV-016 preserves Input focus."""
|
||||||
|
self.query_one("#side-panes", TabbedContent).active = "persona-tab"
|
||||||
|
self.query_one("#pane-name", Static).update("Persona")
|
||||||
|
|
||||||
|
|
||||||
def run_tui(args: ParsedArgs) -> int:
|
def run_tui(args: ParsedArgs) -> int:
|
||||||
"""Sync entry point — delegates to the async resolve-then-run flow.
|
"""Sync entry point — delegates to the async resolve-then-run flow.
|
||||||
|
|||||||
@@ -934,6 +934,156 @@ class TestCancelViaSse:
|
|||||||
assert audit_lines[1].startswith("cancel_post failed turn_id=42 CancelFailed")
|
assert audit_lines[1].startswith("cancel_post failed turn_id=42 CancelFailed")
|
||||||
|
|
||||||
|
|
||||||
|
class TestPersonaFormatters:
|
||||||
|
"""v0.13.0 — _format_persona_header / _format_persona_detail rendering."""
|
||||||
|
|
||||||
|
def test_header_compact_summary(self) -> None:
|
||||||
|
"""header_compact_summary: agent_id · dominant_emotion · pad(P,A,D) · N emotions."""
|
||||||
|
from ratatoskr.tui import _format_persona_header
|
||||||
|
|
||||||
|
snapshot = {
|
||||||
|
"agent_id": "mimir",
|
||||||
|
"dominant_emotion": "curiosity",
|
||||||
|
"pad": {"pleasure": 0.52, "arousal": 0.47, "dominance": 0.50},
|
||||||
|
"emotions_active": [
|
||||||
|
{"type": "curiosity"}, {"type": "joy"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
line = _format_persona_header(snapshot)
|
||||||
|
assert "mimir" in line
|
||||||
|
assert "curiosity" in line
|
||||||
|
assert "pad(0.52, 0.47, 0.5)" in line
|
||||||
|
assert "2 emotions active" in line
|
||||||
|
|
||||||
|
def test_header_singular_emotion(self) -> None:
|
||||||
|
"""header_singular_emotion: single emotion → '1 emotion active' (no 's')."""
|
||||||
|
from ratatoskr.tui import _format_persona_header
|
||||||
|
|
||||||
|
line = _format_persona_header(
|
||||||
|
{
|
||||||
|
"agent_id": "mimir",
|
||||||
|
"dominant_emotion": "calm",
|
||||||
|
"pad": {"pleasure": 0.5, "arousal": 0.4, "dominance": 0.5},
|
||||||
|
"emotions_active": [{"type": "calm"}],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert "1 emotion active" in line
|
||||||
|
assert "1 emotions" not in line
|
||||||
|
|
||||||
|
def test_header_no_emotions_drops_count(self) -> None:
|
||||||
|
"""header_no_emotions_drops_count: empty emotions list → no count suffix."""
|
||||||
|
from ratatoskr.tui import _format_persona_header
|
||||||
|
|
||||||
|
line = _format_persona_header(
|
||||||
|
{
|
||||||
|
"agent_id": "mimir",
|
||||||
|
"dominant_emotion": "?",
|
||||||
|
"pad": {"pleasure": 0.5, "arousal": 0.4, "dominance": 0.5},
|
||||||
|
"emotions_active": [],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert "emotion" not in line # neither "1 emotion" nor "N emotions"
|
||||||
|
|
||||||
|
def test_detail_renders_full_snapshot(self) -> None:
|
||||||
|
"""detail_renders_full_snapshot: PAD axes + baseline + delta + drift +
|
||||||
|
emotions + last_updated_at all surface in the multi-line render.
|
||||||
|
"""
|
||||||
|
from ratatoskr.tui import _format_persona_detail
|
||||||
|
|
||||||
|
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",
|
||||||
|
}
|
||||||
|
detail = _format_persona_detail(snapshot)
|
||||||
|
assert "Persona snapshot · mimir" in detail
|
||||||
|
assert "Dominant emotion: curiosity" in detail
|
||||||
|
assert "pleasure" in detail
|
||||||
|
assert "baseline 0.5" in detail
|
||||||
|
assert "+0.02" in detail or "0.02" in detail
|
||||||
|
assert "valence_delta" in detail
|
||||||
|
assert "curiosity" in detail
|
||||||
|
assert "intensity 0.6" in detail
|
||||||
|
assert "decay 3.4m" in detail
|
||||||
|
assert "2026-05-25T22:30:18+00:00" in detail
|
||||||
|
|
||||||
|
|
||||||
|
class TestPresenterPersonaCallback:
|
||||||
|
"""v0.13.0 — presenter wires AffectUpdate snapshots into a callback."""
|
||||||
|
|
||||||
|
def test_current_invokes_callback_with_snapshot(self) -> None:
|
||||||
|
"""current_invokes_callback_with_snapshot: AffectUpdate(current, snapshot)
|
||||||
|
calls on_persona_snapshot(snapshot)."""
|
||||||
|
from ratatoskr.sse_client import AffectUpdate
|
||||||
|
from ratatoskr.tui import TuiPresenterState
|
||||||
|
|
||||||
|
captured: list = []
|
||||||
|
state = TuiPresenterState()
|
||||||
|
snapshot = {"agent_id": "mimir", "pad": {"pleasure": 0.5}}
|
||||||
|
state.render(
|
||||||
|
AffectUpdate(sse_id=SID, status="current", turn_id=42, snapshot=snapshot),
|
||||||
|
transcript=MagicMock(),
|
||||||
|
tools_log=MagicMock(),
|
||||||
|
debug_log=MagicMock(),
|
||||||
|
thinking_log=MagicMock(),
|
||||||
|
raw=False,
|
||||||
|
on_persona_snapshot=captured.append,
|
||||||
|
)
|
||||||
|
assert captured == [snapshot]
|
||||||
|
|
||||||
|
def test_scheduled_does_not_invoke_callback(self) -> None:
|
||||||
|
"""scheduled_does_not_invoke_callback: status=scheduled has no snapshot,
|
||||||
|
so the callback is skipped (would be called with None otherwise)."""
|
||||||
|
from ratatoskr.sse_client import AffectUpdate
|
||||||
|
from ratatoskr.tui import TuiPresenterState
|
||||||
|
|
||||||
|
captured: list = []
|
||||||
|
state = TuiPresenterState()
|
||||||
|
state.render(
|
||||||
|
AffectUpdate(sse_id=SID, status="scheduled", turn_id=42, snapshot=None),
|
||||||
|
transcript=MagicMock(),
|
||||||
|
tools_log=MagicMock(),
|
||||||
|
debug_log=MagicMock(),
|
||||||
|
thinking_log=MagicMock(),
|
||||||
|
raw=False,
|
||||||
|
on_persona_snapshot=captured.append,
|
||||||
|
)
|
||||||
|
assert captured == []
|
||||||
|
|
||||||
|
def test_callback_exception_swallowed(self) -> None:
|
||||||
|
"""callback_exception_swallowed: a raising callback does NOT crash the
|
||||||
|
presenter — the audit line still landed (it precedes the callback).
|
||||||
|
"""
|
||||||
|
from ratatoskr.sse_client import AffectUpdate
|
||||||
|
from ratatoskr.tui import TuiPresenterState
|
||||||
|
|
||||||
|
def boom(_snap: dict) -> None:
|
||||||
|
raise RuntimeError("widget tearing down")
|
||||||
|
|
||||||
|
debug_log = MagicMock()
|
||||||
|
state = TuiPresenterState()
|
||||||
|
# Should NOT raise
|
||||||
|
state.render(
|
||||||
|
AffectUpdate(
|
||||||
|
sse_id=SID, status="current", turn_id=42, snapshot={"agent_id": "x"}
|
||||||
|
),
|
||||||
|
transcript=MagicMock(),
|
||||||
|
tools_log=MagicMock(),
|
||||||
|
debug_log=debug_log,
|
||||||
|
thinking_log=MagicMock(),
|
||||||
|
raw=False,
|
||||||
|
on_persona_snapshot=boom,
|
||||||
|
)
|
||||||
|
# Audit line still emitted (it runs before the callback)
|
||||||
|
assert debug_log.write.called
|
||||||
|
|
||||||
|
|
||||||
class TestAppMount:
|
class TestAppMount:
|
||||||
"""on_mount narrows per issue #6: only identity-widget population.
|
"""on_mount narrows per issue #6: only identity-widget population.
|
||||||
|
|
||||||
@@ -1104,6 +1254,43 @@ class TestLayoutShape:
|
|||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
assert app.query_one("#side-panes", TabbedContent).active == "debug-tab"
|
assert app.query_one("#side-panes", TabbedContent).active == "debug-tab"
|
||||||
|
|
||||||
|
async def test_persona_tab_exists(self) -> None:
|
||||||
|
"""persona_tab_exists [v0.13.0]: right column has Persona TabPane +
|
||||||
|
#persona-log RichLog as descendant.
|
||||||
|
"""
|
||||||
|
from textual.widgets import RichLog, TabPane
|
||||||
|
|
||||||
|
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
persona_tab = app.query_one("#persona-tab", TabPane)
|
||||||
|
persona_log = app.query_one("#persona-log", RichLog)
|
||||||
|
assert persona_log in persona_tab.walk_children()
|
||||||
|
|
||||||
|
async def test_ctrl_4_activates_persona_tab(self) -> None:
|
||||||
|
"""ctrl_4_activates_persona_tab [v0.13.0]: Ctrl+4 → active == 'persona-tab'."""
|
||||||
|
from textual.widgets import TabbedContent
|
||||||
|
|
||||||
|
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
await pilot.press("ctrl+4")
|
||||||
|
await pilot.pause()
|
||||||
|
assert app.query_one("#side-panes", TabbedContent).active == "persona-tab"
|
||||||
|
|
||||||
|
async def test_persona_header_starts_empty(self) -> None:
|
||||||
|
"""persona_header_starts_empty [v0.13.0]: sticky persona-header widget
|
||||||
|
exists, starts hidden (height collapsed via .empty class) until
|
||||||
|
hydration succeeds.
|
||||||
|
"""
|
||||||
|
from textual.widgets import Static
|
||||||
|
|
||||||
|
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
header = app.query_one("#persona-header", Static)
|
||||||
|
assert "empty" in header.classes
|
||||||
|
|
||||||
async def test_done_label_styled_success(self) -> None:
|
async def test_done_label_styled_success(self) -> None:
|
||||||
"""done_label_styled_success [v0.9.0]: [done] label mounts as Static
|
"""done_label_styled_success [v0.9.0]: [done] label mounts as Static
|
||||||
carrying a RichText with Aurora green style. Inspect the mounted
|
carrying a RichText with Aurora green style. Inspect the mounted
|
||||||
|
|||||||
Reference in New Issue
Block a user