fix(approve): replay cached LLM verdicts on coord SSE reconnect

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.
This commit is contained in:
Patrick Buckley
2026-04-27 10:42:31 -07:00
committed by Patrick Buckley
parent 68e1332c59
commit 4d08a19bd5
2 changed files with 72 additions and 0 deletions
+58
View File
@@ -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
+14
View File
@@ -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