mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-13 23:56:07 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user