refactor(discord): centralize autoThread reply plan (#856)

This commit is contained in:
Peter Steinberger
2026-01-14 23:07:01 +00:00
parent 4ec2222fa9
commit a70937c926
3 changed files with 142 additions and 22 deletions

View File

@@ -25,9 +25,7 @@ import {
import { buildDirectLabel, buildGuildLabel, resolveReplyContext } from "./reply-context.js"; import { buildDirectLabel, buildGuildLabel, resolveReplyContext } from "./reply-context.js";
import { deliverDiscordReply } from "./reply-delivery.js"; import { deliverDiscordReply } from "./reply-delivery.js";
import { import {
maybeCreateDiscordAutoThread, resolveDiscordAutoThreadReplyPlan,
resolveDiscordAutoThreadContext,
resolveDiscordReplyDeliveryPlan,
resolveDiscordThreadStarter, resolveDiscordThreadStarter,
} from "./threading.js"; } from "./threading.js";
import { sendTyping } from "./typing.js"; import { sendTyping } from "./typing.js";
@@ -201,35 +199,22 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
parentSessionKey, parentSessionKey,
useSuffix: false, useSuffix: false,
}); });
const inboundTarget = `channel:${message.channelId}`; const replyPlan = await resolveDiscordAutoThreadReplyPlan({
const createdThreadId = await maybeCreateDiscordAutoThread({
client, client,
message, message,
isGuildMessage, isGuildMessage,
channelConfig, channelConfig,
threadChannel, threadChannel,
baseText: baseText ?? "", baseText: baseText ?? "",
combinedBody, combinedBody,
});
const replyPlan = resolveDiscordReplyDeliveryPlan({
replyTarget: inboundTarget,
replyToMode, replyToMode,
messageId: message.id, agentId: route.agentId,
threadChannel, channel: route.channel,
createdThreadId,
}); });
const deliverTarget = replyPlan.deliverTarget; const deliverTarget = replyPlan.deliverTarget;
const replyTarget = replyPlan.replyTarget; const replyTarget = replyPlan.replyTarget;
const replyReference = replyPlan.replyReference; const replyReference = replyPlan.replyReference;
const autoThreadContext = replyPlan.autoThreadContext;
const autoThreadContext = isGuildMessage
? resolveDiscordAutoThreadContext({
agentId: route.agentId,
channel: route.channel,
messageChannelId: message.channelId,
createdThreadId,
})
: null;
const effectiveFrom = isDirectMessage const effectiveFrom = isDirectMessage
? `discord:${author.id}` ? `discord:${author.id}`

View File

@@ -1,6 +1,11 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { buildAgentSessionKey } from "../../routing/resolve-route.js"; 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", () => { describe("resolveDiscordAutoThreadContext", () => {
it("returns null when no createdThreadId", () => { 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();
});
});

View File

@@ -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<DiscordAutoThreadReplyPlan> {
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: { export async function maybeCreateDiscordAutoThread(params: {
client: Client; client: Client;
message: DiscordMessageEvent["message"]; message: DiscordMessageEvent["message"];