Skip to content

Commit a224810

Browse files
authored
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.
1 parent 1df6226 commit a224810

7 files changed

Lines changed: 124 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ Docs: https://docs.openclaw.ai
9494
- 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.
9595
- 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.
9696
- 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.
97+
- 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.
9798
- 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.
9899
- 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.
99100
- 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.

docs/cli/sessions.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ until a message is processed. Use `openclaw channels status --probe`,
1616
`openclaw status --deep`, or `openclaw health --verbose` when you need live
1717
channel connectivity.
1818

19+
Gateway `sessions.list` responses are bounded by default so large long-lived
20+
stores cannot monopolize the Gateway event loop. Pass an explicit positive
21+
`limit` from RPC clients when a different result window is needed; responses
22+
include `totalCount`, `limitApplied`, and `hasMore` when callers need to show
23+
that more rows exist.
24+
1925
```bash
2026
openclaw sessions
2127
openclaw sessions --agent work

src/gateway/protocol/schema/sessions.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ export const SessionCompactionCheckpointSchema = Type.Object(
3838

3939
export const SessionsListParamsSchema = Type.Object(
4040
{
41+
/**
42+
* Maximum rows to return. Omitted Gateway RPC calls use a bounded default
43+
* to keep large session stores from monopolizing the event loop.
44+
*/
4145
limit: Type.Optional(Type.Integer({ minimum: 1 })),
4246
activeMinutes: Type.Optional(Type.Integer({ minimum: 1 })),
4347
includeGlobal: Type.Optional(Type.Boolean()),

src/gateway/session-utils.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,64 @@ describe("gateway session utils", () => {
8383
expect(res.items).toEqual(["b", "c"]);
8484
});
8585

86+
test("session lists apply a bounded default and expose truncation metadata", async () => {
87+
const cfg = createModelDefaultsConfig({ primary: "openai/gpt-5.4" });
88+
const store = Object.fromEntries(
89+
Array.from({ length: 105 }, (_value, index) => [
90+
`session-${index}`,
91+
{
92+
sessionId: `session-${index}`,
93+
updatedAt: 1_000 - index,
94+
} satisfies SessionEntry,
95+
]),
96+
);
97+
98+
const listed = await listSessionsFromStoreAsync({
99+
cfg,
100+
storePath: "",
101+
store,
102+
opts: {},
103+
});
104+
105+
expect(listed.sessions).toHaveLength(100);
106+
expect(listed.count).toBe(100);
107+
expect(listed.totalCount).toBe(105);
108+
expect(listed.limitApplied).toBe(100);
109+
expect(listed.hasMore).toBe(true);
110+
expect(listed.sessions[0]?.key).toBe("session-0");
111+
expect(listed.sessions.at(-1)?.key).toBe("session-99");
112+
});
113+
114+
test("session lists honor explicit caller limits", () => {
115+
const cfg = createModelDefaultsConfig({ primary: "openai/gpt-5.4" });
116+
const store = Object.fromEntries(
117+
Array.from({ length: 5 }, (_value, index) => [
118+
`session-${index}`,
119+
{
120+
sessionId: `session-${index}`,
121+
updatedAt: 1_000 - index,
122+
} satisfies SessionEntry,
123+
]),
124+
);
125+
126+
const listed = listSessionsFromStore({
127+
cfg,
128+
storePath: "",
129+
store,
130+
opts: { limit: 3 },
131+
});
132+
133+
expect(listed.sessions.map((session) => session.key)).toEqual([
134+
"session-0",
135+
"session-1",
136+
"session-2",
137+
]);
138+
expect(listed.count).toBe(3);
139+
expect(listed.totalCount).toBe(5);
140+
expect(listed.limitApplied).toBe(3);
141+
expect(listed.hasMore).toBe(true);
142+
});
143+
86144
test("parseGroupKey handles group keys", () => {
87145
expect(parseGroupKey("discord:group:dev")).toEqual({
88146
channel: "discord",

src/gateway/session-utils.ts

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1795,18 +1795,25 @@ export function loadGatewaySessionRow(
17951795
*/
17961796
const SESSIONS_LIST_YIELD_BATCH_SIZE = 10;
17971797
const SESSIONS_LIST_TOP_N_LIMIT = 200;
1798+
const SESSIONS_LIST_DEFAULT_LIMIT = 100;
17981799

17991800
type SessionEntryPair = [string, SessionEntry];
1801+
type SessionEntrySelection = {
1802+
entries: SessionEntryPair[];
1803+
totalCount: number;
1804+
limitApplied?: number;
1805+
};
18001806

18011807
function compareSessionEntryPairsByUpdatedAt(a: SessionEntryPair, b: SessionEntryPair): number {
18021808
return (b[1]?.updatedAt ?? 0) - (a[1]?.updatedAt ?? 0);
18031809
}
18041810

18051811
function resolveSessionsListLimit(
18061812
opts: import("./protocol/index.js").SessionsListParams,
1813+
defaultLimit?: number,
18071814
): number | undefined {
18081815
if (typeof opts.limit !== "number" || !Number.isFinite(opts.limit)) {
1809-
return undefined;
1816+
return defaultLimit;
18101817
}
18111818
return Math.max(1, Math.floor(opts.limit));
18121819
}
@@ -1843,12 +1850,12 @@ function sortAndLimitSessionEntries(
18431850
return limit === undefined ? sorted : sorted.slice(0, limit);
18441851
}
18451852

1846-
export function filterAndSortSessionEntries(params: {
1853+
function filterSessionEntries(params: {
18471854
store: Record<string, SessionEntry>;
18481855
opts: import("./protocol/index.js").SessionsListParams;
18491856
now: number;
18501857
rowContext?: SessionListRowContext;
1851-
}): [string, SessionEntry][] {
1858+
}): SessionEntryPair[] {
18521859
const { store, opts, now } = params;
18531860
const rowContext = params.rowContext;
18541861
const includeGlobal = opts.includeGlobal === true;
@@ -1941,7 +1948,33 @@ export function filterAndSortSessionEntries(params: {
19411948
entries = entries.filter(([, entry]) => (entry?.updatedAt ?? 0) >= cutoff);
19421949
}
19431950

1944-
return sortAndLimitSessionEntries(entries, resolveSessionsListLimit(opts));
1951+
return entries;
1952+
}
1953+
1954+
function selectSessionEntries(params: {
1955+
store: Record<string, SessionEntry>;
1956+
opts: import("./protocol/index.js").SessionsListParams;
1957+
now: number;
1958+
rowContext?: SessionListRowContext;
1959+
defaultLimit?: number;
1960+
}): SessionEntrySelection {
1961+
const filtered = filterSessionEntries(params);
1962+
const limit = resolveSessionsListLimit(params.opts, params.defaultLimit);
1963+
const entries = sortAndLimitSessionEntries(filtered, limit);
1964+
return {
1965+
entries,
1966+
totalCount: filtered.length,
1967+
limitApplied: limit,
1968+
};
1969+
}
1970+
1971+
export function filterAndSortSessionEntries(params: {
1972+
store: Record<string, SessionEntry>;
1973+
opts: import("./protocol/index.js").SessionsListParams;
1974+
now: number;
1975+
rowContext?: SessionListRowContext;
1976+
}): [string, SessionEntry][] {
1977+
return selectSessionEntries(params).entries;
19451978
}
19461979

19471980
export function listSessionsFromStore(params: {
@@ -1964,12 +1997,14 @@ export function listSessionsFromStore(params: {
19641997
const includeLastMessage = opts.includeLastMessage === true;
19651998
const hasSpawnedByFilter = typeof opts.spawnedBy === "string" && opts.spawnedBy.length > 0;
19661999

1967-
const entries = filterAndSortSessionEntries({
2000+
const selection = selectSessionEntries({
19682001
store,
19692002
opts,
19702003
now,
19712004
rowContext: hasSpawnedByFilter ? getRowContext() : undefined,
2005+
defaultLimit: SESSIONS_LIST_DEFAULT_LIMIT,
19722006
});
2007+
const { entries, totalCount, limitApplied } = selection;
19732008

19742009
const sessions = entries.map(([key, entry], index) => {
19752010
const includeTranscriptFields = index < sessionListTranscriptFieldRows;
@@ -1993,6 +2028,9 @@ export function listSessionsFromStore(params: {
19932028
ts: now,
19942029
path: storePath,
19952030
count: sessions.length,
2031+
totalCount,
2032+
limitApplied,
2033+
hasMore: sessions.length < totalCount,
19962034
defaults: getSessionDefaults(cfg, params.modelCatalog),
19972035
sessions,
19982036
};
@@ -2028,12 +2066,14 @@ export async function listSessionsFromStoreAsync(params: {
20282066
const includeLastMessage = opts.includeLastMessage === true;
20292067
const hasSpawnedByFilter = typeof opts.spawnedBy === "string" && opts.spawnedBy.length > 0;
20302068

2031-
const entries = filterAndSortSessionEntries({
2069+
const selection = selectSessionEntries({
20322070
store,
20332071
opts,
20342072
now,
20352073
rowContext: hasSpawnedByFilter ? getRowContext() : undefined,
2074+
defaultLimit: SESSIONS_LIST_DEFAULT_LIMIT,
20362075
});
2076+
const { entries, totalCount, limitApplied } = selection;
20372077

20382078
const sessions: GatewaySessionRow[] = [];
20392079
for (let i = 0; i < entries.length; i++) {
@@ -2089,6 +2129,9 @@ export async function listSessionsFromStoreAsync(params: {
20892129
ts: now,
20902130
path: storePath,
20912131
count: sessions.length,
2132+
totalCount,
2133+
limitApplied,
2134+
hasMore: sessions.length < totalCount,
20922135
defaults: getSessionDefaults(cfg, params.modelCatalog),
20932136
sessions,
20942137
};

src/shared/session-types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ export type SessionsListResultBase<TDefaults, TRow> = {
3030
ts: number;
3131
path: string;
3232
count: number;
33+
totalCount?: number;
34+
limitApplied?: number;
35+
hasMore?: boolean;
3336
defaults: TDefaults;
3437
sessions: TRow[];
3538
};

src/tui/tui-backend.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ export type TuiSessionList = {
2525
ts: number;
2626
path: string;
2727
count: number;
28+
totalCount?: number;
29+
limitApplied?: number;
30+
hasMore?: boolean;
2831
defaults?: {
2932
model?: string | null;
3033
modelProvider?: string | null;

0 commit comments

Comments
 (0)