mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-04 12:01:33 +00:00
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:
committed by
GitHub
parent
b74f04ada8
commit
5678c74e4d
@@ -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({
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
Reference in New Issue
Block a user