From 5678c74e4da0d73cf4ca0171c5ee29dfd60b4592 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 23 Jul 2026 01:38:11 -0400 Subject: [PATCH] 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 --- ui/src/app/agent-selection.test.ts | 56 +++++++++++++++++-- ui/src/app/agent-selection.ts | 26 ++++++++- ui/src/app/bootstrap.ts | 2 +- ui/src/components/agent-scope-control.test.ts | 55 ++++++++++++++++++ ui/src/components/agent-scope-control.ts | 24 ++++++-- 5 files changed, 149 insertions(+), 14 deletions(-) diff --git a/ui/src/app/agent-selection.test.ts b/ui/src/app/agent-selection.test.ts index 25bd81f93d1e..a29ca27bb2b6 100644 --- a/ui/src/app/agent-selection.test.ts +++ b/ui/src/app/agent-selection.test.ts @@ -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({ diff --git a/ui/src/app/agent-selection.ts b/ui/src/app/agent-selection.ts index 5db17935f17e..2657175896a7 100644 --- a/ui/src/app/agent-selection.ts +++ b/ui/src/app/agent-selection.ts @@ -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() { diff --git a/ui/src/app/bootstrap.ts b/ui/src/app/bootstrap.ts index b46dab7fbe07..9ba50b6ba076 100644 --- a/ui/src/app/bootstrap.ts +++ b/ui/src/app/bootstrap.ts @@ -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, diff --git a/ui/src/components/agent-scope-control.test.ts b/ui/src/components/agent-scope-control.test.ts index d8366da533c2..e0076be719b6 100644 --- a/ui/src/components/agent-scope-control.test.ts +++ b/ui/src/components/agent-scope-control.test.ts @@ -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("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("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); diff --git a/ui/src/components/agent-scope-control.ts b/ui/src/components/agent-scope-control.ts index 0060e745d573..a96f66a188e3 100644 --- a/ui/src/components/agent-scope-control.ts +++ b/ui/src/components/agent-scope-control.ts @@ -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) => ({