Files
turnstone/tests/test_webui_content.py
T
Patrick Buckley 38a0d9c3b6 feat(coord): Stage 3 SessionManager Children primitive lift + cluster bus push paths
Lift the Children primitive out of CoordinatorAdapter into universal
SessionManager core primitives, replace the fragile poll + state-event
piggyback paths with first-class cluster bus event types for inline
approval delivery, and clean up the resulting frontend reducer.

Architecture
- New `turnstone/core/children_registry.py` — universal parent → children
  + reverse-lookup primitive with atomic `add_child` (returns parent UI
  for race-free dispatch). Lifted from `CoordinatorAdapter`.
- New `turnstone/core/child_source.py` — `ChildSource` Protocol with
  `SameNodeChildSource` (in-process via SessionManager state observer)
  and `ClusterChildSource` (cross-node via ClusterCollector listener).
- `SessionManager._on_state_change` upgraded to multi-subscriber
  (`subscribe_to_state` / `unsubscribe_from_state`) under a dedicated
  lock; CLI consumer migrated.
- `CoordinatorAdapter` shrunk: 731 → ~640 LOC. Children data lives in
  the registry; fan-out lives in ClusterChildSource. Backward-compat
  property facades dropped; tests updated to use the registry surface.

Cluster bus event vocabulary
- New event types `intent_verdict`, `approval_resolved`,
  `approve_request` flow through both `ClusterCollector._apply_delta`
  (translation from node SSE) and `emit_console_ws_*` (synthesis on
  console pseudo-node).
- `CoordinatorAdapter._dispatch_child_event` re-emits as
  `child_ws_intent_verdict` / `child_ws_approval_resolved` /
  `child_ws_approve_request` on the parent coord's SSE stream.
- New `_broadcast_intent_verdict` / `_broadcast_approval_resolved` /
  `_broadcast_approve_request` no-op hooks on `SessionUIBase`. WebUI
  pushes to the global queue; ConsoleCoordinatorUI pushes to the
  collector. `approve_tools` calls `_broadcast_approve_request` right
  after setting `_pending_approval` so the items reach the coord tree
  immediately, eliminating the bulk-fetch race.

Cleanups
- `pending_approval_detail` piggyback on `ws_state` / `cluster_state`
  removed end-to-end. Bulk fetch + explicit verdict / approve-request
  push are the canonical carriers.
- Browser `_judgePollTick` 90-second poll loop deleted; push path is
  authoritative.
- `urgent` flag on `scheduleLiveFetch` deleted (only caller was 409
  retry; replaced with `invalidateLiveBadge` + standard schedule).
- Console `_fetch_live_block` derives `pending_approval` from a
  disjunction (`activity_state="approval"` OR `state="attention"`
  OR detail present) so the bulk fetch can't return false during the
  state-transition race window.
- Coord-side merge guard in `flushLiveFetches` no longer clobbered:
  `handleChildState` only stamps `sseUpdatedAt` when authoritatively
  clearing detail.
- `child_locality` capability flag removed (was inert dead code).

Reliability
- Selective drop on listener queue overflow: critical event types
  (verdicts, approvals, ws_closed, child_ws_*) evict one oldest item
  to make room rather than dropping themselves on a full queue.
  Best-effort events (state ticks, content tokens, status, activity)
  drop as before. Applied to `SessionUIBase._enqueue`,
  `ClusterCollector._fanout`, and the `WebUI._global_queue` puts in
  the new broadcast hooks.
- `_state_subscribers` snapshot under a dedicated lock so concurrent
  subscribe / unsubscribe during dispatch can't shift the iterator.

UX / a11y
- Loading placeholder in renderChildRow keeps row height stable while
  the bulk fetch is in-flight (sr-friendly aria-label).
- Focus preservation across `_renderChildrenNow` (capture +
  restore by row + marker) and across targeted `_updateChildRow` swaps.
