diff --git a/src/auto-reply/reply/session-delivery.ts b/src/auto-reply/reply/session-delivery.ts index 855450bd26d..86370f544ef 100644 --- a/src/auto-reply/reply/session-delivery.ts +++ b/src/auto-reply/reply/session-delivery.ts @@ -1,6 +1,6 @@ import type { SessionEntry } from "../../config/sessions.js"; import { buildAgentMainSessionKey } from "../../routing/session-key.js"; -import { parseAgentSessionKey } from "../../sessions/session-key-utils.js"; +import { deriveSessionChatType, parseAgentSessionKey } from "../../sessions/session-key-utils.js"; import { deliveryContextFromSession, deliveryContextKey, @@ -38,6 +38,10 @@ function isMainSessionKey(sessionKey?: string): boolean { return parsed.rest.trim().toLowerCase() === "main"; } +function isDirectSessionKey(sessionKey?: string): boolean { + return deriveSessionChatType(sessionKey) === "direct"; +} + function isExternalRoutingChannel(channel?: string): channel is string { return Boolean( channel && channel !== INTERNAL_MESSAGE_CHANNEL && isDeliverableMessageChannel(channel), @@ -50,7 +54,12 @@ export function resolveLastChannelRaw(params: { sessionKey?: string; }): string | undefined { const originatingChannel = normalizeMessageChannel(params.originatingChannelRaw); - if (originatingChannel === INTERNAL_MESSAGE_CHANNEL && isMainSessionKey(params.sessionKey)) { + // WebChat should own reply routing for direct-session UI turns, even when the + // session previously replied through an external channel like iMessage. + if ( + originatingChannel === INTERNAL_MESSAGE_CHANNEL && + (isMainSessionKey(params.sessionKey) || isDirectSessionKey(params.sessionKey)) + ) { return params.originatingChannelRaw; } const persistedChannel = normalizeMessageChannel(params.persistedLastChannel); @@ -77,7 +86,10 @@ export function resolveLastToRaw(params: { sessionKey?: string; }): string | undefined { const originatingChannel = normalizeMessageChannel(params.originatingChannelRaw); - if (originatingChannel === INTERNAL_MESSAGE_CHANNEL && isMainSessionKey(params.sessionKey)) { + if ( + originatingChannel === INTERNAL_MESSAGE_CHANNEL && + (isMainSessionKey(params.sessionKey) || isDirectSessionKey(params.sessionKey)) + ) { return params.originatingToRaw || params.toRaw; } const persistedChannel = normalizeMessageChannel(params.persistedLastChannel); diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index b0feaca4a23..58d6b893267 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -1926,6 +1926,43 @@ 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-"); + const sessionKey = "agent:main:imessage:direct:+1555"; + await writeSessionStoreFast(storePath, { + [sessionKey]: { + sessionId: "sess-webchat-direct", + updatedAt: Date.now(), + lastChannel: "imessage", + lastTo: "+1555", + deliveryContext: { + channel: "imessage", + to: "+1555", + }, + }, + }); + 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"); + expect(result.sessionEntry.deliveryContext?.to).toBe("session:dashboard"); + }); + it("keeps persisted external route when OriginatingChannel is non-deliverable", async () => { const storePath = await createStorePath("preserve-nondeliverable-route-"); const sessionKey = "agent:main:discord:channel:24680";