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
This commit is contained in:
Vincent Koc
2026-04-27 15:09:01 -07:00
committed by GitHub
parent 5488175b22
commit 61a18e5596
8 changed files with 228 additions and 10 deletions

View File

@@ -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:<mainKey>` 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.

View File

@@ -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),
);
};

View File

@@ -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(),

View File

@@ -23,6 +23,7 @@ vi.mock("../../config/sessions/main-session.js", () => ({
vi.mock("../agent-scope.js", () => ({
listAgentIds: () => hoisted.listAgentIdsMock(),
resolveDefaultAgentId: () => "main",
}));
const { resolveSessionKeyForRequest, resolveStoredSessionKeyForSessionId } =

View File

@@ -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<string, SessionEntry>;
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<string, SessionEntry>;
@@ -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

View File

@@ -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<string, SessionStoreEntry>;
@@ -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({

View File

@@ -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({

View File

@@ -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}`;
}