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:
@@ -6,6 +6,7 @@ import respx
|
||||
|
||||
from ratatoskr.sse_client import (
|
||||
AffectUpdate,
|
||||
AwaitingLlmFirstToken,
|
||||
CancelAlreadyCompleted,
|
||||
Cancelled,
|
||||
CancelResult,
|
||||
@@ -955,3 +956,87 @@ class TestAffectUpdate:
|
||||
assert affect.turn_id == 42
|
||||
assert affect.snapshot is None
|
||||
assert affect.sse_id == SseId(42, 2)
|
||||
|
||||
|
||||
class TestAwaitingLlmFirstToken:
|
||||
"""Worldtree #201 / v0.29.0 — `awaiting_llm_first_token` SSE heartbeat.
|
||||
|
||||
Top-level event (not a worker_phase extension) fired during the
|
||||
BuildingPrompt → CallingLLM gap at the configured interval (default
|
||||
5s). Server-authoritative elapsed_ms is time.monotonic()-based and
|
||||
monotonically increasing across the heartbeat sequence.
|
||||
|
||||
See docs/conversation-api-spec.md § awaiting_llm_first_token.
|
||||
"""
|
||||
|
||||
@respx.mock
|
||||
async def test_single_heartbeat_parsed(self) -> None:
|
||||
"""single_heartbeat_parsed [tracer]: type=awaiting_llm_first_token →
|
||||
AwaitingLlmFirstToken(turn_id, elapsed_ms_since_building_prompt).
|
||||
"""
|
||||
stream = _sse_chunk(
|
||||
"42:1",
|
||||
{
|
||||
"type": "awaiting_llm_first_token",
|
||||
"turn_id": 42,
|
||||
"elapsed_ms_since_building_prompt": 5012.3,
|
||||
},
|
||||
) + _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")]
|
||||
beat = events[0]
|
||||
assert isinstance(beat, AwaitingLlmFirstToken)
|
||||
assert beat.turn_id == 42
|
||||
assert beat.elapsed_ms_since_building_prompt == 5012.3
|
||||
assert beat.sse_id == SseId(42, 1)
|
||||
|
||||
@respx.mock
|
||||
async def test_heartbeat_sequence_monotonic(self) -> None:
|
||||
"""heartbeat_sequence_monotonic [scenario]: three consecutive heartbeats
|
||||
in one turn — elapsed_ms_since_building_prompt monotonically increases,
|
||||
all carry the same turn_id.
|
||||
"""
|
||||
stream = (
|
||||
_sse_chunk(
|
||||
"42:1",
|
||||
{
|
||||
"type": "awaiting_llm_first_token",
|
||||
"turn_id": 42,
|
||||
"elapsed_ms_since_building_prompt": 5000.0,
|
||||
},
|
||||
)
|
||||
+ _sse_chunk(
|
||||
"42:2",
|
||||
{
|
||||
"type": "awaiting_llm_first_token",
|
||||
"turn_id": 42,
|
||||
"elapsed_ms_since_building_prompt": 10005.4,
|
||||
},
|
||||
)
|
||||
+ _sse_chunk(
|
||||
"42:3",
|
||||
{
|
||||
"type": "awaiting_llm_first_token",
|
||||
"turn_id": 42,
|
||||
"elapsed_ms_since_building_prompt": 15011.8,
|
||||
},
|
||||
)
|
||||
+ _sse_chunk("42:4", _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")]
|
||||
beats = [e for e in events if isinstance(e, AwaitingLlmFirstToken)]
|
||||
assert len(beats) == 3
|
||||
elapsed = [b.elapsed_ms_since_building_prompt for b in beats]
|
||||
assert elapsed == sorted(elapsed) # monotonically increasing
|
||||
assert all(b.turn_id == 42 for b in beats)
|
||||
|
||||
Reference in New Issue
Block a user