diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index ca8a5d43aafb..26d3eb8b53a0 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -5095,6 +5095,75 @@ describe("initSessionState stale threadId fallback", () => { expect(result.sessionEntry.deliveryContext?.threadId).toBe("650.000"); }); + it("preserves external thread routing for internal turns and clears it for external non-thread turns", async () => { + const storePath = await createStorePath("internal-thread-route-"); + const cfg = { session: { store: storePath } } as OpenClawConfig; + const sessionKey = "agent:main:main"; + + await writeSessionStoreFast(storePath, { + [sessionKey]: { + sessionId: "session-internal-thread-route", + updatedAt: Date.now(), + delivery: { + kind: "external", + route: { + channel: "imessage", + accountId: "imessage-default", + target: { to: "+15551234567", chatType: "direct" }, + thread: { id: "thread-42", kind: "thread", source: "session" }, + }, + context: { + channel: "imessage", + to: "+15551234567", + accountId: "imessage-default", + threadId: "thread-42", + }, + origin: { + provider: "webchat", + to: "+15551234567", + accountId: "imessage-default", + threadId: "thread-42", + chatType: "direct", + surface: "webchat", + }, + }, + }, + }); + + const internal = await initSessionState({ + ctx: { + Body: "internal control-ui turn", + SessionKey: sessionKey, + OriginatingChannel: "webchat", + }, + cfg, + }); + expect(internal.sessionEntry.lastThreadId).toBe("thread-42"); + expect(internal.sessionEntry.deliveryContext).toEqual({ + channel: "imessage", + to: "+15551234567", + accountId: "imessage-default", + threadId: "thread-42", + }); + + const plainExternal = await initSessionState({ + ctx: { + Body: "plain external turn", + SessionKey: sessionKey, + OriginatingChannel: "imessage", + OriginatingTo: "+15551234567", + AccountId: "imessage-default", + }, + cfg, + }); + expect(plainExternal.sessionEntry.lastThreadId).toBeUndefined(); + expect(plainExternal.sessionEntry.deliveryContext).toEqual({ + channel: "imessage", + to: "+15551234567", + accountId: "imessage-default", + }); + }); + it("preserves lastThreadId within the same thread session", async () => { const storePath = await createStorePath("preserve-thread-"); const cfg = { session: { store: storePath } } as OpenClawConfig; diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index dcaaa70e6a3f..5edfaf473cfa 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -91,6 +91,7 @@ import { sessionDeliveryOrigin, sessionDeliveryRoute, } from "../../utils/delivery-context.shared.js"; +import { isInternalMessageChannel } from "../../utils/message-channel.js"; import { resolveCommandTurnTargetSessionKey } from "../command-turn-context.js"; import type { FinalizedRuntimeMsgContext, @@ -839,14 +840,14 @@ async function initSessionStateAttemptLocked( accountIdRaw: ctx.AccountId, persistedLastAccountId: baseDeliveryContext?.accountId, }); - // 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. + // Internal turns share the established external route and must not erase its + // thread. External non-thread turns still clear stale thread routing. + const preservePersistedThread = isThread || isInternalMessageChannel(originatingChannelRaw); const lastThreadIdRaw = isSystemEvent ? baseDeliveryContext?.threadId : (ctx.MessageThreadId ?? ctx.TransportThreadId ?? - (isThread ? baseDeliveryContext?.threadId : undefined)); + (preservePersistedThread ? baseDeliveryContext?.threadId : undefined)); const delivery = isSystemEvent ? normalizeSessionDeliveryState({ route: isThread ? baseDeliveryRoute : stripThreadFromSessionRoute(baseDeliveryRoute),