From 32291be01a3549e06a55ce3261344732002f55c6 Mon Sep 17 00:00:00 2001 From: Zenetusken Date: Sun, 2 Aug 2026 17:07:16 -0400 Subject: [PATCH] fix(webui): apply agent-state snapshot fields before the message render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a WebSocket reconnect mid-turn, applySnapshot sequenced the cheap agent-state updates (progress indicator, paused flag, notifications) behind 'await setMessages(...)'. On large chats the re-render can take a very long time, so a stale 'A0: Reasoning...' indicator persisted after the agent had actually finished — the Web UI appeared to still be working while the CLI (cheap render, fresh connection) correctly reported completion. Apply updateProgress, notificationStore.updateFromPoll and inputStore.paused before the render await. Log-version cursors intentionally stay behind the render so a failed render retries the same version on the next poll. Regression coverage pins the ordering in webui/index.js (discriminating: fails on pre-fix v2.8, passes with the fix). --- tests/test_webui_snapshot_ordering.py | 100 ++++++++++++++++++++++++++ webui/index.js | 21 +++--- 2 files changed, 113 insertions(+), 8 deletions(-) create mode 100644 tests/test_webui_snapshot_ordering.py diff --git a/tests/test_webui_snapshot_ordering.py b/tests/test_webui_snapshot_ordering.py new file mode 100644 index 0000000000..499a9e8195 --- /dev/null +++ b/tests/test_webui_snapshot_ordering.py @@ -0,0 +1,100 @@ +"""Regression tests for the Web UI snapshot-application ordering. + +Root cause (2026-08-02): after a WebSocket reconnect mid-turn, the frontend +receives a snapshot whose log version advanced while disconnected. In +``applySnapshot`` the cheap agent-state fields (progress indicator, paused +flag, notifications) were applied only AFTER ``await setMessages(...)``. +For large chats the message re-render can take a very long time, so a stale +"A0: Reasoning..." indicator stayed on screen — making an idle, finished +agent look like it was still working — until the render backlog drained. + +The fix sequences the cheap state updates before the render await. These +tests pin that ordering in ``webui/index.js``. +""" + +import sys +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + + +def _load_apply_snapshot_body() -> str: + js = (PROJECT_ROOT / "webui" / "index.js").read_text(encoding="utf-8") + start = js.find("export async function applySnapshot(") + assert start != -1, "applySnapshot function not found in webui/index.js" + # Skip the parameter list first (its `options = {}` default would fool a + # naive brace counter), then brace-count the body to its matching close. + paren_open = js.find("(", start) + depth = 0 + pos = paren_open + while pos < len(js): + char = js[pos] + if char == "(": + depth += 1 + elif char == ")": + depth -= 1 + if depth == 0: + break + pos += 1 + body_start = js.find("{", pos) + assert body_start != -1, "applySnapshot body opening brace not found" + depth = 0 + for pos in range(body_start, len(js)): + char = js[pos] + if char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return js[start : pos + 1] + raise AssertionError("applySnapshot body is unbalanced in webui/index.js") + + +def test_apply_snapshot_updates_progress_before_render(): + body = _load_apply_snapshot_body() + progress_pos = body.find("updateProgress(snapshot.log_progress") + render_pos = body.find("await setMessages(modelGateStore") + assert progress_pos != -1, "updateProgress call not found in applySnapshot" + assert render_pos != -1, "setMessages render call not found in applySnapshot" + assert progress_pos < render_pos, ( + "updateProgress must run before `await setMessages(...)` so the " + "progress indicator cannot go stale behind a slow re-render" + ) + + +def test_apply_snapshot_updates_paused_before_render(): + body = _load_apply_snapshot_body() + paused_pos = body.find("inputStore.paused = snapshot.paused") + render_pos = body.find("await setMessages(modelGateStore") + assert paused_pos != -1, "paused assignment not found in applySnapshot" + assert render_pos != -1, "setMessages render call not found in applySnapshot" + assert paused_pos < render_pos, ( + "inputStore.paused must run before `await setMessages(...)` so the " + "paused state cannot go stale behind a slow re-render" + ) + + +def test_apply_snapshot_updates_notifications_before_render(): + body = _load_apply_snapshot_body() + notif_pos = body.find("notificationStore.updateFromPoll(snapshot)") + render_pos = body.find("await setMessages(modelGateStore") + assert notif_pos != -1, "notification update not found in applySnapshot" + assert render_pos != -1, "setMessages render call not found in applySnapshot" + assert notif_pos < render_pos, ( + "notifications must update before `await setMessages(...)` so they " + "cannot be delayed by a slow re-render" + ) + + +def test_apply_snapshot_keeps_log_cursors_after_render(): + """Cursor updates must stay after the render block: if the render throws, + the next poll must retry the same log version instead of skipping it.""" + body = _load_apply_snapshot_body() + render_pos = body.find("await setMessages(modelGateStore") + cursor_pos = body.find("lastLogVersion = snapshot.log_version") + assert render_pos != -1 and cursor_pos != -1 + assert cursor_pos > render_pos, ( + "lastLogVersion must only advance after setMessages succeeds" + ) diff --git a/webui/index.js b/webui/index.js index 6dfc56e470..06e388fb1a 100644 --- a/webui/index.js +++ b/webui/index.js @@ -408,6 +408,19 @@ export async function applySnapshot(snapshot, options = {}) { lastLogGuid = snapshot.log_guid; } + // Apply cheap agent-state fields BEFORE the potentially expensive message + // render below. After a reconnect, a large chat log can take a long time to + // re-render; sequencing these behind `await setMessages(...)` leaves a stale + // "Reasoning..."/paused indicator on screen until the render backlog drains, + // making an idle agent look like it is still working. + updateProgress(snapshot.log_progress, snapshot.log_progress_active); + + // Update notifications from snapshot + notificationStore.updateFromPoll(snapshot); + + // set ui model vars from backend + inputStore.paused = snapshot.paused; + if (lastLogVersion != snapshot.log_version) { updated = true; if (snapshot.logs?.[0]?.no === 0) { @@ -420,14 +433,6 @@ export async function applySnapshot(snapshot, options = {}) { lastLogVersion = snapshot.log_version; lastLogGuid = snapshot.log_guid; - updateProgress(snapshot.log_progress, snapshot.log_progress_active); - - // Update notifications from snapshot - notificationStore.updateFromPoll(snapshot); - - // set ui model vars from backend - inputStore.paused = snapshot.paused; - // Optional: treat snapshot application as proof of connectivity (poll path) if (touchConnectionStatus) { setConnectionStatus(true);