diff --git a/src/config/sessions/combined-store-gateway.ts b/src/config/sessions/combined-store-gateway.ts index 47c48b6a614..7840b5944a6 100644 --- a/src/config/sessions/combined-store-gateway.ts +++ b/src/config/sessions/combined-store-gateway.ts @@ -7,7 +7,10 @@ import { normalizeAgentId } from "../../routing/session-key.js"; import type { OpenClawConfig } from "../types.openclaw.js"; import { resolveStorePath } from "./paths.js"; import { loadSessionStore } from "./store-load.js"; -import { resolveAllAgentSessionStoreTargetsSync } from "./targets.js"; +import { + resolveAgentSessionStoreTargetsSync, + resolveAllAgentSessionStoreTargetsSync, +} from "./targets.js"; import type { SessionEntry } from "./types.js"; function isStorePathTemplate(store?: string): boolean { @@ -25,20 +28,31 @@ function mergeSessionEntryIntoCombined(params: { const existing = combined[canonicalKey]; if (existing && (existing.updatedAt ?? 0) > (entry.updatedAt ?? 0)) { + const spawnedBy = canonicalizeSpawnedByForAgent( + cfg, + agentId, + existing.spawnedBy ?? entry.spawnedBy, + ); combined[canonicalKey] = { ...entry, ...existing, - spawnedBy: canonicalizeSpawnedByForAgent(cfg, agentId, existing.spawnedBy ?? entry.spawnedBy), + spawnedBy, }; + return; + } + + const spawnedBy = canonicalizeSpawnedByForAgent( + cfg, + agentId, + entry.spawnedBy ?? existing?.spawnedBy, + ); + if (!existing && entry.spawnedBy === spawnedBy) { + combined[canonicalKey] = entry; } else { combined[canonicalKey] = { ...existing, ...entry, - spawnedBy: canonicalizeSpawnedByForAgent( - cfg, - agentId, - entry.spawnedBy ?? existing?.spawnedBy, - ), + spawnedBy, }; } } @@ -77,9 +91,9 @@ export function loadCombinedSessionStoreForGateway( typeof opts.agentId === "string" && opts.agentId.trim() ? normalizeAgentId(opts.agentId) : undefined; - const targets = resolveAllAgentSessionStoreTargetsSync(cfg).filter( - (target) => !requestedAgentId || normalizeAgentId(target.agentId) === requestedAgentId, - ); + const targets = requestedAgentId + ? resolveAgentSessionStoreTargetsSync(cfg, requestedAgentId) + : resolveAllAgentSessionStoreTargetsSync(cfg); const combined: Record = {}; for (const target of targets) { const agentId = target.agentId; diff --git a/src/config/sessions/targets.test.ts b/src/config/sessions/targets.test.ts index b6f3b91308d..5f7c6d77bb7 100644 --- a/src/config/sessions/targets.test.ts +++ b/src/config/sessions/targets.test.ts @@ -6,6 +6,7 @@ import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config.js"; import { resolveStorePath } from "./paths.js"; import { + resolveAgentSessionStoreTargetsSync, resolveAllAgentSessionStoreTargets, resolveAllAgentSessionStoreTargetsSync, resolveSessionStoreTargets, @@ -138,6 +139,38 @@ describe("resolveSessionStoreTargets", () => { }); }); +describe("resolveAgentSessionStoreTargetsSync", () => { + it("resolves one requested agent store from the direct path", async () => { + await withTempHome(async (home) => { + const customRoot = path.join(home, "custom-state"); + const storePaths = await createAgentSessionStores(customRoot, ["main", "codex"]); + const cfg = createCustomRootCfg(customRoot, "main"); + + expect(resolveAgentSessionStoreTargetsSync(cfg, "codex", { env: process.env })).toEqual([ + { + agentId: "codex", + storePath: storePaths.codex, + }, + ]); + }); + }); + + it("finds discovered directories whose names normalize to the requested agent", async () => { + await withTempHome(async (home) => { + const customRoot = path.join(home, "custom-state"); + const storePaths = await createAgentSessionStores(customRoot, ["main", "Retired Agent"]); + const cfg = createCustomRootCfg(customRoot, "main"); + + expect( + resolveAgentSessionStoreTargetsSync(cfg, "retired-agent", { env: process.env }), + ).toContainEqual({ + agentId: "retired-agent", + storePath: storePaths["Retired Agent"], + }); + }); + }); +}); + describe("resolveAllAgentSessionStoreTargets", () => { it("includes discovered on-disk agent stores alongside configured targets", async () => { await withTempHome(async (home) => { diff --git a/src/config/sessions/targets.ts b/src/config/sessions/targets.ts index 1c41f574a28..1ca75d0f048 100644 --- a/src/config/sessions/targets.ts +++ b/src/config/sessions/targets.ts @@ -209,6 +209,91 @@ export function resolveAllAgentSessionStoreTargetsSync( return dedupeTargetsByStorePath([...validatedConfiguredTargets, ...discoveredTargets]); } +export function resolveAgentSessionStoreTargetsSync( + cfg: OpenClawConfig, + agentId: string, + params: { env?: NodeJS.ProcessEnv } = {}, +): SessionStoreTarget[] { + const env = params.env ?? process.env; + const requested = normalizeAgentId(agentId); + const storePaths = new Set([ + resolveStorePath(cfg.session?.store, { agentId: requested, env }), + resolveStorePath(undefined, { agentId: requested, env }), + ]); + const targets: SessionStoreTarget[] = []; + const realAgentsRoots = new Map(); + const getRealAgentsRoot = (agentsRoot: string): string | undefined => { + if (realAgentsRoots.has(agentsRoot)) { + return realAgentsRoots.get(agentsRoot); + } + try { + const realAgentsRoot = fsSync.realpathSync.native(agentsRoot); + realAgentsRoots.set(agentsRoot, realAgentsRoot); + return realAgentsRoot; + } catch (err) { + if (shouldSkipDiscoveryError(err)) { + realAgentsRoots.set(agentsRoot, undefined); + return undefined; + } + throw err; + } + }; + + for (const storePath of storePaths) { + const agentsRoot = resolveAgentsDirFromSessionStorePath(storePath); + if (!agentsRoot) { + targets.push({ agentId: requested, storePath }); + continue; + } + const realAgentsRoot = getRealAgentsRoot(agentsRoot); + if (!realAgentsRoot) { + continue; + } + const validatedStorePath = resolveValidatedDiscoveredStorePathSync({ + sessionsDir: path.dirname(storePath), + agentsRoot, + realAgentsRoot, + }); + if (validatedStorePath) { + targets.push({ agentId: requested, storePath: validatedStorePath }); + } + } + + const { agentsRoots } = resolveSessionStoreDiscoveryState(cfg, env); + for (const agentsDir of agentsRoots) { + try { + const realAgentsRoot = getRealAgentsRoot(agentsDir); + if (!realAgentsRoot) { + continue; + } + for (const sessionsDir of resolveAgentSessionDirsFromAgentsDirSync(agentsDir)) { + const target = toDiscoveredSessionStoreTarget( + sessionsDir, + path.join(sessionsDir, "sessions.json"), + ); + if (!target || normalizeAgentId(target.agentId) !== requested) { + continue; + } + const validatedStorePath = resolveValidatedDiscoveredStorePathSync({ + sessionsDir, + agentsRoot: agentsDir, + realAgentsRoot, + }); + if (validatedStorePath) { + targets.push({ ...target, storePath: validatedStorePath }); + } + } + } catch (err) { + if (shouldSkipDiscoveryError(err)) { + continue; + } + throw err; + } + } + + return dedupeTargetsByStorePath(targets); +} + export async function resolveAllAgentSessionStoreTargets( cfg: OpenClawConfig, params: { env?: NodeJS.ProcessEnv } = {}, diff --git a/src/gateway/session-utils.subagent.test.ts b/src/gateway/session-utils.subagent.test.ts index 092926f1292..c7ce3b9dfe4 100644 --- a/src/gateway/session-utils.subagent.test.ts +++ b/src/gateway/session-utils.subagent.test.ts @@ -7,7 +7,7 @@ import { resetSubagentRegistryForTests, } from "../agents/subagent-registry.js"; import type { OpenClawConfig } from "../config/config.js"; -import type { SessionEntry } from "../config/sessions.js"; +import { loadSessionStore, type SessionEntry } from "../config/sessions.js"; import { registerAgentRunContext, resetAgentRunContextForTest } from "../infra/agent-events.js"; import { withStateDirEnv } from "../test-helpers/state-dir-env.js"; import { withEnv } from "../test-utils/env.js"; @@ -1208,4 +1208,42 @@ describe("loadCombinedSessionStoreForGateway includes disk-only agents (#32804)" readSpy.mockRestore(); }); }); + + test("keeps canonical single-target entries by reference", async () => { + await withStateDirEnv("openclaw-acp-canonical-", async ({ stateDir }) => { + const customRoot = path.join(stateDir, "custom-state"); + const codexDir = path.join(customRoot, "agents", "codex", "sessions"); + fs.mkdirSync(codexDir, { recursive: true }); + + const codexStorePath = path.join(codexDir, "sessions.json"); + fs.writeFileSync( + codexStorePath, + JSON.stringify({ + "agent:codex:acp-task": { + sessionId: "s-codex", + updatedAt: 200, + spawnedBy: "agent:codex:main", + }, + }), + "utf8", + ); + + const cfg = { + session: { + mainKey: "main", + store: path.join(customRoot, "agents", "{agentId}", "sessions", "sessions.json"), + }, + agents: { + list: [{ id: "codex", default: true }], + }, + } as OpenClawConfig; + + const cachedStore = loadSessionStore(fs.realpathSync.native(codexStorePath), { + clone: false, + }); + const { store } = loadCombinedSessionStoreForGateway(cfg, { agentId: "codex" }); + + expect(store["agent:codex:acp-task"]).toBe(cachedStore["agent:codex:acp-task"]); + }); + }); }); diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts index ec9b3f1d5e0..5f364086e8a 100644 --- a/src/gateway/session-utils.test.ts +++ b/src/gateway/session-utils.test.ts @@ -1217,6 +1217,30 @@ describe("listSessionsFromStore selected model display", () => { } }); + test("uses bounded top-N selection for small limited lists", () => { + const now = Date.now(); + const store: Record = { + "agent:main:old": { sessionId: "old", updatedAt: now - 10_000 } as SessionEntry, + "agent:main:newest": { sessionId: "newest", updatedAt: now } as SessionEntry, + "agent:main:middle-a": { sessionId: "middle-a", updatedAt: now - 5_000 } as SessionEntry, + "agent:main:middle-b": { sessionId: "middle-b", updatedAt: now - 5_000 } as SessionEntry, + "agent:main:newer": { sessionId: "newer", updatedAt: now - 1_000 } as SessionEntry, + }; + const result = listSessionsFromStore({ + cfg: createModelDefaultsConfig({ primary: "openai/gpt-5.4" }), + storePath: "/tmp/sessions.json", + store, + opts: { limit: 4 }, + }); + + expect(result.sessions.map((session) => session.key)).toEqual([ + "agent:main:newest", + "agent:main:newer", + "agent:main:middle-a", + "agent:main:middle-b", + ]); + }); + test("shows the selected override model even when a fallback runtime model exists", () => { const cfg = createModelDefaultsConfig({ primary: "anthropic/claude-opus-4-6", diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index ba1b500d86e..8bde5ee269b 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -1758,6 +1758,54 @@ export function loadGatewaySessionRow( * avoiding excessive yielding overhead for small stores. */ const SESSIONS_LIST_YIELD_BATCH_SIZE = 10; +const SESSIONS_LIST_TOP_N_LIMIT = 200; + +type SessionEntryPair = [string, SessionEntry]; + +function compareSessionEntryPairsByUpdatedAt(a: SessionEntryPair, b: SessionEntryPair): number { + return (b[1]?.updatedAt ?? 0) - (a[1]?.updatedAt ?? 0); +} + +function resolveSessionsListLimit( + opts: import("./protocol/index.js").SessionsListParams, +): number | undefined { + if (typeof opts.limit !== "number" || !Number.isFinite(opts.limit)) { + return undefined; + } + return Math.max(1, Math.floor(opts.limit)); +} + +function selectNewestLimitedEntries( + entries: SessionEntryPair[], + limit: number, +): SessionEntryPair[] { + const selected: SessionEntryPair[] = []; + for (const entry of entries) { + const insertAt = selected.findIndex( + (candidate) => compareSessionEntryPairsByUpdatedAt(entry, candidate) < 0, + ); + if (insertAt >= 0) { + selected.splice(insertAt, 0, entry); + if (selected.length > limit) { + selected.pop(); + } + } else if (selected.length < limit) { + selected.push(entry); + } + } + return selected; +} + +function sortAndLimitSessionEntries( + entries: SessionEntryPair[], + limit: number | undefined, +): SessionEntryPair[] { + if (limit !== undefined && limit <= SESSIONS_LIST_TOP_N_LIMIT) { + return selectNewestLimitedEntries(entries, limit); + } + const sorted = entries.toSorted(compareSessionEntryPairsByUpdatedAt); + return limit === undefined ? sorted : sorted.slice(0, limit); +} export function filterAndSortSessionEntries(params: { store: Record; @@ -1829,8 +1877,7 @@ export function filterAndSortSessionEntries(params: { return true; } return entry?.label === label; - }) - .toSorted((a, b) => (b[1]?.updatedAt ?? 0) - (a[1]?.updatedAt ?? 0)); + }); if (search) { entries = entries.filter(([key, entry]) => { @@ -1852,12 +1899,7 @@ export function filterAndSortSessionEntries(params: { 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)); - entries = entries.slice(0, limit); - } - - return entries; + return sortAndLimitSessionEntries(entries, resolveSessionsListLimit(opts)); } export function listSessionsFromStore(params: {