From 00d409952687d3cdb3da8235dc904f626b1751ce Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Apr 2026 12:40:25 +0100 Subject: [PATCH] fix(discord): inherit thread model overrides without transcript fork --- CHANGELOG.md | 1 + docs/channels/discord.md | 1 + .../monitor/message-handler.process.test.ts | 33 +++++++++++++++++++ .../src/monitor/message-handler.process.ts | 4 +++ .../monitor/monitor.threading-utils.test.ts | 26 +++++++++++++++ extensions/discord/src/monitor/threading.ts | 17 +++++----- src/auto-reply/reply/get-reply-directives.ts | 3 +- src/auto-reply/reply/get-reply.ts | 7 ++-- src/auto-reply/templating.ts | 6 ++++ 9 files changed, 86 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b4d423b7b02..8dd9dda5a95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ Docs: https://docs.openclaw.ai - Feishu/Windows: normalize bundled channel sidecar loads before Jiti evaluates them, so Feishu outbound sends no longer fail with raw `C:` ESM loader errors on Windows. Fixes #72783. Thanks @jackychen-png. - Agents/tools: ignore volatile `exec` runtime metadata when comparing tool-loop outcomes, so enabled loop detection can stop repeated identical shell-command results instead of resetting on duration, PID, session, or cwd changes. Fixes #34574; supersedes #41502. Thanks @gucasbrg and @Zcg2021. - Agents/fallback: classify internal live-session model switch conflicts as unknown fallback failures instead of provider overloads, preventing local vLLM endpoints from receiving misleading overloaded cooldowns. Refs #63229. Thanks @clawdia-lobster. +- Discord: let thread sessions inherit the parent channel's session-level `/model` override as a model-only fallback without enabling parent transcript inheritance. Fixes #72755. Thanks @solavrc. - Control UI: keep session-specific assistant identity loads authoritative after WebSocket connect, so non-main agent chat sessions do not show the main agent name in the header after bootstrap refreshes. Fixes #72776. Thanks @rockytian-top. - Agents/Qwen: preserve exact custom `modelstudio` provider configs with foreign `api` owners so explicit OpenAI-compatible Model Studio endpoints no longer get normalized into the bundled Qwen plugin path. Fixes #64483. Thanks @FiredMosquito831. - MCP/bundle-mcp: normalize CLI-native `type: "http"` MCP server entries to OpenClaw `transport: "streamable-http"` on save, repair existing configs with doctor, and keep embedded Pi from falling back to legacy SSE GET-first startup for those servers. Fixes #72757. Thanks @Studioscale. diff --git a/docs/channels/discord.md b/docs/channels/discord.md index 4a3bbd2e549..25b5c153c31 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -585,6 +585,7 @@ Default slash command settings: Thread behavior: - Discord threads route as channel sessions and inherit parent channel config unless overridden. + - Thread sessions inherit the parent channel's session-level `/model` selection as a model-only fallback; thread-local `/model` selections still take precedence and parent transcript history is not copied unless transcript inheritance is enabled. - `channels.discord.thread.inheritParent` (default `false`) opts new auto-threads into seeding from the parent transcript. Per-account overrides live under `channels.discord.accounts..thread.inheritParent`. - Message-tool reactions can resolve `user:` DM targets. - `guilds..channels..requireMention: false` is preserved during reply-stage activation fallback. diff --git a/extensions/discord/src/monitor/message-handler.process.test.ts b/extensions/discord/src/monitor/message-handler.process.test.ts index 38f62be1c52..d85aa262ee2 100644 --- a/extensions/discord/src/monitor/message-handler.process.test.ts +++ b/extensions/discord/src/monitor/message-handler.process.test.ts @@ -290,6 +290,8 @@ function getLastDispatchCtx(): CommandBody?: string; MediaTranscribedIndexes?: number[]; MessageThreadId?: string | number; + ModelParentSessionKey?: string; + ParentSessionKey?: string; SessionKey?: string; Transcript?: string; } @@ -302,6 +304,8 @@ function getLastDispatchCtx(): CommandBody?: string; MediaTranscribedIndexes?: number[]; MessageThreadId?: string | number; + ModelParentSessionKey?: string; + ParentSessionKey?: string; SessionKey?: string; Transcript?: string; }; @@ -788,6 +792,35 @@ describe("processDiscordMessage session routing", () => { accountId: "default", }); }); + + it("passes Discord thread parent only for model inheritance when transcript inheritance is off", async () => { + const ctx = await createBaseContext({ + baseSessionKey: "agent:main:discord:channel:thread-1", + route: { + ...BASE_CHANNEL_ROUTE, + sessionKey: "agent:main:discord:channel:thread-1", + }, + messageChannelId: "thread-1", + message: { + id: "m1", + channelId: "thread-1", + timestamp: new Date().toISOString(), + attachments: [], + }, + threadChannel: { id: "thread-1", name: "child-thread" }, + threadParentId: "parent-1", + discordConfig: { thread: { inheritParent: false } }, + }); + + await processDiscordMessage(ctx as any); + + expect(getLastDispatchCtx()).toMatchObject({ + SessionKey: "agent:main:discord:channel:thread-1", + MessageThreadId: "thread-1", + ModelParentSessionKey: "agent:main:discord:channel:parent-1", + }); + expect(getLastDispatchCtx()?.ParentSessionKey).toBeUndefined(); + }); }); describe("processDiscordMessage draft streaming", () => { diff --git a/extensions/discord/src/monitor/message-handler.process.ts b/extensions/discord/src/monitor/message-handler.process.ts index 868b10352bd..2ee52e2dbfc 100644 --- a/extensions/discord/src/monitor/message-handler.process.ts +++ b/extensions/discord/src/monitor/message-handler.process.ts @@ -384,6 +384,7 @@ export async function processDiscordMessage( let threadStarterBody: string | undefined; let threadLabel: string | undefined; let parentSessionKey: string | undefined; + let modelParentSessionKey: string | undefined; if (threadChannel) { const includeThreadStarter = channelConfig?.includeThreadStarter !== false; if (includeThreadStarter) { @@ -423,6 +424,7 @@ export async function processDiscordMessage( channel: route.channel, peer: { kind: "channel", id: threadParentId }, }); + modelParentSessionKey = parentSessionKey; } if (!threadParentInheritanceEnabled) { parentSessionKey = undefined; @@ -522,6 +524,8 @@ export async function processDiscordMessage( ReplyToBody: filteredReplyContext?.body, ReplyToSender: filteredReplyContext?.sender, ParentSessionKey: autoThreadContext?.ParentSessionKey ?? threadKeys.parentSessionKey, + ModelParentSessionKey: + autoThreadContext?.ModelParentSessionKey ?? modelParentSessionKey ?? undefined, MessageThreadId: threadChannel?.id ?? autoThreadContext?.createdThreadId ?? undefined, ThreadStarterBody: threadStarterBody, ThreadLabel: threadLabel, diff --git a/extensions/discord/src/monitor/monitor.threading-utils.test.ts b/extensions/discord/src/monitor/monitor.threading-utils.test.ts index 581fb3c0801..c211f77b7f3 100644 --- a/extensions/discord/src/monitor/monitor.threading-utils.test.ts +++ b/extensions/discord/src/monitor/monitor.threading-utils.test.ts @@ -292,6 +292,11 @@ describe("resolveDiscordAutoThreadContext", () => { createdThreadId: "thread", expectedNull: false, parentInheritanceEnabled: false, + expectedModelParentSessionKey: buildAgentSessionKey({ + agentId: "agent", + channel: "discord", + peer: { kind: "channel", id: "parent" }, + }), expectedParentSessionKey: undefined, }, { @@ -299,6 +304,11 @@ describe("resolveDiscordAutoThreadContext", () => { createdThreadId: "thread", expectedNull: false, parentInheritanceEnabled: true, + expectedModelParentSessionKey: buildAgentSessionKey({ + agentId: "agent", + channel: "discord", + peer: { kind: "channel", id: "parent" }, + }), expectedParentSessionKey: buildAgentSessionKey({ agentId: "agent", channel: "discord", @@ -333,6 +343,9 @@ describe("resolveDiscordAutoThreadContext", () => { }), ); expect(context?.ParentSessionKey, testCase.name).toBe(testCase.expectedParentSessionKey); + expect(context?.ModelParentSessionKey, testCase.name).toBe( + testCase.expectedModelParentSessionKey, + ); } }); }); @@ -511,6 +524,11 @@ describe("resolveDiscordAutoThreadReplyPlan", () => { channel: "discord", peer: { kind: "channel", id: "thread" }, }), + expectedModelParentSessionKey: buildAgentSessionKey({ + agentId: "agent", + channel: "discord", + peer: { kind: "channel", id: "parent" }, + }), expectedParentSessionKey: undefined, }, { @@ -525,6 +543,11 @@ describe("resolveDiscordAutoThreadReplyPlan", () => { channel: "discord", peer: { kind: "channel", id: "thread" }, }), + expectedModelParentSessionKey: buildAgentSessionKey({ + agentId: "agent", + channel: "discord", + peer: { kind: "channel", id: "parent" }, + }), expectedParentSessionKey: buildAgentSessionKey({ agentId: "agent", channel: "discord", @@ -566,6 +589,9 @@ describe("resolveDiscordAutoThreadReplyPlan", () => { expect(plan.autoThreadContext?.ParentSessionKey, testCase.name).toBe( testCase.expectedParentSessionKey, ); + expect(plan.autoThreadContext?.ModelParentSessionKey, testCase.name).toBe( + testCase.expectedModelParentSessionKey, + ); } } }); diff --git a/extensions/discord/src/monitor/threading.ts b/extensions/discord/src/monitor/threading.ts index 9aefe42ba16..e75da23e227 100644 --- a/extensions/discord/src/monitor/threading.ts +++ b/extensions/discord/src/monitor/threading.ts @@ -389,6 +389,7 @@ export type DiscordAutoThreadContext = { To: string; OriginatingTo: string; SessionKey: string; + ModelParentSessionKey?: string; ParentSessionKey?: string; }; @@ -413,14 +414,11 @@ export function resolveDiscordAutoThreadContext(params: { channel: params.channel, peer: { kind: "channel", id: createdThreadId }, }); - const parentSessionKey = - params.parentInheritanceEnabled === true - ? buildAgentSessionKey({ - agentId: params.agentId, - channel: params.channel, - peer: { kind: "channel", id: messageChannelId }, - }) - : undefined; + const parentSessionKey = buildAgentSessionKey({ + agentId: params.agentId, + channel: params.channel, + peer: { kind: "channel", id: messageChannelId }, + }); return { createdThreadId, @@ -428,7 +426,8 @@ export function resolveDiscordAutoThreadContext(params: { To: `channel:${createdThreadId}`, OriginatingTo: `channel:${createdThreadId}`, SessionKey: threadSessionKey, - ...(parentSessionKey ? { ParentSessionKey: parentSessionKey } : {}), + ModelParentSessionKey: parentSessionKey, + ...(params.parentInheritanceEnabled === true ? { ParentSessionKey: parentSessionKey } : {}), }; } diff --git a/src/auto-reply/reply/get-reply-directives.ts b/src/auto-reply/reply/get-reply-directives.ts index 1bbf2218d47..a02e1b6bc31 100644 --- a/src/auto-reply/reply/get-reply-directives.ts +++ b/src/auto-reply/reply/get-reply-directives.ts @@ -480,7 +480,8 @@ export async function resolveReplyDirectives(params: { sessionEntry: targetSessionEntry, sessionStore, sessionKey, - parentSessionKey: targetSessionEntry?.parentSessionKey ?? ctx.ParentSessionKey, + parentSessionKey: + targetSessionEntry?.parentSessionKey ?? ctx.ModelParentSessionKey ?? ctx.ParentSessionKey, storePath, defaultProvider, defaultModel, diff --git a/src/auto-reply/reply/get-reply.ts b/src/auto-reply/reply/get-reply.ts index 6f17e3cc654..0ee4fa3dea6 100644 --- a/src/auto-reply/reply/get-reply.ts +++ b/src/auto-reply/reply/get-reply.ts @@ -328,7 +328,7 @@ export async function getReplyFromConfig( groupChannel: sessionEntry.groupChannel ?? sessionCtx.GroupChannel ?? finalized.GroupChannel, groupSubject: sessionEntry.subject ?? sessionCtx.GroupSubject ?? finalized.GroupSubject, - parentSessionKey: sessionCtx.ParentSessionKey, + parentSessionKey: sessionCtx.ModelParentSessionKey ?? sessionCtx.ParentSessionKey, }) : null; const hasSessionModelOverride = Boolean( @@ -339,7 +339,10 @@ export async function getReplyFromConfig( sessionEntry, sessionStore, sessionKey, - parentSessionKey: sessionEntry.parentSessionKey ?? sessionCtx.ParentSessionKey, + parentSessionKey: + sessionEntry.parentSessionKey ?? + sessionCtx.ModelParentSessionKey ?? + sessionCtx.ParentSessionKey, defaultProvider, }); if (storedModelOverride?.model && !hasResolvedHeartbeatModelOverride) { diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index 92aec055046..996b3475774 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -70,6 +70,12 @@ export type MsgContext = { /** Provider account id (multi-account). */ AccountId?: string; ParentSessionKey?: string; + /** + * Session key used only for inheriting session-scoped model/provider + * overrides. Unlike ParentSessionKey, this must not trigger transcript + * forking or parent-session lifecycle behavior. + */ + ModelParentSessionKey?: string; MessageSid?: string; /** Provider-specific full message id when MessageSid is a shortened alias. */ MessageSidFull?: string;