From eed8cd383fac35cb2e86f89149d102ef6266fbbc Mon Sep 17 00:00:00 2001 From: Marcus Castro Date: Fri, 13 Feb 2026 14:46:54 -0300 Subject: [PATCH] fix(agent): search all agent stores when resolving --session-id (#13579) * fix(agent): search all agent stores when resolving --session-id When `--session-id` was provided without `--to` or `--agent`, the reverse lookup only searched the default agent's session store. Sessions created under a specific agent (e.g. `--agent mybot`) live in that agent's store file, so the lookup silently failed and the session was not reused. Now `resolveSessionKeyForRequest` iterates all configured agent stores when the primary store doesn't contain the requested sessionId. Fixes #12881 * fix: search other agent stores when --to key does not match --session-id When --to derives a session key whose stored sessionId doesn't match the requested --session-id, the cross-store search now also runs. This handles the case where a user provides both --to and --session-id targeting a session in a different agent's store. --- src/commands/agent/session.test.ts | 227 +++++++++++++++++++++++++++++ src/commands/agent/session.ts | 26 ++++ 2 files changed, 253 insertions(+) create mode 100644 src/commands/agent/session.test.ts diff --git a/src/commands/agent/session.test.ts b/src/commands/agent/session.test.ts new file mode 100644 index 0000000000..1bae455a26 --- /dev/null +++ b/src/commands/agent/session.test.ts @@ -0,0 +1,227 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; + +const mocks = vi.hoisted(() => ({ + loadSessionStore: vi.fn(), + resolveStorePath: vi.fn(), + listAgentIds: vi.fn(), +})); + +vi.mock("../../config/sessions.js", async () => { + const actual = await vi.importActual( + "../../config/sessions.js", + ); + return { + ...actual, + loadSessionStore: mocks.loadSessionStore, + resolveStorePath: mocks.resolveStorePath, + }; +}); + +vi.mock("../../agents/agent-scope.js", () => ({ + listAgentIds: mocks.listAgentIds, +})); + +describe("resolveSessionKeyForRequest", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.listAgentIds.mockReturnValue(["main"]); + }); + + async function importFresh() { + return await import("./session.js"); + } + + const baseCfg: OpenClawConfig = {}; + + it("returns sessionKey when --to resolves a session key via context", async () => { + const { resolveSessionKeyForRequest } = await importFresh(); + + mocks.resolveStorePath.mockReturnValue("/tmp/main-store.json"); + mocks.loadSessionStore.mockReturnValue({ + "agent:main:main": { sessionId: "sess-1", updatedAt: 0 }, + }); + + const result = resolveSessionKeyForRequest({ + cfg: baseCfg, + to: "+15551234567", + }); + expect(result.sessionKey).toBe("agent:main:main"); + }); + + it("finds session by sessionId via reverse lookup in primary store", async () => { + const { resolveSessionKeyForRequest } = await importFresh(); + + mocks.resolveStorePath.mockReturnValue("/tmp/main-store.json"); + mocks.loadSessionStore.mockReturnValue({ + "agent:main:main": { sessionId: "target-session-id", updatedAt: 0 }, + }); + + const result = resolveSessionKeyForRequest({ + cfg: baseCfg, + sessionId: "target-session-id", + }); + expect(result.sessionKey).toBe("agent:main:main"); + }); + + it("finds session by sessionId in non-primary agent store", async () => { + const { resolveSessionKeyForRequest } = await importFresh(); + + mocks.listAgentIds.mockReturnValue(["main", "mybot"]); + mocks.resolveStorePath.mockImplementation( + (_store: string | undefined, opts?: { agentId?: string }) => { + if (opts?.agentId === "mybot") { + return "/tmp/mybot-store.json"; + } + return "/tmp/main-store.json"; + }, + ); + mocks.loadSessionStore.mockImplementation((storePath: string) => { + if (storePath === "/tmp/mybot-store.json") { + return { + "agent:mybot:main": { sessionId: "target-session-id", updatedAt: 0 }, + }; + } + return {}; + }); + + const result = resolveSessionKeyForRequest({ + cfg: baseCfg, + sessionId: "target-session-id", + }); + expect(result.sessionKey).toBe("agent:mybot:main"); + expect(result.storePath).toBe("/tmp/mybot-store.json"); + }); + + it("returns correct sessionStore when session found in non-primary agent store", async () => { + const { resolveSessionKeyForRequest } = await importFresh(); + + const mybotStore = { + "agent:mybot:main": { sessionId: "target-session-id", updatedAt: 0 }, + }; + mocks.listAgentIds.mockReturnValue(["main", "mybot"]); + mocks.resolveStorePath.mockImplementation( + (_store: string | undefined, opts?: { agentId?: string }) => { + if (opts?.agentId === "mybot") { + return "/tmp/mybot-store.json"; + } + return "/tmp/main-store.json"; + }, + ); + mocks.loadSessionStore.mockImplementation((storePath: string) => { + if (storePath === "/tmp/mybot-store.json") { + return { ...mybotStore }; + } + return {}; + }); + + const result = resolveSessionKeyForRequest({ + cfg: baseCfg, + sessionId: "target-session-id", + }); + expect(result.sessionStore["agent:mybot:main"]?.sessionId).toBe("target-session-id"); + }); + + it("returns undefined sessionKey when sessionId not found in any store", async () => { + const { resolveSessionKeyForRequest } = await importFresh(); + + mocks.listAgentIds.mockReturnValue(["main", "mybot"]); + mocks.resolveStorePath.mockImplementation( + (_store: string | undefined, opts?: { agentId?: string }) => { + if (opts?.agentId === "mybot") { + return "/tmp/mybot-store.json"; + } + return "/tmp/main-store.json"; + }, + ); + mocks.loadSessionStore.mockReturnValue({}); + + const result = resolveSessionKeyForRequest({ + cfg: baseCfg, + sessionId: "nonexistent-id", + }); + expect(result.sessionKey).toBeUndefined(); + }); + + it("does not search other stores when explicitSessionKey is set", async () => { + const { resolveSessionKeyForRequest } = await importFresh(); + + mocks.listAgentIds.mockReturnValue(["main", "mybot"]); + mocks.resolveStorePath.mockReturnValue("/tmp/main-store.json"); + mocks.loadSessionStore.mockReturnValue({ + "agent:main:main": { sessionId: "other-id", updatedAt: 0 }, + }); + + const result = resolveSessionKeyForRequest({ + cfg: baseCfg, + sessionKey: "agent:main:main", + sessionId: "target-session-id", + }); + // explicitSessionKey is set, so sessionKey comes from it, not from sessionId lookup + expect(result.sessionKey).toBe("agent:main:main"); + }); + + it("searches other stores when --to derives a key that does not match --session-id", async () => { + const { resolveSessionKeyForRequest } = await importFresh(); + + mocks.listAgentIds.mockReturnValue(["main", "mybot"]); + mocks.resolveStorePath.mockImplementation( + (_store: string | undefined, opts?: { agentId?: string }) => { + if (opts?.agentId === "mybot") { + return "/tmp/mybot-store.json"; + } + return "/tmp/main-store.json"; + }, + ); + mocks.loadSessionStore.mockImplementation((storePath: string) => { + if (storePath === "/tmp/main-store.json") { + return { + "agent:main:main": { sessionId: "other-session-id", updatedAt: 0 }, + }; + } + if (storePath === "/tmp/mybot-store.json") { + return { + "agent:mybot:main": { sessionId: "target-session-id", updatedAt: 0 }, + }; + } + return {}; + }); + + const result = resolveSessionKeyForRequest({ + cfg: baseCfg, + to: "+15551234567", + sessionId: "target-session-id", + }); + // --to derives agent:main:main, but its sessionId doesn't match target-session-id, + // so the cross-store search finds it in the mybot store + expect(result.sessionKey).toBe("agent:mybot:main"); + expect(result.storePath).toBe("/tmp/mybot-store.json"); + }); + + it("skips already-searched primary store when iterating agents", async () => { + const { resolveSessionKeyForRequest } = await importFresh(); + + mocks.listAgentIds.mockReturnValue(["main", "mybot"]); + mocks.resolveStorePath.mockImplementation( + (_store: string | undefined, opts?: { agentId?: string }) => { + if (opts?.agentId === "mybot") { + return "/tmp/mybot-store.json"; + } + return "/tmp/main-store.json"; + }, + ); + mocks.loadSessionStore.mockReturnValue({}); + + resolveSessionKeyForRequest({ + cfg: baseCfg, + sessionId: "nonexistent-id", + }); + + // loadSessionStore should be called twice: once for main, once for mybot + // (not twice for main) + const storePaths = mocks.loadSessionStore.mock.calls.map((call: [string]) => call[0]); + expect(storePaths).toHaveLength(2); + expect(storePaths).toContain("/tmp/main-store.json"); + expect(storePaths).toContain("/tmp/mybot-store.json"); + }); +}); diff --git a/src/commands/agent/session.ts b/src/commands/agent/session.ts index 889e8e5594..ec29f1798a 100644 --- a/src/commands/agent/session.ts +++ b/src/commands/agent/session.ts @@ -1,6 +1,7 @@ import crypto from "node:crypto"; import type { MsgContext } from "../../auto-reply/templating.js"; import type { OpenClawConfig } from "../../config/config.js"; +import { listAgentIds } from "../../agents/agent-scope.js"; import { normalizeThinkLevel, normalizeVerboseLevel, @@ -78,6 +79,31 @@ export function resolveSessionKeyForRequest(opts: { } } + // When sessionId was provided but not found in the primary store, search all agent stores. + // Sessions created under a specific agent live in that agent's store file; the primary + // store (derived from the default agent) won't contain them. + // Also covers the case where --to derived a sessionKey that doesn't match the requested sessionId. + if ( + opts.sessionId && + !explicitSessionKey && + (!sessionKey || sessionStore[sessionKey]?.sessionId !== opts.sessionId) + ) { + const allAgentIds = listAgentIds(opts.cfg); + for (const agentId of allAgentIds) { + if (agentId === storeAgentId) { + continue; + } + const altStorePath = resolveStorePath(sessionCfg?.store, { agentId }); + const altStore = loadSessionStore(altStorePath); + const foundKey = Object.keys(altStore).find( + (key) => altStore[key]?.sessionId === opts.sessionId, + ); + if (foundKey) { + return { sessionKey: foundKey, sessionStore: altStore, storePath: altStorePath }; + } + } + } + return { sessionKey, sessionStore, storePath }; }