fix(discord): make thread parent inheritance opt-in

This commit is contained in:
Oliver Camp
2026-04-22 00:54:15 -04:00
committed by Peter Steinberger
parent 40e19cc9a1
commit 956cf9b6b2
9 changed files with 160 additions and 22 deletions

View File

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

View File

@@ -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.<id>.inboundWorker.runTimeoutMs.",

View File

@@ -25,12 +25,18 @@ async function createHandler(cfg: Config) {
function createOpenGuildConfig(
channels: Record<string, { allow: boolean; includeThreadStarter?: boolean }>,
extra: Partial<Config> = {},
discordOverrides: Partial<NonNullable<Config["channels"]>["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");
});
});

View File

@@ -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<ReturnType<typeof dispatchInboundMessage>> | null = null;
let dispatchResult: { queuedFinal: boolean; counts: { final: number } } | null = null;
let dispatchError = false;
let dispatchAborted = false;
try {

View File

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

View File

@@ -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<DiscordAutoThreadReplyPlan> {
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 };

View File

@@ -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.<id>.inboundWorker.runTimeoutMs.",

View File

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

View File

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