fix(ui): bound sessions to configured agents

Fixes #41685.\n\nSummary:\n- Adds an additive sessions.list configuredAgentsOnly option for Control UI.\n- Filters default Control UI session listing to configured agents while preserving broad Gateway discovery for explicit callers.\n- Falls back restored unconfigured agent session keys before chat refresh.\n\nValidation:\n- pnpm protocol:check\n- pnpm test ui/src/ui/controllers/sessions.test.ts ui/src/ui/app-gateway.node.test.ts src/gateway/server.sessions.store-rpc.test.ts -- --reporter=verbose\n- pnpm format:docs:check\n- pnpm lint:swift\n- pnpm check:no-conflict-markers\n- git diff --check
This commit is contained in:
Val Alexander
2026-05-07 03:26:47 -05:00
committed by GitHub
parent d5eabbd36c
commit 6b4ff8be81
11 changed files with 214 additions and 6 deletions

View File

@@ -267,6 +267,7 @@ Docs: https://docs.openclaw.ai
- CLI/update: make dev-channel preflight lint opt-in and constrained when enabled, so `openclaw update --channel dev` no longer walks back otherwise-good main commits when Ubuntu hosts OOM-kill or fail parallel oxlint shards. Thanks @vincentkoc.
- Google Meet: fork the caller's current agent transcript into agent-mode meeting consultant sessions, so Meet replies inherit the context from the tool call that joined the meeting.
- Google Meet: log the concrete agent-mode TTS provider, model, voice, output format, and sample rate after speech synthesis, so Meet logs show which voice backend spoke each reply.
- Control UI/Sessions: hide disk-discovered unregistered-agent sessions by default and fall back from restored unconfigured agent session keys before chat refresh, preventing deleted-agent stores from reopening the wrong workspace. Fixes #41685. Thanks @BunsDev.
- Google Meet: log the resolved audio provider model when starting Chrome and paired-node Meet talk-back bridges, so agent-mode joins show the STT model and bidi joins show the realtime voice model.
- Google Meet: stop advertising legacy `mode: "realtime"` to agents and config UIs, while keeping it as a hidden compatibility alias for `mode: "agent"`, so new joins use the STT -> OpenClaw agent -> TTS path instead of selecting the direct realtime voice fallback.
- Google Meet: add `chrome.audioBufferBytes` for generated command-pair SoX audio commands and lower the default buffer from SoX's 8192 bytes to 4096 bytes to reduce Chrome talk-back latency.

View File

