From 4d08a19bd55ab8b4280b90b503042a6301c0f04b Mon Sep 17 00:00:00 2001 From: Patrick Buckley Date: Mon, 27 Apr 2026 10:42:31 -0700 Subject: [PATCH] fix(approve): replay cached LLM verdicts on coord SSE reconnect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The coord's _coord_events_replay re-yielded _pending_approval on connect but not the cached _llm_verdicts entries. A tab refreshing mid-approval saw the approve_request prompt without the judge chip because intent_verdict is a one-shot SSE event with no late-subscriber push — the chip would only ever land if the operator re-invoked the tool call. Mirrored the interactive path at turnstone/server.py:875-878: after re-injecting the pending_approval prompt, walk ui._llm_verdicts under _ws_lock and yield each cached verdict as an intent_verdict event. Pre-existing bug surfaced during the inline-child-approvals work but the coord-self dock UX was always affected on reconnect — not introduced by this PR. Two new tests: cached verdicts replay after pending_approval; stale verdicts from a prior round don't replay when no approval is pending. --- tests/test_coordinator_endpoints.py | 58 +++++++++++++++++++++++++++++ turnstone/console/server.py | 14 +++++++ 2 files changed, 72 insertions(+) diff --git a/tests/test_coordinator_endpoints.py b/tests/test_coordinator_endpoints.py index bf583fec..1e2c499d 100644 --- a/tests/test_coordinator_endpoints.py +++ b/tests/test_coordinator_endpoints.py @@ -1312,6 +1312,64 @@ def test_coord_events_replay_yields_pending_approval_then_pending_plan(): assert out[1]["type"] == "plan_review" +def test_coord_events_replay_yields_cached_verdicts_after_pending_approval(): + """When the SSE stream reconnects mid-approval, the verdict-cache + replay must follow the pending_approval re-injection. Without this, + a refreshing tab sees the approve_request prompt but no judge chip + until the operator re-invokes the action — intent_verdict is a + one-shot SSE event with no late-subscriber push. Mirrors the + interactive replay path.""" + import threading + + from turnstone.console.server import _coord_events_replay + + ui = MagicMock() + ui._pending_approval = { + "type": "approve_request", + "items": [{"call_id": "c-1"}], + } + ui._pending_plan_review = None + ui._llm_verdicts = { + "c-1": { + "verdict_id": "v-1", + "call_id": "c-1", + "recommendation": "deny", + "risk_level": "high", + } + } + ui._ws_lock = threading.Lock() + ws = MagicMock() + request = MagicMock() + + out = list(_coord_events_replay(ws, ui, request)) + # approve_request first, then any cached verdicts. + assert out[0]["type"] == "approve_request" + assert out[1]["type"] == "intent_verdict" + assert out[1]["verdict_id"] == "v-1" + assert out[1]["recommendation"] == "deny" + + +def test_coord_events_replay_skips_verdict_replay_without_pending_approval(): + """Verdict replay rides on top of pending_approval — no prompt, + no chip. Stale verdicts from a previously-resolved round must + not surface on a fresh connect.""" + import threading + + from turnstone.console.server import _coord_events_replay + + ui = MagicMock() + ui._pending_approval = None + ui._pending_plan_review = None + # Stale entries — should NOT be replayed. + ui._llm_verdicts = {"old": {"verdict_id": "stale"}} + ui._ws_lock = threading.Lock() + ws = MagicMock() + request = MagicMock() + + out = list(_coord_events_replay(ws, ui, request)) + assert out == [] + + def test_coord_events_replay_yields_nothing_when_no_pending(): """A workstream with no pending approval / plan review yields an empty replay. The lifted body falls through to the live loop diff --git a/turnstone/console/server.py b/turnstone/console/server.py index 5a5bea69..6fb93964 100644 --- a/turnstone/console/server.py +++ b/turnstone/console/server.py @@ -2519,6 +2519,20 @@ def _coord_events_replay( pending_approval = getattr(ui, "_pending_approval", None) if pending_approval is not None: yield pending_approval + # Cached LLM verdicts that fired since the approval prompt + # — without this replay, a reconnecting / refreshing tab + # sees the approve_request prompt but no judge chip, and + # since intent_verdict only fires once per call_id (no + # push to a late subscriber), the chip would never appear + # until the operator re-invokes the action. Mirrors the + # interactive path at ``turnstone/server.py:875-878``. + llm_verdicts = getattr(ui, "_llm_verdicts", None) + ws_lock = getattr(ui, "_ws_lock", None) + if llm_verdicts and ws_lock is not None: + with ws_lock: + cached_verdicts = list(llm_verdicts.values()) + for v in cached_verdicts: + yield {"type": "intent_verdict", **v} pending_plan = getattr(ui, "_pending_plan_review", None) if pending_plan is not None: yield pending_plan