Version
2026.2.22-2
OS
macOS (Darwin 25.2.0)
Description
When session.dmScope is set to "per-channel-peer", WhatsApp DMs correctly resolve isolated session keys (e.g. agent:main:whatsapp:direct:+447513451349), but updateLastRouteInBackground unconditionally writes lastTo to the main session key (agent:main:main). This causes:
- Reply routing corruption: Every WhatsApp DM sender overwrites the main session's
lastTo — subsequent auto-replies and heartbeats route to whoever messaged last ("last writer wins"), not the session owner
- Privacy violation: Agent replies intended for User A get delivered to User B if User B messaged more recently
- Heartbeat/cron misrouting: Heartbeats and cron announcements using
channel: "last" resolve against the corrupted main session
This is the exact same bug that PR #13580 fixes for Telegram, but in the WhatsApp channel handler.
Expected Behavior
With dmScope: "per-channel-peer":
- Each WhatsApp DM sender should get their own isolated session
updateLastRoute should not write to mainSessionKey when the DM session is isolated
- Replies should route back to the original sender, not the last person who messaged
Actual Behavior
- DM from User A →
updateLastRoute writes lastTo=+userA to agent:main:main
- DM from User B →
updateLastRoute writes lastTo=+userB to agent:main:main
- Agent reply to User A → resolves
lastTo from main session → sends to User B (WRONG)
- Heartbeat/cron → uses
lastTo from main session → goes to User B instead of owner
Root Cause
In src/web/auto-reply/monitor/process-dm.ts (compiled: channel-web-pz2P6JD5.js line 1618-1628):
if (dmRouteTarget) updateLastRouteInBackground({
cfg: params.cfg,
backgroundTasks: params.backgroundTasks,
storeAgentId: params.route.agentId,
sessionKey: params.route.mainSessionKey, // ← BUG: always writes to main session
channel: "whatsapp",
to: dmRouteTarget,
accountId: params.route.accountId,
ctx: ctxPayload,
warn: params.replyLogger.warn.bind(params.replyLogger)
});
params.route.mainSessionKey is always agent:main:main. When dmScope isolates DMs, params.route.sessionKey differs from mainSessionKey, but updateLastRoute still writes to the main session unconditionally.
Proposed Fix
Same pattern as PR #13580 (Telegram fix). Gate updateLastRoute on session key equality:
// Only update main session's lastRoute when DM actually IS the main session
if (dmRouteTarget && params.route.sessionKey === params.route.mainSessionKey) {
updateLastRouteInBackground({
cfg: params.cfg,
backgroundTasks: params.backgroundTasks,
storeAgentId: params.route.agentId,
sessionKey: params.route.mainSessionKey,
channel: "whatsapp",
to: dmRouteTarget,
accountId: params.route.accountId,
ctx: ctxPayload,
warn: params.replyLogger.warn.bind(params.replyLogger)
});
}
When dmScope="main" (default): sessionKey === mainSessionKey → updateLastRoute fires as before. No behavior change.
When dmScope="per-channel-peer": sessionKey !== mainSessionKey → updateLastRoute is skipped, preventing main session corruption.
Additional Context
There may also be additional updateLastRoute call sites in the broader codebase (subagent-registry-BCf5fWt9.js has 10+ calls) that need the same guard. A comprehensive audit of all updateLastRoute callers for dmScope-awareness would prevent the same bug in other channel handlers.
Reproduction
- Set
session.dmScope: "per-channel-peer" in openclaw.json
- Have two different WhatsApp numbers DM the agent
- Observe that
sessions.json shows lastTo on agent:main:main flipping between senders
- Observe replies routing to the wrong sender
Steps Already Verified
- Config hot-reload applies
session.dmScope successfully (confirmed in gateway logs)
resolveAgentRoute correctly reads dmScope and passes it to buildAgentPeerSessionKey
buildAgentPeerSessionKey correctly produces per-peer session keys when dmScope="per-channel-peer" and peerId is set
- The bug is specifically in
updateLastRouteInBackground unconditionally targeting mainSessionKey
Related
Version
2026.2.22-2
OS
macOS (Darwin 25.2.0)
Description
When
session.dmScopeis set to"per-channel-peer", WhatsApp DMs correctly resolve isolated session keys (e.g.agent:main:whatsapp:direct:+447513451349), butupdateLastRouteInBackgroundunconditionally writeslastToto the main session key (agent:main:main). This causes:lastTo— subsequent auto-replies and heartbeats route to whoever messaged last ("last writer wins"), not the session ownerchannel: "last"resolve against the corrupted main sessionThis is the exact same bug that PR #13580 fixes for Telegram, but in the WhatsApp channel handler.
Expected Behavior
With
dmScope: "per-channel-peer":updateLastRouteshould not write tomainSessionKeywhen the DM session is isolatedActual Behavior
updateLastRoutewriteslastTo=+userAtoagent:main:mainupdateLastRoutewriteslastTo=+userBtoagent:main:mainlastTofrom main session → sends to User B (WRONG)lastTofrom main session → goes to User B instead of ownerRoot Cause
In
src/web/auto-reply/monitor/process-dm.ts(compiled:channel-web-pz2P6JD5.jsline 1618-1628):params.route.mainSessionKeyis alwaysagent:main:main. WhendmScopeisolates DMs,params.route.sessionKeydiffers frommainSessionKey, butupdateLastRoutestill writes to the main session unconditionally.Proposed Fix
Same pattern as PR #13580 (Telegram fix). Gate
updateLastRouteon session key equality:When
dmScope="main"(default):sessionKey === mainSessionKey→updateLastRoutefires as before. No behavior change.When
dmScope="per-channel-peer":sessionKey !== mainSessionKey→updateLastRouteis skipped, preventing main session corruption.Additional Context
There may also be additional
updateLastRoutecall sites in the broader codebase (subagent-registry-BCf5fWt9.jshas 10+ calls) that need the same guard. A comprehensive audit of allupdateLastRoutecallers for dmScope-awareness would prevent the same bug in other channel handlers.Reproduction
session.dmScope: "per-channel-peer"inopenclaw.jsonsessions.jsonshowslastToonagent:main:mainflipping between sendersSteps Already Verified
session.dmScopesuccessfully (confirmed in gateway logs)resolveAgentRoutecorrectly readsdmScopeand passes it tobuildAgentPeerSessionKeybuildAgentPeerSessionKeycorrectly produces per-peer session keys whendmScope="per-channel-peer"andpeerIdis setupdateLastRouteInBackgroundunconditionally targetingmainSessionKeyRelated
turnSourceChannelapproach for cross-channel guardresolveAgentRoutedoesn't normalize peer.kind