fix(discord): inherit thread model overrides without transcript fork

This commit is contained in:
Peter Steinberger
2026-04-27 12:40:25 +01:00
parent b056d594b4
commit 00d4099526
9 changed files with 86 additions and 12 deletions

View File

@@ -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.

View File

@@ -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.<id>.thread.inheritParent`.
- Message-tool reactions can resolve `user:<id>` DM targets.
- `guilds.<guild>.channels.<channel>.requireMention: false` is preserved during reply-stage activation fallback.

View File

@@ -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", () => {

View File

@@ -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,

View File

@@ -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,
);
}
}
});

View File

@@ -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 } : {}),
};
}

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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;