mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
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:
committed by
Patrick Buckley
parent
68e1332c59
commit
4d08a19bd5
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user