mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user