refactor(discord): clean autoThread context wiring (#856)

Build reply/session context once (no post-hoc ctx mutation) and type into the actual delivery target.

Thanks @davidguttman.

Co-authored-by: David Guttman <david@davidguttman.com>
This commit is contained in:
Peter Steinberger
2026-01-14 20:04:07 +00:00
parent e943e63174
commit 0235eb6c72
3 changed files with 181 additions and 106 deletions

View File

@@ -26,6 +26,7 @@ import { buildDirectLabel, buildGuildLabel, resolveReplyContext } from "./reply-
import { deliverDiscordReply } from "./reply-delivery.js"; import { deliverDiscordReply } from "./reply-delivery.js";
import { import {
maybeCreateDiscordAutoThread, maybeCreateDiscordAutoThread,
resolveDiscordAutoThreadContext,
resolveDiscordReplyDeliveryPlan, resolveDiscordReplyDeliveryPlan,
resolveDiscordThreadStarter, resolveDiscordThreadStarter,
} from "./threading.js"; } from "./threading.js";
@@ -192,100 +193,85 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
peer: { kind: "channel", id: threadParentId }, peer: { kind: "channel", id: threadParentId },
}); });
} }
} }
const mediaPayload = buildDiscordMediaPayload(mediaList); const mediaPayload = buildDiscordMediaPayload(mediaList);
const discordTo = `channel:${message.channelId}`; const threadKeys = resolveThreadSessionKeys({
const threadKeys = resolveThreadSessionKeys({ baseSessionKey,
baseSessionKey, threadId: threadChannel ? message.channelId : undefined,
threadId: threadChannel ? message.channelId : undefined, parentSessionKey,
parentSessionKey, useSuffix: false,
useSuffix: false, });
}); const inboundTarget = `channel:${message.channelId}`;
let ctxPayload = { const createdThreadId = await maybeCreateDiscordAutoThread({
Body: combinedBody, client,
RawBody: baseText, message,
CommandBody: baseText, isGuildMessage,
From: isDirectMessage ? `discord:${author.id}` : `group:${message.channelId}`, channelConfig,
To: discordTo,
SessionKey: threadKeys.sessionKey,
AccountId: route.accountId,
ChatType: isDirectMessage ? "direct" : "group",
SenderName: data.member?.nickname ?? author.globalName ?? author.username,
SenderId: author.id,
SenderUsername: author.username,
SenderTag: formatDiscordUserTag(author),
GroupSubject: groupSubject,
GroupRoom: groupRoom,
GroupSystemPrompt: isGuildMessage ? groupSystemPrompt : undefined,
GroupSpace: isGuildMessage ? (guildInfo?.id ?? guildSlug) || undefined : undefined,
Provider: "discord" as const,
Surface: "discord" as const,
WasMentioned: effectiveWasMentioned,
MessageSid: message.id,
ParentSessionKey: threadKeys.parentSessionKey,
ThreadStarterBody: threadStarterBody,
ThreadLabel: threadLabel,
Timestamp: resolveTimestampMs(message.timestamp),
...mediaPayload,
CommandAuthorized: commandAuthorized,
CommandSource: "text" as const,
// Originating channel for reply routing.
OriginatingChannel: "discord" as const,
OriginatingTo: discordTo,
};
let replyTarget = ctxPayload.To ?? undefined;
if (!replyTarget) {
runtime.error?.(danger("discord: missing reply target"));
return;
}
const createdThreadId = await maybeCreateDiscordAutoThread({
client,
message,
isGuildMessage,
channelConfig,
threadChannel, threadChannel,
baseText: baseText ?? "", baseText: baseText ?? "",
combinedBody, combinedBody,
}); });
const replyPlan = resolveDiscordReplyDeliveryPlan({ const replyPlan = resolveDiscordReplyDeliveryPlan({
replyTarget, replyTarget: inboundTarget,
replyToMode, replyToMode,
messageId: message.id, messageId: message.id,
threadChannel, threadChannel,
createdThreadId, createdThreadId,
}); });
const deliverTarget = replyPlan.deliverTarget; const deliverTarget = replyPlan.deliverTarget;
replyTarget = replyPlan.replyTarget; const replyTarget = replyPlan.replyTarget;
const replyReference = replyPlan.replyReference; const replyReference = replyPlan.replyReference;
// If autoThread created a new thread, ensure we also isolate session context to that thread. const autoThreadContext = isGuildMessage
if (createdThreadId && replyTarget === `channel:${createdThreadId}`) { ? resolveDiscordAutoThreadContext({
const threadSessionKey = buildAgentSessionKey({ agentId: route.agentId,
agentId: route.agentId, channel: route.channel,
channel: route.channel, messageChannelId: message.channelId,
peer: { kind: "channel", id: createdThreadId }, createdThreadId,
}); })
const autoParentSessionKey = buildAgentSessionKey({ : null;
agentId: route.agentId,
channel: route.channel,
peer: { kind: "channel", id: message.channelId },
});
const autoThreadKeys = resolveThreadSessionKeys({
baseSessionKey: threadSessionKey,
threadId: createdThreadId,
parentSessionKey: autoParentSessionKey,
useSuffix: false,
});
ctxPayload = { const effectiveFrom = isDirectMessage
...ctxPayload, ? `discord:${author.id}`
From: `group:${createdThreadId}`, : (autoThreadContext?.From ?? `group:${message.channelId}`);
To: `channel:${createdThreadId}`, const effectiveTo = autoThreadContext?.To ?? replyTarget;
OriginatingTo: `channel:${createdThreadId}`, if (!effectiveTo) {
SessionKey: autoThreadKeys.sessionKey, runtime.error?.(danger("discord: missing reply target"));
ParentSessionKey: autoThreadKeys.parentSessionKey, return;
}; }
}
const ctxPayload = {
Body: combinedBody,
RawBody: baseText,
CommandBody: baseText,
From: effectiveFrom,
To: effectiveTo,
SessionKey: autoThreadContext?.SessionKey ?? threadKeys.sessionKey,
AccountId: route.accountId,
ChatType: isDirectMessage ? "direct" : "group",
SenderName: data.member?.nickname ?? author.globalName ?? author.username,
SenderId: author.id,
SenderUsername: author.username,
SenderTag: formatDiscordUserTag(author),
GroupSubject: groupSubject,
GroupRoom: groupRoom,
GroupSystemPrompt: isGuildMessage ? groupSystemPrompt : undefined,
GroupSpace: isGuildMessage ? (guildInfo?.id ?? guildSlug) || undefined : undefined,
Provider: "discord" as const,
Surface: "discord" as const,
WasMentioned: effectiveWasMentioned,
MessageSid: message.id,
ParentSessionKey: autoThreadContext?.ParentSessionKey ?? threadKeys.parentSessionKey,
ThreadStarterBody: threadStarterBody,
ThreadLabel: threadLabel,
Timestamp: resolveTimestampMs(message.timestamp),
...mediaPayload,
CommandAuthorized: commandAuthorized,
CommandSource: "text" as const,
// Originating channel for reply routing.
OriginatingChannel: "discord" as const,
OriginatingTo: autoThreadContext?.OriginatingTo ?? replyTarget,
};
if (isDirectMessage) { if (isDirectMessage) {
const sessionCfg = cfg.session; const sessionCfg = cfg.session;
@@ -301,17 +287,20 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
}); });
} }
if (shouldLogVerbose()) { if (shouldLogVerbose()) {
const preview = truncateUtf16Safe(combinedBody, 200).replace(/\n/g, "\\n"); const preview = truncateUtf16Safe(combinedBody, 200).replace(/\n/g, "\\n");
logVerbose( logVerbose(
`discord inbound: channel=${message.channelId} from=${ctxPayload.From} preview="${preview}"`, `discord inbound: channel=${message.channelId} deliver=${deliverTarget} from=${ctxPayload.From} preview="${preview}"`,
); );
} }
let didSendReply = false; let didSendReply = false;
const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({ const typingChannelId = deliverTarget.startsWith("channel:")
responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix, ? deliverTarget.slice("channel:".length)
humanDelay: resolveHumanDelayConfig(cfg, route.agentId), : message.channelId;
const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({
responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix,
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
deliver: async (payload: ReplyPayload) => { deliver: async (payload: ReplyPayload) => {
const replyToId = replyReference.use(); const replyToId = replyReference.use();
await deliverDiscordReply({ await deliverDiscordReply({
@@ -328,11 +317,11 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
didSendReply = true; didSendReply = true;
replyReference.markSent(); replyReference.markSent();
}, },
onError: (err, info) => { onError: (err, info) => {
runtime.error?.(danger(`discord ${info.kind} reply failed: ${String(err)}`)); runtime.error?.(danger(`discord ${info.kind} reply failed: ${String(err)}`));
}, },
onReplyStart: () => sendTyping({ client, channelId: message.channelId }), onReplyStart: () => sendTyping({ client, channelId: typingChannelId }),
}); });
const { queuedFinal, counts } = await dispatchReplyFromConfig({ const { queuedFinal, counts } = await dispatchReplyFromConfig({
ctx: ctxPayload, ctx: ctxPayload,

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { buildAgentSessionKey } from "../../routing/resolve-route.js";
import { resolveDiscordAutoThreadContext } from "./threading.js";
describe("resolveDiscordAutoThreadContext", () => {
it("returns null when no createdThreadId", () => {
expect(
resolveDiscordAutoThreadContext({
agentId: "agent",
channel: "discord",
messageChannelId: "parent",
createdThreadId: undefined,
}),
).toBeNull();
});
it("re-keys session context to the created thread", () => {
const context = resolveDiscordAutoThreadContext({
agentId: "agent",
channel: "discord",
messageChannelId: "parent",
createdThreadId: "thread",
});
expect(context).not.toBeNull();
expect(context?.To).toBe("channel:thread");
expect(context?.From).toBe("group:thread");
expect(context?.OriginatingTo).toBe("channel:thread");
expect(context?.SessionKey).toBe(
buildAgentSessionKey({
agentId: "agent",
channel: "discord",
peer: { kind: "channel", id: "thread" },
}),
);
expect(context?.ParentSessionKey).toBe(
buildAgentSessionKey({
agentId: "agent",
channel: "discord",
peer: { kind: "channel", id: "parent" },
}),
);
});
});

View File

@@ -3,6 +3,7 @@ import { Routes } from "discord-api-types/v10";
import { createReplyReferencePlanner } from "../../auto-reply/reply/reply-reference.js"; import { createReplyReferencePlanner } from "../../auto-reply/reply/reply-reference.js";
import type { ReplyToMode } from "../../config/config.js"; import type { ReplyToMode } from "../../config/config.js";
import { logVerbose } from "../../globals.js"; import { logVerbose } from "../../globals.js";
import { buildAgentSessionKey } from "../../routing/resolve-route.js";
import { truncateUtf16Safe } from "../../utils.js"; import { truncateUtf16Safe } from "../../utils.js";
import type { DiscordChannelConfigResolved } from "./allow-list.js"; import type { DiscordChannelConfigResolved } from "./allow-list.js";
import type { DiscordMessageEvent } from "./listeners.js"; import type { DiscordMessageEvent } from "./listeners.js";
@@ -160,6 +161,47 @@ type DiscordReplyDeliveryPlan = {
replyReference: ReturnType<typeof createReplyReferencePlanner>; replyReference: ReturnType<typeof createReplyReferencePlanner>;
}; };
export type DiscordAutoThreadContext = {
createdThreadId: string;
From: string;
To: string;
OriginatingTo: string;
SessionKey: string;
ParentSessionKey: string;
};
export function resolveDiscordAutoThreadContext(params: {
agentId: string;
channel: string;
messageChannelId: string;
createdThreadId?: string | null;
}): DiscordAutoThreadContext | null {
const createdThreadId = String(params.createdThreadId ?? "").trim();
if (!createdThreadId) return null;
const messageChannelId = params.messageChannelId.trim();
if (!messageChannelId) return null;
const threadSessionKey = buildAgentSessionKey({
agentId: params.agentId,
channel: params.channel,
peer: { kind: "channel", id: createdThreadId },
});
const parentSessionKey = buildAgentSessionKey({
agentId: params.agentId,
channel: params.channel,
peer: { kind: "channel", id: messageChannelId },
});
return {
createdThreadId,
From: `group:${createdThreadId}`,
To: `channel:${createdThreadId}`,
OriginatingTo: `channel:${createdThreadId}`,
SessionKey: threadSessionKey,
ParentSessionKey: parentSessionKey,
};
}
export async function maybeCreateDiscordAutoThread(params: { export async function maybeCreateDiscordAutoThread(params: {
client: Client; client: Client;
message: DiscordMessageEvent["message"]; message: DiscordMessageEvent["message"];