fix(ui): refresh agent roster on config changes (#111395)

This commit is contained in:
Peter Steinberger
2026-07-19 06:23:46 -07:00
committed by GitHub
parent be831088e9
commit 0cc182844a
4 changed files with 249 additions and 0 deletions
+141
View File
@@ -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;
+70
View File
@@ -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) {
+23
View File
@@ -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);
+15
View File
@@ -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) {