Skip to content

Commit 64f2dc4

Browse files
committed
fix(discord): harden reply delivery accounting
1 parent 743bce2 commit 64f2dc4

7 files changed

Lines changed: 66 additions & 13 deletions

File tree

extensions/discord/src/channel-actions.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,12 @@ describe("discordMessageActions", () => {
351351
);
352352

353353
it("extracts send targets for message and thread reply actions", () => {
354+
expect(
355+
discordMessageActions.extractToolSend?.({
356+
args: { action: "send", to: "channel:123" },
357+
}),
358+
).toEqual({ to: "channel:123" });
359+
354360
expect(
355361
discordMessageActions.extractToolSend?.({
356362
args: { action: "sendMessage", to: "channel:123" },

extensions/discord/src/channel-actions.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,8 +168,8 @@ export const discordMessageActions: ChannelMessageActionAdapter = {
168168
describeMessageTool: describeDiscordMessageTool,
169169
extractToolSend: ({ args }) => {
170170
const action = normalizeOptionalString(args.action) ?? "";
171-
if (action === "sendMessage") {
172-
return extractToolSend(args, "sendMessage");
171+
if (action === "send" || action === "sendMessage") {
172+
return extractToolSend(args, action);
173173
}
174174
if (action === "threadReply") {
175175
const channelId = normalizeOptionalString(args.channelId) ?? "";

extensions/discord/src/monitor/native-command-agent-reply.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ export async function dispatchDiscordNativeAgentReply(params: {
6262
return;
6363
}
6464
try {
65-
await deliverDiscordInteractionReply({
65+
const delivered = await deliverDiscordInteractionReply({
6666
interaction: params.interaction,
6767
payload,
6868
mediaLocalRoots: params.mediaLocalRoots,
@@ -78,6 +78,9 @@ export async function dispatchDiscordNativeAgentReply(params: {
7878
responseEphemeral: params.responseEphemeral,
7979
chunkMode: resolveChunkMode(params.cfg, "discord", params.accountId),
8080
});
81+
if (!delivered) {
82+
return;
83+
}
8184
} catch (error) {
8285
if (isDiscordUnknownInteraction(error)) {
8386
logVerbose("discord: interaction reply skipped (interaction expired)");

extensions/discord/src/monitor/native-command-reply.test.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ describe("deliverDiscordInteractionReply", () => {
2626

2727
expect(hasRenderableReplyPayload(payload)).toBe(true);
2828

29-
await deliverDiscordInteractionReply({
29+
const delivered = await deliverDiscordInteractionReply({
3030
interaction: interaction as never,
3131
payload,
3232
textLimit: 2000,
@@ -40,13 +40,14 @@ describe("deliverDiscordInteractionReply", () => {
4040
ephemeral: true,
4141
});
4242
expect(interaction.reply).not.toHaveBeenCalled();
43+
expect(delivered).toBe(true);
4344
});
4445

4546
it("sends component-only native command replies through the initial reply when not deferred", async () => {
4647
const interaction = createInteraction();
4748
const components = [new Container([new TextDisplay("Choose an action")])];
4849

49-
await deliverDiscordInteractionReply({
50+
const delivered = await deliverDiscordInteractionReply({
5051
interaction: interaction as never,
5152
payload: {
5253
channelData: {
@@ -64,5 +65,25 @@ describe("deliverDiscordInteractionReply", () => {
6465
components,
6566
});
6667
expect(interaction.followUp).not.toHaveBeenCalled();
68+
expect(delivered).toBe(true);
69+
});
70+
71+
it("reports no delivery when Discord rejects an expired interaction", async () => {
72+
const interaction = createInteraction();
73+
interaction.reply.mockRejectedValueOnce({
74+
rawBody: { code: 10062, message: "Unknown interaction" },
75+
});
76+
77+
const delivered = await deliverDiscordInteractionReply({
78+
interaction: interaction as never,
79+
payload: { text: "too late" },
80+
textLimit: 2000,
81+
preferFollowUp: false,
82+
chunkMode: "length",
83+
});
84+
85+
expect(interaction.reply).toHaveBeenCalledTimes(1);
86+
expect(interaction.followUp).not.toHaveBeenCalled();
87+
expect(delivered).toBe(false);
6788
});
6889
});

extensions/discord/src/monitor/native-command-reply.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ export async function deliverDiscordInteractionReply(params: {
7474
preferFollowUp: boolean;
7575
responseEphemeral?: boolean;
7676
chunkMode: "length" | "newline";
77-
}) {
77+
}): Promise<boolean> {
7878
const { interaction, payload, textLimit, maxLinesPerMessage, preferFollowUp, chunkMode } = params;
7979
const reply = resolveSendableOutboundReplyParts(payload);
8080
const discordData = payload.channelData?.discord as
@@ -115,17 +115,19 @@ export async function deliverDiscordInteractionReply(params: {
115115
? { ephemeral: params.responseEphemeral }
116116
: {}),
117117
};
118-
await safeDiscordInteractionCall("interaction send", async () => {
118+
const result = await safeDiscordInteractionCall("interaction send", async () => {
119119
if (!preferFollowUp && !hasReplied) {
120120
await interaction.reply(payload);
121121
hasReplied = true;
122122
firstMessageComponents = undefined;
123-
return;
123+
return true;
124124
}
125125
await interaction.followUp(payload);
126126
hasReplied = true;
127127
firstMessageComponents = undefined;
128+
return true;
128129
});
130+
return result === true;
129131
};
130132

131133
if (reply.hasMedia) {
@@ -149,18 +151,18 @@ export async function deliverDiscordInteractionReply(params: {
149151
}),
150152
);
151153
const caption = chunks[0] ?? "";
152-
await sendMessage(caption, media, firstMessageComponents);
154+
let delivered = await sendMessage(caption, media, firstMessageComponents);
153155
for (const chunk of chunks.slice(1)) {
154156
if (!chunk.trim()) {
155157
continue;
156158
}
157-
await sendMessage(chunk);
159+
delivered = (await sendMessage(chunk)) || delivered;
158160
}
159-
return;
161+
return delivered;
160162
}
161163

162164
if (!reply.hasText && !firstMessageComponents) {
163-
return;
165+
return false;
164166
}
165167
let chunks =
166168
reply.text || firstMessageComponents
@@ -176,10 +178,12 @@ export async function deliverDiscordInteractionReply(params: {
176178
if (chunks.length === 0 && firstMessageComponents) {
177179
chunks = [""];
178180
}
181+
let delivered = false;
179182
for (const chunk of chunks) {
180183
if (!chunk.trim() && !firstMessageComponents) {
181184
continue;
182185
}
183-
await sendMessage(chunk, undefined, firstMessageComponents);
186+
delivered = (await sendMessage(chunk, undefined, firstMessageComponents)) || delivered;
184187
}
188+
return delivered;
185189
}

extensions/discord/src/monitor/reply-delivery.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,22 @@ describe("deliverDiscordReply", () => {
157157
).rejects.toThrow("discord final reply produced no delivered message for channel:101");
158158
});
159159

160+
it("fails when a final reply sanitizes down to no visible Discord payload", async () => {
161+
await expect(
162+
deliverDiscordReply({
163+
replies: [{ text: "analysis: internal only\ncommentary: tool trace only" }],
164+
target: "channel:101",
165+
token: "token",
166+
accountId: "default",
167+
runtime,
168+
cfg,
169+
textLimit: 2000,
170+
kind: "final",
171+
}),
172+
).rejects.toThrow("discord final reply sanitized to empty for channel:101");
173+
expect(sendDurableMessageBatchMock).not.toHaveBeenCalled();
174+
});
175+
160176
it("preserves explicit tool progress payloads at the tool delivery boundary", async () => {
161177
await deliverDiscordReply({
162178
replies: [{ text: "🛠️ Exec: `echo visible`" }],

extensions/discord/src/monitor/reply-delivery.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,9 @@ export async function deliverDiscordReply(params: {
179179
const delivery = resolveDiscordDeliveryOptions(params);
180180
const payloads = sanitizeDiscordFrontChannelReplyPayloads(params.replies, { kind: params.kind });
181181
if (payloads.length === 0) {
182+
if (params.kind === "final") {
183+
throw new Error(`discord final reply sanitized to empty for ${delivery.to}`);
184+
}
182185
return;
183186
}
184187

0 commit comments

Comments
 (0)