fix: inter-session messages must not overwrite established external lastRoute (#58013)

Merged via squash.

Prepared head SHA: 820ea20cb8
Co-authored-by: duqaXxX <12242811+duqaXxX@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
This commit is contained in:
Accunza
2026-04-09 00:12:27 +02:00
committed by GitHub
parent 2787b5bcae
commit 8190cc4d21
4 changed files with 99 additions and 0 deletions

View File

@@ -1,6 +1,83 @@
import { describe, expect, it } from "vitest";
import { resolveLastChannelRaw, resolveLastToRaw } from "./session-delivery.js";
describe("inter-session lastRoute preservation (fixes #54441)", () => {
it("inter-session message does NOT overwrite established Discord lastChannel", () => {
expect(
resolveLastChannelRaw({
originatingChannelRaw: "webchat",
persistedLastChannel: "discord",
sessionKey: "agent:samantha:main",
isInterSession: true,
}),
).toBe("discord");
});
it("inter-session message does NOT overwrite established Telegram lastChannel", () => {
expect(
resolveLastChannelRaw({
originatingChannelRaw: "webchat",
persistedLastChannel: "telegram",
sessionKey: "agent:main:telegram:direct:123456",
isInterSession: true,
}),
).toBe("telegram");
});
it("inter-session message does NOT overwrite established external lastTo", () => {
expect(
resolveLastToRaw({
originatingChannelRaw: "webchat",
originatingToRaw: "session:somekey",
toRaw: "session:somekey",
persistedLastTo: "channel:1234567890",
persistedLastChannel: "discord",
sessionKey: "agent:samantha:main",
isInterSession: true,
}),
).toBe("channel:1234567890");
});
it("regular Discord user message DOES update lastChannel normally", () => {
expect(
resolveLastChannelRaw({
originatingChannelRaw: "discord",
persistedLastChannel: "discord",
sessionKey: "agent:main:discord:channel:123",
isInterSession: false,
}),
).toBe("discord");
});
it("inter-session on a NEW session (no persisted external route) may set webchat", () => {
// When there is no established external route, inter-session should not
// forcefully block the update — the session has no external route to protect.
const result = resolveLastChannelRaw({
originatingChannelRaw: "webchat",
persistedLastChannel: undefined,
sessionKey: "agent:samantha:main",
isInterSession: true,
});
// No external route existed — falls through to normal resolution (webchat or undefined)
// The important thing is it does NOT throw and returns a defined or undefined value.
expect(result === "webchat" || result === undefined).toBe(true);
});
it("inter-session on session with no persisted lastTo does not crash", () => {
const result = resolveLastToRaw({
originatingChannelRaw: "webchat",
originatingToRaw: "session:somekey",
toRaw: "session:somekey",
persistedLastTo: undefined,
persistedLastChannel: undefined,
sessionKey: "agent:samantha:main",
isInterSession: true,
});
// No external route — falls through to normal resolution
expect(result === "session:somekey" || result === undefined).toBe(true);
});
});
describe("session delivery direct-session routing overrides", () => {
it.each([
"agent:main:direct:user-1",

View File

@@ -97,6 +97,7 @@ export function resolveLastChannelRaw(params: {
originatingChannelRaw?: string;
persistedLastChannel?: string;
sessionKey?: string;
isInterSession?: boolean;
}): string | undefined {
const originatingChannel = normalizeMessageChannel(params.originatingChannelRaw);
// WebChat should own reply routing for direct-session UI turns, but only when
@@ -109,6 +110,14 @@ export function resolveLastChannelRaw(params: {
const sessionKeyChannelHint = resolveSessionKeyChannelHint(params.sessionKey);
const hasEstablishedExternalRoute =
isExternalRoutingChannel(persistedChannel) || isExternalRoutingChannel(sessionKeyChannelHint);
// Inter-session messages (sessions_send) always arrive with channel=webchat,
// but must never overwrite an already-established external delivery route.
// Without this guard, a sessions_send call resets lastChannel to webchat,
// causing subsequent Discord (or other external) deliveries to be lost.
// See: https://github.com/openclaw/openclaw/issues/54441
if (params.isInterSession && hasEstablishedExternalRoute) {
return persistedChannel || sessionKeyChannelHint;
}
if (
originatingChannel === INTERNAL_MESSAGE_CHANNEL &&
!hasEstablishedExternalRoute &&
@@ -136,12 +145,20 @@ export function resolveLastToRaw(params: {
persistedLastTo?: string;
persistedLastChannel?: string;
sessionKey?: string;
isInterSession?: boolean;
}): string | undefined {
const originatingChannel = normalizeMessageChannel(params.originatingChannelRaw);
const persistedChannel = normalizeMessageChannel(params.persistedLastChannel);
const sessionKeyChannelHint = resolveSessionKeyChannelHint(params.sessionKey);
const hasEstablishedExternalRouteForTo =
isExternalRoutingChannel(persistedChannel) || isExternalRoutingChannel(sessionKeyChannelHint);
// Inter-session messages must not replace a persisted external `to` with
// webchat-scoped identifiers (e.g. session keys). Preserve the established
// external destination so deliveries continue routing to the correct channel.
// See: https://github.com/openclaw/openclaw/issues/54441
if (params.isInterSession && hasEstablishedExternalRouteForTo && params.persistedLastTo) {
return params.persistedLastTo;
}
if (
originatingChannel === INTERNAL_MESSAGE_CHANNEL &&
!hasEstablishedExternalRouteForTo &&

View File

@@ -38,6 +38,7 @@ import {
normalizeOptionalLowercaseString,
normalizeOptionalString,
} from "../../shared/string-coerce.js";
import { isInterSessionInputProvenance } from "../../sessions/input-provenance.js";
import { normalizeSessionDeliveryFields } from "../../utils/delivery-context.js";
import { isInternalMessageChannel } from "../../utils/message-channel.js";
import { resolveCommandAuthorization } from "../command-auth.js";
@@ -476,10 +477,12 @@ export async function initSessionState(params: {
const baseEntry = !isNewSession && freshEntry ? entry : undefined;
// Track the originating channel/to for announce routing (subagent announce-back).
const originatingChannelRaw = ctx.OriginatingChannel as string | undefined;
const isInterSession = isInterSessionInputProvenance(ctx.InputProvenance);
const lastChannelRaw = resolveLastChannelRaw({
originatingChannelRaw,
persistedLastChannel: baseEntry?.lastChannel,
sessionKey,
isInterSession,
});
const lastToRaw = resolveLastToRaw({
originatingChannelRaw,
@@ -488,6 +491,7 @@ export async function initSessionState(params: {
persistedLastTo: baseEntry?.lastTo,
persistedLastChannel: baseEntry?.lastChannel,
sessionKey,
isInterSession,
});
const lastAccountIdRaw = resolveSessionDefaultAccountId({
cfg,