From 61a18e5596c8c003f1ee92ee124306db8ffd5185 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 27 Apr 2026 15:09:01 -0700 Subject: [PATCH] fix(agent): preserve default-agent session routing compatibility (#72414) * fix(agent): preserve default-agent session routing compatibility * fix(clownfish): address review for ghcrawl-207038-agentic-merge (1) * fix(agent): migrate legacy default-agent sessions * fix(slack): use narrow agent runtime import --- CHANGELOG.md | 1 + extensions/slack/src/monitor/context.ts | 2 + extensions/slack/src/monitor/monitor.test.ts | 12 ++ .../session.resolve-session-key.test.ts | 1 + src/agents/command/session.ts | 83 +++++++++++++- src/commands/agent/session.test.ts | 106 +++++++++++++++++- src/config/sessions/session-key.test.ts | 20 ++++ src/config/sessions/session-key.ts | 13 ++- 8 files changed, 228 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d041fbee119..26abd73008a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -233,6 +233,7 @@ Docs: https://docs.openclaw.ai - CLI/Gateway: treat local restart probe policy closes for connect, exact `device required`, pairing, and auth failures as Gateway reachability proof without accepting empty, broad standalone token/password/scope/role, or pair-substring 1008 close reasons. Fixes #48771; carries forward #48801; related #63491. Thanks @MarsDoge and @genoooool. - Feishu: send outgoing interactive reply payloads as native cards with clickable buttons while preserving text, media, and document-comment fallbacks. Fixes #13175 and #58298; carries forward #47891. Thanks @Horacehxw. - Control UI/WebChat: skip redundant final-event history reloads when the assistant payload already rendered, and keep deferred `session.message` reloads attached to the active run so final reconciliation no longer splits, duplicates, or drops assistant bubbles. Fixes #66875 and #66274; follows #66997 and #67037. Thanks @BiznessFish, @scotthuang, and @hansolo949. +- CLI/Agents: route new `openclaw agent --to` sessions through the configured default agent while migrating legacy `agent:main:` rows into the default-agent store, preserving the default-agent fix from #64108. Fixes #63992; related #56370, #56453, and #42009. Thanks @mushuiyu886 and @voocel. - Process/Windows: decode command stdout and stderr from raw bytes with console-codepage awareness, while preserving valid UTF-8 output and multibyte characters split across chunks. Fixes #50519. Thanks @iready, @kevinten10, @zhangyongjie1997, @knightplat-blip, @heiqishi666, and @slepybear. - Bonjour/Windows: hide the bundled mDNS advertiser's Windows ARP shell probe so Gateway startup no longer flashes command-prompt windows. Fixes #70238. Thanks @alexandre-leng, @PratikRai0101, @infinitypacific, and @tomerpeled. - Agents/bootstrap: dedupe hook-injected bootstrap context files by workspace-relative path and store normalized resolved paths so duplicate relative and absolute hook paths no longer depend on the process cwd. (#59344; fixes #59319; related #56721, #56725, and #57587) Thanks @koen666. diff --git a/extensions/slack/src/monitor/context.ts b/extensions/slack/src/monitor/context.ts index 8cc542b6744..977dfa7abc2 100644 --- a/extensions/slack/src/monitor/context.ts +++ b/extensions/slack/src/monitor/context.ts @@ -7,6 +7,7 @@ import type { import type { SessionScope } from "openclaw/plugin-sdk/config-types"; import type { DmPolicy, GroupPolicy } from "openclaw/plugin-sdk/config-types"; import { createDedupeCache } from "openclaw/plugin-sdk/dedupe-runtime"; +import { resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-runtime"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import type { HistoryEntry } from "openclaw/plugin-sdk/reply-history"; import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; @@ -218,6 +219,7 @@ export function createSlackMonitorContext(params: { params.sessionScope, { From: from, ChatType: chatType, Provider: "slack" }, params.mainKey, + resolveDefaultAgentId(params.cfg), ); }; diff --git a/extensions/slack/src/monitor/monitor.test.ts b/extensions/slack/src/monitor/monitor.test.ts index ee3604f0d2d..4520f38cfca 100644 --- a/extensions/slack/src/monitor/monitor.test.ts +++ b/extensions/slack/src/monitor/monitor.test.ts @@ -196,6 +196,18 @@ describe("resolveSlackSystemEventSessionKey", () => { ); }); + it("uses the configured default agent for fallback system-event sessions", () => { + const ctx = createSlackMonitorContext({ + ...baseParams(), + cfg: { + agents: { list: [{ id: "ops", default: true }] }, + }, + }); + expect(ctx.resolveSlackSystemEventSessionKey({ channelId: "C123" })).toBe( + "agent:ops:slack:channel:c123", + ); + }); + it("routes channel system events through account bindings", () => { const ctx = createSlackMonitorContext({ ...baseParams(), diff --git a/src/agents/command/session.resolve-session-key.test.ts b/src/agents/command/session.resolve-session-key.test.ts index 106c9e28279..9be8ea32858 100644 --- a/src/agents/command/session.resolve-session-key.test.ts +++ b/src/agents/command/session.resolve-session-key.test.ts @@ -23,6 +23,7 @@ vi.mock("../../config/sessions/main-session.js", () => ({ vi.mock("../agent-scope.js", () => ({ listAgentIds: () => hoisted.listAgentIdsMock(), + resolveDefaultAgentId: () => "main", })); const { resolveSessionKeyForRequest, resolveStoredSessionKeyForSessionId } = diff --git a/src/agents/command/session.ts b/src/agents/command/session.ts index 494a9e68113..93b4978b4b4 100644 --- a/src/agents/command/session.ts +++ b/src/agents/command/session.ts @@ -21,9 +21,14 @@ import { resolveSessionKey } from "../../config/sessions/session-key.js"; import { loadSessionStore } from "../../config/sessions/store-load.js"; import type { SessionEntry } from "../../config/sessions/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; -import { normalizeAgentId, normalizeMainKey } from "../../routing/session-key.js"; +import { + buildAgentMainSessionKey, + DEFAULT_AGENT_ID, + normalizeAgentId, + normalizeMainKey, +} from "../../routing/session-key.js"; import { resolveSessionIdMatchSelection } from "../../sessions/session-id-resolution.js"; -import { listAgentIds } from "../agent-scope.js"; +import { listAgentIds, resolveDefaultAgentId } from "../agent-scope.js"; import { clearBootstrapSnapshotOnSessionRollover } from "../bootstrap-cache.js"; export type SessionResolution = { @@ -53,6 +58,61 @@ function buildExplicitSessionIdSessionKey(params: { sessionId: string; agentId?: return `agent:${normalizeAgentId(params.agentId)}:explicit:${params.sessionId.trim()}`; } +function resolveLegacyMainStoreSessionForDefaultAgent(opts: { + cfg: OpenClawConfig; + defaultAgentId: string; + mainKey: string; + sessionKey?: string; + sessionStore: Record; + storePath: string; +}): SessionKeyResolution | undefined { + if (opts.defaultAgentId === DEFAULT_AGENT_ID || !opts.sessionKey) { + return undefined; + } + const defaultMainSessionKey = buildAgentMainSessionKey({ + agentId: opts.defaultAgentId, + mainKey: opts.mainKey, + }); + if (opts.sessionKey !== defaultMainSessionKey || opts.sessionStore[opts.sessionKey]) { + return undefined; + } + + const legacyStorePath = resolveStorePath(opts.cfg.session?.store, { + agentId: DEFAULT_AGENT_ID, + }); + const legacyKeys = [ + buildAgentMainSessionKey({ agentId: DEFAULT_AGENT_ID, mainKey: opts.mainKey }), + buildAgentMainSessionKey({ agentId: DEFAULT_AGENT_ID, mainKey: "main" }), + ]; + if (legacyStorePath === opts.storePath) { + for (const legacyKey of legacyKeys) { + const legacyEntry = opts.sessionStore[legacyKey]; + if (legacyEntry) { + opts.sessionStore[opts.sessionKey] = { ...legacyEntry }; + return { + sessionKey: opts.sessionKey, + sessionStore: opts.sessionStore, + storePath: opts.storePath, + }; + } + } + return undefined; + } + const legacyStore = loadSessionStore(legacyStorePath); + for (const legacyKey of legacyKeys) { + const legacyEntry = legacyStore[legacyKey]; + if (legacyEntry) { + opts.sessionStore[opts.sessionKey] = { ...legacyEntry }; + return { + sessionKey: opts.sessionKey, + sessionStore: opts.sessionStore, + storePath: opts.storePath, + }; + } + } + return undefined; +} + function collectSessionIdMatchesForRequest(opts: { cfg: OpenClawConfig; sessionStore: Record; @@ -143,6 +203,7 @@ export function resolveSessionKeyForRequest(opts: { const sessionCfg = opts.cfg.session; const scope = sessionCfg?.scope ?? "per-sender"; const mainKey = normalizeMainKey(sessionCfg?.mainKey); + const defaultAgentId = normalizeAgentId(resolveDefaultAgentId(opts.cfg)); const requestedAgentId = opts.agentId?.trim() ? normalizeAgentId(opts.agentId) : undefined; const requestedSessionId = opts.sessionId?.trim() || undefined; const explicitSessionKey = @@ -155,7 +216,7 @@ export function resolveSessionKeyForRequest(opts: { : undefined); const storeAgentId = explicitSessionKey ? resolveAgentIdFromSessionKey(explicitSessionKey) - : (requestedAgentId ?? normalizeAgentId(undefined)); + : (requestedAgentId ?? defaultAgentId); const storePath = resolveStorePath(sessionCfg?.store, { agentId: storeAgentId, }); @@ -163,7 +224,21 @@ export function resolveSessionKeyForRequest(opts: { const ctx: MsgContext | undefined = opts.to?.trim() ? { From: opts.to } : undefined; let sessionKey: string | undefined = - explicitSessionKey ?? (ctx ? resolveSessionKey(scope, ctx, mainKey) : undefined); + explicitSessionKey ?? (ctx ? resolveSessionKey(scope, ctx, mainKey, storeAgentId) : undefined); + + if (ctx && !requestedAgentId && !requestedSessionId && !explicitSessionKey) { + const legacyMainSession = resolveLegacyMainStoreSessionForDefaultAgent({ + cfg: opts.cfg, + defaultAgentId, + mainKey, + sessionKey, + sessionStore, + storePath, + }); + if (legacyMainSession) { + return legacyMainSession; + } + } // If a session id was provided, prefer to re-use its existing entry (by id) even when no key was // derived. When duplicates exist across agent stores, pick the same deterministic best match used diff --git a/src/commands/agent/session.test.ts b/src/commands/agent/session.test.ts index dd02bbab73c..a4130fd654e 100644 --- a/src/commands/agent/session.test.ts +++ b/src/commands/agent/session.test.ts @@ -27,13 +27,23 @@ vi.mock("../../config/sessions/paths.js", () => ({ resolveStorePath: mocks.resolveStorePath, })); -vi.mock("../../agents/agent-scope.js", () => ({ - listAgentIds: mocks.listAgentIds, -})); +vi.mock("../../agents/agent-scope.js", async () => { + const { normalizeAgentId } = await vi.importActual< + typeof import("../../routing/session-key.js") + >("../../routing/session-key.js"); + return { + listAgentIds: mocks.listAgentIds, + resolveDefaultAgentId: (cfg: OpenClawConfig) => { + const agents = cfg.agents?.list ?? []; + return normalizeAgentId(agents.find((agent) => agent?.default)?.id ?? agents[0]?.id); + }, + }; +}); describe("resolveSessionKeyForRequest", () => { const MAIN_STORE_PATH = "/tmp/main-store.json"; const MYBOT_STORE_PATH = "/tmp/mybot-store.json"; + const SHARED_STORE_PATH = "/tmp/shared-store.json"; type SessionStoreEntry = { sessionId: string; updatedAt: number }; type SessionStoreMap = Record; @@ -74,6 +84,96 @@ describe("resolveSessionKeyForRequest", () => { expect(result.sessionKey).toBe("agent:main:main"); }); + it("uses the configured default agent store for new --to sessions", async () => { + setupMainAndMybotStorePaths(); + mockStoresByPath({ + [MAIN_STORE_PATH]: {}, + [MYBOT_STORE_PATH]: {}, + }); + + const result = resolveSessionKeyForRequest({ + cfg: { + agents: { list: [{ id: "mybot", default: true }] }, + } satisfies OpenClawConfig, + to: "+15551234567", + }); + + expect(result.sessionKey).toBe("agent:mybot:main"); + expect(result.storePath).toBe(MYBOT_STORE_PATH); + }); + + it("migrates legacy main-store main-key sessions for plain --to default-agent requests", async () => { + setupMainAndMybotStorePaths(); + const mainStore = { + "agent:main:main": { sessionId: "legacy-session-id", updatedAt: 1 }, + }; + const mybotStore = {}; + mockStoresByPath({ + [MAIN_STORE_PATH]: mainStore, + [MYBOT_STORE_PATH]: mybotStore, + }); + + const result = resolveSessionKeyForRequest({ + cfg: { + agents: { list: [{ id: "mybot", default: true }] }, + } satisfies OpenClawConfig, + to: "+15551234567", + }); + + expect(result.sessionKey).toBe("agent:mybot:main"); + expect(result.sessionStore).toBe(mybotStore); + expect(result.storePath).toBe(MYBOT_STORE_PATH); + expect(result.sessionStore["agent:mybot:main"]?.sessionId).toBe("legacy-session-id"); + }); + + it("migrates legacy main-key sessions for plain --to default-agent requests with a literal shared store", async () => { + const sharedStore = { + "agent:main:main": { sessionId: "legacy-session-id", updatedAt: 1 }, + }; + mocks.listAgentIds.mockReturnValue(["main", "mybot"]); + mocks.resolveStorePath.mockReturnValue(SHARED_STORE_PATH); + mocks.loadSessionStore.mockReturnValue(sharedStore); + + const result = resolveSessionKeyForRequest({ + cfg: { + agents: { list: [{ id: "mybot", default: true }] }, + session: { store: SHARED_STORE_PATH }, + } satisfies OpenClawConfig, + to: "+15551234567", + }); + + expect(result.sessionKey).toBe("agent:mybot:main"); + expect(result.sessionStore).toBe(sharedStore); + expect(result.storePath).toBe(SHARED_STORE_PATH); + expect(result.sessionStore["agent:mybot:main"]?.sessionId).toBe("legacy-session-id"); + expect(mocks.loadSessionStore).toHaveBeenCalledTimes(1); + expect(mocks.loadSessionStore).toHaveBeenCalledWith(SHARED_STORE_PATH); + }); + + it("prefers the configured default-agent session over legacy main-store rows", async () => { + setupMainAndMybotStorePaths(); + const mybotStore = { + "agent:mybot:main": { sessionId: "current-session-id", updatedAt: 2 }, + }; + mockStoresByPath({ + [MAIN_STORE_PATH]: { + "agent:main:main": { sessionId: "legacy-session-id", updatedAt: 1 }, + }, + [MYBOT_STORE_PATH]: mybotStore, + }); + + const result = resolveSessionKeyForRequest({ + cfg: { + agents: { list: [{ id: "mybot", default: true }] }, + } satisfies OpenClawConfig, + to: "+15551234567", + }); + + expect(result.sessionKey).toBe("agent:mybot:main"); + expect(result.sessionStore).toBe(mybotStore); + expect(result.storePath).toBe(MYBOT_STORE_PATH); + }); + it("finds session by sessionId via reverse lookup in primary store", async () => { mocks.resolveStorePath.mockReturnValue(MAIN_STORE_PATH); mocks.loadSessionStore.mockReturnValue({ diff --git a/src/config/sessions/session-key.test.ts b/src/config/sessions/session-key.test.ts index 941200523a9..d2ec3efd33b 100644 --- a/src/config/sessions/session-key.test.ts +++ b/src/config/sessions/session-key.test.ts @@ -5,6 +5,26 @@ import { installDiscordSessionKeyNormalizerFixture, makeCtx } from "./session-ke installDiscordSessionKeyNormalizerFixture(); describe("resolveSessionKey", () => { + it("uses an explicit agent id for canonical direct-chat keys", () => { + const ctx = makeCtx({ + From: "+15551234567", + }); + + expect(resolveSessionKey("per-sender", ctx, "main", "ops")).toBe("agent:ops:main"); + }); + + it("uses an explicit agent id for group keys", () => { + const ctx = makeCtx({ + From: "C123", + ChatType: "channel", + Provider: "slack", + }); + + expect(resolveSessionKey("per-sender", ctx, "main", "ops")).toBe( + "agent:ops:slack:channel:c123", + ); + }); + describe("Discord DM session key normalization", () => { it("passes through correct discord:direct keys unchanged", () => { const ctx = makeCtx({ diff --git a/src/config/sessions/session-key.ts b/src/config/sessions/session-key.ts index 37b47276920..efe08553f1c 100644 --- a/src/config/sessions/session-key.ts +++ b/src/config/sessions/session-key.ts @@ -2,6 +2,7 @@ import type { MsgContext } from "../../auto-reply/templating.js"; import { buildAgentMainSessionKey, DEFAULT_AGENT_ID, + normalizeAgentId, normalizeMainKey, } from "../../routing/session-key.js"; import { normalizeE164 } from "../../utils.js"; @@ -26,7 +27,12 @@ export function deriveSessionKey(scope: SessionScope, ctx: MsgContext) { * Resolve the session key with a canonical direct-chat bucket (default: "main"). * All non-group direct chats collapse to this bucket; groups stay isolated. */ -export function resolveSessionKey(scope: SessionScope, ctx: MsgContext, mainKey?: string) { +export function resolveSessionKey( + scope: SessionScope, + ctx: MsgContext, + mainKey?: string, + agentId: string = DEFAULT_AGENT_ID, +) { const explicit = ctx.SessionKey?.trim(); if (explicit) { return normalizeExplicitSessionKey(explicit, ctx); @@ -35,14 +41,15 @@ export function resolveSessionKey(scope: SessionScope, ctx: MsgContext, mainKey? if (scope === "global") { return raw; } + const canonicalAgentId = normalizeAgentId(agentId); const canonicalMainKey = normalizeMainKey(mainKey); const canonical = buildAgentMainSessionKey({ - agentId: DEFAULT_AGENT_ID, + agentId: canonicalAgentId, mainKey: canonicalMainKey, }); const isGroup = raw.includes(":group:") || raw.includes(":channel:"); if (!isGroup) { return canonical; } - return `agent:${DEFAULT_AGENT_ID}:${raw}`; + return `agent:${canonicalAgentId}:${raw}`; }