mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-13 23:56:07 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -763,6 +763,7 @@ export async function refreshChat(
|
||||
limit: 0,
|
||||
includeGlobal: true,
|
||||
includeUnknown: true,
|
||||
agentId: resolveAgentIdForSession(host) ?? undefined,
|
||||
}),
|
||||
refreshChatAvatar(host),
|
||||
refreshChatModels(host),
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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> })
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user