refactor: streamline thread reply planning

This commit is contained in:
Peter Steinberger
2026-01-13 00:15:26 +00:00
parent 3636a2bf51
commit a4bd960880
2 changed files with 135 additions and 50 deletions

View File

@@ -366,6 +366,72 @@ export function sanitizeDiscordThreadName(
return truncateUtf16Safe(base, 100) || `Thread ${fallbackId}`;
}
type DiscordReplyDeliveryPlan = {
deliverTarget: string;
replyTarget: string;
replyReference: ReturnType<typeof createReplyReferencePlanner>;
};
async function maybeCreateDiscordAutoThread(params: {
client: Client;
message: DiscordMessageEvent["message"];
isGuildMessage: boolean;
channelConfig?: DiscordChannelConfigResolved;
threadChannel?: DiscordThreadChannel | null;
baseText: string;
combinedBody: string;
}): Promise<string | undefined> {
if (!params.isGuildMessage) return undefined;
if (!params.channelConfig?.autoThread) return undefined;
if (params.threadChannel) return undefined;
try {
const threadName = sanitizeDiscordThreadName(
params.baseText || params.combinedBody || "Thread",
params.message.id,
);
const created = (await params.client.rest.post(
`${Routes.channelMessage(params.message.channelId, params.message.id)}/threads`,
{
body: {
name: threadName,
auto_archive_duration: 60,
},
},
)) as { id?: string };
const createdId = created?.id ? String(created.id) : "";
return createdId || undefined;
} catch (err) {
logVerbose(
`discord: autoThread failed for ${params.message.channelId}/${params.message.id}: ${String(err)}`,
);
return undefined;
}
}
function resolveDiscordReplyDeliveryPlan(params: {
replyTarget: string;
replyToMode: ReplyToMode;
messageId: string;
threadChannel?: DiscordThreadChannel | null;
createdThreadId?: string | null;
}): DiscordReplyDeliveryPlan {
const originalReplyTarget = params.replyTarget;
let deliverTarget = originalReplyTarget;
let replyTarget = originalReplyTarget;
if (params.createdThreadId) {
deliverTarget = `channel:${params.createdThreadId}`;
replyTarget = deliverTarget;
}
const allowReference = deliverTarget === originalReplyTarget;
const replyReference = createReplyReferencePlanner({
replyToMode: allowReference ? params.replyToMode : "off",
existingId: params.threadChannel ? params.messageId : undefined,
startId: params.messageId,
allowReference,
});
return { deliverTarget, replyTarget, replyReference };
}
function summarizeAllowList(list?: Array<string | number>) {
if (!list || list.length === 0) return "any";
const sample = list.slice(0, 4).map((entry) => String(entry));
@@ -1205,45 +1271,25 @@ export function createDiscordMessageHandler(params: {
runtime.error?.(danger("discord: missing reply target"));
return;
}
const originalReplyTarget = replyTarget;
let deliverTarget = replyTarget;
if (isGuildMessage && channelConfig?.autoThread && !threadChannel) {
try {
const threadName = sanitizeDiscordThreadName(
baseText || combinedBody || "Thread",
message.id,
);
const created = (await client.rest.post(
`${Routes.channelMessage(message.channelId, message.id)}/threads`,
{
body: {
name: threadName,
auto_archive_duration: 60,
},
},
)) as { id?: string };
const createdId = created?.id ? String(created.id) : "";
if (createdId) {
deliverTarget = `channel:${createdId}`;
replyTarget = deliverTarget;
}
} catch (err) {
logVerbose(
`discord: autoThread failed for ${message.channelId}/${message.id}: ${String(err)}`,
);
}
}
const replyReference = createReplyReferencePlanner({
replyToMode:
deliverTarget !== originalReplyTarget ? "off" : replyToMode,
existingId: threadChannel ? message.id : undefined,
startId: message.id,
allowReference: deliverTarget === originalReplyTarget,
const createdThreadId = await maybeCreateDiscordAutoThread({
client,
message,
isGuildMessage,
channelConfig,
threadChannel,
baseText: baseText ?? "",
combinedBody,
});
const replyPlan = resolveDiscordReplyDeliveryPlan({
replyTarget,
replyToMode,
messageId: message.id,
threadChannel,
createdThreadId,
});
const deliverTarget = replyPlan.deliverTarget;
replyTarget = replyPlan.replyTarget;
const replyReference = replyPlan.replyReference;
if (isDirectMessage) {
const sessionCfg = cfg.session;

View File

@@ -1136,11 +1136,11 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
// Shared mutable ref for tracking if a reply was sent (used by both
// auto-reply path and tool path for "first" threading mode).
const hasRepliedRef = { value: false };
const replyReference = createReplyReferencePlanner({
const replyPlan = createSlackReplyDeliveryPlan({
replyToMode,
existingId: incomingThreadTs,
startId: messageTs,
hasReplied: hasRepliedRef.value,
incomingThreadTs,
messageTs,
hasRepliedRef,
});
const onReplyStart = async () => {
didSetStatus = true;
@@ -1153,11 +1153,11 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
let didSendReply = false;
const { dispatcher, replyOptions, markDispatchIdle } =
createReplyDispatcherWithTyping({
responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId)
.responsePrefix,
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId)
.responsePrefix,
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
deliver: async (payload) => {
const replyThreadTs = replyReference.use();
const replyThreadTs = replyPlan.nextThreadTs();
await deliverReplies({
replies: [payload],
target: replyTarget,
@@ -1168,8 +1168,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
replyThreadTs,
});
didSendReply = true;
replyReference.markSent();
hasRepliedRef.value = replyReference.hasReplied();
replyPlan.markSent();
},
onError: (err, info) => {
runtime.error?.(
@@ -2074,13 +2073,53 @@ export function resolveSlackThreadTs(params: {
messageTs: string | undefined;
hasReplied: boolean;
}): string | undefined {
const planner = createReplyReferencePlanner({
const planner = createSlackReplyReferencePlanner({
replyToMode: params.replyToMode,
incomingThreadTs: params.incomingThreadTs,
messageTs: params.messageTs,
hasReplied: params.hasReplied,
});
return planner.use();
}
type SlackReplyDeliveryPlan = {
nextThreadTs: () => string | undefined;
markSent: () => void;
};
function createSlackReplyReferencePlanner(params: {
replyToMode: "off" | "first" | "all";
incomingThreadTs: string | undefined;
messageTs: string | undefined;
hasReplied?: boolean;
}) {
return createReplyReferencePlanner({
replyToMode: params.replyToMode,
existingId: params.incomingThreadTs,
startId: params.messageTs,
hasReplied: params.hasReplied,
});
return planner.use();
}
function createSlackReplyDeliveryPlan(params: {
replyToMode: "off" | "first" | "all";
incomingThreadTs: string | undefined;
messageTs: string | undefined;
hasRepliedRef: { value: boolean };
}): SlackReplyDeliveryPlan {
const replyReference = createSlackReplyReferencePlanner({
replyToMode: params.replyToMode,
incomingThreadTs: params.incomingThreadTs,
messageTs: params.messageTs,
hasReplied: params.hasRepliedRef.value,
});
return {
nextThreadTs: () => replyReference.use(),
markSent: () => {
replyReference.markSent();
params.hasRepliedRef.value = replyReference.hasReplied();
},
};
}
async function deliverSlackSlashReplies(params: {