From a224810a7f96e4688aa0bac397a58ce7d3b7322e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 06:51:56 +0100 Subject: [PATCH] fix(gateway): bound sessions list responses Bound default Gateway sessions.list responses to 100 rows when callers omit limit, with response metadata for totalCount, limitApplied, and hasMore.\n\nFixes #77062. --- CHANGELOG.md | 1 + docs/cli/sessions.md | 6 +++ src/gateway/protocol/schema/sessions.ts | 4 ++ src/gateway/session-utils.test.ts | 58 +++++++++++++++++++++++++ src/gateway/session-utils.ts | 55 ++++++++++++++++++++--- src/shared/session-types.ts | 3 ++ src/tui/tui-backend.ts | 3 ++ 7 files changed, 124 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da4b67ad7bc..94d1717a994 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -94,6 +94,7 @@ Docs: https://docs.openclaw.ai - Release validation: let Windows packaged-upgrade checks continue after the shipped 2026.5.2 updater hits its native-module swap cleanup fallback, verifying the fallback-installed candidate through package metadata and downstream smoke instead of crashing on the immediate update-status probe. Thanks @vincentkoc. - Doctor/plugins: skip channel-derived official plugin installs when another configured plugin is the effective owner for the same channel, so `doctor --repair` does not reinstall `feishu` while `openclaw-lark` handles `channels.feishu`. Fixes #76623. Thanks @fuyizheng3120. - Gateway/sessions: memoize repeated thinking-option enrichment and skip unused cost fallback checks while listing sessions, reducing per-row work on large multi-agent stores. Fixes #76931. +- Gateway/sessions: bound default `sessions.list` RPC responses and report truncation metadata, preventing Slack-heavy long-lived stores from forcing unbounded Gateway row construction. Fixes #77062. - Agents/tools: use config-only runtime snapshots for plugin tool registration and live runtime config getters, avoiding expensive full secrets snapshot clones on the core-plugin-tools prep path. Fixes #76295. - Agents/tools: honor the effective tool denylist before constructing optional PDF/media tool factories, so `tools.deny: ["pdf"]` skips PDF setup before later policy filtering. Fixes #76997. - MCP/plugin tools: apply global `tools.profile`, `tools.alsoAllow`, and `tools.deny` policy while exposing plugin tools over the standalone MCP bridge, so ACP clients do not see policy-hidden plugin tools or miss opt-in optional tools. Thanks @vincentkoc. diff --git a/docs/cli/sessions.md b/docs/cli/sessions.md index 62dfb146162..9dfdefa1993 100644 --- a/docs/cli/sessions.md +++ b/docs/cli/sessions.md @@ -16,6 +16,12 @@ until a message is processed. Use `openclaw channels status --probe`, `openclaw status --deep`, or `openclaw health --verbose` when you need live channel connectivity. +Gateway `sessions.list` responses are bounded by default so large long-lived +stores cannot monopolize the Gateway event loop. Pass an explicit positive +`limit` from RPC clients when a different result window is needed; responses +include `totalCount`, `limitApplied`, and `hasMore` when callers need to show +that more rows exist. + ```bash openclaw sessions openclaw sessions --agent work diff --git a/src/gateway/protocol/schema/sessions.ts b/src/gateway/protocol/schema/sessions.ts index 8e05a0468bc..83c31ad3758 100644 --- a/src/gateway/protocol/schema/sessions.ts +++ b/src/gateway/protocol/schema/sessions.ts @@ -38,6 +38,10 @@ export const SessionCompactionCheckpointSchema = Type.Object( export const SessionsListParamsSchema = Type.Object( { + /** + * Maximum rows to return. Omitted Gateway RPC calls use a bounded default + * to keep large session stores from monopolizing the event loop. + */ limit: Type.Optional(Type.Integer({ minimum: 1 })), activeMinutes: Type.Optional(Type.Integer({ minimum: 1 })), includeGlobal: Type.Optional(Type.Boolean()), diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts index 421a3efd0e8..e5e7e64d63f 100644 --- a/src/gateway/session-utils.test.ts +++ b/src/gateway/session-utils.test.ts @@ -83,6 +83,64 @@ describe("gateway session utils", () => { expect(res.items).toEqual(["b", "c"]); }); + test("session lists apply a bounded default and expose truncation metadata", async () => { + const cfg = createModelDefaultsConfig({ primary: "openai/gpt-5.4" }); + const store = Object.fromEntries( + Array.from({ length: 105 }, (_value, index) => [ + `session-${index}`, + { + sessionId: `session-${index}`, + updatedAt: 1_000 - index, + } satisfies SessionEntry, + ]), + ); + + const listed = await listSessionsFromStoreAsync({ + cfg, + storePath: "", + store, + opts: {}, + }); + + expect(listed.sessions).toHaveLength(100); + expect(listed.count).toBe(100); + expect(listed.totalCount).toBe(105); + expect(listed.limitApplied).toBe(100); + expect(listed.hasMore).toBe(true); + expect(listed.sessions[0]?.key).toBe("session-0"); + expect(listed.sessions.at(-1)?.key).toBe("session-99"); + }); + + test("session lists honor explicit caller limits", () => { + const cfg = createModelDefaultsConfig({ primary: "openai/gpt-5.4" }); + const store = Object.fromEntries( + Array.from({ length: 5 }, (_value, index) => [ + `session-${index}`, + { + sessionId: `session-${index}`, + updatedAt: 1_000 - index, + } satisfies SessionEntry, + ]), + ); + + const listed = listSessionsFromStore({ + cfg, + storePath: "", + store, + opts: { limit: 3 }, + }); + + expect(listed.sessions.map((session) => session.key)).toEqual([ + "session-0", + "session-1", + "session-2", + ]); + expect(listed.count).toBe(3); + expect(listed.totalCount).toBe(5); + expect(listed.limitApplied).toBe(3); + expect(listed.hasMore).toBe(true); + }); + test("parseGroupKey handles group keys", () => { expect(parseGroupKey("discord:group:dev")).toEqual({ channel: "discord", diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index a3412a78f8b..1a3ac6664a2 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -1795,8 +1795,14 @@ export function loadGatewaySessionRow( */ const SESSIONS_LIST_YIELD_BATCH_SIZE = 10; const SESSIONS_LIST_TOP_N_LIMIT = 200; +const SESSIONS_LIST_DEFAULT_LIMIT = 100; type SessionEntryPair = [string, SessionEntry]; +type SessionEntrySelection = { + entries: SessionEntryPair[]; + totalCount: number; + limitApplied?: number; +}; function compareSessionEntryPairsByUpdatedAt(a: SessionEntryPair, b: SessionEntryPair): number { return (b[1]?.updatedAt ?? 0) - (a[1]?.updatedAt ?? 0); @@ -1804,9 +1810,10 @@ function compareSessionEntryPairsByUpdatedAt(a: SessionEntryPair, b: SessionEntr function resolveSessionsListLimit( opts: import("./protocol/index.js").SessionsListParams, + defaultLimit?: number, ): number | undefined { if (typeof opts.limit !== "number" || !Number.isFinite(opts.limit)) { - return undefined; + return defaultLimit; } return Math.max(1, Math.floor(opts.limit)); } @@ -1843,12 +1850,12 @@ function sortAndLimitSessionEntries( return limit === undefined ? sorted : sorted.slice(0, limit); } -export function filterAndSortSessionEntries(params: { +function filterSessionEntries(params: { store: Record; opts: import("./protocol/index.js").SessionsListParams; now: number; rowContext?: SessionListRowContext; -}): [string, SessionEntry][] { +}): SessionEntryPair[] { const { store, opts, now } = params; const rowContext = params.rowContext; const includeGlobal = opts.includeGlobal === true; @@ -1941,7 +1948,33 @@ export function filterAndSortSessionEntries(params: { entries = entries.filter(([, entry]) => (entry?.updatedAt ?? 0) >= cutoff); } - return sortAndLimitSessionEntries(entries, resolveSessionsListLimit(opts)); + return entries; +} + +function selectSessionEntries(params: { + store: Record; + opts: import("./protocol/index.js").SessionsListParams; + now: number; + rowContext?: SessionListRowContext; + defaultLimit?: number; +}): SessionEntrySelection { + const filtered = filterSessionEntries(params); + const limit = resolveSessionsListLimit(params.opts, params.defaultLimit); + const entries = sortAndLimitSessionEntries(filtered, limit); + return { + entries, + totalCount: filtered.length, + limitApplied: limit, + }; +} + +export function filterAndSortSessionEntries(params: { + store: Record; + opts: import("./protocol/index.js").SessionsListParams; + now: number; + rowContext?: SessionListRowContext; +}): [string, SessionEntry][] { + return selectSessionEntries(params).entries; } export function listSessionsFromStore(params: { @@ -1964,12 +1997,14 @@ export function listSessionsFromStore(params: { const includeLastMessage = opts.includeLastMessage === true; const hasSpawnedByFilter = typeof opts.spawnedBy === "string" && opts.spawnedBy.length > 0; - const entries = filterAndSortSessionEntries({ + const selection = selectSessionEntries({ store, opts, now, rowContext: hasSpawnedByFilter ? getRowContext() : undefined, + defaultLimit: SESSIONS_LIST_DEFAULT_LIMIT, }); + const { entries, totalCount, limitApplied } = selection; const sessions = entries.map(([key, entry], index) => { const includeTranscriptFields = index < sessionListTranscriptFieldRows; @@ -1993,6 +2028,9 @@ export function listSessionsFromStore(params: { ts: now, path: storePath, count: sessions.length, + totalCount, + limitApplied, + hasMore: sessions.length < totalCount, defaults: getSessionDefaults(cfg, params.modelCatalog), sessions, }; @@ -2028,12 +2066,14 @@ export async function listSessionsFromStoreAsync(params: { const includeLastMessage = opts.includeLastMessage === true; const hasSpawnedByFilter = typeof opts.spawnedBy === "string" && opts.spawnedBy.length > 0; - const entries = filterAndSortSessionEntries({ + const selection = selectSessionEntries({ store, opts, now, rowContext: hasSpawnedByFilter ? getRowContext() : undefined, + defaultLimit: SESSIONS_LIST_DEFAULT_LIMIT, }); + const { entries, totalCount, limitApplied } = selection; const sessions: GatewaySessionRow[] = []; for (let i = 0; i < entries.length; i++) { @@ -2089,6 +2129,9 @@ export async function listSessionsFromStoreAsync(params: { ts: now, path: storePath, count: sessions.length, + totalCount, + limitApplied, + hasMore: sessions.length < totalCount, defaults: getSessionDefaults(cfg, params.modelCatalog), sessions, }; diff --git a/src/shared/session-types.ts b/src/shared/session-types.ts index dd06fc8d6a3..7833bcf28ec 100644 --- a/src/shared/session-types.ts +++ b/src/shared/session-types.ts @@ -30,6 +30,9 @@ export type SessionsListResultBase = { ts: number; path: string; count: number; + totalCount?: number; + limitApplied?: number; + hasMore?: boolean; defaults: TDefaults; sessions: TRow[]; }; diff --git a/src/tui/tui-backend.ts b/src/tui/tui-backend.ts index 81ca84c1cb6..de3ca03137d 100644 --- a/src/tui/tui-backend.ts +++ b/src/tui/tui-backend.ts @@ -25,6 +25,9 @@ export type TuiSessionList = { ts: number; path: string; count: number; + totalCount?: number; + limitApplied?: number; + hasMore?: boolean; defaults?: { model?: string | null; modelProvider?: string | null;