fix(session): preserve external lastTo routing for internal turns

This commit is contained in:
graysurf
2026-03-02 04:01:57 +08:00
committed by Peter Steinberger
parent 0fa5d6ed2e
commit 95db5bb5e8
2 changed files with 82 additions and 14 deletions

View File

@@ -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 () => {

View File

@@ -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