fix(session): preserve route on system events

This commit is contained in:
mbelinky
2026-04-13 18:58:33 +02:00
parent 907df51478
commit 314a93578e
4 changed files with 154 additions and 33 deletions

View File

@@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai
- Gateway/update: unify service entrypoint resolution around the canonical bundled gateway entrypoint so update, reinstall, and doctor repair stop drifting between stale `dist/entry.js` and current `dist/index.js` paths. (#65984) Thanks @mbelinky.
- Heartbeat/Telegram topics: keep isolated heartbeat replies on the bound forum topic when `target=last`, instead of dropping them into the group root chat. (#66035) Thanks @mbelinky.
- Browser/CDP: let managed local Chrome readiness, status probes, and managed loopback CDP control bypass browser SSRF policy for their own loopback control plane, so OpenClaw no longer misclassifies a healthy child browser as "not reachable after start". (#65695, #66043) Thanks @mbelinky.
- Gateway/sessions: stop heartbeat, cron-event, and exec-event turns from overwriting shared-session routing and origin metadata, preventing synthetic `heartbeat` targets from poisoning later cron or user delivery. (#63733, #35300)
## 2026.4.12
### Changes

View File

@@ -2520,6 +2520,89 @@ describe("initSessionState dmScope delivery migration", () => {
});
describe("initSessionState internal channel routing preservation", () => {
it("does not synthesize heartbeat routing on a session with no external route", async () => {
const storePath = await createStorePath("system-event-no-route-");
const sessionKey = "agent:main:main";
await writeSessionStoreFast(storePath, {
[sessionKey]: {
sessionId: "sess-system-event-no-route",
updatedAt: Date.now(),
},
});
const cfg = { session: { store: storePath } } as OpenClawConfig;
const result = await initSessionState({
ctx: {
Body: "HEARTBEAT_OK",
SessionKey: sessionKey,
Provider: "heartbeat",
From: "heartbeat",
To: "heartbeat",
},
cfg,
commandAuthorized: true,
});
expect(result.sessionEntry.lastChannel).toBeUndefined();
expect(result.sessionEntry.lastTo).toBeUndefined();
expect(result.sessionEntry.deliveryContext).toBeUndefined();
expect(result.sessionEntry.origin).toBeUndefined();
});
it("preserves the existing user route when a heartbeat targets a different chat on the shared session", async () => {
const storePath = await createStorePath("system-event-preserve-user-route-");
const sessionKey = "agent:main:main";
await writeSessionStoreFast(storePath, {
[sessionKey]: {
sessionId: "sess-system-event-shared",
updatedAt: Date.now(),
lastChannel: "feishu",
lastTo: "user:ou_sender_1",
deliveryContext: {
channel: "feishu",
to: "user:ou_sender_1",
accountId: "default",
},
origin: {
provider: "feishu",
from: "user:ou_sender_1",
to: "user:ou_sender_1",
accountId: "default",
},
},
});
const cfg = { session: { store: storePath } } as OpenClawConfig;
const result = await initSessionState({
ctx: {
Body: "heartbeat tick",
SessionKey: sessionKey,
Provider: "heartbeat",
From: "chat:oc_group_chat",
To: "chat:oc_group_chat",
OriginatingChannel: "feishu",
OriginatingTo: "chat:oc_group_chat",
AccountId: "default",
},
cfg,
commandAuthorized: true,
});
expect(result.sessionEntry.lastChannel).toBe("feishu");
expect(result.sessionEntry.lastTo).toBe("user:ou_sender_1");
expect(result.sessionEntry.deliveryContext).toEqual({
channel: "feishu",
to: "user:ou_sender_1",
accountId: "default",
});
expect(result.sessionEntry.origin).toEqual({
provider: "feishu",
from: "user:ou_sender_1",
to: "user:ou_sender_1",
accountId: "default",
});
});
it("keeps persisted external lastChannel when OriginatingChannel is internal webchat", async () => {
const storePath = await createStorePath("preserve-external-channel-");
const sessionKey = "agent:main:telegram:group:12345";

View File

@@ -485,39 +485,64 @@ export async function initSessionState(params: {
// 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,
originatingToRaw: ctx.OriginatingTo,
toRaw: ctx.To,
persistedLastTo: baseEntry?.lastTo,
persistedLastChannel: baseEntry?.lastChannel,
sessionKey,
isInterSession,
});
const lastAccountIdRaw = resolveSessionDefaultAccountId({
cfg,
channelRaw: lastChannelRaw,
accountIdRaw: ctx.AccountId,
persistedLastAccountId: baseEntry?.lastAccountId,
});
// Only fall back to persisted threadId for thread sessions. Non-thread
// Automated heartbeat/cron/exec turns run inside the conversation session,
// but they must not rewrite the session's remembered external delivery route.
// Otherwise a heartbeat target like "group:..." or a synthetic sender like
// "heartbeat" leaks into the shared session and later user replies route to
// the wrong chat.
const lastChannelRaw = isSystemEvent
? baseEntry?.lastChannel
: resolveLastChannelRaw({
originatingChannelRaw,
persistedLastChannel: baseEntry?.lastChannel,
sessionKey,
isInterSession,
});
const lastToRaw = isSystemEvent
? baseEntry?.lastTo
: resolveLastToRaw({
originatingChannelRaw,
originatingToRaw: ctx.OriginatingTo,
toRaw: ctx.To,
persistedLastTo: baseEntry?.lastTo,
persistedLastChannel: baseEntry?.lastChannel,
sessionKey,
isInterSession,
});
const lastAccountIdRaw = isSystemEvent
? baseEntry?.lastAccountId
: resolveSessionDefaultAccountId({
cfg,
channelRaw: lastChannelRaw,
accountIdRaw: ctx.AccountId,
persistedLastAccountId: 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
// previous interaction that happened inside a topic/thread.
const lastThreadIdRaw = ctx.MessageThreadId || (isThread ? baseEntry?.lastThreadId : undefined);
const deliveryFields = normalizeSessionDeliveryFields({
deliveryContext: {
channel: lastChannelRaw,
to: lastToRaw,
accountId: lastAccountIdRaw,
threadId: lastThreadIdRaw,
},
});
const lastThreadIdRaw = isSystemEvent
? baseEntry?.lastThreadId
: ctx.MessageThreadId || (isThread ? baseEntry?.lastThreadId : undefined);
const deliveryFields = isSystemEvent
? normalizeSessionDeliveryFields({
channel: baseEntry?.channel,
lastChannel: baseEntry?.lastChannel,
lastTo: baseEntry?.lastTo,
lastAccountId: baseEntry?.lastAccountId,
lastThreadId:
baseEntry?.lastThreadId ??
baseEntry?.deliveryContext?.threadId ??
baseEntry?.origin?.threadId,
deliveryContext: baseEntry?.deliveryContext,
})
: normalizeSessionDeliveryFields({
deliveryContext: {
channel: lastChannelRaw,
to: lastToRaw,
accountId: lastAccountIdRaw,
threadId: lastThreadIdRaw,
},
});
const lastChannel = deliveryFields.lastChannel ?? lastChannelRaw;
const lastTo = deliveryFields.lastTo ?? lastToRaw;
const lastAccountId = deliveryFields.lastAccountId ?? lastAccountIdRaw;
@@ -577,6 +602,7 @@ export async function initSessionState(params: {
sessionKey,
existing: sessionEntry,
groupResolution,
skipSystemEventOrigin: isSystemEvent,
});
if (metaPatch) {
sessionEntry = { ...sessionEntry, ...metaPatch };

View File

@@ -51,7 +51,15 @@ const mergeOrigin = (
return Object.keys(merged).length > 0 ? merged : undefined;
};
export function deriveSessionOrigin(ctx: MsgContext): SessionOrigin | undefined {
export function deriveSessionOrigin(
ctx: MsgContext,
opts?: { skipSystemEventOrigin?: boolean },
): SessionOrigin | undefined {
const isSystemEventProvider =
ctx.Provider === "heartbeat" || ctx.Provider === "cron-event" || ctx.Provider === "exec-event";
if (opts?.skipSystemEventOrigin && isSystemEventProvider) {
return undefined;
}
const label = normalizeOptionalString(resolveConversationLabel(ctx));
const providerRaw =
(typeof ctx.OriginatingChannel === "string" && ctx.OriginatingChannel) ||
@@ -175,9 +183,12 @@ export function deriveSessionMetaPatch(params: {
sessionKey: string;
existing?: SessionEntry;
groupResolution?: GroupKeyResolution | null;
skipSystemEventOrigin?: boolean;
}): Partial<SessionEntry> | null {
const groupPatch = deriveGroupSessionPatch(params);
const origin = deriveSessionOrigin(params.ctx);
const origin = deriveSessionOrigin(params.ctx, {
skipSystemEventOrigin: params.skipSystemEventOrigin,
});
if (!groupPatch && !origin) {
return null;
}