diff --git a/src/discord/monitor/message-handler.process.ts b/src/discord/monitor/message-handler.process.ts index eef07acd9..cff059f9f 100644 --- a/src/discord/monitor/message-handler.process.ts +++ b/src/discord/monitor/message-handler.process.ts @@ -25,9 +25,7 @@ import { import { buildDirectLabel, buildGuildLabel, resolveReplyContext } from "./reply-context.js"; import { deliverDiscordReply } from "./reply-delivery.js"; import { - maybeCreateDiscordAutoThread, - resolveDiscordAutoThreadContext, - resolveDiscordReplyDeliveryPlan, + resolveDiscordAutoThreadReplyPlan, resolveDiscordThreadStarter, } from "./threading.js"; import { sendTyping } from "./typing.js"; @@ -201,35 +199,22 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) parentSessionKey, useSuffix: false, }); - const inboundTarget = `channel:${message.channelId}`; - const createdThreadId = await maybeCreateDiscordAutoThread({ + const replyPlan = await resolveDiscordAutoThreadReplyPlan({ client, message, isGuildMessage, channelConfig, - threadChannel, + threadChannel, baseText: baseText ?? "", combinedBody, - }); - const replyPlan = resolveDiscordReplyDeliveryPlan({ - replyTarget: inboundTarget, replyToMode, - messageId: message.id, - threadChannel, - createdThreadId, + agentId: route.agentId, + channel: route.channel, }); const deliverTarget = replyPlan.deliverTarget; const replyTarget = replyPlan.replyTarget; const replyReference = replyPlan.replyReference; - - const autoThreadContext = isGuildMessage - ? resolveDiscordAutoThreadContext({ - agentId: route.agentId, - channel: route.channel, - messageChannelId: message.channelId, - createdThreadId, - }) - : null; + const autoThreadContext = replyPlan.autoThreadContext; const effectiveFrom = isDirectMessage ? `discord:${author.id}` diff --git a/src/discord/monitor/threading.test.ts b/src/discord/monitor/threading.test.ts index 35e8de72e..2f31c1547 100644 --- a/src/discord/monitor/threading.test.ts +++ b/src/discord/monitor/threading.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vitest"; import { buildAgentSessionKey } from "../../routing/resolve-route.js"; -import { resolveDiscordAutoThreadContext } from "./threading.js"; +import type { Client } from "@buape/carbon"; +import { + resolveDiscordAutoThreadContext, + resolveDiscordAutoThreadReplyPlan, + resolveDiscordReplyDeliveryPlan, +} from "./threading.js"; describe("resolveDiscordAutoThreadContext", () => { it("returns null when no createdThreadId", () => { @@ -42,3 +47,88 @@ describe("resolveDiscordAutoThreadContext", () => { }); }); +describe("resolveDiscordReplyDeliveryPlan", () => { + it("uses reply references when posting to the original target", () => { + const plan = resolveDiscordReplyDeliveryPlan({ + replyTarget: "channel:parent", + replyToMode: "all", + messageId: "m1", + threadChannel: null, + createdThreadId: null, + }); + expect(plan.deliverTarget).toBe("channel:parent"); + expect(plan.replyTarget).toBe("channel:parent"); + expect(plan.replyReference.use()).toBe("m1"); + }); + + it("disables reply references when autoThread creates a new thread", () => { + const plan = resolveDiscordReplyDeliveryPlan({ + replyTarget: "channel:parent", + replyToMode: "all", + messageId: "m1", + threadChannel: null, + createdThreadId: "thread", + }); + expect(plan.deliverTarget).toBe("channel:thread"); + expect(plan.replyTarget).toBe("channel:thread"); + expect(plan.replyReference.use()).toBeUndefined(); + }); + + it("always uses existingId when inside a thread", () => { + const plan = resolveDiscordReplyDeliveryPlan({ + replyTarget: "channel:thread", + replyToMode: "off", + messageId: "m1", + threadChannel: { id: "thread" }, + createdThreadId: null, + }); + expect(plan.replyReference.use()).toBe("m1"); + }); +}); + +describe("resolveDiscordAutoThreadReplyPlan", () => { + it("switches delivery + session context to the created thread", async () => { + const client = { + rest: { post: async () => ({ id: "thread" }) }, + } as unknown as Client; + const plan = await resolveDiscordAutoThreadReplyPlan({ + client, + message: { id: "m1", channelId: "parent" } as unknown as import("./listeners.js").DiscordMessageEvent["message"], + isGuildMessage: true, + channelConfig: { autoThread: true } as unknown as import("./allow-list.js").DiscordChannelConfigResolved, + threadChannel: null, + baseText: "hello", + combinedBody: "hello", + replyToMode: "all", + agentId: "agent", + channel: "discord", + }); + expect(plan.deliverTarget).toBe("channel:thread"); + expect(plan.replyReference.use()).toBeUndefined(); + expect(plan.autoThreadContext?.SessionKey).toBe( + buildAgentSessionKey({ + agentId: "agent", + channel: "discord", + peer: { kind: "channel", id: "thread" }, + }), + ); + }); + + it("does nothing when autoThread is disabled", async () => { + const client = { rest: { post: async () => ({ id: "thread" }) } } as unknown as Client; + const plan = await resolveDiscordAutoThreadReplyPlan({ + client, + message: { id: "m1", channelId: "parent" } as unknown as import("./listeners.js").DiscordMessageEvent["message"], + isGuildMessage: true, + channelConfig: { autoThread: false } as unknown as import("./allow-list.js").DiscordChannelConfigResolved, + threadChannel: null, + baseText: "hello", + combinedBody: "hello", + replyToMode: "all", + agentId: "agent", + channel: "discord", + }); + expect(plan.deliverTarget).toBe("channel:parent"); + expect(plan.autoThreadContext).toBeNull(); + }); +}); diff --git a/src/discord/monitor/threading.ts b/src/discord/monitor/threading.ts index f7d9f7de8..ecb355d37 100644 --- a/src/discord/monitor/threading.ts +++ b/src/discord/monitor/threading.ts @@ -202,6 +202,51 @@ export function resolveDiscordAutoThreadContext(params: { }; } +export type DiscordAutoThreadReplyPlan = DiscordReplyDeliveryPlan & { + createdThreadId?: string; + autoThreadContext: DiscordAutoThreadContext | null; +}; + +export async function resolveDiscordAutoThreadReplyPlan(params: { + client: Client; + message: DiscordMessageEvent["message"]; + isGuildMessage: boolean; + channelConfig?: DiscordChannelConfigResolved | null; + threadChannel?: DiscordThreadChannel | null; + baseText: string; + combinedBody: string; + replyToMode: ReplyToMode; + agentId: string; + channel: string; +}): Promise { + const originalReplyTarget = `channel:${params.message.channelId}`; + const createdThreadId = await maybeCreateDiscordAutoThread({ + client: params.client, + message: params.message, + isGuildMessage: params.isGuildMessage, + channelConfig: params.channelConfig, + threadChannel: params.threadChannel, + baseText: params.baseText, + combinedBody: params.combinedBody, + }); + const deliveryPlan = resolveDiscordReplyDeliveryPlan({ + replyTarget: originalReplyTarget, + replyToMode: params.replyToMode, + messageId: params.message.id, + threadChannel: params.threadChannel, + createdThreadId, + }); + const autoThreadContext = params.isGuildMessage + ? resolveDiscordAutoThreadContext({ + agentId: params.agentId, + channel: params.channel, + messageChannelId: params.message.channelId, + createdThreadId, + }) + : null; + return { ...deliveryPlan, createdThreadId, autoThreadContext }; +} + export async function maybeCreateDiscordAutoThread(params: { client: Client; message: DiscordMessageEvent["message"];