fix: limit session list enrichment

This commit is contained in:
Peter Steinberger
2026-04-27 20:57:24 +01:00
parent 72f3c840c7
commit 9402bca614
4 changed files with 113 additions and 21 deletions

View File

@@ -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.

View File

@@ -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<string, SessionEntry> = {};
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,

View File

@@ -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<string, SessionEntry> = {
"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<string, SessionEntry> = {

View File

@@ -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,