diff --git a/tests/test_coordinator_page.py b/tests/test_coordinator_page.py index f383d43b..5813971d 100644 --- a/tests/test_coordinator_page.py +++ b/tests/test_coordinator_page.py @@ -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)." diff --git a/turnstone/console/static/coordinator/coordinator.js b/turnstone/console/static/coordinator/coordinator.js index c6188e89..e5b93df7 100644 --- a/turnstone/console/static/coordinator/coordinator.js +++ b/turnstone/console/static/coordinator/coordinator.js @@ -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