diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index 3a25c5d1552..9a85f277223 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -1474,13 +1474,50 @@ describe("initSessionState internal channel routing preservation", () => { Body: "internal follow-up", SessionKey: sessionKey, OriginatingChannel: "webchat", + OriginatingTo: "session:dashboard", }, cfg, commandAuthorized: true, }); expect(result.sessionEntry.lastChannel).toBe("telegram"); + expect(result.sessionEntry.lastTo).toBe("group:12345"); expect(result.sessionEntry.deliveryContext?.channel).toBe("telegram"); + expect(result.sessionEntry.deliveryContext?.to).toBe("group:12345"); + }); + + 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"; + await saveSessionStore(storePath, { + [sessionKey]: { + sessionId: "sess-2", + updatedAt: Date.now(), + lastChannel: "discord", + lastTo: "channel:24680", + deliveryContext: { + channel: "discord", + to: "channel:24680", + }, + }, + }); + const cfg = { session: { store: storePath } } as OpenClawConfig; + + const result = await initSessionState({ + ctx: { + Body: "internal handoff", + SessionKey: sessionKey, + OriginatingChannel: "sessions_send", + OriginatingTo: "session:handoff", + }, + cfg, + commandAuthorized: true, + }); + + expect(result.sessionEntry.lastChannel).toBe("discord"); + expect(result.sessionEntry.lastTo).toBe("channel:24680"); + expect(result.sessionEntry.deliveryContext?.channel).toBe("discord"); + expect(result.sessionEntry.deliveryContext?.to).toBe("channel:24680"); }); it("uses session key channel hint when first turn is internal webchat", async () => { diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index ed789ece8da..88711b140b4 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -62,6 +62,12 @@ function resolveSessionKeyChannelHint(sessionKey?: string): string | undefined { return normalizeMessageChannel(head); } +function isExternalRoutingChannel(channel?: string): channel is string { + return Boolean( + channel && channel !== INTERNAL_MESSAGE_CHANNEL && isDeliverableMessageChannel(channel), + ); +} + function resolveLastChannelRaw(params: { originatingChannelRaw?: string; persistedLastChannel?: string; @@ -71,26 +77,44 @@ function resolveLastChannelRaw(params: { const persistedChannel = normalizeMessageChannel(params.persistedLastChannel); const sessionKeyChannelHint = resolveSessionKeyChannelHint(params.sessionKey); let resolved = params.originatingChannelRaw || params.persistedLastChannel; - // Internal webchat/system turns should not overwrite previously known external - // delivery routes (or explicit channel hints encoded in the session key). - if (originatingChannel === INTERNAL_MESSAGE_CHANNEL) { - if ( - persistedChannel && - persistedChannel !== INTERNAL_MESSAGE_CHANNEL && - isDeliverableMessageChannel(persistedChannel) - ) { + // Internal/non-deliverable sources should not overwrite previously known + // external delivery routes (or explicit channel hints from the session key). + if (!isExternalRoutingChannel(originatingChannel)) { + if (isExternalRoutingChannel(persistedChannel)) { resolved = persistedChannel; - } else if ( - sessionKeyChannelHint && - sessionKeyChannelHint !== INTERNAL_MESSAGE_CHANNEL && - isDeliverableMessageChannel(sessionKeyChannelHint) - ) { + } else if (isExternalRoutingChannel(sessionKeyChannelHint)) { resolved = sessionKeyChannelHint; } } return resolved; } +function resolveLastToRaw(params: { + originatingChannelRaw?: string; + originatingToRaw?: string; + toRaw?: string; + persistedLastTo?: string; + persistedLastChannel?: string; + sessionKey?: string; +}): string | undefined { + const originatingChannel = normalizeMessageChannel(params.originatingChannelRaw); + const persistedChannel = normalizeMessageChannel(params.persistedLastChannel); + const sessionKeyChannelHint = resolveSessionKeyChannelHint(params.sessionKey); + + // When the turn originates from an internal/non-deliverable source, do not + // replace an established external destination with internal routing ids + // (e.g., session/webchat ids). + if (!isExternalRoutingChannel(originatingChannel)) { + const hasExternalFallback = + isExternalRoutingChannel(persistedChannel) || isExternalRoutingChannel(sessionKeyChannelHint); + if (hasExternalFallback && params.persistedLastTo) { + return params.persistedLastTo; + } + } + + return params.originatingToRaw || params.toRaw || params.persistedLastTo; +} + export type SessionInitResult = { sessionCtx: TemplateContext; sessionEntry: SessionEntry; @@ -422,7 +446,14 @@ export async function initSessionState(params: { persistedLastChannel: baseEntry?.lastChannel, sessionKey, }); - const lastToRaw = ctx.OriginatingTo || ctx.To || baseEntry?.lastTo; + const lastToRaw = resolveLastToRaw({ + originatingChannelRaw, + originatingToRaw: ctx.OriginatingTo, + toRaw: ctx.To, + persistedLastTo: baseEntry?.lastTo, + persistedLastChannel: baseEntry?.lastChannel, + sessionKey, + }); const lastAccountIdRaw = ctx.AccountId || baseEntry?.lastAccountId; // Only fall back to persisted threadId for thread sessions. Non-thread // sessions (e.g. DM without topics) must not inherit a stale threadId from a