mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-04 05:00:21 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user