@@ -1517,6 +1517,7 @@ public struct SessionsListParams: Codable, Sendable {
public let activeminutes: Int?
public let includeglobal: Bool?
public let includeunknown: Bool?
public let configuredagentsonly: Bool?
public let includederivedtitles: Bool?
public let includelastmessage: Bool?
public let label: String?
@@ -1529,6 +1530,7 @@ public struct SessionsListParams: Codable, Sendable {
activeminutes: Int?,
includeglobal: Bool?,
includeunknown: Bool?,
configuredagentsonly: Bool?,
includederivedtitles: Bool?,
includelastmessage: Bool?,
label: String?,
@@ -1540,6 +1542,7 @@ public struct SessionsListParams: Codable, Sendable {
self.activeminutes = activeminutes
self.includeglobal = includeglobal
self.includeunknown = includeunknown
self.configuredagentsonly = configuredagentsonly
self.includederivedtitles = includederivedtitles
self.includelastmessage = includelastmessage
self.label = label
@@ -1553,6 +1556,7 @@ public struct SessionsListParams: Codable, Sendable {
case activeminutes = "activeMinutes"
case includeglobal = "includeGlobal"
case includeunknown = "includeUnknown"
case configuredagentsonly = "configuredAgentsOnly"
case includederivedtitles = "includeDerivedTitles"
case includelastmessage = "includeLastMessage"
case label

View File

@@ -23,6 +23,11 @@ event loop. The CLI returns the newest 100 sessions by default; pass
need the full store. JSON responses include `totalCount`, `limitApplied`, and
`hasMore` when callers need to show that more rows exist.
RPC clients can pass `configuredAgentsOnly: true` to keep the broad combined
discovery source but return only rows for agents currently present in config.
Control UI uses that mode by default so deleted or disk-only agent stores do
not reappear in the Sessions view.
```bash
openclaw sessions
openclaw sessions --agent work

View File

@@ -105,7 +105,7 @@ Imported themes are stored only in the current browser profile. They are not wri
- Channels: built-in plus bundled/external plugin channels status, QR login, and per-channel config (`channels.status`, `web.login.*`, `config.patch`).
- Channel probe refreshes keep the previous snapshot visible while slow provider checks finish, and partial snapshots are labeled when a probe or audit exceeds its UI budget.
- Instances: presence list + refresh (`system-presence`).
- Sessions: list + per-session model/thinking/fast/verbose/trace/reasoning overrides (`sessions.list`, `sessions.patch`).
- Sessions: list configured-agent sessions by default, fall back from stale unconfigured agent session keys, and apply per-session model/thinking/fast/verbose/trace/reasoning overrides (`sessions.list`, `sessions.patch`).
- Dreams: dreaming status, enable/disable toggle, and Dream Diary reader (`doctor.memory.status`, `doctor.memory.dreamDiary`, `config.patch`).
</Accordion>

View File

@@ -46,6 +46,11 @@ export const SessionsListParamsSchema = Type.Object(
activeMinutes: Type.Optional(Type.Integer({ minimum: 1 })),
includeGlobal: Type.Optional(Type.Boolean()),
includeUnknown: Type.Optional(Type.Boolean()),
/**
* Limit returned agent-scoped rows to agents currently present in config.
* Broad disk discovery remains the default for recovery/ACP consumers.
*/
configuredAgentsOnly: Type.Optional(Type.Boolean()),
/**
* Read first 8KB of each session transcript to derive title from first user message.
* Performs a file read per session - use `limit` to bound result set on large stores.

View File

@@ -3,7 +3,11 @@ import fs from "node:fs";
import path from "node:path";
import { CURRENT_SESSION_VERSION } from "@mariozechner/pi-coding-agent";
import { resolveAgentRuntimeMetadata } from "../../agents/agent-runtime-metadata.js";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
import {
listAgentIds,
resolveAgentWorkspaceDir,
resolveDefaultAgentId,
} from "../../agents/agent-scope.js";
import {
abortEmbeddedPiRun,
isEmbeddedPiRunActive,
@@ -111,6 +115,22 @@ import type {
} from "./types.js";
import { assertValidParams } from "./validation.js";
function filterSessionStoreToConfiguredAgents(
cfg: OpenClawConfig,
store: Record<string, SessionEntry>,
): Record<string, SessionEntry> {
const configuredAgentIds = new Set(listAgentIds(cfg).map((agentId) => normalizeAgentId(agentId)));
return Object.fromEntries(
Object.entries(store).filter(([key]) => {
if (key === "global" || key === "unknown") {
return true;
}
const parsed = parseAgentSessionKey(key);
return parsed ? configuredAgentIds.has(normalizeAgentId(parsed.agentId)) : false;
}),
);
}
type SessionsRuntimeModule = typeof import("./sessions.runtime.js");
let sessionsRuntimeModulePromise: Promise<SessionsRuntimeModule> | undefined;
@@ -670,11 +690,13 @@ export const sessionsHandlers: GatewayRequestHandlers = {
const p = params;
const cfg = context.getRuntimeConfig();
const { storePath, store } = loadCombinedSessionStoreForGateway(cfg, { agentId: p.agentId });
const listStore =
p.configuredAgentsOnly === true ? filterSessionStoreToConfiguredAgents(cfg, store) : store;
const modelCatalog = await loadOptionalSessionsListModelCatalog(context);
const result = await listSessionsFromStoreAsync({
cfg,
storePath,
store,
store: listStore,
modelCatalog,
opts: p,
});

View File

@@ -1,8 +1,9 @@
import fs from "node:fs/promises";
import path from "node:path";
import { expect, test, vi } from "vitest";
import { piSdkMock, rpcReq, writeSessionStore } from "./test-helpers.js";
import { piSdkMock, rpcReq, testState, writeSessionStore } from "./test-helpers.js";
import {
directSessionReq as directSessionHandlerReq,
setupGatewaySessionsTestHarness,
getGatewayConfigModule,
getSessionsHandlers,
@@ -435,3 +436,48 @@ test("lists and patches session store via sessions.* RPC", async () => {
/invalid thinkinglevel/i,
);
});
test("sessions.list configuredAgentsOnly hides disk-discovered unregistered agent stores", async () => {
const stateDir = process.env.OPENCLAW_STATE_DIR;
if (!stateDir) {
throw new Error("OPENCLAW_STATE_DIR is required for gateway session tests");
}
testState.agentsConfig = { list: [{ id: "main", default: true }] };
testState.sessionConfig = {
store: path.join(stateDir, "agents", "{agentId}", "sessions", "sessions.json"),
};
const mainStorePath = path.join(stateDir, "agents", "main", "sessions", "sessions.json");
const diskOnlyStorePath = path.join(stateDir, "agents", "local", "sessions", "sessions.json");
await fs.mkdir(path.dirname(mainStorePath), { recursive: true });
await fs.mkdir(path.dirname(diskOnlyStorePath), { recursive: true });
await fs.writeFile(
mainStorePath,
JSON.stringify({ main: { sessionId: "sess-main", updatedAt: 20 } }, null, 2),
"utf-8",
);
await fs.writeFile(
diskOnlyStorePath,
JSON.stringify({ main: { sessionId: "sess-local", updatedAt: 10 } }, null, 2),
"utf-8",
);
const broad = await directSessionHandlerReq<{ sessions: Array<{ key: string }> }>(
"sessions.list",
{ includeGlobal: false, includeUnknown: false },
);
expect(broad.ok).toBe(true);
expect(broad.payload?.sessions.map((session) => session.key)).toEqual([
"agent:main:main",
"agent:local:main",
]);
const configuredOnly = await directSessionHandlerReq<{ sessions: Array<{ key: string }> }>(
"sessions.list",
{ includeGlobal: false, includeUnknown: false, configuredAgentsOnly: true },
);
expect(configuredOnly.ok).toBe(true);
expect(configuredOnly.payload?.sessions.map((session) => session.key)).toEqual([
"agent:main:main",
]);
});

View File

@@ -179,6 +179,7 @@ function createHost(): TestGatewayHost {
execApprovalQueue: [],
execApprovalError: null,
updateAvailable: null,
updateComplete: new Promise(() => undefined),
} as unknown as TestGatewayHost;
}
@@ -214,8 +215,14 @@ describe("connectGateway", () => {
gatewayClientInstances.length = 0;
loadChatHistoryMock.mockClear();
loadControlUiBootstrapConfigMock.mockClear();
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) =>
setTimeout(() => callback(Date.now()), 0),
);
vi.stubGlobal("cancelAnimationFrame", (id: number) => clearTimeout(id));
vi.stubGlobal("window", {
setTimeout: globalThis.setTimeout,
requestAnimationFrame: globalThis.requestAnimationFrame,
cancelAnimationFrame: globalThis.cancelAnimationFrame,
});
});
@@ -657,6 +664,58 @@ describe("connectGateway", () => {
expect(loadControlUiBootstrapConfigMock).toHaveBeenCalledWith(host, { applyIdentity: false });
});
it("falls back from restored unconfigured agent sessions before refreshing chat", async () => {
const host = createHost();
host.tab = "chat";
host.sessionKey = "agent:local:main";
host.settings = {
...host.settings,
sessionKey: "agent:local:main",
lastActiveSessionKey: "agent:local:main",
};
connectGateway(host);
const client = gatewayClientInstances[0];
expect(client).toBeDefined();
client.request.mockImplementation(async (method: string) => {
if (method === "agents.list") {
return {
defaultId: "main",
mainKey: "agent:main:main",
scope: "all",
agents: [{ id: "main", name: "Main" }],
};
}
if (method === "update.status") {
return { sentinel: null };
}
if (method === "models.authStatus") {
return { ts: 0, providers: [] };
}
return {};
});
client.emitHello({
type: "hello-ok",
protocol: 3,
auth: { role: "operator", scopes: [] },
snapshot: {
sessionDefaults: {
defaultAgentId: "main",
mainKey: "main",
mainSessionKey: "agent:main:main",
},
},
} as GatewayHelloOk);
await vi.waitFor(() => {
expect(loadChatHistoryMock).toHaveBeenCalledWith(host);
});
expect(host.sessionKey).toBe("agent:main:main");
expect(host.settings.sessionKey).toBe("agent:main:main");
expect(host.settings.lastActiveSessionKey).toBe("agent:main:main");
});
it("sends queued chat aborts after reconnect before clearing pending state", async () => {
const host = createHost();
host.chatRunId = "run-main";

View File

@@ -15,6 +15,7 @@ import {
loadCron,
refreshActiveTab,
setLastActiveSessionKey,
syncUrlWithSessionKey,
} from "./app-settings.ts";
import { handleAgentEvent, resetToolStream, type AgentEventPayload } from "./app-tool-stream.ts";
import { shouldReloadHistoryForFinalEvent } from "./chat-event-reload.ts";
@@ -58,6 +59,7 @@ import {
} from "./gateway.ts";
import { GatewayBrowserClient } from "./gateway.ts";
import type { Tab } from "./navigation.ts";
import { buildAgentMainSessionKey, normalizeAgentId, parseAgentSessionKey } from "./session-key.ts";
import type { UiSettings } from "./storage.ts";
import type {
AgentsListResult,
@@ -401,6 +403,60 @@ function applySessionDefaults(host: GatewayHost, defaults?: SessionDefaultsSnaps
}
}
function resolveMainSessionFallback(host: GatewayHost): string {
const snapshot = host.hello?.snapshot as
| { sessionDefaults?: SessionDefaultsSnapshot }
| undefined;
const mainSessionKey = snapshot?.sessionDefaults?.mainSessionKey?.trim();
if (mainSessionKey) {
return mainSessionKey;
}
const configuredMainKey =
snapshot?.sessionDefaults?.mainKey?.trim() || host.agentsList?.mainKey?.trim();
if (configuredMainKey && parseAgentSessionKey(configuredMainKey)) {
return configuredMainKey;
}
const defaultAgentId = host.agentsList?.defaultId?.trim() || "main";
return buildAgentMainSessionKey({
agentId: defaultAgentId,
mainKey: configuredMainKey,
});
}
function fallbackUnconfiguredSessionSelection(host: GatewayHost) {
const parsed = parseAgentSessionKey(host.sessionKey);
if (!parsed) {
return;
}
const configuredAgentIds = new Set(
(host.agentsList?.agents ?? []).map((entry) => normalizeAgentId(entry.id)),
);
if (configuredAgentIds.size === 0 || configuredAgentIds.has(normalizeAgentId(parsed.agentId))) {
return;
}
const nextSessionKey = resolveMainSessionFallback(host);
host.sessionKey = nextSessionKey;
applySettings(host as unknown as Parameters<typeof applySettings>[0], {
...host.settings,
sessionKey: nextSessionKey,
lastActiveSessionKey: nextSessionKey,
});
syncUrlWithSessionKey(
host as unknown as Parameters<typeof syncUrlWithSessionKey>[0],
nextSessionKey,
true,
);
}
async function loadAgentsThenRefreshActiveTab(host: GatewayHost) {
try {
await loadAgents(host as unknown as AgentsState);
fallbackUnconfiguredSessionSelection(host);
} finally {
await refreshActiveTab(host as unknown as Parameters<typeof refreshActiveTab>[0]);
}
}
export function connectGateway(host: GatewayHost, options?: ConnectGatewayOptions) {
const shutdownHost = host as GatewayHostWithShutdownMessage;
const reconnectReason = options?.reason ?? "initial";
@@ -486,11 +542,10 @@ export function connectGateway(host: GatewayHost, options?: ConnectGatewayOption
if (host.tab !== "chat") {
void refreshChatAvatar(host as unknown as Parameters<typeof refreshChatAvatar>[0]);
}
void loadAgents(host as unknown as AgentsState);
void loadHealthState(host as unknown as HealthState);
void loadNodes(host as unknown as NodesState, { quiet: true });
void loadDevices(host as unknown as DevicesState, { quiet: true });
void refreshActiveTab(host as unknown as Parameters<typeof refreshActiveTab>[0]);
void loadAgentsThenRefreshActiveTab(host);
// Re-run push reconciliation now that the gateway client is available.
void host.reconcileWebPushState?.();
void verifyPendingUpdateVersion(host, client);

View File

@@ -88,6 +88,7 @@ describe("createSessionAndRefresh", () => {
expect(request).toHaveBeenNthCalledWith(2, "sessions.list", {
includeGlobal: true,
includeUnknown: true,
configuredAgentsOnly: true,
});
expect(state.sessionsResult?.sessions[0]?.key).toBe("agent:main:dashboard:abc");
expect(state.sessionsLoading).toBe(false);
@@ -150,6 +151,7 @@ describe("deleteSessionsAndRefresh", () => {
expect(request).toHaveBeenNthCalledWith(3, "sessions.list", {
includeGlobal: true,
includeUnknown: true,
configuredAgentsOnly: true,
});
expect(state.sessionsLoading).toBe(false);
});
@@ -244,6 +246,7 @@ describe("deleteSessionsAndRefresh", () => {
expect(request).toHaveBeenNthCalledWith(2, "sessions.list", {
includeGlobal: true,
includeUnknown: true,
configuredAgentsOnly: true,
});
expect(state.sessionsLoading).toBe(false);
});
@@ -372,6 +375,7 @@ describe("loadSessions", () => {
limit: 50,
includeGlobal: true,
includeUnknown: true,
configuredAgentsOnly: true,
});
});
@@ -401,6 +405,7 @@ describe("loadSessions", () => {
limit: 50,
includeGlobal: true,
includeUnknown: true,
configuredAgentsOnly: true,
});
});
@@ -449,10 +454,12 @@ describe("loadSessions", () => {
limit: 10,
includeGlobal: true,
includeUnknown: true,
configuredAgentsOnly: true,
});
expect(request).toHaveBeenNthCalledWith(2, "sessions.list", {
includeGlobal: true,
includeUnknown: true,
configuredAgentsOnly: true,
});
expect(state.sessionsResult?.ts).toBe(2);
expect(state.sessionsLoading).toBe(false);
@@ -535,6 +542,7 @@ describe("loadSessions", () => {
expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {
includeGlobal: true,
includeUnknown: true,
configuredAgentsOnly: true,
});
expect(request).toHaveBeenNthCalledWith(2, "sessions.compaction.list", {
key: "agent:main:main",

View File

@@ -37,6 +37,7 @@ type LoadSessionsOverrides = {
includeGlobal?: boolean;
includeUnknown?: boolean;
showArchived?: boolean;
configuredAgentsOnly?: boolean;
};
type CreateSessionParams = {
@@ -427,9 +428,11 @@ async function loadSessionsOnce(
? 0
: (overrides?.activeMinutes ?? toNumber(state.sessionsFilterActive, 0));
const limit = overrides?.limit ?? toNumber(state.sessionsFilterLimit, 0);
const configuredAgentsOnly = overrides?.configuredAgentsOnly ?? true;
const params: Record<string, unknown> = {
includeGlobal,
includeUnknown,
configuredAgentsOnly,
};
if (activeMinutes > 0) {
params.activeMinutes = activeMinutes;