- Layout-shift transition on the approval block max-height; respects
  `prefers-reduced-motion`.
- Sidebar pending count: `(N children · M pending)`.
- Risk pill `aria-label` spells out level + confidence for SR users.
- Per-coord SSE listener queue depth surfaced in the status bar
  (`queue N/500`) with color escalation (warn at >50%, danger at >80%).

Tests
- 305+ test changes across 8 files. New unit tests for
  `ChildrenRegistry`, `ChildSource` (both impls + multi-subscriber
  observer), the new collector emit + apply_delta cases, the dispatch
  cases for new event types, the broadcast hook overrides on both
  WebUI and ConsoleCoordinatorUI, and the focus / placeholder /
  pending-count frontend assertions in `test_coordinator_page.py`.

5024 passed, ruff + mypy clean.
2026-04-30 23:20:14 -07:00

334 lines
12 KiB
Python

"""Tests for WebUI content accumulation — server-side single source of truth."""
import queue
import pytest
from turnstone.server import WebUI
@pytest.fixture(autouse=True)
def _reset_global_queue():
"""Ensure WebUI._global_queue is set for tests and cleaned up after."""
WebUI._global_queue = queue.Queue()
yield
WebUI._global_queue = None
def _make_ui() -> WebUI:
"""Create a WebUI with a global queue for capturing broadcast events."""
return WebUI(ws_id="ws-test")
def _drain_global() -> list[dict]:
"""Drain all events from the global queue."""
events = []
assert WebUI._global_queue is not None
while not WebUI._global_queue.empty():
events.append(WebUI._global_queue.get_nowait())
return events
class TestContentAccumulation:
"""WebUI should accumulate content tokens and include in idle broadcast."""
def test_content_token_accumulates(self):
"""on_content_token should append to _ws_turn_content."""
ui = _make_ui()
ui.on_content_token("Hello ")
ui.on_content_token("world")
assert ui._ws_turn_content == ["Hello ", "world"]
def test_idle_broadcast_includes_content(self):
"""_broadcast_state('idle') should include joined content and reset."""
ui = _make_ui()
ui.on_content_token("Hello ")
ui.on_content_token("world")
ui._broadcast_state("idle")
events = _drain_global()
idle_events = [e for e in events if e.get("state") == "idle"]
assert len(idle_events) == 1
assert idle_events[0]["content"] == "Hello world"
# Accumulator should be reset
assert ui._ws_turn_content == []
assert ui._ws_turn_content_size == 0
def test_error_broadcast_resets_without_content(self):
"""_broadcast_state('error') should reset accumulator without content in event."""
ui = _make_ui()
ui.on_content_token("partial")
ui._broadcast_state("error")
events = _drain_global()
error_events = [e for e in events if e.get("state") == "error"]
assert len(error_events) == 1
assert "content" not in error_events[0]
assert ui._ws_turn_content == []
assert ui._ws_turn_content_size == 0
def test_thinking_broadcast_does_not_touch_accumulator(self):
"""_broadcast_state('thinking') should not affect the accumulator."""
ui = _make_ui()
ui.on_content_token("in progress")
ui._broadcast_state("thinking")
assert ui._ws_turn_content == ["in progress"]
events = _drain_global()
thinking_events = [e for e in events if e.get("state") == "thinking"]
assert len(thinking_events) == 1
assert "content" not in thinking_events[0]
def test_multi_round_accumulation(self):
"""Content from multiple streaming rounds accumulates before idle."""
ui = _make_ui()
# Round 1
ui.on_content_token("I'll check ")
ui.on_content_token("that. ")
# Round 2 (after tool execution)
ui.on_content_token("Here's ")
ui.on_content_token("the result.")
ui._broadcast_state("idle")
events = _drain_global()
idle_events = [e for e in events if e.get("state") == "idle"]
assert len(idle_events) == 1
assert idle_events[0]["content"] == "I'll check that. Here's the result."
def test_empty_content_on_idle_without_tokens(self):
"""idle with no content tokens should include empty content string."""
ui = _make_ui()
ui._broadcast_state("idle")
events = _drain_global()
idle_events = [e for e in events if e.get("state") == "idle"]
assert len(idle_events) == 1
assert idle_events[0]["content"] == ""
def test_cancellation_preserves_partial_content(self):
"""Partial content accumulated before cancel should appear in idle event."""
ui = _make_ui()
ui.on_content_token("I'll ")
ui.on_content_token("start by...")
# Cancellation triggers idle broadcast with partial content
ui._broadcast_state("idle")
events = _drain_global()
idle_events = [e for e in events if e.get("state") == "idle"]
assert len(idle_events) == 1
assert idle_events[0]["content"] == "I'll start by..."
def test_consecutive_turns_isolated(self):
"""Content from turn 1 should not leak into turn 2."""
ui = _make_ui()
# Turn 1
ui.on_content_token("first response")
ui._broadcast_state("idle")
_drain_global()
# Turn 2
ui.on_content_token("second response")
ui._broadcast_state("idle")
events = _drain_global()
idle_events = [e for e in events if e.get("state") == "idle"]
assert len(idle_events) == 1
assert idle_events[0]["content"] == "second response"
def test_content_cap_prevents_unbounded_growth(self):
"""Content exceeding the cap should stop accumulating."""
# Constant lifted from turnstone.server to turnstone.core.session_ui_base
# in the rich ws_state payload work so coord enforces the same ceiling.
from turnstone.core.session_ui_base import _MAX_TURN_CONTENT_CHARS
ui = _make_ui()
# Fill to capacity
chunk = "x" * 1024
for _ in range(_MAX_TURN_CONTENT_CHARS // 1024 + 10):
ui.on_content_token(chunk)
assert ui._ws_turn_content_size <= _MAX_TURN_CONTENT_CHARS + 1024
ui._broadcast_state("idle")
events = _drain_global()
idle_events = [e for e in events if e.get("state") == "idle"]
assert len(idle_events) == 1
# Content should be capped, not contain everything
assert len(idle_events[0]["content"]) <= _MAX_TURN_CONTENT_CHARS + 1024
class TestPendingApprovalDetailNotPiggybacked:
"""Stage 3 cleanup — ``pending_approval_detail`` is no longer
piggybacked on ``ws_state`` events. Approval items now arrive via
bulk fetch when the coord tree's reducer sees the
``activity_state="approval"`` transition; verdicts via the explicit
``intent_verdict`` event class; resolution via
``approval_resolved``. These tests lock the no-piggyback contract
down so a future regression doesn't silently re-introduce the
duplicated path."""
def test_state_broadcast_omits_field_when_no_approval_pending(self):
ui = _make_ui()
assert ui._pending_approval is None
ui._broadcast_state("running")
events = _drain_global()
running_events = [e for e in events if e.get("state") == "running"]
assert len(running_events) == 1
assert "pending_approval_detail" not in running_events[0]
def test_state_broadcast_omits_field_even_when_approval_pending(self):
"""The piggyback is gone: even when ``_pending_approval`` is set,
the state broadcast must NOT carry ``pending_approval_detail``.
The browser triggers a bulk fetch off the
``activity_state="approval"`` transition to get the items."""
ui = _make_ui()
ui._pending_approval = {
"type": "approve_request",
"items": [
{
"call_id": "c1",
"header": "tool x",
"func_args": "{}",
"intent_summary": "do x",
"needs_approval": True,
}
],
"judge_pending": False,
}
ui._broadcast_state("attention")
events = _drain_global()
attn = [e for e in events if e.get("state") == "attention"]
assert len(attn) == 1
assert "pending_approval_detail" not in attn[0]
def test_field_stays_absent_after_approval_resolves(self):
ui = _make_ui()
ui._pending_approval = {
"type": "approve_request",
"items": [{"call_id": "c1", "header": "x"}],
"judge_pending": False,
}
ui._broadcast_state("attention")
_drain_global()
ui._pending_approval = None
ui._broadcast_state("running")
events = _drain_global()
running = [e for e in events if e.get("state") == "running"]
assert len(running) == 1
assert "pending_approval_detail" not in running[0]
class TestBroadcastIntentVerdict:
"""Producer-side coverage for ``WebUI._broadcast_intent_verdict``.
The collector-side test (``test_apply_delta_intent_verdict_*`` in
test_console.py) covers consumption; this pins the event shape the
producer puts on the global queue. A field rename or missed key
here would slip past the consumer test because the consumer reads
via ``data.get(...)``.
"""
def test_pushes_intent_verdict_event_to_global_queue(self):
ui = _make_ui()
verdict = {
"call_id": "c1",
"risk_level": "low",
"confidence": 0.92,
"recommendation": "approve",
"reasoning": "tool reads only",
}
ui._broadcast_intent_verdict(verdict)
events = _drain_global()
assert len(events) == 1
ev = events[0]
assert ev["type"] == "intent_verdict"
assert ev["ws_id"] == "ws-test"
assert ev["verdict"] == verdict
def test_no_op_when_global_queue_unset(self):
WebUI._global_queue = None
ui = _make_ui()
# Doesn't raise.
ui._broadcast_intent_verdict({"call_id": "c1"})
def test_queue_full_swallowed(self):
# Force a tiny queue then fill it so the next put_nowait
# raises queue.Full — the broadcast must absorb it without
# propagating (matches _broadcast_state's queue.Full handling).
WebUI._global_queue = queue.Queue(maxsize=1)
WebUI._global_queue.put_nowait({"sentinel": True})
ui = _make_ui()
# Doesn't raise.
ui._broadcast_intent_verdict({"call_id": "c1"})
class TestBroadcastApprovalResolved:
"""Producer-side coverage for ``WebUI._broadcast_approval_resolved``."""
def test_pushes_approval_resolved_event_to_global_queue(self):
ui = _make_ui()
ui._broadcast_approval_resolved(True, "lgtm", always=False)
events = _drain_global()
assert len(events) == 1
ev = events[0]
assert ev["type"] == "approval_resolved"
assert ev["ws_id"] == "ws-test"
assert ev["approved"] is True
assert ev["feedback"] == "lgtm"
assert ev["always"] is False
def test_normalises_none_feedback_to_empty_string(self):
ui = _make_ui()
ui._broadcast_approval_resolved(False, None)
events = _drain_global()
assert events[0]["feedback"] == ""
assert events[0]["approved"] is False
assert events[0]["always"] is False
def test_always_kwarg_propagates(self):
ui = _make_ui()
ui._broadcast_approval_resolved(True, "ok", always=True)
events = _drain_global()
assert events[0]["always"] is True
def test_no_op_when_global_queue_unset(self):
WebUI._global_queue = None
ui = _make_ui()
# Doesn't raise.
ui._broadcast_approval_resolved(True, None)
class TestBroadcastApproveRequest:
"""Producer-side coverage for ``WebUI._broadcast_approve_request`` —
push path for the initial approval items so a coord parent's tree
UI can render the inline approve/deny block immediately without
waiting for a bulk-fetch round-trip."""
def test_pushes_approve_request_event_to_global_queue(self):
ui = _make_ui()
detail = {
"type": "approve_request",
"items": [{"call_id": "c1", "header": "tool x"}],
"judge_pending": True,
}
ui._broadcast_approve_request(detail)
events = _drain_global()
assert len(events) == 1
ev = events[0]
assert ev["type"] == "approve_request"
assert ev["ws_id"] == "ws-test"
assert ev["detail"] == detail
def test_no_op_when_global_queue_unset(self):
WebUI._global_queue = None
ui = _make_ui()
# Doesn't raise.
ui._broadcast_approve_request({"items": []})