mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 10:40:43 +00:00
fix(discord): make thread parent inheritance opt-in
This commit is contained in:
committed by
Peter Steinberger
parent
40e19cc9a1
commit
956cf9b6b2
@@ -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: {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user