From 9402bca614d0b353d3a58f166560487653cd0abf Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Apr 2026 20:57:24 +0100 Subject: [PATCH] fix: limit session list enrichment --- CHANGELOG.md | 1 + src/config/sessions/combined-store-gateway.ts | 4 +- src/gateway/session-utils.subagent.test.ts | 64 +++++++++++++++++- src/gateway/session-utils.ts | 65 ++++++++++++++----- 4 files changed, 113 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1dc9d4f4523..f5d93e00d71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai - CLI/message: load only the selected channel plugin for targeted `openclaw message` actions, and fall back to configured channel plugins when the channel must be inferred, so scripted sends avoid full bundled plugin registry scans. Fixes #73006. Thanks @jasonftl. - CLI/models: keep route-first `models status --json` stdout reserved for the JSON payload by routing auth-profile and startup diagnostics to stderr. Fixes #72962. Thanks @vishutdhar. - Sessions: ignore future-dated session activity timestamps during reset freshness checks and cap future `updatedAt` values at the merge boundary so clock-skewed messages cannot keep stale sessions alive forever. Fixes #72989. Thanks @martingarramon. +- Sessions: apply search, activity filters, and limits before gateway row enrichment so bounded session lists avoid scanning discarded transcripts. Carries forward #72978. Thanks @yeager. - Plugins/CLI: allow managed plugin installs when the active extensions root is a symlink to a real state directory, while keeping nested target symlinks blocked and suppressing misleading hook-pack fallback errors for install-boundary failures. Fixes #72946. Thanks @mayank6136. - Providers/Ollama: mark discovered Ollama catalog models as supporting streaming usage metadata so token accounting stays enabled for local models. (#72976) Thanks @sdeyang. - Gateway/startup: keep hot Gateway boot paths on leaf config imports and add max-RSS reporting to the gateway startup bench so low-memory startup regressions are visible before release. Thanks @vincentkoc. diff --git a/src/config/sessions/combined-store-gateway.ts b/src/config/sessions/combined-store-gateway.ts index abc446973b5..8efa89ec709 100644 --- a/src/config/sessions/combined-store-gateway.ts +++ b/src/config/sessions/combined-store-gateway.ts @@ -51,7 +51,7 @@ export function loadCombinedSessionStoreForGateway(cfg: OpenClawConfig): { if (storeConfig && !isStorePathTemplate(storeConfig)) { const storePath = resolveStorePath(storeConfig); const defaultAgentId = normalizeAgentId(resolveDefaultAgentId(cfg)); - const store = loadSessionStore(storePath); + const store = loadSessionStore(storePath, { clone: false }); const combined: Record = {}; for (const [key, entry] of Object.entries(store)) { const canonicalKey = resolveStoredSessionKeyForAgentStore({ @@ -75,7 +75,7 @@ export function loadCombinedSessionStoreForGateway(cfg: OpenClawConfig): { for (const target of targets) { const agentId = target.agentId; const storePath = target.storePath; - const store = loadSessionStore(storePath); + const store = loadSessionStore(storePath, { clone: false }); for (const [key, entry] of Object.entries(store)) { const canonicalKey = resolveStoredSessionKeyForAgentStore({ cfg, diff --git a/src/gateway/session-utils.subagent.test.ts b/src/gateway/session-utils.subagent.test.ts index d0627f07d29..7f14fbefffd 100644 --- a/src/gateway/session-utils.subagent.test.ts +++ b/src/gateway/session-utils.subagent.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { addSubagentRunForTests, resetSubagentRegistryForTests, @@ -32,6 +32,68 @@ describe("listSessionsFromStore subagent metadata", () => { agents: { list: [{ id: "main", default: true }] }, } as OpenClawConfig; + test("searches channel-derived display names before row enrichment", () => { + const result = listSessionsFromStore({ + cfg, + storePath: "/tmp/sessions.json", + store: { + "agent:main:slack:group:general": { + sessionId: "slack-general-session", + updatedAt: 2, + channel: "slack", + } as SessionEntry, + "agent:main:discord:group:random": { + sessionId: "discord-random-session", + updatedAt: 1, + channel: "discord", + } as SessionEntry, + }, + opts: { search: "slack:g-general" }, + }); + + expect(result.sessions.map((session) => session.key)).toEqual([ + "agent:main:slack:group:general", + ]); + expect(result.sessions[0]?.displayName).toBe("slack:g-general"); + }); + + test("applies limit before transcript enrichment", () => { + const store: Record = { + "agent:main:newest": { + sessionId: "newest-session", + sessionFile: "/tmp/newest-session.jsonl", + updatedAt: 300, + } as SessionEntry, + "agent:main:middle": { + sessionId: "middle-session", + sessionFile: "/tmp/middle-session.jsonl", + updatedAt: 200, + } as SessionEntry, + "agent:main:oldest": { + sessionId: "old-session", + sessionFile: "/tmp/old-session.jsonl", + updatedAt: 100, + } as SessionEntry, + }; + const existsSpy = vi.spyOn(fs, "existsSync").mockReturnValue(false); + try { + const result = listSessionsFromStore({ + cfg, + storePath: "/tmp/sessions.json", + store, + opts: { limit: 2 }, + }); + + expect(result.sessions.map((session) => session.sessionId)).toEqual([ + "newest-session", + "middle-session", + ]); + expect(existsSpy.mock.calls.flat().join("\n")).not.toContain("old-session"); + } finally { + existsSpy.mockRestore(); + } + }); + test("includes subagent status timing and direct child session keys", () => { const now = Date.now(); const store: Record = { diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index 212b9cf1e29..291c20f2511 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -1487,6 +1487,28 @@ export function buildGatewaySessionRow(params: { }; } +function resolveSessionListSearchDisplayName( + key: string, + entry?: SessionEntry, +): string | undefined { + if (entry?.displayName) { + return entry.displayName; + } + const parsed = parseGroupKey(key); + const channel = entry?.channel ?? parsed?.channel; + if (!channel) { + return undefined; + } + return buildGroupDisplayName({ + provider: channel, + subject: entry?.subject, + groupChannel: entry?.groupChannel, + space: entry?.space, + id: parsed?.id, + key, + }); +} + export function loadGatewaySessionRow( sessionKey: string, options?: { includeDerivedTitles?: boolean; includeLastMessage?: boolean; now?: number }, @@ -1529,7 +1551,7 @@ export function listSessionsFromStore(params: { ? Math.max(1, Math.floor(opts.activeMinutes)) : undefined; - let sessions = Object.entries(store) + let entries = Object.entries(store) .filter(([key]) => { if (isCronRunSessionKey(key)) { return false; @@ -1583,23 +1605,17 @@ export function listSessionsFromStore(params: { } return entry?.label === label; }) - .map(([key, entry]) => - buildGatewaySessionRow({ - cfg, - storePath, - store, - key, - entry, - now, - includeDerivedTitles, - includeLastMessage, - }), - ) - .toSorted((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)); + .toSorted((a, b) => (b[1]?.updatedAt ?? 0) - (a[1]?.updatedAt ?? 0)); if (search) { - sessions = sessions.filter((s) => { - const fields = [s.displayName, s.label, s.subject, s.sessionId, s.key]; + entries = entries.filter(([key, entry]) => { + const fields = [ + resolveSessionListSearchDisplayName(key, entry), + entry?.label, + entry?.subject, + entry?.sessionId, + key, + ]; return fields.some( (f) => typeof f === "string" && normalizeLowercaseStringOrEmpty(f).includes(search), ); @@ -1608,14 +1624,27 @@ export function listSessionsFromStore(params: { if (activeMinutes !== undefined) { const cutoff = now - activeMinutes * 60_000; - sessions = sessions.filter((s) => (s.updatedAt ?? 0) >= cutoff); + entries = entries.filter(([, entry]) => (entry?.updatedAt ?? 0) >= cutoff); } if (typeof opts.limit === "number" && Number.isFinite(opts.limit)) { const limit = Math.max(1, Math.floor(opts.limit)); - sessions = sessions.slice(0, limit); + entries = entries.slice(0, limit); } + const sessions = entries.map(([key, entry]) => + buildGatewaySessionRow({ + cfg, + storePath, + store, + key, + entry, + now, + includeDerivedTitles, + includeLastMessage, + }), + ); + return { ts: now, path: storePath,