feat(sse,tui): bump spec pin to v0.29.0 + AwaitingLlmFirstToken (v0.14.0)

Spec pin moved da93ca7 (v0.28.0) → 562001a (v0.29.0); vendored
conversation-api-spec.md + conversation_api.contract.md re-snapshotted.
The only material delta is Worldtree #201's awaiting_llm_first_token
SSE heartbeat — a top-level event (NOT a worker_phase extension, per
INV-053's three-field stability) that fires at a configurable interval
(default 5s) during the BuildingPrompt → CallingLLM gap.

Wire layer (sse_client.py):
- New `AwaitingLlmFirstToken` dataclass: sse_id / turn_id /
  elapsed_ms_since_building_prompt (server-authoritative monotonic)
- Added to Event union + _envelope_for_type dispatch branch
- Without this, ratatoskr would crash on any slow-first-token turn
  from a v0.29.0 server (unknown SSE event type → ValueError)

TUI layer (tui.py):
- Audit pipeline: per-event debug-pane line with elapsed in seconds
- Live transcript indicator: first heartbeat mounts a Static
  ("awaiting first token · 5.0s"); subsequent heartbeats update it
  in place; any non-heartbeat event removes it (the gap closed)
- Turn-summary line now carries heartbeat count
- Indicator demoted via .awaiting-label CSS so it reads as ambient
  progress, not content

Tests: 2 wire-layer (single + monotonic sequence) + 3 presenter
(audit line shape, single-mount semantic, indicator removal on gap
close). Suite: 318 passing.
This commit is contained in:
vh
2026-05-25 22:57:33 -07:00
parent 44138590ad
commit 78bfcadb9e
9 changed files with 363 additions and 11 deletions
+89
View File
@@ -719,6 +719,95 @@ class TestPresenterAuditLogging:
assert not tools_log.write.called
assert not thinking_log.write.called
def test_awaiting_llm_first_token_mounts_indicator(self) -> None:
"""awaiting_llm_first_token_mounts_indicator [v0.14.0]: first heartbeat
mounts a Static into the transcript and bumps heartbeat_count;
debug-pane audit line carries turn_id + elapsed in seconds.
"""
from ratatoskr.sse_client import AwaitingLlmFirstToken
from ratatoskr.tui import TuiPresenterState
transcript = MagicMock()
debug_log = MagicMock()
state = TuiPresenterState()
state.render(
AwaitingLlmFirstToken(
sse_id=SID, turn_id=42, elapsed_ms_since_building_prompt=5012.3
),
transcript=transcript,
tools_log=MagicMock(),
debug_log=debug_log,
thinking_log=MagicMock(),
raw=False,
)
assert state.heartbeat_count == 1
assert state.awaiting_widget is not None
assert transcript.mount.call_count == 1
audit = _text_of(debug_log.write.call_args[0][0])
assert "awaitingllmfirsttoken" in audit
assert "turn_id=42" in audit
assert "elapsed=5.0s" in audit
def test_awaiting_subsequent_heartbeats_update_in_place(self) -> None:
"""awaiting_subsequent_heartbeats_update_in_place [v0.14.0]: second+
heartbeats reuse the existing Static (no new mount); heartbeat_count
tracks the total.
"""
from ratatoskr.sse_client import AwaitingLlmFirstToken
from ratatoskr.tui import TuiPresenterState
transcript = MagicMock()
state = TuiPresenterState()
for elapsed in (5000.0, 10005.4, 15011.8):
state.render(
AwaitingLlmFirstToken(
sse_id=SID, turn_id=42, elapsed_ms_since_building_prompt=elapsed
),
transcript=transcript,
tools_log=MagicMock(),
debug_log=MagicMock(),
thinking_log=MagicMock(),
raw=False,
)
assert state.heartbeat_count == 3
assert transcript.mount.call_count == 1 # mounted once on first
def test_awaiting_indicator_removed_when_gap_closes(self) -> None:
"""awaiting_indicator_removed_when_gap_closes [v0.14.0]: any non-
heartbeat event after one or more heartbeats removes the indicator
and clears the awaiting_widget reference. Text event simulates the
gap closing (CallingLLM fires, text begins).
"""
from ratatoskr.sse_client import AwaitingLlmFirstToken
from ratatoskr.tui import TuiPresenterState
transcript = MagicMock()
state = TuiPresenterState()
state.render(
AwaitingLlmFirstToken(
sse_id=SID, turn_id=42, elapsed_ms_since_building_prompt=5000.0
),
transcript=transcript,
tools_log=MagicMock(),
debug_log=MagicMock(),
thinking_log=MagicMock(),
raw=False,
)
widget = state.awaiting_widget
assert widget is not None
state.render(
Text(sse_id=SID, content="hello"),
transcript=transcript,
tools_log=MagicMock(),
debug_log=MagicMock(),
thinking_log=MagicMock(),
raw=False,
)
# State reference cleared (the widget itself is a real Static whose
# .remove() schedules removal — we verify the cleanup intent via
# the state field, which is the contract callers actually observe).
assert state.awaiting_widget is None
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