mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:20:43 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -23,6 +23,7 @@ vi.mock("../../config/sessions/main-session.js", () => ({
|
||||
|
||||
vi.mock("../agent-scope.js", () => ({
|
||||
listAgentIds: () => hoisted.listAgentIdsMock(),
|
||||
resolveDefaultAgentId: () => "main",
|
||||
}));
|
||||
|
||||
const { resolveSessionKeyForRequest, resolveStoredSessionKeyForSessionId } =
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user