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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user