Skip to content

Commit 61a18e5

Browse files
authored
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
1 parent 5488175 commit 61a18e5

8 files changed

Lines changed: 228 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,7 @@ Docs: https://docs.openclaw.ai
233233
- 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.
234234
- 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.
235235
- 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.
236+
- 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.
236237
- 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.
237238
- 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.
238239
- 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.

extensions/slack/src/monitor/context.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type {
77
import type { SessionScope } from "openclaw/plugin-sdk/config-types";
88
import type { DmPolicy, GroupPolicy } from "openclaw/plugin-sdk/config-types";
99
import { createDedupeCache } from "openclaw/plugin-sdk/dedupe-runtime";
10+
import { resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-runtime";
1011
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
1112
import type { HistoryEntry } from "openclaw/plugin-sdk/reply-history";
1213
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
@@ -218,6 +219,7 @@ export function createSlackMonitorContext(params: {
218219
params.sessionScope,
219220
{ From: from, ChatType: chatType, Provider: "slack" },
220221
params.mainKey,
222+
resolveDefaultAgentId(params.cfg),
221223
);
222224
};
223225

extensions/slack/src/monitor/monitor.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,18 @@ describe("resolveSlackSystemEventSessionKey", () => {
196196
);
197197
});
198198

199+
it("uses the configured default agent for fallback system-event sessions", () => {
200+
const ctx = createSlackMonitorContext({
201+
...baseParams(),
202+
cfg: {
203+
agents: { list: [{ id: "ops", default: true }] },
204+
},
205+
});
206+
expect(ctx.resolveSlackSystemEventSessionKey({ channelId: "C123" })).toBe(
207+
"agent:ops:slack:channel:c123",
208+
);
209+
});
210+
199211
it("routes channel system events through account bindings", () => {
200212
const ctx = createSlackMonitorContext({
201213
...baseParams(),

src/agents/command/session.resolve-session-key.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ vi.mock("../../config/sessions/main-session.js", () => ({
2323

2424
vi.mock("../agent-scope.js", () => ({
2525
listAgentIds: () => hoisted.listAgentIdsMock(),
26+
resolveDefaultAgentId: () => "main",
2627
}));
2728

2829
const { resolveSessionKeyForRequest, resolveStoredSessionKeyForSessionId } =

src/agents/command/session.ts

Lines changed: 79 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,14 @@ import { resolveSessionKey } from "../../config/sessions/session-key.js";
2121
import { loadSessionStore } from "../../config/sessions/store-load.js";
2222
import type { SessionEntry } from "../../config/sessions/types.js";
2323
import type { OpenClawConfig } from "../../config/types.openclaw.js";
24-
import { normalizeAgentId, normalizeMainKey } from "../../routing/session-key.js";
24+
import {
25+
buildAgentMainSessionKey,
26+
DEFAULT_AGENT_ID,
27+
normalizeAgentId,
28+
normalizeMainKey,
29+
} from "../../routing/session-key.js";
2530
import { resolveSessionIdMatchSelection } from "../../sessions/session-id-resolution.js";
26-
import { listAgentIds } from "../agent-scope.js";
31+
import { listAgentIds, resolveDefaultAgentId } from "../agent-scope.js";
2732
import { clearBootstrapSnapshotOnSessionRollover } from "../bootstrap-cache.js";
2833

2934
export type SessionResolution = {
@@ -53,6 +58,61 @@ function buildExplicitSessionIdSessionKey(params: { sessionId: string; agentId?:
5358
return `agent:${normalizeAgentId(params.agentId)}:explicit:${params.sessionId.trim()}`;
5459
}
5560

61+
function resolveLegacyMainStoreSessionForDefaultAgent(opts: {
62+
cfg: OpenClawConfig;
63+
defaultAgentId: string;
64+
mainKey: string;
65+
sessionKey?: string;
66+
sessionStore: Record<string, SessionEntry>;
67+
storePath: string;
68+
}): SessionKeyResolution | undefined {
69+
if (opts.defaultAgentId === DEFAULT_AGENT_ID || !opts.sessionKey) {
70+
return undefined;
71+
}
72+
const defaultMainSessionKey = buildAgentMainSessionKey({
73+
agentId: opts.defaultAgentId,
74+
mainKey: opts.mainKey,
75+
});
76+
if (opts.sessionKey !== defaultMainSessionKey || opts.sessionStore[opts.sessionKey]) {
77+
return undefined;
78+
}
79+
80+
const legacyStorePath = resolveStorePath(opts.cfg.session?.store, {
81+
agentId: DEFAULT_AGENT_ID,
82+
});
83+
const legacyKeys = [
84+
buildAgentMainSessionKey({ agentId: DEFAULT_AGENT_ID, mainKey: opts.mainKey }),
85+
buildAgentMainSessionKey({ agentId: DEFAULT_AGENT_ID, mainKey: "main" }),
86+
];
87+
if (legacyStorePath === opts.storePath) {
88+
for (const legacyKey of legacyKeys) {
89+
const legacyEntry = opts.sessionStore[legacyKey];
90+
if (legacyEntry) {
91+
opts.sessionStore[opts.sessionKey] = { ...legacyEntry };
92+
return {
93+
sessionKey: opts.sessionKey,
94+
sessionStore: opts.sessionStore,
95+
storePath: opts.storePath,
96+
};
97+
}
98+
}
99+
return undefined;
100+
}
101+
const legacyStore = loadSessionStore(legacyStorePath);
102+
for (const legacyKey of legacyKeys) {
103+
const legacyEntry = legacyStore[legacyKey];
104+
if (legacyEntry) {
105+
opts.sessionStore[opts.sessionKey] = { ...legacyEntry };
106+
return {
107+
sessionKey: opts.sessionKey,
108+
sessionStore: opts.sessionStore,
109+
storePath: opts.storePath,
110+
};
111+
}
112+
}
113+
return undefined;
114+
}
115+
56116
function collectSessionIdMatchesForRequest(opts: {
57117
cfg: OpenClawConfig;
58118
sessionStore: Record<string, SessionEntry>;
@@ -143,6 +203,7 @@ export function resolveSessionKeyForRequest(opts: {
143203
const sessionCfg = opts.cfg.session;
144204
const scope = sessionCfg?.scope ?? "per-sender";
145205
const mainKey = normalizeMainKey(sessionCfg?.mainKey);
206+
const defaultAgentId = normalizeAgentId(resolveDefaultAgentId(opts.cfg));
146207
const requestedAgentId = opts.agentId?.trim() ? normalizeAgentId(opts.agentId) : undefined;
147208
const requestedSessionId = opts.sessionId?.trim() || undefined;
148209
const explicitSessionKey =
@@ -155,15 +216,29 @@ export function resolveSessionKeyForRequest(opts: {
155216
: undefined);
156217
const storeAgentId = explicitSessionKey
157218
? resolveAgentIdFromSessionKey(explicitSessionKey)
158-
: (requestedAgentId ?? normalizeAgentId(undefined));
219+
: (requestedAgentId ?? defaultAgentId);
159220
const storePath = resolveStorePath(sessionCfg?.store, {
160221
agentId: storeAgentId,
161222
});
162223
const sessionStore = loadSessionStore(storePath);
163224

164225
const ctx: MsgContext | undefined = opts.to?.trim() ? { From: opts.to } : undefined;
165226
let sessionKey: string | undefined =
166-
explicitSessionKey ?? (ctx ? resolveSessionKey(scope, ctx, mainKey) : undefined);
227+
explicitSessionKey ?? (ctx ? resolveSessionKey(scope, ctx, mainKey, storeAgentId) : undefined);
228+
229+
if (ctx && !requestedAgentId && !requestedSessionId && !explicitSessionKey) {
230+
const legacyMainSession = resolveLegacyMainStoreSessionForDefaultAgent({
231+
cfg: opts.cfg,
232+
defaultAgentId,
233+
mainKey,
234+
sessionKey,
235+
sessionStore,
236+
storePath,
237+
});
238+
if (legacyMainSession) {
239+
return legacyMainSession;
240+
}
241+
}
167242

168243
// If a session id was provided, prefer to re-use its existing entry (by id) even when no key was
169244
// derived. When duplicates exist across agent stores, pick the same deterministic best match used

src/commands/agent/session.test.ts

Lines changed: 103 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,23 @@ vi.mock("../../config/sessions/paths.js", () => ({
2727
resolveStorePath: mocks.resolveStorePath,
2828
}));
2929

30-
vi.mock("../../agents/agent-scope.js", () => ({
31-
listAgentIds: mocks.listAgentIds,
32-
}));
30+
vi.mock("../../agents/agent-scope.js", async () => {
31+
const { normalizeAgentId } = await vi.importActual<
32+
typeof import("../../routing/session-key.js")
33+
>("../../routing/session-key.js");
34+
return {
35+
listAgentIds: mocks.listAgentIds,
36+
resolveDefaultAgentId: (cfg: OpenClawConfig) => {
37+
const agents = cfg.agents?.list ?? [];
38+
return normalizeAgentId(agents.find((agent) => agent?.default)?.id ?? agents[0]?.id);
39+
},
40+
};
41+
});
3342

3443
describe("resolveSessionKeyForRequest", () => {
3544
const MAIN_STORE_PATH = "/tmp/main-store.json";
3645
const MYBOT_STORE_PATH = "/tmp/mybot-store.json";
46+
const SHARED_STORE_PATH = "/tmp/shared-store.json";
3747
type SessionStoreEntry = { sessionId: string; updatedAt: number };
3848
type SessionStoreMap = Record<string, SessionStoreEntry>;
3949

@@ -74,6 +84,96 @@ describe("resolveSessionKeyForRequest", () => {
7484
expect(result.sessionKey).toBe("agent:main:main");
7585
});
7686

87+
it("uses the configured default agent store for new --to sessions", async () => {
88+
setupMainAndMybotStorePaths();
89+
mockStoresByPath({
90+
[MAIN_STORE_PATH]: {},
91+
[MYBOT_STORE_PATH]: {},
92+
});
93+
94+
const result = resolveSessionKeyForRequest({
95+
cfg: {
96+
agents: { list: [{ id: "mybot", default: true }] },
97+
} satisfies OpenClawConfig,
98+
to: "+15551234567",
99+
});
100+
101+
expect(result.sessionKey).toBe("agent:mybot:main");
102+
expect(result.storePath).toBe(MYBOT_STORE_PATH);
103+
});
104+
105+
it("migrates legacy main-store main-key sessions for plain --to default-agent requests", async () => {
106+
setupMainAndMybotStorePaths();
107+
const mainStore = {
108+
"agent:main:main": { sessionId: "legacy-session-id", updatedAt: 1 },
109+
};
110+
const mybotStore = {};
111+
mockStoresByPath({
112+
[MAIN_STORE_PATH]: mainStore,
113+
[MYBOT_STORE_PATH]: mybotStore,
114+
});
115+
116+
const result = resolveSessionKeyForRequest({
117+
cfg: {
118+
agents: { list: [{ id: "mybot", default: true }] },
119+
} satisfies OpenClawConfig,
120+
to: "+15551234567",
121+
});
122+
123+
expect(result.sessionKey).toBe("agent:mybot:main");
124+
expect(result.sessionStore).toBe(mybotStore);
125+
expect(result.storePath).toBe(MYBOT_STORE_PATH);
126+
expect(result.sessionStore["agent:mybot:main"]?.sessionId).toBe("legacy-session-id");
127+
});
128+
129+
it("migrates legacy main-key sessions for plain --to default-agent requests with a literal shared store", async () => {
130+
const sharedStore = {
131+
"agent:main:main": { sessionId: "legacy-session-id", updatedAt: 1 },
132+
};
133+
mocks.listAgentIds.mockReturnValue(["main", "mybot"]);
134+
mocks.resolveStorePath.mockReturnValue(SHARED_STORE_PATH);
135+
mocks.loadSessionStore.mockReturnValue(sharedStore);
136+
137+
const result = resolveSessionKeyForRequest({
138+
cfg: {
139+
agents: { list: [{ id: "mybot", default: true }] },
140+
session: { store: SHARED_STORE_PATH },
141+
} satisfies OpenClawConfig,
142+
to: "+15551234567",
143+
});
144+
145+
expect(result.sessionKey).toBe("agent:mybot:main");
146+
expect(result.sessionStore).toBe(sharedStore);
147+
expect(result.storePath).toBe(SHARED_STORE_PATH);
148+
expect(result.sessionStore["agent:mybot:main"]?.sessionId).toBe("legacy-session-id");
149+
expect(mocks.loadSessionStore).toHaveBeenCalledTimes(1);
150+
expect(mocks.loadSessionStore).toHaveBeenCalledWith(SHARED_STORE_PATH);
151+
});
152+
153+
it("prefers the configured default-agent session over legacy main-store rows", async () => {
154+
setupMainAndMybotStorePaths();
155+
const mybotStore = {
156+
"agent:mybot:main": { sessionId: "current-session-id", updatedAt: 2 },
157+
};
158+
mockStoresByPath({
159+
[MAIN_STORE_PATH]: {
160+
"agent:main:main": { sessionId: "legacy-session-id", updatedAt: 1 },
161+
},
162+
[MYBOT_STORE_PATH]: mybotStore,
163+
});
164+
165+
const result = resolveSessionKeyForRequest({
166+
cfg: {
167+
agents: { list: [{ id: "mybot", default: true }] },
168+
} satisfies OpenClawConfig,
169+
to: "+15551234567",
170+
});
171+
172+
expect(result.sessionKey).toBe("agent:mybot:main");
173+
expect(result.sessionStore).toBe(mybotStore);
174+
expect(result.storePath).toBe(MYBOT_STORE_PATH);
175+
});
176+
77177
it("finds session by sessionId via reverse lookup in primary store", async () => {
78178
mocks.resolveStorePath.mockReturnValue(MAIN_STORE_PATH);
79179
mocks.loadSessionStore.mockReturnValue({

src/config/sessions/session-key.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,26 @@ import { installDiscordSessionKeyNormalizerFixture, makeCtx } from "./session-ke
55
installDiscordSessionKeyNormalizerFixture();
66

77
describe("resolveSessionKey", () => {
8+
it("uses an explicit agent id for canonical direct-chat keys", () => {
9+
const ctx = makeCtx({
10+
From: "+15551234567",
11+
});
12+
13+
expect(resolveSessionKey("per-sender", ctx, "main", "ops")).toBe("agent:ops:main");
14+
});
15+
16+
it("uses an explicit agent id for group keys", () => {
17+
const ctx = makeCtx({
18+
From: "C123",
19+
ChatType: "channel",
20+
Provider: "slack",
21+
});
22+
23+
expect(resolveSessionKey("per-sender", ctx, "main", "ops")).toBe(
24+
"agent:ops:slack:channel:c123",
25+
);
26+
});
27+
828
describe("Discord DM session key normalization", () => {
929
it("passes through correct discord:direct keys unchanged", () => {
1030
const ctx = makeCtx({

src/config/sessions/session-key.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { MsgContext } from "../../auto-reply/templating.js";
22
import {
33
buildAgentMainSessionKey,
44
DEFAULT_AGENT_ID,
5+
normalizeAgentId,
56
normalizeMainKey,
67
} from "../../routing/session-key.js";
78
import { normalizeE164 } from "../../utils.js";
@@ -26,7 +27,12 @@ export function deriveSessionKey(scope: SessionScope, ctx: MsgContext) {
2627
* Resolve the session key with a canonical direct-chat bucket (default: "main").
2728
* All non-group direct chats collapse to this bucket; groups stay isolated.
2829
*/
29-
export function resolveSessionKey(scope: SessionScope, ctx: MsgContext, mainKey?: string) {
30+
export function resolveSessionKey(
31+
scope: SessionScope,
32+
ctx: MsgContext,
33+
mainKey?: string,
34+
agentId: string = DEFAULT_AGENT_ID,
35+
) {
3036
const explicit = ctx.SessionKey?.trim();
3137
if (explicit) {
3238
return normalizeExplicitSessionKey(explicit, ctx);
@@ -35,14 +41,15 @@ export function resolveSessionKey(scope: SessionScope, ctx: MsgContext, mainKey?
3541
if (scope === "global") {
3642
return raw;
3743
}
44+
const canonicalAgentId = normalizeAgentId(agentId);
3845
const canonicalMainKey = normalizeMainKey(mainKey);
3946
const canonical = buildAgentMainSessionKey({
40-
agentId: DEFAULT_AGENT_ID,
47+
agentId: canonicalAgentId,
4148
mainKey: canonicalMainKey,
4249
});
4350
const isGroup = raw.includes(":group:") || raw.includes(":channel:");
4451
if (!isGroup) {
4552
return canonical;
4653
}
47-
return `agent:${DEFAULT_AGENT_ID}:${raw}`;
54+
return `agent:${canonicalAgentId}:${raw}`;
4855
}

0 commit comments

Comments
 (0)