diff --git a/src/auto-reply/reply/session-delivery.test.ts b/src/auto-reply/reply/session-delivery.test.ts index 2bfb4812f64..bd5f0d7c27b 100644 --- a/src/auto-reply/reply/session-delivery.test.ts +++ b/src/auto-reply/reply/session-delivery.test.ts @@ -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", diff --git a/src/auto-reply/reply/session-delivery.ts b/src/auto-reply/reply/session-delivery.ts index ef2f0cde227..1197b7c5245 100644 --- a/src/auto-reply/reply/session-delivery.ts +++ b/src/auto-reply/reply/session-delivery.ts @@ -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 diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index db0870b704a..5c382a74aa9 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -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"); }); });