diff --git a/src/config/sessions.cache.test.ts b/src/config/sessions.cache.test.ts index 39e66c4047d..45618b72615 100644 --- a/src/config/sessions.cache.test.ts +++ b/src/config/sessions.cache.test.ts @@ -108,6 +108,30 @@ describe("Session Store Cache", () => { expect(loaded2["session:1"].skillsSnapshot?.skills?.[0]?.name).toBe("alpha"); }); + it("honors explicit clone:false on cache hits", async () => { + const testStore = createSingleSessionStore( + createSessionEntry({ + origin: { provider: "openai" }, + }), + ); + + await saveSessionStore(storePath, testStore); + + const parseSpy = vi.spyOn(JSON, "parse"); + + const loaded1 = loadSessionStore(storePath, { clone: false }); + expect(parseSpy).not.toHaveBeenCalled(); + + loaded1["session:1"].origin = { provider: "mutated" }; + + const loaded2 = loadSessionStore(storePath, { clone: false }); + expect(loaded2).toBe(loaded1); + expect(loaded2["session:1"].origin?.provider).toBe("mutated"); + expect(parseSpy).not.toHaveBeenCalled(); + + parseSpy.mockRestore(); + }); + it("does not cache pre-migration or pre-normalization disk JSON", () => { fs.writeFileSync( storePath, diff --git a/src/config/sessions/combined-store-gateway.ts b/src/config/sessions/combined-store-gateway.ts index 8efa89ec709..47c48b6a614 100644 --- a/src/config/sessions/combined-store-gateway.ts +++ b/src/config/sessions/combined-store-gateway.ts @@ -43,7 +43,10 @@ function mergeSessionEntryIntoCombined(params: { } } -export function loadCombinedSessionStoreForGateway(cfg: OpenClawConfig): { +export function loadCombinedSessionStoreForGateway( + cfg: OpenClawConfig, + opts: { agentId?: string } = {}, +): { storePath: string; store: Record; } { @@ -70,7 +73,13 @@ export function loadCombinedSessionStoreForGateway(cfg: OpenClawConfig): { return { storePath, store: combined }; } - const targets = resolveAllAgentSessionStoreTargetsSync(cfg); + const requestedAgentId = + typeof opts.agentId === "string" && opts.agentId.trim() + ? normalizeAgentId(opts.agentId) + : undefined; + const targets = resolveAllAgentSessionStoreTargetsSync(cfg).filter( + (target) => !requestedAgentId || normalizeAgentId(target.agentId) === requestedAgentId, + ); const combined: Record = {}; for (const target of targets) { const agentId = target.agentId; @@ -93,6 +102,10 @@ export function loadCombinedSessionStoreForGateway(cfg: OpenClawConfig): { } const storePath = - typeof storeConfig === "string" && storeConfig.trim() ? storeConfig.trim() : "(multiple)"; + targets.length === 1 + ? targets[0].storePath + : typeof storeConfig === "string" && storeConfig.trim() + ? storeConfig.trim() + : "(multiple)"; return { storePath, store: combined }; } diff --git a/src/config/sessions/store-cache.ts b/src/config/sessions/store-cache.ts index a9fc012b3a6..6e27b9e3856 100644 --- a/src/config/sessions/store-cache.ts +++ b/src/config/sessions/store-cache.ts @@ -63,6 +63,7 @@ export function readSessionStoreCache(params: { storePath: string; mtimeMs?: number; sizeBytes?: number; + clone?: boolean; }): Record | null { const cached = SESSION_STORE_CACHE.get(params.storePath); if (!cached) { @@ -72,6 +73,9 @@ export function readSessionStoreCache(params: { invalidateSessionStoreCache(params.storePath); return null; } + if (params.clone === false) { + return cached.store; + } return cloneSessionStoreRecord(cached.store, cached.serialized); } diff --git a/src/config/sessions/store-load.ts b/src/config/sessions/store-load.ts index c9141ec649c..131f8b74331 100644 --- a/src/config/sessions/store-load.ts +++ b/src/config/sessions/store-load.ts @@ -107,6 +107,7 @@ export function loadSessionStore( storePath, mtimeMs: currentFileStat?.mtimeMs, sizeBytes: currentFileStat?.sizeBytes, + clone: opts.clone, }); if (cached) { return cached; diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index 8a1270de621..3d66d4ee026 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -668,7 +668,7 @@ export const sessionsHandlers: GatewayRequestHandlers = { } const p = params; const cfg = context.getRuntimeConfig(); - const { storePath, store } = loadCombinedSessionStoreForGateway(cfg); + const { storePath, store } = loadCombinedSessionStoreForGateway(cfg, { agentId: p.agentId }); const modelCatalog = await loadOptionalSessionsListModelCatalog(context); const result = await listSessionsFromStoreAsync({ cfg, diff --git a/src/gateway/session-utils.subagent.test.ts b/src/gateway/session-utils.subagent.test.ts index 7f14fbefffd..092926f1292 100644 --- a/src/gateway/session-utils.subagent.test.ts +++ b/src/gateway/session-utils.subagent.test.ts @@ -1155,4 +1155,57 @@ describe("loadCombinedSessionStoreForGateway includes disk-only agents (#32804)" expect(store["agent:codex:acp-task"]).toBeDefined(); }); }); + + test("agent-scoped loads read only matching agent stores", async () => { + await withStateDirEnv("openclaw-acp-scoped-", async ({ stateDir }) => { + 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 }); + fs.mkdirSync(codexDir, { recursive: true }); + + const mainStorePath = path.join(mainDir, "sessions.json"); + const codexStorePath = path.join(codexDir, "sessions.json"); + fs.writeFileSync( + mainStorePath, + JSON.stringify({ + "agent:main:main": { sessionId: "s-main", updatedAt: 100 }, + }), + "utf8", + ); + fs.writeFileSync( + codexStorePath, + JSON.stringify({ + "agent:codex:acp-task": { sessionId: "s-codex", updatedAt: 200 }, + }), + "utf8", + ); + + const cfg = { + session: { + mainKey: "main", + store: path.join(customRoot, "agents", "{agentId}", "sessions", "sessions.json"), + }, + agents: { + list: [{ id: "main", default: true }], + }, + } as OpenClawConfig; + + const readSpy = vi.spyOn(fs, "readFileSync"); + + const { store, storePath } = loadCombinedSessionStoreForGateway(cfg, { agentId: "codex" }); + + expect(storePath).toBe(fs.realpathSync.native(codexStorePath)); + expect(store["agent:codex:acp-task"]).toBeDefined(); + expect(store["agent:main:main"]).toBeUndefined(); + const readPaths = readSpy.mock.calls + .map((call) => call[0]) + .filter((arg): arg is string => typeof arg === "string"); + expect(readPaths).toContain(fs.realpathSync.native(codexStorePath)); + expect(readPaths).not.toContain(fs.realpathSync.native(mainStorePath)); + + readSpy.mockRestore(); + }); + }); });