fix(session): preserve external channel route when webchat views session (#47745)

When a Telegram/WhatsApp/iMessage session was viewed or messaged from the
dashboard/webchat, resolveLastChannelRaw() unconditionally returned 'webchat'
for any isDirectSessionKey() or isMainSessionKey() match, overwriting the
persisted external delivery route.

This caused subagent completion events to be delivered to the webchat/dashboard
instead of the original channel (Telegram, WhatsApp, etc.), silently dropping
messages for the channel user.

Fix: only allow webchat to own routing when no external delivery route has been
established (no persisted external lastChannel, no external channel hint in the
session key). If an external route exists, webchat is treated as admin/monitoring
access and must not mutate the delivery route.

Updated/added tests to document the correct behaviour.

Fixes #47745
This commit is contained in:
brokemac79
2026-03-16 02:18:02 +00:00
committed by Peter Steinberger
parent 3838ef9b2a
commit 623ba14031
3 changed files with 96 additions and 36 deletions

View File

@@ -9,24 +9,29 @@ describe("session delivery direct-session routing overrides", () => {
"agent:main:telegram:dm:123456",
"agent:main:telegram:direct:123456:thread:99",
"agent:main:telegram:account-a:direct:123456:topic:ops",
])("lets webchat override persisted routes for strict direct key %s", (sessionKey) => {
expect(
resolveLastChannelRaw({
originatingChannelRaw: "webchat",
persistedLastChannel: "telegram",
sessionKey,
}),
).toBe("webchat");
expect(
resolveLastToRaw({
originatingChannelRaw: "webchat",
originatingToRaw: "session:dashboard",
persistedLastChannel: "telegram",
persistedLastTo: "123456",
sessionKey,
}),
).toBe("session:dashboard");
});
])(
"preserves persisted external route when webchat accesses channel-peer session %s (fixes #47745)",
(sessionKey) => {
// Webchat/dashboard viewing an external-channel session must not overwrite
// the delivery route — subagents must still deliver to the original channel.
expect(
resolveLastChannelRaw({
originatingChannelRaw: "webchat",
persistedLastChannel: "telegram",
sessionKey,
}),
).toBe("telegram");
expect(
resolveLastToRaw({
originatingChannelRaw: "webchat",
originatingToRaw: "session:dashboard",
persistedLastChannel: "telegram",
persistedLastTo: "123456",
sessionKey,
}),
).toBe("123456");
},
);
it.each([
"agent:main:main:direct",

View File

@@ -90,16 +90,25 @@ export function resolveLastChannelRaw(params: {
sessionKey?: string;
}): string | undefined {
const originatingChannel = normalizeMessageChannel(params.originatingChannelRaw);
// WebChat should own reply routing for direct-session UI turns, even when the
// session previously replied through an external channel like iMessage.
// WebChat should own reply routing for direct-session UI turns, but only when
// the session has no established external delivery route. If the session was
// created via an external channel (e.g. Telegram, iMessage), webchat/dashboard
// access must not overwrite the persisted route — doing so causes subagent
// completion events to be delivered to the dashboard instead of the original
// channel. See: https://github.com/openclaw/openclaw/issues/47745
const persistedChannel = normalizeMessageChannel(params.persistedLastChannel);
const sessionKeyChannelHintForCheck = resolveSessionKeyChannelHint(params.sessionKey);
const hasEstablishedExternalRoute =
isExternalRoutingChannel(persistedChannel) ||
isExternalRoutingChannel(sessionKeyChannelHintForCheck);
if (
originatingChannel === INTERNAL_MESSAGE_CHANNEL &&
!hasEstablishedExternalRoute &&
(isMainSessionKey(params.sessionKey) || isDirectSessionKey(params.sessionKey))
) {
return params.originatingChannelRaw;
}
const persistedChannel = normalizeMessageChannel(params.persistedLastChannel);
const sessionKeyChannelHint = resolveSessionKeyChannelHint(params.sessionKey);
const sessionKeyChannelHint = sessionKeyChannelHintForCheck;
let resolved = params.originatingChannelRaw || params.persistedLastChannel;
// Internal/non-deliverable sources should not overwrite previously known
// external delivery routes (or explicit channel hints from the session key).
@@ -122,14 +131,19 @@ export function resolveLastToRaw(params: {
sessionKey?: string;
}): string | undefined {
const originatingChannel = normalizeMessageChannel(params.originatingChannelRaw);
const persistedChannel = normalizeMessageChannel(params.persistedLastChannel);
const sessionKeyChannelHintForToCheck = resolveSessionKeyChannelHint(params.sessionKey);
const hasEstablishedExternalRouteForTo =
isExternalRoutingChannel(persistedChannel) ||
isExternalRoutingChannel(sessionKeyChannelHintForToCheck);
if (
originatingChannel === INTERNAL_MESSAGE_CHANNEL &&
!hasEstablishedExternalRouteForTo &&
(isMainSessionKey(params.sessionKey) || isDirectSessionKey(params.sessionKey))
) {
return params.originatingToRaw || params.toRaw;
}
const persistedChannel = normalizeMessageChannel(params.persistedLastChannel);
const sessionKeyChannelHint = resolveSessionKeyChannelHint(params.sessionKey);
const sessionKeyChannelHint = sessionKeyChannelHintForToCheck;
// When the turn originates from an internal/non-deliverable source, do not
// replace an established external destination with internal routing ids

View File

@@ -1942,8 +1942,11 @@ describe("initSessionState internal channel routing preservation", () => {
expect(result.sessionEntry.deliveryContext?.to).toBe("group:12345");
});
it("lets direct webchat turns override persisted external routes for per-channel-peer sessions", async () => {
const storePath = await createStorePath("webchat-direct-route-override-");
it("preserves persisted external route when webchat views a channel-peer session (fixes #47745)", async () => {
// Regression: dashboard/webchat access must not overwrite an established
// external delivery route (e.g. Telegram/iMessage) on a channel-scoped session.
// Subagent completions should still be delivered to the original channel.
const storePath = await createStorePath("webchat-direct-route-preserve-");
const sessionKey = "agent:main:imessage:direct:+1555";
await writeSessionStoreFast(storePath, {
[sessionKey]: {
@@ -1973,6 +1976,40 @@ describe("initSessionState internal channel routing preservation", () => {
commandAuthorized: true,
});
// External route must be preserved — webchat is admin/monitoring only
expect(result.sessionEntry.lastChannel).toBe("imessage");
expect(result.sessionEntry.lastTo).toBe("+1555");
expect(result.sessionEntry.deliveryContext?.channel).toBe("imessage");
expect(result.sessionEntry.deliveryContext?.to).toBe("+1555");
});
it("lets direct webchat turns own routing for sessions with no prior external route", async () => {
// Webchat should still own routing for sessions that were created via webchat
// (no external channel ever established).
const storePath = await createStorePath("webchat-direct-route-noext-");
const sessionKey = "agent:main:main";
await writeSessionStoreFast(storePath, {
[sessionKey]: {
sessionId: "sess-webchat-noext",
updatedAt: Date.now(),
},
});
const cfg = {
session: { store: storePath, dmScope: "per-channel-peer" },
} as OpenClawConfig;
const result = await initSessionState({
ctx: {
Body: "reply from control ui",
SessionKey: sessionKey,
OriginatingChannel: "webchat",
OriginatingTo: "session:dashboard",
Surface: "webchat",
},
cfg,
commandAuthorized: true,
});
expect(result.sessionEntry.lastChannel).toBe("webchat");
expect(result.sessionEntry.lastTo).toBe("session:dashboard");
expect(result.sessionEntry.deliveryContext?.channel).toBe("webchat");
@@ -2068,8 +2105,10 @@ describe("initSessionState internal channel routing preservation", () => {
expect(result.sessionEntry.lastChannel).toBe("webchat");
});
it("does not reuse stale external lastTo for webchat/main turns without destination", async () => {
const storePath = await createStorePath("webchat-main-no-stale-lastto-");
it("preserves external route for main session when webchat accesses without destination (fixes #47745)", async () => {
// Regression: webchat monitoring a main session that has an established WhatsApp
// route must not clear that route. Subagents should still deliver to WhatsApp.
const storePath = await createStorePath("webchat-main-preserve-external-");
const sessionKey = "agent:main:main";
await writeSessionStoreFast(storePath, {
[sessionKey]: {
@@ -2095,12 +2134,14 @@ describe("initSessionState internal channel routing preservation", () => {
commandAuthorized: true,
});
expect(result.sessionEntry.lastChannel).toBe("webchat");
expect(result.sessionEntry.lastTo).toBeUndefined();
expect(result.sessionEntry.lastChannel).toBe("whatsapp");
expect(result.sessionEntry.lastTo).toBe("+15555550123");
});
it("prefers webchat route over persisted external route for main session turns", async () => {
const storePath = await createStorePath("prefer-webchat-main-route-");
it("preserves external route for main session when webchat sends with destination (fixes #47745)", async () => {
// Regression: webchat sending to a main session with an established WhatsApp route
// must not steal that route for webchat delivery.
const storePath = await createStorePath("preserve-main-external-webchat-send-");
const sessionKey = "agent:main:main";
await writeSessionStoreFast(storePath, {
[sessionKey]: {
@@ -2127,9 +2168,9 @@ describe("initSessionState internal channel routing preservation", () => {
commandAuthorized: true,
});
expect(result.sessionEntry.lastChannel).toBe("webchat");
expect(result.sessionEntry.lastTo).toBe("session:webchat-main");
expect(result.sessionEntry.deliveryContext?.channel).toBe("webchat");
expect(result.sessionEntry.deliveryContext?.to).toBe("session:webchat-main");
expect(result.sessionEntry.lastChannel).toBe("whatsapp");
expect(result.sessionEntry.lastTo).toBe("+15555550123");
expect(result.sessionEntry.deliveryContext?.channel).toBe("whatsapp");
expect(result.sessionEntry.deliveryContext?.to).toBe("+15555550123");
});
});