fix(ui): keep system agents out of page scopes (#112889)

* fix(ui): exclude system agents from page scopes

* perf(ui): keep scope filtering within startup budget
This commit is contained in:
Peter Steinberger
2026-07-23 01:38:11 -04:00
committed by GitHub
parent b74f04ada8
commit 5678c74e4d
5 changed files with 149 additions and 14 deletions

View File

@@ -1,9 +1,10 @@
import { describe, expect, it } from "vitest";
import type { GatewayBrowserClient } from "../api/gateway.ts";
import type { AgentsListResult } from "../api/types.ts";
import { createAgentSelectionCapability } from "./agent-selection.ts";
function createGateway() {
let snapshot = { client: null as GatewayBrowserClient | null, assistantAgentId: "Main" };
function createGateway(assistantAgentId = "Main") {
let snapshot = { client: null as GatewayBrowserClient | null, assistantAgentId };
const listeners = new Set<(next: typeof snapshot) => void>();
return {
gateway: {
@@ -24,10 +25,32 @@ function createGateway() {
};
}
function createRoster() {
let state = { agentsList: null as AgentsListResult | null };
const listeners = new Set<() => void>();
return {
roster: {
get state() {
return state;
},
subscribe(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
},
},
publish(agentsList: AgentsListResult) {
state = { agentsList };
for (const listener of listeners) {
listener();
}
},
};
}
describe("agent selection", () => {
it("keeps page scope separate from the concrete chat agent", () => {
const harness = createGateway();
const selection = createAgentSelectionCapability(harness.gateway);
const selection = createAgentSelectionCapability(harness.gateway, createRoster().roster);
expect(selection.state).toEqual({ selectedId: "main", scopeId: "main" });
selection.setScope(null);
@@ -37,9 +60,34 @@ describe("agent selection", () => {
expect(selection.state).toEqual({ selectedId: "writer", scopeId: "writer" });
});
it("clears system page scopes when the typed roster becomes known", () => {
const gateway = createGateway("OpenClaw");
const roster = createRoster();
const selection = createAgentSelectionCapability(gateway.gateway, roster.roster);
expect(selection.state).toEqual({ selectedId: "openclaw", scopeId: "openclaw" });
roster.publish({
defaultId: "main",
mainKey: "main",
scope: "per-sender",
agents: [
{ id: "main", kind: "agent" },
{ id: "openclaw", kind: "system" },
],
});
expect(selection.state).toEqual({ selectedId: "openclaw", scopeId: null });
selection.setScope("historical");
expect(selection.state.scopeId).toBe("historical");
selection.setScope("main");
expect(selection.state.scopeId).toBe("main");
selection.setScope("openclaw");
expect(selection.state.scopeId).toBeNull();
});
it("resets selection and scope together for a new gateway client", () => {
const harness = createGateway();
const selection = createAgentSelectionCapability(harness.gateway);
const selection = createAgentSelectionCapability(harness.gateway, createRoster().roster);
selection.setScope(null);
harness.publish({

View File

@@ -1,4 +1,5 @@
import type { GatewayBrowserClient } from "../api/gateway.ts";
import type { AgentsListResult } from "../api/types.ts";
import { normalizeAgentId } from "../lib/sessions/session-key.ts";
type AgentSelectionGateway = {
@@ -9,6 +10,11 @@ type AgentSelectionGateway = {
subscribe: (listener: (snapshot: AgentSelectionGateway["snapshot"]) => void) => () => void;
};
type AgentSelectionRoster = {
readonly state: { agentsList: AgentsListResult | null };
subscribe: (listener: () => void) => () => void;
};
type AgentSelectionState = {
selectedId: string | null;
/** Agent filter shared by agent-owned pages; null exposes all agents. */
@@ -24,19 +30,32 @@ export type AgentSelectionCapability = {
export function createAgentSelectionCapability(
gateway: AgentSelectionGateway,
roster: AgentSelectionRoster,
): AgentSelectionCapability {
const resolveScopeId = (value: string | null): string | null => {
const scopeId = value?.trim() ? normalizeAgentId(value) : null;
// System agents remain valid concrete chat targets, but never become shared page filters.
const isSystem = roster.state.agentsList?.agents.some(
(agent) => agent.kind === "system" && normalizeAgentId(agent.id) === scopeId,
);
return isSystem ? null : scopeId;
};
const initialId = gateway.snapshot.assistantAgentId
? normalizeAgentId(gateway.snapshot.assistantAgentId)
: null;
let state: AgentSelectionState = { selectedId: initialId, scopeId: initialId };
let state: AgentSelectionState = {
selectedId: initialId,
scopeId: resolveScopeId(initialId),
};
let client = gateway.snapshot.client;
const listeners = new Set<(next: AgentSelectionState) => void>();
const publish = (next: AgentSelectionState) => {
if (state.selectedId === next.selectedId && state.scopeId === next.scopeId) {
const reconciled = { ...next, scopeId: resolveScopeId(next.scopeId) };
if (state.selectedId === reconciled.selectedId && state.scopeId === reconciled.scopeId) {
return;
}
state = next;
state = reconciled;
for (const listener of listeners) {
listener(state);
}
@@ -49,6 +68,7 @@ export function createAgentSelectionCapability(
publish({ selectedId, scopeId: selectedId });
}
});
roster.subscribe(() => publish(state));
return {
get state() {

View File

@@ -296,7 +296,7 @@ export function bootstrapApplication(): ApplicationRuntime {
);
const agents = createAgentCapability(gateway);
const agentIdentity = createAgentIdentityCapability(gateway);
const agentSelection = createAgentSelectionCapability(gateway);
const agentSelection = createAgentSelectionCapability(gateway, agents);
const channels = createChannelCapability(gateway);
const config = createApplicationConfigCapability({
basePath,

View File

@@ -52,6 +52,61 @@ describe("renderAgentScopeControl", () => {
container.remove();
});
it("keeps semantic system agents out of roster and historical options", async () => {
const container = document.createElement("div");
document.body.append(container);
render(
renderAgentScopeControl({
agents: [
{ id: "main", kind: "agent", name: "Main agent" },
{ id: "ordinary-looking-id", kind: "system", name: "System" },
{ id: "writer", kind: "agent", name: "Writer" },
],
additionalAgentIds: ["ordinary-looking-id", "retired"],
selection: createSelection(vi.fn()),
selectedId: "ordinary-looking-id",
}),
container,
);
const select = container.querySelector<AgentSelectElement>("openclaw-agent-select");
await select?.updateComplete;
expect(select?.value).toBe("");
expect(select?.options.map((option) => option.value)).toEqual([
"",
"main",
"retired",
"writer",
]);
container.remove();
});
it("uses the first selectable agent when a concrete selector receives a system id", async () => {
const container = document.createElement("div");
document.body.append(container);
render(
renderAgentScopeControl({
agents: [
{ id: "main", kind: "agent", name: "Main agent" },
{ id: "ordinary-looking-id", kind: "system", name: "System" },
{ id: "writer", kind: "agent", name: "Writer" },
],
selection: createSelection(vi.fn()),
allowAll: false,
selectedId: "ordinary-looking-id",
}),
container,
);
const select = container.querySelector<AgentSelectElement>("openclaw-agent-select");
await select?.updateComplete;
expect(select?.value).toBe("main");
expect(select?.options.map((option) => option.value)).toEqual(["main", "writer"]);
container.remove();
});
it("supports a concrete-agent selector without an all-agents option", async () => {
const container = document.createElement("div");
document.body.append(container);

View File

@@ -2,7 +2,7 @@ import { html } from "lit";
import type { GatewayAgentRow } from "../api/types.ts";
import type { AgentSelectionCapability } from "../app/agent-selection.ts";
import { t } from "../i18n/index.ts";
import { normalizeAgentLabel } from "../lib/agents/display.ts";
import { listSelectableAgents, normalizeAgentLabel } from "../lib/agents/display.ts";
import { normalizeAgentId } from "../lib/sessions/session-key.ts";
import type { AgentSelectOption } from "./agent-select.ts";
import "./agent-select-registration.ts";
@@ -17,10 +17,17 @@ type AgentScopeControlParams = {
};
export function renderAgentScopeControl(params: AgentScopeControlParams) {
const selected = params.selectedId ?? params.selection.state.scopeId ?? "";
const requestedSelected = params.selectedId ?? params.selection.state.scopeId ?? "";
const selectedId = requestedSelected ? normalizeAgentId(requestedSelected) : "";
const allowAll = params.allowAll !== false;
// Do not let historical or selected IDs reintroduce a typed system row.
const isSystemAgentId = (agentId: string) =>
params.agents.some(
(agent) => agent.kind === "system" && normalizeAgentId(agent.id) === agentId,
);
const selectableAgents = listSelectableAgents(params.agents);
const agentsById = new Map(
params.agents.map((agent) => {
selectableAgents.map((agent) => {
const agentId = normalizeAgentId(agent.id);
return [agentId, agentId === agent.id ? agent : { ...agent, id: agentId }] as const;
}),
@@ -30,16 +37,21 @@ export function renderAgentScopeControl(params: AgentScopeControlParams) {
continue;
}
const agentId = normalizeAgentId(value);
if (!agentsById.has(agentId)) {
if (!isSystemAgentId(agentId) && !agentsById.has(agentId)) {
agentsById.set(agentId, { id: agentId });
}
}
if (selected && !agentsById.has(selected)) {
agentsById.set(selected, { id: selected });
if (selectedId && !isSystemAgentId(selectedId) && !agentsById.has(selectedId)) {
agentsById.set(selectedId, { id: selectedId });
}
const agents = [...agentsById.values()].toSorted((left, right) =>
normalizeAgentLabel(left).localeCompare(normalizeAgentLabel(right)),
);
const selected = isSystemAgentId(selectedId)
? allowAll
? ""
: (agents[0]?.id ?? "")
: selectedId;
const options: AgentSelectOption[] = [
...(allowAll ? [{ value: "", label: t("agentScope.allAgents"), icon: icons.users }] : []),
...agents.map((agent) => ({