mirror of
https://github.com/agent0ai/agent-zero.git
synced 2026-10-19 07:23:51 +00:00
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.
This commit is contained in:
parent
ce48667e98
commit
298cb2c05d
11 changed files with 303 additions and 18 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
263
tests/test_chat_loading_refresh.py
Normal file
263
tests/test_chat_loading_refresh.py
Normal file
|
|
@ -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);
|
||||
""",
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue