mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
38a0d9c3b6
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.
334 lines
12 KiB
Python
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": []})
|