From 5f821ed06731e81002b69af329a151da4efdafa2 Mon Sep 17 00:00:00 2001 From: j2h4u <39818683+j2h4u@users.noreply.github.com> Date: Tue, 17 Feb 2026 01:08:45 +0500 Subject: [PATCH] fix(session): prevent stale threadId leaking into non-thread sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user interacts with the bot inside a DM topic (thread), the session persists `lastThreadId`. If the user later sends a message from the main DM (no topic), `ctx.MessageThreadId` is undefined and the `||` fallback picks up the stale persisted value — causing the bot to reply into the old topic instead of the main conversation. Only fall back to `baseEntry.lastThreadId` for thread sessions where the fallback is meaningful (e.g. consecutive messages in the same thread). Non-thread sessions now correctly leave threadId unset. Co-Authored-By: Claude Opus 4.6 --- src/auto-reply/reply/session.test.ts | 59 ++++++++++++++++++++++++++++ src/auto-reply/reply/session.ts | 5 ++- 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index 5eb8bedc6..bf46a11f5 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -1292,3 +1292,62 @@ describe("persistSessionUsageUpdate", () => { expect(stored[sessionKey].totalTokensFresh).toBe(true); }); }); + +describe("initSessionState stale threadId fallback", () => { + it("does not inherit lastThreadId from a previous thread interaction in non-thread sessions", async () => { + const storePath = await createStorePath("stale-thread-"); + const cfg = { session: { store: storePath } } as OpenClawConfig; + + // First interaction: inside a DM topic (thread session) + const threadResult = await initSessionState({ + ctx: { + Body: "hello from topic", + SessionKey: "agent:main:main:thread:42", + MessageThreadId: 42, + }, + cfg, + commandAuthorized: true, + }); + expect(threadResult.sessionEntry.lastThreadId).toBe(42); + + // Second interaction: plain DM (non-thread session), same store + // The main session should NOT inherit threadId=42 + const mainResult = await initSessionState({ + ctx: { + Body: "hello from DM", + SessionKey: "agent:main:main", + }, + cfg, + commandAuthorized: true, + }); + expect(mainResult.sessionEntry.lastThreadId).toBeUndefined(); + }); + + it("preserves lastThreadId within the same thread session", async () => { + const storePath = await createStorePath("preserve-thread-"); + const cfg = { session: { store: storePath } } as OpenClawConfig; + + // First message in thread + await initSessionState({ + ctx: { + Body: "first", + SessionKey: "agent:main:main:thread:99", + MessageThreadId: 99, + }, + cfg, + commandAuthorized: true, + }); + + // Second message in same thread (MessageThreadId still present) + const result = await initSessionState({ + ctx: { + Body: "second", + SessionKey: "agent:main:main:thread:99", + MessageThreadId: 99, + }, + cfg, + commandAuthorized: true, + }); + expect(result.sessionEntry.lastThreadId).toBe(99); + }); +}); diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index 5979c3966..b73de9991 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -258,7 +258,10 @@ export async function initSessionState(params: { const lastChannelRaw = (ctx.OriginatingChannel as string | undefined) || baseEntry?.lastChannel; const lastToRaw = ctx.OriginatingTo || ctx.To || baseEntry?.lastTo; const lastAccountIdRaw = ctx.AccountId || baseEntry?.lastAccountId; - const lastThreadIdRaw = ctx.MessageThreadId || baseEntry?.lastThreadId; + // 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,