fix(sessions): preserve thread on internal turns (#121321)

This commit is contained in:
Peter Steinberger
2026-08-09 22:32:30 -07:00
committed by GitHub
parent 7d1958bf61
commit 93ba11138f
2 changed files with 74 additions and 4 deletions
+69
View File
@@ -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;
+5 -4
View File
@@ -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),