fix(sse): consume the resume cursor in the coordinator dashboard

make_history_handler is shared by interactive and coord, so coord
/history already trims the executing in-flight orphan turn and returns
a cursor. But coordinator.js never read it -- it connected fresh, so the
trimmed turn was neither in /history nor delta-replayed and vanished
from the dashboard (a regression vs the prior #610 in-flight render).

Mirror the ui/static/app.js fix in coordinator.js: refetchHistory takes
a seedCursor flag (default false) and seeds lastEventId from hist.cursor
only on the initial-connect path; connectSSE gates ?last_event_id= on
!= null so a cursor of 0 isn't dropped. The clear_ui / replay_truncated
re-render callers leave seedCursor false (they run on a live stream and
must not rewind the live cursor). Adds a coordinator.js static guard.
This commit is contained in:
Patrick Buckley
2026-05-30 04:22:34 -07:00
parent 3070bc4eb5
commit a5570f027b
2 changed files with 59 additions and 3 deletions
+42
View File
@@ -347,3 +347,45 @@ def test_coord_history_renders_user_interjection_advisory_after_tool_block():
"advisory text through appendUserMessageWithAttachments so the "
"rendered bubble matches a normal user-row replay."
)
def test_coordinator_js_seeds_resume_cursor_only_on_initial_connect():
"""coordinator.js must consume the /history resume cursor the same way
ui/static/app.js does: the shared make_history_handler trims the
executing in-flight orphan turn and returns a cursor, so the coord
client MUST open its initial SSE with that cursor (?last_event_id=) or
the trimmed turn is neither in /history nor delta-replayed — it vanishes
from the dashboard (a regression vs the prior #610 in-flight render).
Pins three invariants mirroring the app.js guards:
1. ``refetchHistory`` takes a ``seedCursor`` flag (default false) and
seeds ``lastEventId`` from ``hist.cursor`` only when set + non-null,
so the clear_ui / replay_truncated re-render callers (live stream,
no reconnect) don't rewind the live cursor.
2. the initial-connect path opts in via ``refetchHistory(true)``.
3. ``connectSSE`` gates ``?last_event_id=`` on ``!= null`` so a cursor
of 0 (a brand-new ws's first-turn boundary) isn't dropped.
"""
import re
from pathlib import Path
coord_js = Path(__file__).resolve().parent.parent / (
"turnstone/console/static/coordinator/coordinator.js"
)
body = coord_js.read_text(encoding="utf-8")
assert "async function refetchHistory(seedCursor = false)" in body, (
"refetchHistory must take a seedCursor flag (default false) so only "
"the initial-connect caller seeds the resume cursor."
)
assert re.search(
r"if\s*\(\s*seedCursor\s*&&\s*hist\.cursor\s*!=\s*null\s*\)\s*"
r"lastEventId\s*=\s*hist\.cursor",
body,
), "refetchHistory must seed lastEventId from hist.cursor only when seedCursor && != null."
assert "await refetchHistory(true)" in body, (
"the initial-connect path must call refetchHistory(true) to seed the cursor."
)
assert re.search(
r"if\s*\(\s*lastEventId\s*!=\s*null\s*\)\s*\{\s*url\s*\+=\s*\"\?last_event_id=\"",
body,
), "connectSSE must gate ?last_event_id= on lastEventId != null (so cursor 0 isn't dropped)."
@@ -1980,7 +1980,11 @@
// case (scheduleReconnect-driven reconnect after CLOSED state).
const wasReconnecting = disconnectedSinceLastOpen || reconnectAttempts > 0;
let url = "/v1/api/workstreams/" + encodeURIComponent(wsId) + "/events";
if (lastEventId) {
// ``!= null`` (not truthiness): a resume cursor of 0 is valid (the
// ring buffer's first emitted event is id 1), and a brand-new ws's
// first-turn /history cursor can be 0 — a truthiness gate would drop
// it to the lossy fresh-snapshot path. Mirrors ui/static/app.js.
if (lastEventId != null) {
url += "?last_event_id=" + encodeURIComponent(lastEventId);
}
evtSource = new EventSource(url, { withCredentials: true });
@@ -4358,7 +4362,7 @@
appendText("error", "Failed to load coordinator: " + e.message);
return;
}
await refetchHistory();
await refetchHistory(true);
// History alone can't tell whether an orphaned assistant tool_calls turn
// is awaiting approval or merely still running; the live workstream
// snapshot can. First-paint only — a mid-session clear_ui refetch does
@@ -4398,7 +4402,7 @@
// (toolRows / activeBatch, which the render rebuilds) reset up front so a
// mid-session re-render leaves no stale call_id→row mappings pointing at
// detached DOM. On first paint they're already empty — harmless no-ops.
async function refetchHistory() {
async function refetchHistory(seedCursor = false) {
let hist = null;
try {
hist = await getJSON(
@@ -4412,6 +4416,16 @@
toolRows.clear();
activeBatch = null;
if (!hist) return;
// Fresh-connect fast-forward: when the trailing turn is an executing
// in-flight tool batch the server can replay, /history returns a
// non-null ``cursor`` and OMITS that turn. Seed ``lastEventId`` so the
// connectSSE on the initial-connect path (seedCursor=true) opens with
// ?last_event_id= and the replay_ok delta rebuilds the in-flight turn
// — otherwise the trimmed turn would be neither in /history nor
// replayed. The clear_ui / replay_truncated re-render callers leave
// seedCursor false: they run on a live stream and must NOT rewind
// lastEventId off the live position. Mirrors ui/static/app.js.
if (seedCursor && hist.cursor != null) lastEventId = hist.cursor;
// Map call_id → tool name resolved from the most recent
// assistant tool_calls. Storage's `tool` rows carry only
// tool_call_id + content; the function name lives on the