mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 02:06:43 +00:00
fix(ui): refresh agent roster on config changes (#111395)
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
import { render } from "lit";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { GatewayBrowserClient } from "../api/gateway.ts";
|
||||
import type { AgentsListResult, GatewayAgentRow } from "../api/types.ts";
|
||||
import {
|
||||
COMMAND_PALETTE_OPEN_EVENT,
|
||||
SHELL_NAV_DRAWER_TOGGLE_EVENT,
|
||||
@@ -74,6 +75,59 @@ type ShellUiCommandState = ShellKeyboardState & {
|
||||
handleGatewayEvent: (event: { event: string; payload: unknown }) => void;
|
||||
};
|
||||
|
||||
function roster(defaultId: string, agents: GatewayAgentRow[]): AgentsListResult {
|
||||
return { defaultId, mainKey: "main", scope: "per-sender", agents };
|
||||
}
|
||||
|
||||
function createRosterRefreshContext(params: {
|
||||
previous: AgentsListResult;
|
||||
next: AgentsListResult;
|
||||
selectedId: string;
|
||||
}) {
|
||||
const agentsState = { agentsList: params.previous };
|
||||
const selectionState = { selectedId: params.selectedId, scopeId: params.selectedId };
|
||||
const refreshList = vi.fn(async () => {
|
||||
agentsState.agentsList = params.next;
|
||||
return params.next;
|
||||
});
|
||||
const invalidateFiles = vi.fn();
|
||||
const invalidateIdentity = vi.fn();
|
||||
const ensureIdentity = vi.fn(async () => undefined);
|
||||
const setSelection = vi.fn((agentId: string) => {
|
||||
selectionState.selectedId = agentId;
|
||||
selectionState.scopeId = agentId;
|
||||
});
|
||||
const refreshConfig = vi.fn(async () => null);
|
||||
const context = {
|
||||
agents: {
|
||||
state: agentsState,
|
||||
refreshList,
|
||||
invalidateFiles,
|
||||
},
|
||||
agentIdentity: {
|
||||
invalidate: invalidateIdentity,
|
||||
ensure: ensureIdentity,
|
||||
},
|
||||
agentSelection: {
|
||||
state: selectionState,
|
||||
set: setSelection,
|
||||
},
|
||||
runtimeConfig: {
|
||||
state: { configFormDirty: false },
|
||||
refresh: refreshConfig,
|
||||
},
|
||||
} as unknown as ApplicationContext;
|
||||
return {
|
||||
context,
|
||||
refreshList,
|
||||
invalidateFiles,
|
||||
invalidateIdentity,
|
||||
ensureIdentity,
|
||||
setSelection,
|
||||
refreshConfig,
|
||||
};
|
||||
}
|
||||
|
||||
let lazyElementSequence = 0;
|
||||
|
||||
function createLazyElementSpec(label: string): TestOptionalCustomElement {
|
||||
@@ -135,6 +189,7 @@ type MacosTitlebarControlsState = HTMLElement & {
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
Reflect.deleteProperty(window, "webkit");
|
||||
document.documentElement.classList.remove(
|
||||
"openclaw-native-macos",
|
||||
@@ -599,6 +654,92 @@ describe("OpenClaw shell keyboard shortcuts", () => {
|
||||
window.removeEventListener(UI_COMMAND_EVENT, uiCommandEvent);
|
||||
});
|
||||
|
||||
it("refreshes the roster on config.changed and invalidates removed or changed agents", async () => {
|
||||
vi.useFakeTimers();
|
||||
const harness = createRosterRefreshContext({
|
||||
previous: roster("main", [
|
||||
{ id: "main", name: "Main" },
|
||||
{ id: "writer", name: "Writer" },
|
||||
{ id: "retired", name: "Retired" },
|
||||
]),
|
||||
next: roster("main", [
|
||||
{ id: "main", name: "Main" },
|
||||
{ id: "writer", name: "Editor" },
|
||||
{ id: "new-agent", name: "New" },
|
||||
]),
|
||||
selectedId: "main",
|
||||
});
|
||||
const shell = document.createElement("openclaw-app-shell") as unknown as ShellUiCommandState;
|
||||
shell.runtime = { context: harness.context };
|
||||
|
||||
shell.handleGatewayEvent({ event: "config.changed", payload: {} });
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(harness.refreshConfig).toHaveBeenCalledOnce();
|
||||
expect(harness.refreshList).toHaveBeenCalledOnce();
|
||||
expect(harness.invalidateFiles).toHaveBeenCalledWith(["writer", "retired"]);
|
||||
expect(harness.invalidateIdentity).toHaveBeenCalledWith(["writer", "retired"]);
|
||||
expect(harness.ensureIdentity).toHaveBeenCalledWith(["writer"]);
|
||||
expect(harness.setSelection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("moves a deleted active agent to the refreshed roster default", async () => {
|
||||
vi.useFakeTimers();
|
||||
const harness = createRosterRefreshContext({
|
||||
previous: roster("writer", [{ id: "fallback" }, { id: "main" }, { id: "writer" }]),
|
||||
next: roster("main", [{ id: "fallback" }, { id: "main" }]),
|
||||
selectedId: "writer",
|
||||
});
|
||||
const shell = document.createElement("openclaw-app-shell") as unknown as ShellUiCommandState;
|
||||
shell.runtime = { context: harness.context };
|
||||
|
||||
shell.handleGatewayEvent({ event: "config.changed", payload: {} });
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(harness.setSelection).toHaveBeenCalledExactlyOnceWith("main");
|
||||
});
|
||||
|
||||
it("keeps caches intact when a config.changed refresh returns the same roster", async () => {
|
||||
vi.useFakeTimers();
|
||||
const unchanged = roster("main", [{ id: "main", name: "Main" }, { id: "writer" }]);
|
||||
const harness = createRosterRefreshContext({
|
||||
previous: unchanged,
|
||||
next: structuredClone(unchanged),
|
||||
selectedId: "main",
|
||||
});
|
||||
const shell = document.createElement("openclaw-app-shell") as unknown as ShellUiCommandState;
|
||||
shell.runtime = { context: harness.context };
|
||||
|
||||
shell.handleGatewayEvent({ event: "config.changed", payload: {} });
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(harness.refreshList).toHaveBeenCalledOnce();
|
||||
expect(harness.invalidateFiles).not.toHaveBeenCalled();
|
||||
expect(harness.invalidateIdentity).not.toHaveBeenCalled();
|
||||
expect(harness.ensureIdentity).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("coalesces config.changed bursts into one roster refresh", async () => {
|
||||
vi.useFakeTimers();
|
||||
const unchanged = roster("main", [{ id: "main" }]);
|
||||
const harness = createRosterRefreshContext({
|
||||
previous: unchanged,
|
||||
next: unchanged,
|
||||
selectedId: "main",
|
||||
});
|
||||
const shell = document.createElement("openclaw-app-shell") as unknown as ShellUiCommandState;
|
||||
shell.runtime = { context: harness.context };
|
||||
|
||||
shell.handleGatewayEvent({ event: "config.changed", payload: {} });
|
||||
shell.handleGatewayEvent({ event: "config.changed", payload: {} });
|
||||
shell.handleGatewayEvent({ event: "config.changed", payload: {} });
|
||||
await vi.advanceTimersByTimeAsync(99);
|
||||
expect(harness.refreshList).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
expect(harness.refreshList).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("opens Settings with Shift-Command-Comma", () => {
|
||||
const navigate = vi.fn();
|
||||
const shell = document.createElement("openclaw-app-shell") as unknown as ShellKeyboardState;
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
hasStoredGatewayAuth,
|
||||
type GatewayBrowserClient,
|
||||
} from "../api/gateway.ts";
|
||||
import type { GatewayAgentRow } from "../api/types.ts";
|
||||
import "../components/app-sidebar.ts";
|
||||
import "../components/app-topbar.ts";
|
||||
import "../components/connection-banner.ts";
|
||||
@@ -115,6 +116,26 @@ type AppSidebarElement = HTMLElement & {
|
||||
// Stable references so the sidebar's enabledRouteIds property does not churn
|
||||
// on every shell render.
|
||||
const ROUTE_IDS_WITHOUT_WORKBOARD = APP_ROUTE_IDS.filter((routeId) => routeId !== "workboard");
|
||||
const AGENT_ROSTER_REFRESH_DEBOUNCE_MS = 100;
|
||||
|
||||
function diffAgentRoster(
|
||||
previous: readonly GatewayAgentRow[],
|
||||
next: readonly GatewayAgentRow[],
|
||||
): { invalidatedIds: string[]; changedIds: string[] } {
|
||||
const nextById = new Map(next.map((agent) => [agent.id, agent]));
|
||||
const invalidatedIds: string[] = [];
|
||||
const changedIds: string[] = [];
|
||||
for (const agent of previous) {
|
||||
const replacement = nextById.get(agent.id);
|
||||
if (!replacement) {
|
||||
invalidatedIds.push(agent.id);
|
||||
} else if (JSON.stringify(replacement) !== JSON.stringify(agent)) {
|
||||
invalidatedIds.push(agent.id);
|
||||
changedIds.push(agent.id);
|
||||
}
|
||||
}
|
||||
return { invalidatedIds, changedIds };
|
||||
}
|
||||
|
||||
function selectShellRouteState(routerState: RouterState<RouteId>): ShellRouteState {
|
||||
const match = selectRenderedRouteMatch(routerState.matches[0], routerState.pendingMatches[0]);
|
||||
@@ -473,6 +494,7 @@ class OpenClawShell extends OpenClawLightDomElement {
|
||||
private sessionKeyClient: GatewayBrowserClient | null = null;
|
||||
private runtimeConfigClient: GatewayBrowserClient | null = null;
|
||||
private runtimeConfigSource: ApplicationContext["runtimeConfig"] | null = null;
|
||||
private agentRosterRefreshTimer: ReturnType<typeof globalThis.setTimeout> | null = null;
|
||||
private lastNativeNavState: NativeNavState | undefined;
|
||||
private didConsiderNativeRouteRestore = false;
|
||||
private pendingNativeNewSession = false;
|
||||
@@ -652,6 +674,10 @@ class OpenClawShell extends OpenClawLightDomElement {
|
||||
this.sessionKeyClient = null;
|
||||
this.runtimeConfigClient = null;
|
||||
this.runtimeConfigSource = null;
|
||||
if (this.agentRosterRefreshTimer !== null) {
|
||||
globalThis.clearTimeout(this.agentRosterRefreshTimer);
|
||||
this.agentRosterRefreshTimer = null;
|
||||
}
|
||||
resetServerUiPrefsSync();
|
||||
for (const timer of this.settingsPreloadTimers.values()) {
|
||||
globalThis.clearTimeout(timer);
|
||||
@@ -668,6 +694,7 @@ class OpenClawShell extends OpenClawLightDomElement {
|
||||
if (runtimeConfig && !runtimeConfig.state.configFormDirty) {
|
||||
void runtimeConfig.refresh();
|
||||
}
|
||||
this.scheduleAgentRosterRefresh();
|
||||
return;
|
||||
}
|
||||
if (event.event !== "ui.command" || !event.payload) {
|
||||
@@ -714,6 +741,49 @@ class OpenClawShell extends OpenClawLightDomElement {
|
||||
this.navigate("chat", { search: searchForSession(command.sessionKey) });
|
||||
};
|
||||
|
||||
private scheduleAgentRosterRefresh() {
|
||||
// Persisted config writes can arrive as a tight sequence; roster state is
|
||||
// snapshot-based, so only the final forced read in that burst is useful.
|
||||
if (this.agentRosterRefreshTimer !== null) {
|
||||
globalThis.clearTimeout(this.agentRosterRefreshTimer);
|
||||
}
|
||||
this.agentRosterRefreshTimer = globalThis.setTimeout(() => {
|
||||
this.agentRosterRefreshTimer = null;
|
||||
void this.refreshAgentRoster();
|
||||
}, AGENT_ROSTER_REFRESH_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
private async refreshAgentRoster() {
|
||||
const context = this.context;
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
const previous = context.agents.state.agentsList;
|
||||
const activeAgentId = context.agentSelection.state.selectedId;
|
||||
const next = await context.agents.refreshList();
|
||||
if (!next || this.context !== context) {
|
||||
return;
|
||||
}
|
||||
const rosterDiff = diffAgentRoster(previous?.agents ?? [], next.agents);
|
||||
if (rosterDiff.invalidatedIds.length > 0) {
|
||||
context.agents.invalidateFiles(rosterDiff.invalidatedIds);
|
||||
context.agentIdentity.invalidate(rosterDiff.invalidatedIds);
|
||||
}
|
||||
if (rosterDiff.changedIds.length > 0) {
|
||||
void context.agentIdentity.ensure(rosterDiff.changedIds);
|
||||
}
|
||||
const previousIds = new Set(previous?.agents.map((agent) => agent.id) ?? []);
|
||||
const nextIds = new Set(next.agents.map((agent) => agent.id));
|
||||
if (
|
||||
activeAgentId &&
|
||||
context.agentSelection.state.selectedId === activeAgentId &&
|
||||
previousIds.has(activeAgentId) &&
|
||||
!nextIds.has(activeAgentId)
|
||||
) {
|
||||
context.agentSelection.set(next.defaultId);
|
||||
}
|
||||
}
|
||||
|
||||
private readonly handleThemeChange = (event: CustomEvent<ThemeModeChangeDetail>) => {
|
||||
const context = this.context;
|
||||
if (!context) {
|
||||
|
||||
@@ -184,6 +184,29 @@ describe("createAgentCapability lifecycle", () => {
|
||||
agents.dispose();
|
||||
});
|
||||
|
||||
it("invalidates cached and in-flight file lists for changed agents", async () => {
|
||||
const pending = deferred<unknown>();
|
||||
const request = vi
|
||||
.fn<TestRequest>()
|
||||
.mockResolvedValueOnce({ agentId: "main", workspace: "old", files: [] })
|
||||
.mockReturnValueOnce(pending.promise);
|
||||
const client = { request } as unknown as GatewayBrowserClient;
|
||||
const harness = createGatewayHarness(client);
|
||||
const agents = createAgentCapability(harness.gateway);
|
||||
|
||||
await agents.refreshFiles("main");
|
||||
expect(agents.files("main").list?.workspace).toBe("old");
|
||||
|
||||
const staleLoad = agents.refreshFiles("main");
|
||||
agents.invalidateFiles(["main"]);
|
||||
pending.resolve({ agentId: "main", workspace: "stale", files: [] });
|
||||
await staleLoad;
|
||||
|
||||
expect(agents.files("main").list).toBeNull();
|
||||
expect(agents.files("main").loading).toBe(false);
|
||||
agents.dispose();
|
||||
});
|
||||
|
||||
it("does not commit a list request after disposal", async () => {
|
||||
const pending = deferred<unknown>();
|
||||
const request = vi.fn<TestRequest>().mockReturnValue(pending.promise);
|
||||
|
||||
@@ -88,6 +88,7 @@ export type AgentCapability = {
|
||||
ensureList: () => Promise<AgentsListResult | null>;
|
||||
refreshList: () => Promise<AgentsListResult | null>;
|
||||
files: (agentId: string | null | undefined) => AgentFilesStatus;
|
||||
invalidateFiles: (agentIds: readonly (string | null | undefined)[]) => void;
|
||||
ensureFiles: (agentId: string) => Promise<AgentsFilesListResult | null>;
|
||||
refreshFiles: (agentId: string) => Promise<AgentsFilesListResult | null>;
|
||||
subscribe: (listener: (state: AgentCapabilityState) => void) => () => void;
|
||||
@@ -417,6 +418,20 @@ export function createAgentCapability(gateway: AgentGateway): AgentCapability {
|
||||
? (files.get(normalized) ?? emptyAgentFilesStatus())
|
||||
: emptyAgentFilesStatus();
|
||||
},
|
||||
invalidateFiles(agentIds) {
|
||||
let changed = false;
|
||||
const normalizedIds = new Set(
|
||||
agentIds.map(normalizeAgentId).filter((agentId): agentId is string => agentId !== null),
|
||||
);
|
||||
for (const agentId of normalizedIds) {
|
||||
changed = files.delete(agentId) || changed;
|
||||
changed = fileRequests.delete(agentId) || changed;
|
||||
changed = fileRequestOwners.delete(agentId) || changed;
|
||||
}
|
||||
if (changed) {
|
||||
publish();
|
||||
}
|
||||
},
|
||||
ensureFiles: (agentId) => loadFiles(agentId, false),
|
||||
refreshFiles: (agentId) => loadFiles(agentId, true),
|
||||
subscribe(listener) {
|
||||
|
||||
Reference in New Issue
Block a user