diff --git a/docs/tools/acp-agents.md b/docs/tools/acp-agents.md index 65a320f1c52..d8ac5b5f7d3 100644 --- a/docs/tools/acp-agents.md +++ b/docs/tools/acp-agents.md @@ -421,6 +421,8 @@ Some controls depend on backend capabilities. If a backend does not support a co | `/acp doctor` | Backend health, capabilities, actionable fixes. | `/acp doctor` | | `/acp install` | Print deterministic install and enable steps. | `/acp install` | +`/acp sessions` reads the store for the current bound or requester session. Commands that accept `session-key`, `session-id`, or `session-label` tokens resolve targets through gateway session discovery, including custom per-agent `session.store` roots. + ## Runtime options mapping `/acp` has convenience commands and a generic setter. diff --git a/src/acp/runtime/session-meta.test.ts b/src/acp/runtime/session-meta.test.ts new file mode 100644 index 00000000000..f9a0f399f81 --- /dev/null +++ b/src/acp/runtime/session-meta.test.ts @@ -0,0 +1,69 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; + +const hoisted = vi.hoisted(() => { + const resolveAllAgentSessionStoreTargetsMock = vi.fn(); + const loadSessionStoreMock = vi.fn(); + return { + resolveAllAgentSessionStoreTargetsMock, + loadSessionStoreMock, + }; +}); + +vi.mock("../../config/sessions.js", async () => { + const actual = await vi.importActual( + "../../config/sessions.js", + ); + return { + ...actual, + resolveAllAgentSessionStoreTargets: (cfg: OpenClawConfig, opts: unknown) => + hoisted.resolveAllAgentSessionStoreTargetsMock(cfg, opts), + loadSessionStore: (storePath: string) => hoisted.loadSessionStoreMock(storePath), + }; +}); + +const { listAcpSessionEntries } = await import("./session-meta.js"); + +describe("listAcpSessionEntries", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("reads ACP sessions from resolved configured store targets", async () => { + const cfg = { + session: { + store: "/custom/sessions/{agentId}.json", + }, + } as OpenClawConfig; + hoisted.resolveAllAgentSessionStoreTargetsMock.mockResolvedValue([ + { + agentId: "ops", + storePath: "/custom/sessions/ops.json", + }, + ]); + hoisted.loadSessionStoreMock.mockReturnValue({ + "agent:ops:acp:s1": { + updatedAt: 123, + acp: { + backend: "acpx", + agent: "ops", + mode: "persistent", + state: "idle", + }, + }, + }); + + const entries = await listAcpSessionEntries({ cfg }); + + expect(hoisted.resolveAllAgentSessionStoreTargetsMock).toHaveBeenCalledWith(cfg, undefined); + expect(hoisted.loadSessionStoreMock).toHaveBeenCalledWith("/custom/sessions/ops.json"); + expect(entries).toEqual([ + expect.objectContaining({ + cfg, + storePath: "/custom/sessions/ops.json", + sessionKey: "agent:ops:acp:s1", + storeSessionKey: "agent:ops:acp:s1", + }), + ]); + }); +}); diff --git a/src/acp/runtime/session-meta.ts b/src/acp/runtime/session-meta.ts index fd4a5813f9b..ff48d1e1ce6 100644 --- a/src/acp/runtime/session-meta.ts +++ b/src/acp/runtime/session-meta.ts @@ -1,9 +1,11 @@ -import path from "node:path"; -import { resolveAgentSessionDirs } from "../../agents/session-dirs.js"; import type { OpenClawConfig } from "../../config/config.js"; import { loadConfig } from "../../config/config.js"; -import { resolveStateDir } from "../../config/paths.js"; -import { loadSessionStore, resolveStorePath, updateSessionStore } from "../../config/sessions.js"; +import { + loadSessionStore, + resolveAllAgentSessionStoreTargets, + resolveStorePath, + updateSessionStore, +} from "../../config/sessions.js"; import { mergeSessionEntry, type SessionAcpMeta, @@ -88,14 +90,17 @@ export function readAcpSessionEntry(params: { export async function listAcpSessionEntries(params: { cfg?: OpenClawConfig; + env?: NodeJS.ProcessEnv; }): Promise { const cfg = params.cfg ?? loadConfig(); - const stateDir = resolveStateDir(process.env); - const sessionDirs = await resolveAgentSessionDirs(stateDir); + const storeTargets = await resolveAllAgentSessionStoreTargets( + cfg, + params.env ? { env: params.env } : undefined, + ); const entries: AcpSessionStoreEntry[] = []; - for (const sessionsDir of sessionDirs) { - const storePath = path.join(sessionsDir, "sessions.json"); + for (const target of storeTargets) { + const storePath = target.storePath; let store: Record; try { store = loadSessionStore(storePath); diff --git a/src/agents/session-dirs.ts b/src/agents/session-dirs.ts index 1985dcf608a..90f42cdebb9 100644 --- a/src/agents/session-dirs.ts +++ b/src/agents/session-dirs.ts @@ -1,9 +1,15 @@ -import type { Dirent } from "node:fs"; +import fsSync, { type Dirent } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; -export async function resolveAgentSessionDirs(stateDir: string): Promise { - const agentsDir = path.join(stateDir, "agents"); +function mapAgentSessionDirs(agentsDir: string, entries: Dirent[]): string[] { + return entries + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(agentsDir, entry.name, "sessions")) + .toSorted((a, b) => a.localeCompare(b)); +} + +export async function resolveAgentSessionDirsFromAgentsDir(agentsDir: string): Promise { let entries: Dirent[] = []; try { entries = await fs.readdir(agentsDir, { withFileTypes: true }); @@ -15,8 +21,24 @@ export async function resolveAgentSessionDirs(stateDir: string): Promise entry.isDirectory()) - .map((entry) => path.join(agentsDir, entry.name, "sessions")) - .toSorted((a, b) => a.localeCompare(b)); + return mapAgentSessionDirs(agentsDir, entries); +} + +export function resolveAgentSessionDirsFromAgentsDirSync(agentsDir: string): string[] { + let entries: Dirent[] = []; + try { + entries = fsSync.readdirSync(agentsDir, { withFileTypes: true }); + } catch (err) { + const code = (err as { code?: string }).code; + if (code === "ENOENT") { + return []; + } + throw err; + } + + return mapAgentSessionDirs(agentsDir, entries); +} + +export async function resolveAgentSessionDirs(stateDir: string): Promise { + return await resolveAgentSessionDirsFromAgentsDir(path.join(stateDir, "agents")); } diff --git a/src/commands/session-store-targets.test.ts b/src/commands/session-store-targets.test.ts index 62ccab8d3cd..3f3a87b09db 100644 --- a/src/commands/session-store-targets.test.ts +++ b/src/commands/session-store-targets.test.ts @@ -1,17 +1,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { resolveSessionStoreTargets } from "./session-store-targets.js"; -const resolveStorePathMock = vi.hoisted(() => vi.fn()); -const resolveDefaultAgentIdMock = vi.hoisted(() => vi.fn()); -const listAgentIdsMock = vi.hoisted(() => vi.fn()); +const resolveSessionStoreTargetsMock = vi.hoisted(() => vi.fn()); vi.mock("../config/sessions.js", () => ({ - resolveStorePath: resolveStorePathMock, -})); - -vi.mock("../agents/agent-scope.js", () => ({ - resolveDefaultAgentId: resolveDefaultAgentIdMock, - listAgentIds: listAgentIdsMock, + resolveSessionStoreTargets: resolveSessionStoreTargetsMock, })); describe("resolveSessionStoreTargets", () => { @@ -19,61 +12,14 @@ describe("resolveSessionStoreTargets", () => { vi.clearAllMocks(); }); - it("resolves the default agent store when no selector is provided", () => { - resolveDefaultAgentIdMock.mockReturnValue("main"); - resolveStorePathMock.mockReturnValue("/tmp/main-sessions.json"); + it("delegates session store target resolution to the shared config helper", () => { + resolveSessionStoreTargetsMock.mockReturnValue([ + { agentId: "main", storePath: "/tmp/main-sessions.json" }, + ]); const targets = resolveSessionStoreTargets({}, {}); expect(targets).toEqual([{ agentId: "main", storePath: "/tmp/main-sessions.json" }]); - expect(resolveStorePathMock).toHaveBeenCalledWith(undefined, { agentId: "main" }); - }); - - it("resolves all configured agent stores", () => { - listAgentIdsMock.mockReturnValue(["main", "work"]); - resolveStorePathMock - .mockReturnValueOnce("/tmp/main-sessions.json") - .mockReturnValueOnce("/tmp/work-sessions.json"); - - const targets = resolveSessionStoreTargets( - { - session: { store: "~/.openclaw/agents/{agentId}/sessions/sessions.json" }, - }, - { allAgents: true }, - ); - - expect(targets).toEqual([ - { agentId: "main", storePath: "/tmp/main-sessions.json" }, - { agentId: "work", storePath: "/tmp/work-sessions.json" }, - ]); - }); - - it("dedupes shared store paths for --all-agents", () => { - listAgentIdsMock.mockReturnValue(["main", "work"]); - resolveStorePathMock.mockReturnValue("/tmp/shared-sessions.json"); - - const targets = resolveSessionStoreTargets( - { - session: { store: "/tmp/shared-sessions.json" }, - }, - { allAgents: true }, - ); - - expect(targets).toEqual([{ agentId: "main", storePath: "/tmp/shared-sessions.json" }]); - expect(resolveStorePathMock).toHaveBeenCalledTimes(2); - }); - - it("rejects unknown agent ids", () => { - listAgentIdsMock.mockReturnValue(["main", "work"]); - expect(() => resolveSessionStoreTargets({}, { agent: "ghost" })).toThrow(/Unknown agent id/); - }); - - it("rejects conflicting selectors", () => { - expect(() => resolveSessionStoreTargets({}, { agent: "main", allAgents: true })).toThrow( - /cannot be used together/i, - ); - expect(() => - resolveSessionStoreTargets({}, { store: "/tmp/sessions.json", allAgents: true }), - ).toThrow(/cannot be combined/i); + expect(resolveSessionStoreTargetsMock).toHaveBeenCalledWith({}, {}); }); }); diff --git a/src/commands/session-store-targets.ts b/src/commands/session-store-targets.ts index c9e91006e53..c01197c6f88 100644 --- a/src/commands/session-store-targets.ts +++ b/src/commands/session-store-targets.ts @@ -1,84 +1,11 @@ -import { listAgentIds, resolveDefaultAgentId } from "../agents/agent-scope.js"; -import { resolveStorePath } from "../config/sessions.js"; +import { + resolveSessionStoreTargets, + type SessionStoreSelectionOptions, + type SessionStoreTarget, +} from "../config/sessions.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { normalizeAgentId } from "../routing/session-key.js"; import type { RuntimeEnv } from "../runtime.js"; - -export type SessionStoreSelectionOptions = { - store?: string; - agent?: string; - allAgents?: boolean; -}; - -export type SessionStoreTarget = { - agentId: string; - storePath: string; -}; - -function dedupeTargetsByStorePath(targets: SessionStoreTarget[]): SessionStoreTarget[] { - const deduped = new Map(); - for (const target of targets) { - if (!deduped.has(target.storePath)) { - deduped.set(target.storePath, target); - } - } - return [...deduped.values()]; -} - -export function resolveSessionStoreTargets( - cfg: OpenClawConfig, - opts: SessionStoreSelectionOptions, -): SessionStoreTarget[] { - const defaultAgentId = resolveDefaultAgentId(cfg); - const hasAgent = Boolean(opts.agent?.trim()); - const allAgents = opts.allAgents === true; - if (hasAgent && allAgents) { - throw new Error("--agent and --all-agents cannot be used together"); - } - if (opts.store && (hasAgent || allAgents)) { - throw new Error("--store cannot be combined with --agent or --all-agents"); - } - - if (opts.store) { - return [ - { - agentId: defaultAgentId, - storePath: resolveStorePath(opts.store, { agentId: defaultAgentId }), - }, - ]; - } - - if (allAgents) { - const targets = listAgentIds(cfg).map((agentId) => ({ - agentId, - storePath: resolveStorePath(cfg.session?.store, { agentId }), - })); - return dedupeTargetsByStorePath(targets); - } - - if (hasAgent) { - const knownAgents = listAgentIds(cfg); - const requested = normalizeAgentId(opts.agent ?? ""); - if (!knownAgents.includes(requested)) { - throw new Error( - `Unknown agent id "${opts.agent}". Use "openclaw agents list" to see configured agents.`, - ); - } - return [ - { - agentId: requested, - storePath: resolveStorePath(cfg.session?.store, { agentId: requested }), - }, - ]; - } - - return [ - { - agentId: defaultAgentId, - storePath: resolveStorePath(cfg.session?.store, { agentId: defaultAgentId }), - }, - ]; -} +export { resolveSessionStoreTargets, type SessionStoreSelectionOptions, type SessionStoreTarget }; export function resolveSessionStoreTargetsOrExit(params: { cfg: OpenClawConfig; diff --git a/src/config/sessions.ts b/src/config/sessions.ts index 701870ec8a7..1a521836405 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -11,3 +11,4 @@ export * from "./sessions/transcript.js"; export * from "./sessions/session-file.js"; export * from "./sessions/delivery-info.js"; export * from "./sessions/disk-budget.js"; +export * from "./sessions/targets.js"; diff --git a/src/config/sessions/paths.ts b/src/config/sessions/paths.ts index 6112fd6d31c..1be7aec6299 100644 --- a/src/config/sessions/paths.ts +++ b/src/config/sessions/paths.ts @@ -276,19 +276,24 @@ export function resolveSessionFilePath( return resolveSessionTranscriptPathInDir(sessionId, sessionsDir); } -export function resolveStorePath(store?: string, opts?: { agentId?: string }) { +export function resolveStorePath( + store?: string, + opts?: { agentId?: string; env?: NodeJS.ProcessEnv }, +) { const agentId = normalizeAgentId(opts?.agentId ?? DEFAULT_AGENT_ID); + const env = opts?.env ?? process.env; + const homedir = () => resolveRequiredHomeDir(env, os.homedir); if (!store) { - return resolveDefaultSessionStorePath(agentId); + return path.join(resolveAgentSessionsDir(agentId, env, homedir), "sessions.json"); } if (store.includes("{agentId}")) { const expanded = store.replaceAll("{agentId}", agentId); if (expanded.startsWith("~")) { return path.resolve( expandHomePrefix(expanded, { - home: resolveRequiredHomeDir(process.env, os.homedir), - env: process.env, - homedir: os.homedir, + home: resolveRequiredHomeDir(env, homedir), + env, + homedir, }), ); } @@ -297,11 +302,28 @@ export function resolveStorePath(store?: string, opts?: { agentId?: string }) { if (store.startsWith("~")) { return path.resolve( expandHomePrefix(store, { - home: resolveRequiredHomeDir(process.env, os.homedir), - env: process.env, - homedir: os.homedir, + home: resolveRequiredHomeDir(env, homedir), + env, + homedir, }), ); } return path.resolve(store); } + +export function resolveAgentsDirFromSessionStorePath(storePath: string): string | undefined { + const candidateAbsPath = path.resolve(storePath); + if (path.basename(candidateAbsPath) !== "sessions.json") { + return undefined; + } + const sessionsDir = path.dirname(candidateAbsPath); + if (path.basename(sessionsDir) !== "sessions") { + return undefined; + } + const agentDir = path.dirname(sessionsDir); + const agentsDir = path.dirname(agentDir); + if (path.basename(agentsDir) !== "agents") { + return undefined; + } + return agentsDir; +} diff --git a/src/config/sessions/targets.test.ts b/src/config/sessions/targets.test.ts new file mode 100644 index 00000000000..012302c323e --- /dev/null +++ b/src/config/sessions/targets.test.ts @@ -0,0 +1,148 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { withTempHome } from "../../../test/helpers/temp-home.js"; +import type { OpenClawConfig } from "../config.js"; +import { resolveAllAgentSessionStoreTargets } from "./targets.js"; + +describe("resolveAllAgentSessionStoreTargets", () => { + it("includes discovered on-disk agent stores alongside configured targets", async () => { + await withTempHome(async (home) => { + const stateDir = path.join(home, ".openclaw"); + const opsSessionsDir = path.join(stateDir, "agents", "ops", "sessions"); + const retiredSessionsDir = path.join(stateDir, "agents", "retired", "sessions"); + await fs.mkdir(opsSessionsDir, { recursive: true }); + await fs.mkdir(retiredSessionsDir, { recursive: true }); + await fs.writeFile(path.join(opsSessionsDir, "sessions.json"), "{}", "utf8"); + await fs.writeFile(path.join(retiredSessionsDir, "sessions.json"), "{}", "utf8"); + + const cfg: OpenClawConfig = { + agents: { + list: [{ id: "ops", default: true }], + }, + }; + + const targets = await resolveAllAgentSessionStoreTargets(cfg, { env: process.env }); + + expect(targets).toEqual( + expect.arrayContaining([ + { + agentId: "ops", + storePath: path.join(opsSessionsDir, "sessions.json"), + }, + { + agentId: "retired", + storePath: path.join(retiredSessionsDir, "sessions.json"), + }, + ]), + ); + expect( + targets.filter((target) => target.storePath === path.join(opsSessionsDir, "sessions.json")), + ).toHaveLength(1); + }); + }); + + it("discovers retired agent stores under a configured custom session root", async () => { + await withTempHome(async (home) => { + const customRoot = path.join(home, "custom-state"); + const opsSessionsDir = path.join(customRoot, "agents", "ops", "sessions"); + const retiredSessionsDir = path.join(customRoot, "agents", "retired", "sessions"); + await fs.mkdir(opsSessionsDir, { recursive: true }); + await fs.mkdir(retiredSessionsDir, { recursive: true }); + await fs.writeFile(path.join(opsSessionsDir, "sessions.json"), "{}", "utf8"); + await fs.writeFile(path.join(retiredSessionsDir, "sessions.json"), "{}", "utf8"); + + const cfg: OpenClawConfig = { + session: { + store: path.join(customRoot, "agents", "{agentId}", "sessions", "sessions.json"), + }, + agents: { + list: [{ id: "ops", default: true }], + }, + }; + + const targets = await resolveAllAgentSessionStoreTargets(cfg, { env: process.env }); + + expect(targets).toEqual( + expect.arrayContaining([ + { + agentId: "ops", + storePath: path.join(opsSessionsDir, "sessions.json"), + }, + { + agentId: "retired", + storePath: path.join(retiredSessionsDir, "sessions.json"), + }, + ]), + ); + expect( + targets.filter((target) => target.storePath === path.join(opsSessionsDir, "sessions.json")), + ).toHaveLength(1); + }); + }); + + it("keeps the actual on-disk store path for discovered retired agents", async () => { + await withTempHome(async (home) => { + const customRoot = path.join(home, "custom-state"); + const opsSessionsDir = path.join(customRoot, "agents", "ops", "sessions"); + const retiredSessionsDir = path.join(customRoot, "agents", "Retired Agent", "sessions"); + await fs.mkdir(opsSessionsDir, { recursive: true }); + await fs.mkdir(retiredSessionsDir, { recursive: true }); + await fs.writeFile(path.join(opsSessionsDir, "sessions.json"), "{}", "utf8"); + await fs.writeFile(path.join(retiredSessionsDir, "sessions.json"), "{}", "utf8"); + + const cfg: OpenClawConfig = { + session: { + store: path.join(customRoot, "agents", "{agentId}", "sessions", "sessions.json"), + }, + agents: { + list: [{ id: "ops", default: true }], + }, + }; + + const targets = await resolveAllAgentSessionStoreTargets(cfg, { env: process.env }); + + expect(targets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + agentId: "retired-agent", + storePath: path.join(retiredSessionsDir, "sessions.json"), + }), + ]), + ); + }); + }); + + it("respects the caller env when resolving configured and discovered store roots", async () => { + await withTempHome(async (home) => { + const envStateDir = path.join(home, "env-state"); + const mainSessionsDir = path.join(envStateDir, "agents", "main", "sessions"); + const retiredSessionsDir = path.join(envStateDir, "agents", "retired", "sessions"); + await fs.mkdir(mainSessionsDir, { recursive: true }); + await fs.mkdir(retiredSessionsDir, { recursive: true }); + await fs.writeFile(path.join(mainSessionsDir, "sessions.json"), "{}", "utf8"); + await fs.writeFile(path.join(retiredSessionsDir, "sessions.json"), "{}", "utf8"); + + const env = { + ...process.env, + OPENCLAW_STATE_DIR: envStateDir, + }; + const cfg: OpenClawConfig = {}; + + const targets = await resolveAllAgentSessionStoreTargets(cfg, { env }); + + expect(targets).toEqual( + expect.arrayContaining([ + { + agentId: "main", + storePath: path.join(mainSessionsDir, "sessions.json"), + }, + { + agentId: "retired", + storePath: path.join(retiredSessionsDir, "sessions.json"), + }, + ]), + ); + }); + }); +}); diff --git a/src/config/sessions/targets.ts b/src/config/sessions/targets.ts new file mode 100644 index 00000000000..659bd511a86 --- /dev/null +++ b/src/config/sessions/targets.ts @@ -0,0 +1,148 @@ +import path from "node:path"; +import { listAgentIds, resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { + resolveAgentSessionDirsFromAgentsDir, + resolveAgentSessionDirsFromAgentsDirSync, +} from "../../agents/session-dirs.js"; +import { normalizeAgentId } from "../../routing/session-key.js"; +import { resolveStateDir } from "../paths.js"; +import type { OpenClawConfig } from "../types.openclaw.js"; +import { resolveAgentsDirFromSessionStorePath, resolveStorePath } from "./paths.js"; + +export type SessionStoreSelectionOptions = { + store?: string; + agent?: string; + allAgents?: boolean; +}; + +export type SessionStoreTarget = { + agentId: string; + storePath: string; +}; + +function dedupeTargetsByStorePath(targets: SessionStoreTarget[]): SessionStoreTarget[] { + const deduped = new Map(); + for (const target of targets) { + if (!deduped.has(target.storePath)) { + deduped.set(target.storePath, target); + } + } + return [...deduped.values()]; +} + +function resolveSessionStoreDiscoveryState( + cfg: OpenClawConfig, + env: NodeJS.ProcessEnv, +): { + configuredTargets: SessionStoreTarget[]; + agentsRoots: string[]; +} { + const configuredTargets = resolveSessionStoreTargets(cfg, { allAgents: true }, { env }); + const agentsRoots = new Set(); + for (const target of configuredTargets) { + const agentsDir = resolveAgentsDirFromSessionStorePath(target.storePath); + if (agentsDir) { + agentsRoots.add(agentsDir); + } + } + agentsRoots.add(path.join(resolveStateDir(env), "agents")); + return { + configuredTargets, + agentsRoots: [...agentsRoots], + }; +} + +function toDiscoveredSessionStoreTargets(sessionsDirs: string[]): SessionStoreTarget[] { + return sessionsDirs.map((sessionsDir) => { + const agentId = normalizeAgentId(path.basename(path.dirname(sessionsDir))); + return { + agentId, + // Keep the actual on-disk store path so retired/manual agent dirs remain discoverable + // even if their directory name no longer round-trips through normalizeAgentId(). + storePath: path.join(sessionsDir, "sessions.json"), + }; + }); +} + +export function resolveAllAgentSessionStoreTargetsSync( + cfg: OpenClawConfig, + params: { env?: NodeJS.ProcessEnv } = {}, +): SessionStoreTarget[] { + const env = params.env ?? process.env; + const { configuredTargets, agentsRoots } = resolveSessionStoreDiscoveryState(cfg, env); + const discoveredTargets = toDiscoveredSessionStoreTargets( + agentsRoots.flatMap((agentsDir) => resolveAgentSessionDirsFromAgentsDirSync(agentsDir)), + ); + return dedupeTargetsByStorePath([...configuredTargets, ...discoveredTargets]); +} + +export async function resolveAllAgentSessionStoreTargets( + cfg: OpenClawConfig, + params: { env?: NodeJS.ProcessEnv } = {}, +): Promise { + const env = params.env ?? process.env; + const { configuredTargets, agentsRoots } = resolveSessionStoreDiscoveryState(cfg, env); + + const discoveredDirs = await Promise.all( + agentsRoots.map((agentsDir) => resolveAgentSessionDirsFromAgentsDir(agentsDir)), + ); + const discoveredTargets = toDiscoveredSessionStoreTargets(discoveredDirs.flat()); + return dedupeTargetsByStorePath([...configuredTargets, ...discoveredTargets]); +} + +export function resolveSessionStoreTargets( + cfg: OpenClawConfig, + opts: SessionStoreSelectionOptions, + params: { env?: NodeJS.ProcessEnv } = {}, +): SessionStoreTarget[] { + const env = params.env ?? process.env; + const defaultAgentId = resolveDefaultAgentId(cfg); + const hasAgent = Boolean(opts.agent?.trim()); + const allAgents = opts.allAgents === true; + if (hasAgent && allAgents) { + throw new Error("--agent and --all-agents cannot be used together"); + } + if (opts.store && (hasAgent || allAgents)) { + throw new Error("--store cannot be combined with --agent or --all-agents"); + } + + if (opts.store) { + return [ + { + agentId: defaultAgentId, + storePath: resolveStorePath(opts.store, { agentId: defaultAgentId, env }), + }, + ]; + } + + if (allAgents) { + const targets = listAgentIds(cfg).map((agentId) => ({ + agentId, + storePath: resolveStorePath(cfg.session?.store, { agentId, env }), + })); + return dedupeTargetsByStorePath(targets); + } + + if (hasAgent) { + const knownAgents = listAgentIds(cfg); + const requested = normalizeAgentId(opts.agent ?? ""); + if (!knownAgents.includes(requested)) { + throw new Error( + `Unknown agent id "${opts.agent}". Use "openclaw agents list" to see configured agents.`, + ); + } + return [ + { + agentId: requested, + storePath: resolveStorePath(cfg.session?.store, { agentId: requested, env }), + }, + ]; + } + + return [ + { + agentId: defaultAgentId, + storePath: resolveStorePath(cfg.session?.store, { agentId: defaultAgentId, env }), + }, + ]; +} diff --git a/src/gateway/server-session-key.test.ts b/src/gateway/server-session-key.test.ts new file mode 100644 index 00000000000..0baac9d3dc1 --- /dev/null +++ b/src/gateway/server-session-key.test.ts @@ -0,0 +1,54 @@ +import fs from "node:fs"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { resetAgentRunContextForTest } from "../infra/agent-events.js"; +import { withStateDirEnv } from "../test-helpers/state-dir-env.js"; + +const loadConfigMock = vi.hoisted(() => vi.fn<() => OpenClawConfig>()); + +vi.mock("../config/config.js", () => ({ + loadConfig: () => loadConfigMock(), +})); + +const { resolveSessionKeyForRun } = await import("./server-session-key.js"); + +describe("resolveSessionKeyForRun", () => { + beforeEach(() => { + loadConfigMock.mockReset(); + resetAgentRunContextForTest(); + }); + + afterEach(() => { + resetAgentRunContextForTest(); + }); + + it("finds run ids in disk-only agent stores under a custom session root", async () => { + await withStateDirEnv("openclaw-run-key-", async ({ stateDir }) => { + const customRoot = path.join(stateDir, "custom-state"); + const retiredSessionsDir = path.join(customRoot, "agents", "retired", "sessions"); + fs.mkdirSync(retiredSessionsDir, { recursive: true }); + fs.writeFileSync( + path.join(retiredSessionsDir, "sessions.json"), + JSON.stringify({ + "agent:retired:acp:run-1": { sessionId: "run-1", updatedAt: 123 }, + }), + "utf8", + ); + + loadConfigMock.mockReturnValue({ + session: { + store: path.join(customRoot, "agents", "{agentId}", "sessions", "sessions.json"), + }, + agents: { + list: [{ id: "main", default: true }], + }, + }); + + expect(resolveSessionKeyForRun("run-1")).toBe("acp:run-1"); + + fs.rmSync(customRoot, { recursive: true, force: true }); + expect(resolveSessionKeyForRun("run-1")).toBe("acp:run-1"); + }); + }); +}); diff --git a/src/gateway/server-session-key.ts b/src/gateway/server-session-key.ts index 4a9694f66bc..c0eb51d8ca3 100644 --- a/src/gateway/server-session-key.ts +++ b/src/gateway/server-session-key.ts @@ -1,7 +1,7 @@ import { loadConfig } from "../config/config.js"; -import { loadSessionStore, resolveStorePath } from "../config/sessions.js"; import { getAgentRunContext, registerAgentRunContext } from "../infra/agent-events.js"; import { toAgentRequestSessionKey } from "../routing/session-key.js"; +import { loadCombinedSessionStoreForGateway } from "./session-utils.js"; export function resolveSessionKeyForRun(runId: string) { const cached = getAgentRunContext(runId)?.sessionKey; @@ -9,8 +9,7 @@ export function resolveSessionKeyForRun(runId: string) { return cached; } const cfg = loadConfig(); - const storePath = resolveStorePath(cfg.session?.store); - const store = loadSessionStore(storePath); + const { store } = loadCombinedSessionStoreForGateway(cfg); const found = Object.entries(store).find(([, entry]) => entry?.sessionId === runId); const storeKey = found?.[0]; if (storeKey) { diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts index 943aea46e90..796c20167bc 100644 --- a/src/gateway/session-utils.test.ts +++ b/src/gateway/session-utils.test.ts @@ -767,7 +767,8 @@ describe("listSessionsFromStore search", () => { describe("loadCombinedSessionStoreForGateway includes disk-only agents (#32804)", () => { test("ACP agent sessions are visible even when agents.list is configured", async () => { await withStateDirEnv("openclaw-acp-vis-", async ({ stateDir }) => { - const agentsDir = path.join(stateDir, "agents"); + const customRoot = path.join(stateDir, "custom-state"); + const agentsDir = path.join(customRoot, "agents"); const mainDir = path.join(agentsDir, "main", "sessions"); const codexDir = path.join(agentsDir, "codex", "sessions"); fs.mkdirSync(mainDir, { recursive: true }); @@ -792,7 +793,7 @@ describe("loadCombinedSessionStoreForGateway includes disk-only agents (#32804)" const cfg = { session: { mainKey: "main", - store: path.join(stateDir, "agents", "{agentId}", "sessions", "sessions.json"), + store: path.join(customRoot, "agents", "{agentId}", "sessions", "sessions.json"), }, agents: { list: [{ id: "main", default: true }], diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index e16777f4f2c..4d71c32246a 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -15,6 +15,7 @@ import { buildGroupDisplayName, canonicalizeMainSessionAlias, loadSessionStore, + resolveAllAgentSessionStoreTargetsSync, resolveAgentMainSessionKey, resolveFreshSessionTotalTokens, resolveMainSessionKey, @@ -585,10 +586,11 @@ export function loadCombinedSessionStoreForGateway(cfg: OpenClawConfig): { return { storePath, store: combined }; } - const agentIds = listConfiguredAgentIds(cfg); + const targets = resolveAllAgentSessionStoreTargetsSync(cfg); const combined: Record = {}; - for (const agentId of agentIds) { - const storePath = resolveStorePath(storeConfig, { agentId }); + for (const target of targets) { + const agentId = target.agentId; + const storePath = target.storePath; const store = loadSessionStore(storePath); for (const [key, entry] of Object.entries(store)) { const canonicalKey = canonicalizeSessionKeyForAgent(agentId, key);