fix(gateway): scope chat session list refreshes

Fix Control UI post-first-message session switching stalls by scoping chat-specific sessions.list refreshes to the active agent, avoiding disk-only store discovery for configured-only gateway lists, and adding sessions.list diagnostics spans.\n\nVerification:\n- pnpm test ui/src/ui/controllers/sessions.test.ts ui/src/ui/app-render.helpers.node.test.ts ui/src/ui/app-chat.test.ts ui/src/ui/app-gateway.sessions.node.test.ts src/gateway/server.sessions.store-rpc.test.ts src/gateway/server.sessions.list-changed.test.ts src/gateway/session-utils.subagent.test.ts\n- pnpm check:changed\n- pnpm protocol:check\n- pnpm lint:ui:no-raw-window-open\n- Browser QA against Control UI dev server\n\nFixes #79675.
This commit is contained in:
Val Alexander
2026-05-10 03:35:46 -05:00
committed by GitHub
parent a8c745a623
commit 38456f5f03
13 changed files with 227 additions and 45 deletions

View File

@@ -27,6 +27,7 @@ Docs: https://docs.openclaw.ai
- Codex app-server: deliver native image-generation outputs from Codex `savedPath` events as reply media, so blank-text image generation turns still attach the generated file. Thanks @keshavbotagent.
- Network/SSRF: keep pinned automatic DNS lookups on IPv4 when dual-stack hosts also publish AAAA records, and treat `EADDRNOTAVAIL` as a transient gateway network failure instead of a fatal crash. Fixes #80078. Thanks @takamasa-aiso.
- Control UI: show compact one-line live/idle/terminal run status badges in the Sessions table and rename the active-minute filter to its updated-within meaning. Fixes #78307. Thanks @BunsDev.
- Control UI: scope chat session-list refreshes by agent and skip disk-only agent store discovery for configured-only lists, preventing post-first-message session switching stalls on large Windows stores. Fixes #79675. Thanks @lovelefeng-glitch, @BunsDev.
- Media/host-read: allow buffer-verified gzip, tar, and 7z archives in the shared host-local media validator alongside ZIP and document attachments.
- Plugins/doctor: invalidate persisted plugin registry snapshots when plugin diagnostics point at deleted source paths, so `openclaw doctor` stops repeating stale warnings after a local extension is replaced by a managed npm plugin. Fixes #80087. (#80134) Thanks @hclsys.
- Cron: let isolated self-cleanup runs inspect their own job run history while keeping other cron jobs and mutation actions blocked. Fixes #80019. Thanks @hclsys.

View File

