From 956cf9b6b2113d1abd3dcf7d67953e0718f2d29b Mon Sep 17 00:00:00 2001 From: Oliver Camp Date: Wed, 22 Apr 2026 00:54:15 -0400 Subject: [PATCH] fix(discord): make thread parent inheritance opt-in --- extensions/discord/src/config-schema.test.ts | 24 +++++++++ extensions/discord/src/config-ui-hints.ts | 4 ++ ...messages-mentionpatterns-match.e2e.test.ts | 41 ++++++++++++--- .../src/monitor/message-handler.process.ts | 7 ++- .../monitor/monitor.threading-utils.test.ts | 50 ++++++++++++++++--- extensions/discord/src/monitor/threading.ts | 20 +++++--- ...ndled-channel-config-metadata.generated.ts | 22 ++++++++ src/config/types.discord.ts | 7 +++ src/config/zod-schema.providers-core.ts | 7 +++ 9 files changed, 160 insertions(+), 22 deletions(-) diff --git a/extensions/discord/src/config-schema.test.ts b/extensions/discord/src/config-schema.test.ts index 9d3560b6062..74d2aa277ed 100644 --- a/extensions/discord/src/config-schema.test.ts +++ b/extensions/discord/src/config-schema.test.ts @@ -222,6 +222,30 @@ describe("discord config schema", () => { expect(res.success).toBe(true); }); + it("accepts thread.inheritParent at top-level and account scope", () => { + const cases = [ + { + thread: { + inheritParent: true, + }, + }, + { + accounts: { + work: { + thread: { + inheritParent: true, + }, + }, + }, + }, + ] as const; + + for (const config of cases) { + const res = DiscordConfigSchema.safeParse(config); + expect(res.success).toBe(true); + } + }); + it("rejects unknown fields under agentComponents", () => { const res = DiscordConfigSchema.safeParse({ agentComponents: { diff --git a/extensions/discord/src/config-ui-hints.ts b/extensions/discord/src/config-ui-hints.ts index e79ecbfbb18..b53cea3d6af 100644 --- a/extensions/discord/src/config-ui-hints.ts +++ b/extensions/discord/src/config-ui-hints.ts @@ -85,6 +85,10 @@ export const discordChannelConfigUiHints = { label: "Discord Max Lines Per Message", help: "Soft max line count per Discord message (default: 17).", }, + "thread.inheritParent": { + label: "Discord Thread Parent Inheritance", + help: "If true, Discord thread sessions inherit the parent channel transcript (default: false).", + }, "inboundWorker.runTimeoutMs": { label: "Discord Inbound Worker Timeout (ms)", help: "Optional queued Discord inbound worker timeout in ms. This is separate from Carbon listener timeouts; defaults to 1800000 and can be disabled with 0. Set per account via channels.discord.accounts..inboundWorker.runTimeoutMs.", diff --git a/extensions/discord/src/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.e2e.test.ts b/extensions/discord/src/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.e2e.test.ts index 73c411e5407..6ebe2cf7be2 100644 --- a/extensions/discord/src/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.e2e.test.ts +++ b/extensions/discord/src/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.e2e.test.ts @@ -25,12 +25,18 @@ async function createHandler(cfg: Config) { function createOpenGuildConfig( channels: Record, extra: Partial = {}, + discordOverrides: Partial["discord"]> = {}, ): Config { + const base = createMentionRequiredGuildConfig(); const cfg: Config = { - ...createMentionRequiredGuildConfig(), + ...base, ...extra, channels: { + ...base.channels, + ...extra.channels, discord: { + ...base.channels?.discord, + ...extra.channels?.discord, dm: { enabled: true, policy: "open" }, groupPolicy: "open", guilds: { @@ -39,6 +45,7 @@ function createOpenGuildConfig( channels, }, }, + ...discordOverrides, }, }, }; @@ -95,7 +102,7 @@ describe("discord tool result dispatch", () => { expect(getCapturedCtx()?.WasMentioned).toBe(true); }); - it("forks thread sessions and injects starter context", async () => { + it("isolates thread sessions by default and injects starter context", async () => { const getCapturedCtx = captureNextDispatchCtx<{ SessionKey?: string; ParentSessionKey?: string; @@ -117,7 +124,7 @@ describe("discord tool result dispatch", () => { await vi.waitFor(() => expect(dispatchMock).toHaveBeenCalledTimes(1)); const capturedCtx = getCapturedCtx(); expect(capturedCtx?.SessionKey).toBe("agent:main:discord:channel:t1"); - expect(capturedCtx?.ParentSessionKey).toBe("agent:main:discord:channel:p1"); + expect(capturedCtx?.ParentSessionKey).toBeUndefined(); expect(capturedCtx?.ThreadStarterBody).toContain("starter message"); expect(capturedCtx?.ThreadLabel).toContain("Discord thread #general"); }); @@ -172,12 +179,12 @@ describe("discord tool result dispatch", () => { await vi.waitFor(() => expect(dispatchMock).toHaveBeenCalledTimes(1)); const capturedCtx = getCapturedCtx(); expect(capturedCtx?.SessionKey).toBe("agent:main:discord:channel:t1"); - expect(capturedCtx?.ParentSessionKey).toBe("agent:main:discord:channel:forum-1"); + expect(capturedCtx?.ParentSessionKey).toBeUndefined(); expect(capturedCtx?.ThreadStarterBody).toContain("starter message"); expect(capturedCtx?.ThreadLabel).toContain("Discord thread #support"); }); - it("scopes thread sessions to the routed agent", async () => { + it("scopes isolated thread sessions to the routed agent", async () => { const getCapturedCtx = captureNextDispatchCtx<{ SessionKey?: string; ParentSessionKey?: string; @@ -195,6 +202,28 @@ describe("discord tool result dispatch", () => { await vi.waitFor(() => expect(dispatchMock).toHaveBeenCalledTimes(1)); const capturedCtx = getCapturedCtx(); expect(capturedCtx?.SessionKey).toBe("agent:support:discord:channel:t1"); - expect(capturedCtx?.ParentSessionKey).toBe("agent:support:discord:channel:p1"); + expect(capturedCtx?.ParentSessionKey).toBeUndefined(); + }); + + it("inherits parent thread sessions when thread.inheritParent is enabled", async () => { + const getCapturedCtx = captureNextDispatchCtx<{ + SessionKey?: string; + ParentSessionKey?: string; + }>(); + const cfg = createOpenGuildConfig( + { p1: { allow: true } }, + {}, + { thread: { inheritParent: true } }, + ); + + const handler = await createHandler(cfg); + const client = createThreadClient(); + + await handler(createThreadEvent("m8"), client); + + await vi.waitFor(() => expect(dispatchMock).toHaveBeenCalledTimes(1)); + const capturedCtx = getCapturedCtx(); + expect(capturedCtx?.SessionKey).toBe("agent:main:discord:channel:t1"); + expect(capturedCtx?.ParentSessionKey).toBe("agent:main:discord:channel:p1"); }); }); diff --git a/extensions/discord/src/monitor/message-handler.process.ts b/extensions/discord/src/monitor/message-handler.process.ts index 97d11e54362..6dfc7e3710b 100644 --- a/extensions/discord/src/monitor/message-handler.process.ts +++ b/extensions/discord/src/monitor/message-handler.process.ts @@ -275,6 +275,7 @@ export async function processDiscordMessage( const forumParentSlug = isForumParent && threadParentName ? normalizeDiscordSlug(threadParentName) : ""; const threadChannelId = threadChannel?.id; + const threadInheritParent = discordConfig.thread?.inheritParent ?? false; const isForumStarter = Boolean(threadChannelId && isForumParent && forumParentSlug) && message.id === threadChannelId; const forumContextLine = isForumStarter ? `[Forum parent: #${forumParentSlug}]` : null; @@ -410,6 +411,9 @@ export async function processDiscordMessage( peer: { kind: "channel", id: threadParentId }, }); } + if (!threadInheritParent) { + parentSessionKey = undefined; + } } const mediaPayload = buildDiscordMediaPayload(mediaList); const threadKeys = resolveThreadSessionKeys({ @@ -434,6 +438,7 @@ export async function processDiscordMessage( agentId: route.agentId, channel: route.channel, cfg, + threadInheritParent, }); const deliverTarget = replyPlan.deliverTarget; const replyTarget = replyPlan.replyTarget; @@ -899,7 +904,7 @@ export async function processDiscordMessage( }); const resolvedBlockStreamingEnabled = resolveChannelStreamingBlockEnabled(discordConfig); - let dispatchResult: Awaited> | null = null; + let dispatchResult: { queuedFinal: boolean; counts: { final: number } } | null = null; let dispatchError = false; let dispatchAborted = false; try { diff --git a/extensions/discord/src/monitor/monitor.threading-utils.test.ts b/extensions/discord/src/monitor/monitor.threading-utils.test.ts index 681ff668406..915bc488538 100644 --- a/extensions/discord/src/monitor/monitor.threading-utils.test.ts +++ b/extensions/discord/src/monitor/monitor.threading-utils.test.ts @@ -286,9 +286,22 @@ describe("resolveDiscordAutoThreadContext", () => { expectedNull: true, }, { - name: "created thread", + name: "created thread without parent inheritance", createdThreadId: "thread", expectedNull: false, + inheritParent: false, + expectedParentSessionKey: undefined, + }, + { + name: "created thread with parent inheritance", + createdThreadId: "thread", + expectedNull: false, + inheritParent: true, + expectedParentSessionKey: buildAgentSessionKey({ + agentId: "agent", + channel: "discord", + peer: { kind: "channel", id: "parent" }, + }), }, ] as const; @@ -298,6 +311,7 @@ describe("resolveDiscordAutoThreadContext", () => { channel: "discord", messageChannelId: "parent", createdThreadId: testCase.createdThreadId, + inheritParent: testCase.inheritParent, }); if (testCase.expectedNull) { @@ -316,13 +330,7 @@ describe("resolveDiscordAutoThreadContext", () => { peer: { kind: "channel", id: "thread" }, }), ); - expect(context?.ParentSessionKey, testCase.name).toBe( - buildAgentSessionKey({ - agentId: "agent", - channel: "discord", - peer: { kind: "channel", id: "parent" }, - }), - ); + expect(context?.ParentSessionKey, testCase.name).toBe(testCase.expectedParentSessionKey); } }); }); @@ -463,6 +471,7 @@ describe("resolveDiscordAutoThreadReplyPlan", () => { client?: Client; channelConfig?: DiscordChannelConfigResolved; threadChannel?: { id: string } | null; + threadInheritParent?: boolean; }) { return { client: @@ -482,6 +491,7 @@ describe("resolveDiscordAutoThreadReplyPlan", () => { replyToMode: "all" as const, agentId: "agent", channel: "discord" as const, + threadInheritParent: overrides?.threadInheritParent, }; } @@ -497,6 +507,25 @@ describe("resolveDiscordAutoThreadReplyPlan", () => { channel: "discord", peer: { kind: "channel", id: "thread" }, }), + expectedParentSessionKey: undefined, + }, + { + name: "created thread with parent inheritance", + params: { + threadInheritParent: true, + }, + expectedDeliverTarget: "channel:thread", + expectedReplyReference: undefined, + expectedSessionKey: buildAgentSessionKey({ + agentId: "agent", + channel: "discord", + peer: { kind: "channel", id: "thread" }, + }), + expectedParentSessionKey: buildAgentSessionKey({ + agentId: "agent", + channel: "discord", + peer: { kind: "channel", id: "parent" }, + }), }, { name: "existing thread channel", @@ -506,6 +535,7 @@ describe("resolveDiscordAutoThreadReplyPlan", () => { expectedDeliverTarget: "channel:thread", expectedReplyReference: "m1", expectedSessionKey: null, + expectedParentSessionKey: undefined, }, { name: "autoThread disabled", @@ -515,6 +545,7 @@ describe("resolveDiscordAutoThreadReplyPlan", () => { expectedDeliverTarget: "channel:parent", expectedReplyReference: "m1", expectedSessionKey: null, + expectedParentSessionKey: undefined, }, ] as const; @@ -528,6 +559,9 @@ describe("resolveDiscordAutoThreadReplyPlan", () => { expect(plan.autoThreadContext, testCase.name).toBeNull(); } else { expect(plan.autoThreadContext?.SessionKey, testCase.name).toBe(testCase.expectedSessionKey); + expect(plan.autoThreadContext?.ParentSessionKey, testCase.name).toBe( + testCase.expectedParentSessionKey, + ); } } }); diff --git a/extensions/discord/src/monitor/threading.ts b/extensions/discord/src/monitor/threading.ts index b57fabc07a1..da2752e1c24 100644 --- a/extensions/discord/src/monitor/threading.ts +++ b/extensions/discord/src/monitor/threading.ts @@ -380,7 +380,7 @@ export type DiscordAutoThreadContext = { To: string; OriginatingTo: string; SessionKey: string; - ParentSessionKey: string; + ParentSessionKey?: string; }; export function resolveDiscordAutoThreadContext(params: { @@ -388,6 +388,7 @@ export function resolveDiscordAutoThreadContext(params: { channel: string; messageChannelId: string; createdThreadId?: string | null; + inheritParent?: boolean; }): DiscordAutoThreadContext | null { const createdThreadId = normalizeOptionalStringifiedId(params.createdThreadId) ?? ""; if (!createdThreadId) { @@ -403,11 +404,14 @@ export function resolveDiscordAutoThreadContext(params: { channel: params.channel, peer: { kind: "channel", id: createdThreadId }, }); - const parentSessionKey = buildAgentSessionKey({ - agentId: params.agentId, - channel: params.channel, - peer: { kind: "channel", id: messageChannelId }, - }); + const parentSessionKey = + params.inheritParent === true + ? buildAgentSessionKey({ + agentId: params.agentId, + channel: params.channel, + peer: { kind: "channel", id: messageChannelId }, + }) + : undefined; return { createdThreadId, @@ -415,7 +419,7 @@ export function resolveDiscordAutoThreadContext(params: { To: `channel:${createdThreadId}`, OriginatingTo: `channel:${createdThreadId}`, SessionKey: threadSessionKey, - ParentSessionKey: parentSessionKey, + ...(parentSessionKey ? { ParentSessionKey: parentSessionKey } : {}), }; } @@ -447,6 +451,7 @@ export async function resolveDiscordAutoThreadReplyPlan( agentId: string; channel: string; cfg?: OpenClawConfig; + threadInheritParent?: boolean; }, ): Promise { const messageChannelId = resolveTrimmedDiscordMessageChannelId(params); @@ -482,6 +487,7 @@ export async function resolveDiscordAutoThreadReplyPlan( channel: params.channel, messageChannelId, createdThreadId, + inheritParent: params.threadInheritParent, }) : null; return { ...deliveryPlan, createdThreadId, autoThreadContext }; diff --git a/src/config/bundled-channel-config-metadata.generated.ts b/src/config/bundled-channel-config-metadata.generated.ts index c50a82e7eb7..86b25a9b91e 100644 --- a/src/config/bundled-channel-config-metadata.generated.ts +++ b/src/config/bundled-channel-config-metadata.generated.ts @@ -1047,6 +1047,15 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ }, ], }, + thread: { + type: "object", + properties: { + inheritParent: { + type: "boolean", + }, + }, + additionalProperties: false, + }, dmPolicy: { type: "string", enum: ["pairing", "allowlist", "open", "disabled"], @@ -2211,6 +2220,15 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ }, ], }, + thread: { + type: "object", + properties: { + inheritParent: { + type: "boolean", + }, + }, + additionalProperties: false, + }, dmPolicy: { type: "string", enum: ["pairing", "allowlist", "open", "disabled"], @@ -3083,6 +3101,10 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ label: "Discord Max Lines Per Message", help: "Soft max line count per Discord message (default: 17).", }, + "thread.inheritParent": { + label: "Discord Thread Parent Inheritance", + help: "If true, Discord thread sessions inherit the parent channel transcript (default: false).", + }, "inboundWorker.runTimeoutMs": { label: "Discord Inbound Worker Timeout (ms)", help: "Optional queued Discord inbound worker timeout in ms. This is separate from Carbon listener timeouts; defaults to 1800000 and can be disabled with 0. Set per account via channels.discord.accounts..inboundWorker.runTimeoutMs.", diff --git a/src/config/types.discord.ts b/src/config/types.discord.ts index 6e4b658fffd..e096cb02c5b 100644 --- a/src/config/types.discord.ts +++ b/src/config/types.discord.ts @@ -203,6 +203,11 @@ export type DiscordSlashCommandConfig = { ephemeral?: boolean; }; +export type DiscordThreadConfig = { + /** If true, Discord thread sessions inherit the parent channel transcript. Default: false. */ + inheritParent?: boolean; +}; + export type DiscordAutoPresenceConfig = { /** Enable automatic runtime/quota-based Discord presence updates. Default: false. */ enabled?: boolean; @@ -272,6 +277,8 @@ export type DiscordAccountConfig = { actions?: DiscordActionConfig; /** Control reply threading when reply tags are present (off|first|all|batched). */ replyToMode?: ReplyToMode; + /** Thread session behavior. */ + thread?: DiscordThreadConfig; /** * Alias for dm.policy (prefer this so it inherits cleanly via base->account shallow merge). * Legacy key: channels.discord.dm.policy. diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 6664d0dfed8..e3d8fd8345c 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -439,6 +439,12 @@ export const DiscordDmSchema = z }) .strict(); +export const DiscordThreadSchema = z + .object({ + inheritParent: z.boolean().optional(), + }) + .strict(); + export const DiscordGuildChannelSchema = z .object({ requireMention: z.boolean().optional(), @@ -558,6 +564,7 @@ export const DiscordAccountSchema = z .strict() .optional(), replyToMode: ReplyToModeSchema.optional(), + thread: DiscordThreadSchema.optional(), // Aliases for channels.discord.dm.policy / channels.discord.dm.allowFrom. Prefer these for // inheritance in multi-account setups (shallow merge works; nested dm object doesn't). dmPolicy: DmPolicySchema.optional(),