From fbdbd998d38e0f619dbc0a1fbb7245a8ff379131 Mon Sep 17 00:00:00 2001 From: Mariano Belinky Date: Mon, 13 Apr 2026 19:55:09 +0200 Subject: [PATCH] fix(session): clear stale thread route on system events --- src/auto-reply/reply/session.test.ts | 70 ++++++++++++++++++++++++++++ src/auto-reply/reply/session.ts | 26 +++++++++++ 2 files changed, 96 insertions(+) diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index 2bf9aeab3a8..872fb5d4af9 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -2520,6 +2520,76 @@ describe("initSessionState dmScope delivery migration", () => { }); describe("initSessionState internal channel routing preservation", () => { + it("clears stale thread routing on non-thread system-event sessions", async () => { + const storePath = await createStorePath("system-event-clears-stale-thread-"); + const sessionKey = "agent:main:mattermost:channel:chan1"; + await writeSessionStoreFast(storePath, { + [sessionKey]: { + sessionId: "sess-system-event-stale-thread", + updatedAt: Date.now(), + lastChannel: "mattermost", + lastTo: "channel:CHAN1", + lastAccountId: "default", + lastThreadId: "stale-root", + deliveryContext: { + channel: "mattermost", + to: "channel:CHAN1", + accountId: "default", + threadId: "stale-root", + }, + origin: { + provider: "mattermost", + to: "channel:CHAN1", + accountId: "default", + threadId: "stale-root", + }, + }, + }); + const cfg = { session: { store: storePath } } as OpenClawConfig; + + const result = await initSessionState({ + ctx: { + Body: "heartbeat tick", + SessionKey: sessionKey, + Provider: "heartbeat", + From: "heartbeat", + To: "heartbeat", + }, + cfg, + commandAuthorized: true, + }); + + expect(result.sessionEntry.lastChannel).toBe("mattermost"); + expect(result.sessionEntry.lastTo).toBe("channel:CHAN1"); + expect(result.sessionEntry.lastThreadId).toBeUndefined(); + expect(result.sessionEntry.deliveryContext).toEqual({ + channel: "mattermost", + to: "channel:CHAN1", + accountId: "default", + }); + expect(result.sessionEntry.origin).toEqual({ + provider: "mattermost", + to: "channel:CHAN1", + accountId: "default", + }); + + const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record< + string, + SessionEntry + >; + expect(persisted[sessionKey]?.lastThreadId).toBeUndefined(); + expect(persisted[sessionKey]?.deliveryContext).toEqual({ + channel: "mattermost", + to: "channel:CHAN1", + accountId: "default", + }); + expect(persisted[sessionKey]?.origin).toEqual({ + provider: "mattermost", + to: "channel:CHAN1", + accountId: "default", + }); + }); + 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"; diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index 25bbc688dc8..d72b6152995 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -67,6 +67,24 @@ function loadSessionArchiveRuntime() { return sessionArchiveRuntimePromise; } +function stripThreadIdFromDeliveryContext( + context: SessionEntry["deliveryContext"], +): SessionEntry["deliveryContext"] { + if (!context || context.threadId == null || context.threadId === "") { + return context; + } + const { threadId: _threadId, ...rest } = context; + return Object.keys(rest).length > 0 ? rest : undefined; +} + +function stripThreadIdFromOrigin(origin: SessionEntry["origin"]): SessionEntry["origin"] { + if (!origin || origin.threadId == null || origin.threadId === "") { + return origin; + } + const { threadId: _threadId, ...rest } = origin; + return Object.keys(rest).length > 0 ? rest : undefined; +} + function resolveExplicitSessionEndReason( matchedResetTriggerLower?: string, ): PluginHookSessionEndReason { @@ -607,6 +625,14 @@ export async function initSessionState(params: { if (metaPatch) { sessionEntry = { ...sessionEntry, ...metaPatch }; } + if (isSystemEvent && !isThread) { + sessionEntry = { + ...sessionEntry, + lastThreadId: undefined, + deliveryContext: stripThreadIdFromDeliveryContext(sessionEntry.deliveryContext), + origin: stripThreadIdFromOrigin(sessionEntry.origin), + }; + } if (!sessionEntry.chatType) { sessionEntry.chatType = "direct"; }