@@ -10,6 +10,7 @@ import { loadSessionStore } from "./store-load.js";
import {
resolveAgentSessionStoreTargetsSync,
resolveAllAgentSessionStoreTargetsSync,
resolveSessionStoreTargets,
} from "./targets.js";
import type { SessionEntry } from "./types.js";
@@ -59,7 +60,7 @@ function mergeSessionEntryIntoCombined(params: {
export function loadCombinedSessionStoreForGateway(
cfg: OpenClawConfig,
opts: { agentId?: string } = {},
opts: { agentId?: string; configuredAgentsOnly?: boolean } = {},
): {
storePath: string;
store: Record<string, SessionEntry>;
@@ -93,7 +94,9 @@ export function loadCombinedSessionStoreForGateway(
: undefined;
const targets = requestedAgentId
? resolveAgentSessionStoreTargetsSync(cfg, requestedAgentId)
: resolveAllAgentSessionStoreTargetsSync(cfg);
: opts.configuredAgentsOnly === true
? resolveSessionStoreTargets(cfg, { allAgents: true })
: resolveAllAgentSessionStoreTargetsSync(cfg);
const combined: Record<string, SessionEntry> = {};
for (const target of targets) {
const agentId = target.agentId;

View File

@@ -35,6 +35,10 @@ import {
type SessionPatchHookContext,
type SessionPatchHookEvent,
} from "../../hooks/internal-hooks.js";
import {
measureDiagnosticsTimelineSpan,
measureDiagnosticsTimelineSpanSync,
} from "../../infra/diagnostics-timeline.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { patchPluginSessionExtension } from "../../plugins/host-hook-state.js";
import { isPluginJsonValue } from "../../plugins/host-hooks.js";
@@ -456,20 +460,28 @@ function resolveAbortSessionKey(params: {
return params.requestedKey;
}
function collectTrackedActiveSessionRunKeys(
context: Partial<Pick<GatewayRequestContext, "chatAbortControllers">>,
): Set<string> {
const keys = new Set<string>();
if (!(context.chatAbortControllers instanceof Map)) {
return keys;
}
for (const active of context.chatAbortControllers.values()) {
if (typeof active.sessionKey === "string" && active.sessionKey.trim()) {
keys.add(active.sessionKey);
}
}
return keys;
}
function hasTrackedActiveSessionRun(params: {
context: Partial<Pick<GatewayRequestContext, "chatAbortControllers">>;
requestedKey: string;
canonicalKey: string;
}): boolean {
if (!(params.context.chatAbortControllers instanceof Map)) {
return false;
}
for (const active of params.context.chatAbortControllers.values()) {
if (active.sessionKey === params.canonicalKey || active.sessionKey === params.requestedKey) {
return true;
}
}
return false;
const activeSessionKeys = collectTrackedActiveSessionRunKeys(params.context);
return activeSessionKeys.has(params.canonicalKey) || activeSessionKeys.has(params.requestedKey);
}
async function interruptSessionRunIfActive(params: {
@@ -690,33 +702,88 @@ 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: listStore,
modelCatalog,
opts: p,
});
respond(
true,
{
...result,
sessions: result.sessions.map((session) =>
Object.assign({}, session, {
hasActiveRun: hasTrackedActiveSessionRun({
context,
requestedKey: session.key,
canonicalKey: session.key,
const configuredAgentsOnly = p.configuredAgentsOnly === true;
const payload = await measureDiagnosticsTimelineSpan(
"gateway.sessions.list",
async () => {
const { storePath, store } = measureDiagnosticsTimelineSpanSync(
"gateway.sessions.list.store_load",
() =>
loadCombinedSessionStoreForGateway(cfg, {
agentId: p.agentId,
configuredAgentsOnly,
}),
}),
),
{
config: cfg,
phase: "sessions.list",
attributes: {
agentId: p.agentId ?? null,
configuredAgentsOnly,
},
},
);
const listStore = configuredAgentsOnly
? filterSessionStoreToConfiguredAgents(cfg, store)
: store;
const modelCatalog = await measureDiagnosticsTimelineSpan(
"gateway.sessions.list.model_catalog",
() => loadOptionalSessionsListModelCatalog(context),
{
config: cfg,
phase: "sessions.list",
},
);
const result = await measureDiagnosticsTimelineSpan(
"gateway.sessions.list.rows",
() =>
listSessionsFromStoreAsync({
cfg,
storePath,
store: listStore,
modelCatalog,
opts: p,
}),
{
config: cfg,
phase: "sessions.list",
attributes: {
storeEntries: Object.keys(listStore).length,
},
},
);
const sessions = measureDiagnosticsTimelineSpanSync(
"gateway.sessions.list.active_run_flags",
() => {
const activeSessionKeys = collectTrackedActiveSessionRunKeys(context);
return result.sessions.map((session) =>
Object.assign({}, session, {
hasActiveRun: activeSessionKeys.has(session.key),
}),
);
},
{
config: cfg,
phase: "sessions.list",
attributes: {
sessions: result.sessions.length,
},
},
);
return {
...result,
sessions,
};
},
{
config: cfg,
phase: "sessions.list",
attributes: {
agentId: p.agentId ?? null,
configuredAgentsOnly,
},
},
undefined,
);
respond(true, payload, undefined);
},
"sessions.cleanup": async ({ params, respond, context }) => {
if (!assertValidParams(params, validateSessionsCleanupParams, "sessions.cleanup", respond)) {

View File

@@ -1,3 +1,4 @@
import fsSync from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { expect, test, vi } from "vitest";
@@ -472,6 +473,28 @@ test("sessions.list configuredAgentsOnly hides disk-discovered unregistered agen
"utf-8",
);
const readFileSyncSpy = vi.spyOn(fsSync, "readFileSync");
const realDiskOnlyStorePath = await fs.realpath(diskOnlyStorePath);
try {
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",
]);
expect(
readFileSyncSpy.mock.calls.some(
([file]) =>
typeof file === "string" && fsSync.realpathSync.native(file) === realDiskOnlyStorePath,
),
).toBe(false);
} finally {
readFileSyncSpy.mockRestore();
}
const broad = await directSessionHandlerReq<{ sessions: Array<{ key: string }> }>(
"sessions.list",
{ includeGlobal: false, includeUnknown: false },
@@ -481,13 +504,4 @@ test("sessions.list configuredAgentsOnly hides disk-discovered unregistered agen
"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

@@ -191,6 +191,14 @@ describe("refreshChat", () => {
maxChars: 4000,
});
expect(request).toHaveBeenCalledWith("models.list", { view: "configured" });
expect(request).toHaveBeenCalledWith(
"sessions.list",
expect.objectContaining({
agentId: "main",
includeGlobal: true,
includeUnknown: true,
}),
);
expect(request).toHaveBeenCalledWith("commands.list", {
agentId: "main",
includeArgs: true,
@@ -557,6 +565,7 @@ describe("refreshChat", () => {
expect(request).toHaveBeenCalledWith(
"sessions.list",
expect.objectContaining({
agentId: "main",
includeGlobal: true,
includeUnknown: true,
}),

View File

@@ -763,6 +763,7 @@ export async function refreshChat(
limit: 0,
includeGlobal: true,
includeUnknown: true,
agentId: resolveAgentIdForSession(host) ?? undefined,
}),
refreshChatAvatar(host),
refreshChatModels(host),

View File

@@ -4,9 +4,11 @@ import { afterAll, describe, expect, it, vi } from "vitest";
const loadSessionsMock = vi.fn();
const loadChatHistoryMock = vi.fn();
const applySessionsChangedEventMock = vi.fn();
const handleChatEventMock = vi.fn(() => "idle");
vi.mock("./app-chat.ts", () => ({
CHAT_SESSIONS_ACTIVE_MINUTES: 10,
clearPendingQueueItemsForRun: vi.fn(),
flushChatQueueForEvent: vi.fn(),
refreshChatAvatar: vi.fn(),
}));
@@ -29,7 +31,7 @@ vi.mock("./controllers/assistant-identity.ts", () => ({
}));
vi.mock("./controllers/chat.ts", () => ({
loadChatHistory: loadChatHistoryMock,
handleChatEvent: vi.fn(() => "idle"),
handleChatEvent: handleChatEventMock,
}));
vi.mock("./controllers/devices.ts", () => ({
loadDevices: vi.fn(),
@@ -121,6 +123,7 @@ function createHost() {
serverVersion: null,
sessionKey: "main",
chatRunId: null,
toolStreamOrder: [],
refreshSessionsAfterChat: new Set<string>(),
execApprovalQueue: [],
execApprovalError: null,
@@ -129,8 +132,29 @@ function createHost() {
}
describe("handleGatewayEvent sessions.changed", () => {
it("scopes post-chat final session refreshes to the run's agent", () => {
loadSessionsMock.mockReset();
handleChatEventMock.mockReset().mockReturnValue("final");
const host = createHost();
host.sessionKey = "agent:ops:main";
host.refreshSessionsAfterChat.add("run-1");
handleGatewayEvent(host, {
type: "event",
event: "chat",
payload: { state: "final", runId: "run-1", sessionKey: "agent:ops:main" },
seq: 1,
});
expect(loadSessionsMock).toHaveBeenCalledWith(host, {
activeMinutes: 10,
agentId: "ops",
});
});
it("applies reliable session change snapshots without refetching the list", () => {
loadSessionsMock.mockReset();
handleChatEventMock.mockReset().mockReturnValue("idle");
applySessionsChangedEventMock.mockReset().mockReturnValue({ applied: true, change: "updated" });
const host = createHost();
const payload = {

View File

@@ -641,6 +641,7 @@ function handleTerminalChatEvent(
if (state === "final") {
void loadSessions(host as unknown as SessionsState, {
activeMinutes: CHAT_SESSIONS_ACTIVE_MINUTES,
agentId: resolveChatEventSessionListAgentId(host, payload),
});
}
}
@@ -669,6 +670,21 @@ function isEventForDifferentActiveRun(
return Boolean(activeRunId && payload && payload.runId !== activeRunId);
}
function resolveChatEventSessionListAgentId(
host: GatewayHost,
payload: ChatEventPayload | undefined,
): string {
const sessionKey = payload?.sessionKey?.trim() || host.sessionKey;
const parsed = parseAgentSessionKey(sessionKey);
if (parsed?.agentId) {
return parsed.agentId;
}
const snapshot = host.hello?.snapshot as
| { sessionDefaults?: SessionDefaultsSnapshot }
| undefined;
return normalizeAgentId(snapshot?.sessionDefaults?.defaultAgentId);
}
function handleChatGatewayEvent(host: GatewayHost, payload: ChatEventPayload | undefined) {
if (payload?.sessionKey) {
setLastActiveSessionKey(

View File

@@ -739,6 +739,7 @@ describe("createChatSession", () => {
includeGlobal: true,
includeUnknown: true,
showArchived: false,
agentId: "ops",
},
);
expect(state.sessionKey).toBe("agent:ops:dashboard:new-chat");
@@ -940,6 +941,7 @@ describe("switchChatSession", () => {
includeGlobal: true,
includeUnknown: true,
showArchived: false,
agentId: "main",
});
expect(
(state as unknown as { announceSessionSwitch: ReturnType<typeof vi.fn> })

View File

@@ -647,6 +647,7 @@ export async function createChatSession(state: AppViewState) {
includeGlobal: true,
includeUnknown: true,
showArchived: state.sessionsShowArchived,
agentId: resolveAgentIdFromSessionKey(previousSessionKey),
},
);
if (
@@ -678,6 +679,7 @@ async function refreshSessionOptions(state: AppViewState) {
includeGlobal: true,
includeUnknown: true,
showArchived: state.sessionsShowArchived,
agentId: parseAgentSessionKey(state.sessionKey)?.agentId,
});
}

View File

@@ -143,9 +143,16 @@ async function refreshSessionOptions(state: AppViewState) {
includeGlobal: true,
includeUnknown: true,
showArchived: state.sessionsShowArchived,
agentId: resolveSessionOptionsAgentId(state),
});
}
function resolveSessionOptionsAgentId(state: AppViewState): string {
return (
parseAgentSessionKey(state.sessionKey)?.agentId ?? normalizeAgentId(state.agentsList?.defaultId)
);
}
async function refreshVisibleToolsEffectiveForCurrentSessionLazy(state: AppViewState) {
return refreshVisibleToolsEffectiveForCurrentSession(state);
}

View File

@@ -409,6 +409,37 @@ describe("loadSessions", () => {
});
});
it("forwards scoped agent refreshes to sessions.list", async () => {
const request = vi.fn(async (method: string) => {
if (method !== "sessions.list") {
throw new Error(`unexpected method: ${method}`);
}
return {
ts: 1,
path: "(multiple)",
count: 0,
defaults: { modelProvider: null, model: null, contextTokens: null },
sessions: [],
};
});
const state = createState(request);
await loadSessions(state, {
activeMinutes: 0,
limit: 0,
includeGlobal: true,
includeUnknown: true,
agentId: "ops",
});
expect(request).toHaveBeenCalledWith("sessions.list", {
includeGlobal: true,
includeUnknown: true,
configuredAgentsOnly: true,
agentId: "ops",
});
});
it("coalesces overlapping refreshes instead of dropping the latest request", async () => {
let resolveFirst: () => void = () => undefined;
const firstBlocker = new Promise<void>((resolve) => {

View File

@@ -32,6 +32,7 @@ export type SessionsState = {
};
type LoadSessionsOverrides = {
agentId?: string;
activeMinutes?: number;
limit?: number;
includeGlobal?: boolean;
@@ -434,6 +435,10 @@ async function loadSessionsOnce(
includeUnknown,
configuredAgentsOnly,
};
const agentId = overrides?.agentId?.trim();
if (agentId) {
params.agentId = agentId;
}
if (activeMinutes > 0) {
params.activeMinutes = activeMinutes;
}