From 298cb2c05d8bcce44c6c10b98c71f8bfd2d64abd Mon Sep 17 00:00:00 2001 From: Alessandro <155005371+3clyp50@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:10:35 +0200 Subject: [PATCH] Speed up chat loading and guard selector refresh races Run model/profile, context-usage and goal display refreshes independently of snapshot rendering so chat switches do not wait for their API calls. Guard refreshes and save completions by request sequence and selected chat. Pending saves still finish for their original chat, while late responses cannot replace the current selector state or finish a newer loading cycle. Add deterministic pending-request and save-race regressions and update the owning plugin contracts. Validation: 94 focused tests passed for the loading change, followed by 47 focused tests after the model/profile race fixes. Live checks at localhost:32081 covered delayed API responses, rapid chat switching, late profile/preset/clear saves, and desktop/mobile behavior. --- plugins/_context_window/AGENTS.md | 2 + .../refresh-context-window.js | 4 +- .../tests/test_context_window.py | 2 +- .../webui/context-window-store.js | 2 +- plugins/_goal/AGENTS.md | 3 + .../apply_snapshot_before/refresh-goal.js | 4 +- plugins/_goal/webui/goal-store.js | 17 +- plugins/_model_config/AGENTS.md | 6 + .../apply_snapshot_before/refresh-switcher.js | 6 +- plugins/_model_config/webui/switcher-mixin.js | 12 +- tests/test_chat_loading_refresh.py | 263 ++++++++++++++++++ 11 files changed, 303 insertions(+), 18 deletions(-) create mode 100644 tests/test_chat_loading_refresh.py diff --git a/plugins/_context_window/AGENTS.md b/plugins/_context_window/AGENTS.md index ef4f246ad..b76778910 100644 --- a/plugins/_context_window/AGENTS.md +++ b/plugins/_context_window/AGENTS.md @@ -59,6 +59,8 @@ tool calls without another refresh, when the final response completes the run, and when the active chat log GUID changes (including Clear Chat). Streamed updates to an existing generation or tool log do not refetch it. +- Snapshot-triggered usage refreshes must not delay message rendering. Apply + results only from the latest request while its chat is still selected. - `_model_config` supplies the effective model limit and the `model-context-strip-end` WebUI slot; it does not own this feature's state. - The `contextWindowUsage` Interface setting defaults to visible on mobile and diff --git a/plugins/_context_window/extensions/webui/apply_snapshot_before/refresh-context-window.js b/plugins/_context_window/extensions/webui/apply_snapshot_before/refresh-context-window.js index f5dc58c3f..fb040d9bd 100644 --- a/plugins/_context_window/extensions/webui/apply_snapshot_before/refresh-context-window.js +++ b/plugins/_context_window/extensions/webui/apply_snapshot_before/refresh-context-window.js @@ -30,7 +30,7 @@ function rootToolNos(logs) { .filter(Number.isInteger))]; } -export default async function refreshContextWindow(ctx) { +export default function refreshContextWindow(ctx) { const snapshot = ctx?.snapshot; const contextId = String(snapshot?.context || ""); if (!contextId) { @@ -72,5 +72,5 @@ export default async function refreshContextWindow(ctx) { lastLogGuid = logGuid; if (generationKey) lastGenerationKey = generationKey; toolCallsSinceRefresh = 0; - await contextWindowStore.refresh(contextId); + void contextWindowStore.refresh(contextId); } diff --git a/plugins/_context_window/tests/test_context_window.py b/plugins/_context_window/tests/test_context_window.py index 774ba28c2..7ee2be5e7 100644 --- a/plugins/_context_window/tests/test_context_window.py +++ b/plugins/_context_window/tests/test_context_window.py @@ -243,7 +243,7 @@ def test_webui_display_toggles_are_independent(): source = """ const createStore = (_name, model) => model; const callJsonApi = async () => globalThis.payload; -const chatsStore = {}; +const chatsStore = { getSelectedChatId: () => "chat-1" }; const preferencesStore = { registerUiControlVisibility() {} }; """ + source module_url = "data:text/javascript;base64," + base64.b64encode( diff --git a/plugins/_context_window/webui/context-window-store.js b/plugins/_context_window/webui/context-window-store.js index 0b66459e8..308f85c2d 100644 --- a/plugins/_context_window/webui/context-window-store.js +++ b/plugins/_context_window/webui/context-window-store.js @@ -156,7 +156,7 @@ const model = { } try { const data = await callJsonApi(API_PATH, { context: contextId }); - if (requestSeq === this.loadSeq) this.usage = buildUsage(data); + if (requestSeq === this.loadSeq && contextId === this.contextId) this.usage = buildUsage(data); } catch (error) { if (requestSeq === this.loadSeq) console.error("Context window load failed:", error); } diff --git a/plugins/_goal/AGENTS.md b/plugins/_goal/AGENTS.md index 282a4e473..b000942f8 100644 --- a/plugins/_goal/AGENTS.md +++ b/plugins/_goal/AGENTS.md @@ -32,6 +32,9 @@ - Goal state changes publish a context revision through the shared state-push lifecycle; the WebUI refreshes on context or revision changes and never polls the Goal API while idle. +- Snapshot refreshes run without delaying chat rendering. Clear the previous + chat's goal when refreshing a new selection; only the latest selected-chat + request may apply results, and stale requests must not finish a newer load. ## Work Guidance diff --git a/plugins/_goal/extensions/webui/apply_snapshot_before/refresh-goal.js b/plugins/_goal/extensions/webui/apply_snapshot_before/refresh-goal.js index b937fb863..4ded0dc3d 100644 --- a/plugins/_goal/extensions/webui/apply_snapshot_before/refresh-goal.js +++ b/plugins/_goal/extensions/webui/apply_snapshot_before/refresh-goal.js @@ -3,7 +3,7 @@ import { store as goalStore } from "/plugins/_goal/webui/goal-store.js"; let lastContextId = ""; let lastRevision = null; -export default async function refreshGoalOnRevision(ctx) { +export default function refreshGoalOnRevision(ctx) { const snapshot = ctx?.snapshot; const contextId = String(snapshot?.context || ""); const activeContext = (snapshot?.contexts || []).find(item => item?.id === contextId); @@ -12,5 +12,5 @@ export default async function refreshGoalOnRevision(ctx) { if (contextId === lastContextId && revision === lastRevision) return; lastContextId = contextId; lastRevision = revision; - await goalStore.refresh(true); + void goalStore.refresh(true); } diff --git a/plugins/_goal/webui/goal-store.js b/plugins/_goal/webui/goal-store.js index 78f8a8a0c..a5488c319 100644 --- a/plugins/_goal/webui/goal-store.js +++ b/plugins/_goal/webui/goal-store.js @@ -12,6 +12,7 @@ const GOAL_API_PATH = "/plugins/_goal/goal"; const model = { goal: null, loading: false, + loadSeq: 0, saving: false, editing: false, draft: "", @@ -101,12 +102,18 @@ const model = { async refresh(force = false) { const contextId = this.contextId; + if (!force && this.loading && contextId === this.lastContextId) return; + const requestSeq = ++this.loadSeq; + if (contextId !== this.lastContextId) { + this.goal = null; + this.editing = false; + } + this.lastContextId = contextId; if (!contextId) { this.goal = null; - this.lastContextId = ""; + this.loading = false; return; } - if (!force && this.loading) return; this.loading = true; try { @@ -114,16 +121,16 @@ const model = { action: "get", context_id: contextId, }); + if (requestSeq !== this.loadSeq || contextId !== this.contextId) return; this.goal = response?.goal || null; this.now = Date.now(); - this.lastContextId = contextId; if (!this.goal) this.editing = false; } catch (error) { + if (requestSeq !== this.loadSeq || contextId !== this.contextId) return; console.error("Failed to load goal:", error); this.goal = null; - this.lastContextId = contextId; } finally { - this.loading = false; + if (requestSeq === this.loadSeq) this.loading = false; } }, diff --git a/plugins/_model_config/AGENTS.md b/plugins/_model_config/AGENTS.md index 1488bfa60..eb5ba487f 100644 --- a/plugins/_model_config/AGENTS.md +++ b/plugins/_model_config/AGENTS.md @@ -51,6 +51,12 @@ - Concurrent agent-profile catalog loads for the same chat share one request; across chats, only the newest request may replace selector state or finish its loading lifecycle. +- Snapshot-triggered profile and preset refreshes run independently of chat + rendering and of each other. Only the latest refresh for the selected chat + may apply selector state; older completions must not clear a newer loading flag. +- Ignore refreshes for unselected chats before advancing the request sequence. + Pending preset/profile saves keep their original chat target; preset save or + clear results may update the selector only while that chat is selected. - Preset editor reset actions must remove the user override through the preset API and refresh the open draft from bundled defaults. - Preset rename, delete, and reset actions must repair scoped config and durable/live chat references; removed definitions fall back to `Default`. - Save/reset embedding comparisons use the matching before/after preset snapshots, including Default inheritance. Do not reload the collection per preset or persist a cross-request cache for this comparison. diff --git a/plugins/_model_config/extensions/webui/apply_snapshot_before/refresh-switcher.js b/plugins/_model_config/extensions/webui/apply_snapshot_before/refresh-switcher.js index e85b3c419..0b8dc4a14 100644 --- a/plugins/_model_config/extensions/webui/apply_snapshot_before/refresh-switcher.js +++ b/plugins/_model_config/extensions/webui/apply_snapshot_before/refresh-switcher.js @@ -6,7 +6,7 @@ let lastContextId = ""; let lastRevision = null; let lastScopeKey = ""; -export default async function refreshSwitcherOnOverrideRevision(ctx) { +export default function refreshSwitcherOnOverrideRevision(ctx) { const snapshot = ctx?.snapshot; const contextId = String(snapshot?.context || ""); @@ -37,6 +37,6 @@ export default async function refreshSwitcherOnOverrideRevision(ctx) { lastContextId = contextId; lastRevision = revision; lastScopeKey = scopeKey; - if (scopeChanged) await modelConfigStore.loadAgentProfiles(true); - await modelConfigStore.refreshSwitcher(contextId); + if (scopeChanged) void modelConfigStore.loadAgentProfiles(true); + void modelConfigStore.refreshSwitcher(contextId); } diff --git a/plugins/_model_config/webui/switcher-mixin.js b/plugins/_model_config/webui/switcher-mixin.js index 84aabfddd..b1fe02b66 100644 --- a/plugins/_model_config/webui/switcher-mixin.js +++ b/plugins/_model_config/webui/switcher-mixin.js @@ -31,6 +31,7 @@ export const switcherState = { switcherEffectivePreset: "Default", switcherPresets: [], switcherLoading: true, + switcherLoadSeq: 0, agentProfiles: [], agentProfilesLoading: true, agentProfilesLoaded: false, @@ -56,7 +57,7 @@ export const switcherMethods = { action: "list", context_id: contextId, }); - if (requestSeq !== this.agentProfilesLoadSeq) return this.agentProfiles; + if (requestSeq !== this.agentProfilesLoadSeq || contextId !== (window.Alpine?.store("chats")?.selected || "")) return this.agentProfiles; this.agentProfiles = (data.profiles || []) .filter(profile => profile.id && !["_example", "default"].includes(profile.id) && profile.enabled !== false) .map(profile => ({ @@ -218,9 +219,12 @@ export const switcherMethods = { }, async refreshSwitcher(contextId) { + if (contextId !== (window.Alpine?.store("chats")?.selected || "")) return; + const requestSeq = ++this.switcherLoadSeq; this.switcherLoading = true; try { const state = await this.loadSwitcherState(contextId); + if (requestSeq !== this.switcherLoadSeq || contextId !== (window.Alpine?.store("chats")?.selected || "")) return; this.switcherAllowed = state.allowed; this.switcherPresets = state.presets; this.switcherOverride = state.override; @@ -229,13 +233,13 @@ export const switcherMethods = { } catch (e) { console.error('Model switcher refresh failed:', e); } finally { - this.switcherLoading = false; + if (requestSeq === this.switcherLoadSeq) this.switcherLoading = false; } }, async selectPresetSwitch(contextId, presetName) { const data = await this.setPresetOverride(contextId, presetName); - if (data) { + if (data && contextId === (window.Alpine?.store("chats")?.selected || "")) { const selected = data.preset_name || presetName; this.switcherOverride = { preset_name: selected }; this.switcherEffectivePreset = selected; @@ -245,7 +249,7 @@ export const switcherMethods = { async clearOverrideSwitch(contextId) { const data = await this.clearOverride(contextId); - if (data) { + if (data && contextId === (window.Alpine?.store("chats")?.selected || "")) { this.switcherOverride = null; this.switcherEffectivePreset = data.effective_preset || this.switcherConfiguredPreset || 'Default'; } diff --git a/tests/test_chat_loading_refresh.py b/tests/test_chat_loading_refresh.py new file mode 100644 index 000000000..fda62c5b7 --- /dev/null +++ b/tests/test_chat_loading_refresh.py @@ -0,0 +1,263 @@ +"""Display-only snapshot refreshes must not hold up chat rendering.""" + +from pathlib import Path +import re +import shutil +import subprocess + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +pytestmark = pytest.mark.skipif(not shutil.which("node"), reason="Node.js is required") + + +def run_module(path: str, harness: str, checks: str) -> None: + source = (ROOT / path).read_text() + source = re.sub(r"^import[\s\S]*?;\n", "", source, flags=re.MULTILINE) + result = subprocess.run( + ["node", "--input-type=module"], + input='import assert from "node:assert/strict";\n' + harness + source + checks, + text=True, + capture_output=True, + timeout=10, + ) + assert result.returncode == 0, result.stderr + + +@pytest.mark.parametrize( + ("plugin", "filename", "function"), + [ + ("_model_config", "refresh-switcher", "refreshSwitcherOnOverrideRevision"), + ("_context_window", "refresh-context-window", "refreshContextWindow"), + ("_goal", "refresh-goal", "refreshGoalOnRevision"), + ], +) +def test_display_hooks_finish_while_requests_are_pending(plugin, filename, function): + run_module( + f"plugins/{plugin}/extensions/webui/apply_snapshot_before/{filename}.js", + """ +const calls = []; +let contextId; +const pending = () => { calls.push(contextId); return new Promise(() => {}); }; +const modelConfigStore = { loadAgentProfiles: pending, refreshSwitcher: pending }; +const contextWindowStore = { refresh: pending }; +const goalStore = { refresh: pending }; +""", + f""" +const refresh = {function}; +const snapshots = ["a", "b", "a"]; +for (contextId of snapshots) {{ + const ctx = {{ snapshot: {{ context: contextId, contexts: [{{ id: contextId }}], logs: [] }} }}; + let finished = false; + Promise.resolve(refresh(ctx)).then(() => {{ finished = true; }}); + await Promise.resolve(); + assert.ok(finished, "chat rendering waited for a display API"); + const count = calls.length; + await refresh(ctx); + assert.equal(calls.length, count, "unchanged snapshot started another refresh"); +}} +assert.deepEqual([...new Set(calls)], ["a", "b"]); +assert.equal(calls.filter(id => id === "a").length, 2 * calls.filter(id => id === "b").length); +""", + ) + + +DEFERRED = """ +const requests = []; +const pending = () => new Promise((resolve, reject) => requests.push({ resolve, reject })); +const errors = []; +console.error = (...args) => errors.push(args); +let selected = "a"; +const chatsStore = { getSelectedChatId: () => selected }; +const createStore = (_name, model) => model; +""" + + +def test_model_switcher_keeps_only_latest_selected_chat_and_loading_state(): + run_module( + "plugins/_model_config/webui/switcher-mixin.js", + DEFERRED + """ +globalThis.window = { Alpine: { store: () => ({ selected }) } }; +const callJsonApi = pending; +""", + """ +const store = { ...switcherState, ...switcherMethods, loadSwitcherState: pending }; +const state = name => ({ allowed: true, presets: [], override: null, + configuredPreset: name, effectivePreset: name }); +const first = store.refreshSwitcher("a"); +selected = "b"; +const second = store.refreshSwitcher("b"); +selected = "a"; +const latest = store.refreshSwitcher("a"); +requests[0].resolve(state("old-a")); +await first; +assert.ok(store.switcherLoading, "old refresh cleared the current loading flag"); +requests[2].resolve(state("new-a")); +await latest; +requests[1].resolve(state("old-b")); +await second; +assert.equal(store.switcherEffectivePreset, "new-a"); +assert.equal(store.switcherLoading, false); + +const deselected = store.refreshSwitcher("a"); +selected = ""; +requests[3].resolve(state("late-a")); +await deselected; +assert.equal(store.switcherEffectivePreset, "new-a", "deselected chat replaced selector state"); +const failed = store.refreshSwitcher(""); +requests[4].reject(new Error("offline")); +await failed; +assert.equal(store.switcherLoading, false); +assert.equal(errors.length, 1); + +selected = "a"; +const profiles = store.loadAgentProfiles(true); +selected = "b"; +requests[5].resolve({ profiles: [{ id: "private-to-a", title: "A" }] }); +await profiles; +assert.equal(store.agentProfiles.length, 0, "old profile catalog escaped into the next chat"); +""", + ) + + +def test_profile_save_in_old_chat_does_not_cancel_current_selector_refresh(): + run_module( + "plugins/_model_config/webui/switcher-mixin.js", + DEFERRED + """ +const contexts = { a: { agent_profile: "agent0" }, b: { agent_profile: "researcher" } }; +globalThis.window = { Alpine: { store: () => ({ selected, selectedContext: contexts[selected] }) } }; +const fetchApi = pending; +""", + """ +const store = { ...switcherState, ...switcherMethods, + loadAgentProfiles: async () => [], loadSwitcherState: pending }; +const saved = store.selectAgentProfile("a", "developer"); +await Promise.resolve(); +selected = "b"; +const current = store.refreshSwitcher("b"); +requests[0].resolve({ ok: true, json: async () => ({ + ok: true, agent_profile: "developer", agent_profile_label: "Developer", +}) }); +for (let tick = 0; tick < 4; tick++) await Promise.resolve(); +assert.equal(requests.length, 2, "old profile save started a refresh for the wrong chat"); +assert.ok(store.switcherLoading, "old profile save finished the current load"); +requests[1].resolve({ allowed: true, presets: [], override: null, + configuredPreset: "b", effectivePreset: "b" }); +assert.equal(await saved, true); +await current; +assert.equal(store.switcherEffectivePreset, "b"); +assert.equal(store.switcherLoading, false); +assert.equal(store.agentProfileSaving, false); +assert.equal(contexts.a.agent_profile, "developer"); +assert.equal(contexts.b.agent_profile, "researcher"); +""", + ) + + +@pytest.mark.parametrize("operation", ["selectPresetSwitch", "clearOverrideSwitch"]) +@pytest.mark.parametrize("switch_chat", [False, True]) +def test_model_save_updates_selector_only_for_selected_chat(operation, switch_chat): + run_module( + "plugins/_model_config/webui/switcher-mixin.js", + DEFERRED + f"const operation = {operation!r};\nconst switchChat = {str(switch_chat).lower()};\n" + """ +globalThis.window = { Alpine: { store: () => ({ selected }) } }; +""", + """ +const store = { ...switcherState, ...switcherMethods, + setPresetOverride: pending, clearOverride: pending, loadSwitcherState: pending }; +const saved = store[operation]("a", "new-a"); +if (switchChat) selected = "b"; +const current = store.refreshSwitcher(selected); +requests[1].resolve({ allowed: true, presets: [], override: { preset_name: selected }, + configuredPreset: selected, effectivePreset: selected }); +await current; +requests[0].resolve({ ok: true, preset_name: "new-a", effective_preset: "scoped-a" }); +assert.equal(await saved, true, "successful save in the original chat was lost"); +const expectedPreset = switchChat ? "b" : operation === "selectPresetSwitch" ? "new-a" : "scoped-a"; +const expectedOverride = switchChat ? { preset_name: "b" } : operation === "selectPresetSwitch" ? { preset_name: "new-a" } : null; +assert.equal(store.switcherEffectivePreset, expectedPreset); +assert.deepEqual(store.switcherOverride, expectedOverride); +assert.equal(store.switcherLoading, false); +""", + ) + + +def test_goal_refresh_drops_stale_success_and_failure_and_clears_on_deselect(): + run_module( + "plugins/_goal/webui/goal-store.js", + DEFERRED + "const callJsonApi = pending;\n", + """ +const first = store.refresh(true); +store.goal = { objective: "old goal" }; +store.editing = true; +selected = "b"; +const second = store.refresh(true); +assert.equal(store.goal, null); +assert.equal(store.editing, false); +selected = "a"; +const latest = store.refresh(true); +requests[0].resolve({ goal: { objective: "stale" } }); +await first; +requests[1].reject(new Error("stale failure")); +await second; +assert.equal(store.goal, null); +assert.ok(store.loading, "stale failure cleared the current loading flag"); +assert.equal(errors.length, 0); +requests[2].resolve({ goal: { objective: "latest" } }); +await latest; +assert.equal(store.goal.objective, "latest"); +assert.equal(store.loading, false); + +const deselected = store.refresh(true); +selected = ""; +await store.refresh(true); +requests[3].resolve({ goal: { objective: "late" } }); +await deselected; +assert.equal(store.goal, null); +assert.equal(store.loading, false); +selected = "b"; +const failed = store.refresh(true); +requests[4].reject(new Error("offline")); +await failed; +assert.equal(store.goal, null); +assert.equal(store.loading, false); +assert.equal(errors.length, 1); +""", + ) + + +def test_context_usage_ignores_stale_requests_and_deselected_chat(): + run_module( + "plugins/_context_window/webui/context-window-store.js", + DEFERRED + """ +const callJsonApi = pending; +const preferencesStore = { registerUiControlVisibility() {} }; +""", + """ +const first = store.refresh(); +selected = "b"; +const second = store.refresh(); +selected = "a"; +const latest = store.refresh(); +requests[2].resolve({ tokens: 30, context_window: 100 }); +await latest; +requests[0].resolve({ tokens: 10, context_window: 100 }); +requests[1].resolve({ tokens: 20, context_window: 100 }); +await Promise.all([first, second]); +assert.equal(store.usage.ringLabel, "30%"); +const deselected = store.refresh(); +selected = "b"; +requests[3].resolve({ tokens: 90, context_window: 100 }); +await deselected; +assert.equal(store.usage.ringLabel, "30%", "request landed after selection changed"); +selected = ""; +await store.refresh(); +assert.equal(store.usage.ringLabel, "–"); +selected = "a"; +const failed = store.refresh(); +requests[4].reject(new Error("offline")); +await failed; +assert.equal(errors.length, 1); +""", + )