fix(discord): fail dropped final reply delivery

This commit is contained in:
Patrick Erichsen
2026-05-04 17:13:32 -07:00
committed by Peter Steinberger
parent 1a4c078399
commit 9e97cdb213
8 changed files with 106 additions and 13 deletions

View File

@@ -139,9 +139,11 @@ type DispatchInboundParams = {
};
const dispatchInboundMessage = vi.hoisted(() =>
vi.fn<
(
params?: DispatchInboundParams,
) => Promise<{ queuedFinal: boolean; counts: { final: number; tool: number; block: number } }>
(params?: DispatchInboundParams) => Promise<{
queuedFinal: boolean;
counts: { final: number; tool: number; block: number };
failedCounts?: { final?: number; tool?: number; block?: number };
}>
>(async (_params?: DispatchInboundParams) => ({
queuedFinal: false,
counts: { final: 0, tool: 0, block: 0 },
@@ -621,6 +623,22 @@ describe("processDiscordMessage ack reactions", () => {
expect(emojis).not.toContain(DEFAULT_EMOJIS.coding);
});
it("marks automatic visible replies as failed when final Discord delivery fails", async () => {
dispatchInboundMessage.mockResolvedValueOnce({
queuedFinal: false,
counts: { final: 0, tool: 0, block: 0 },
failedCounts: { final: 1 },
});
const ctx = await createAutomaticSourceDeliveryContext();
await runProcessDiscordMessage(ctx);
const emojis = getReactionEmojis();
expect(emojis).toContain(DEFAULT_EMOJIS.error);
expect(emojis).not.toContain(DEFAULT_EMOJIS.done);
});
it("can bind status reactions to an explicitly tracked reaction target", async () => {
vi.useFakeTimers();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {

View File

@@ -802,6 +802,7 @@ export async function processDiscordMessage(
markDispatchIdle();
}
}
const finalDeliveryFailed = (dispatchResult?.failedCounts?.final ?? 0) > 0;
if (statusReactionsActive) {
if (dispatchAborted) {
if (removeAckAfterReply) {
@@ -810,14 +811,18 @@ export async function processDiscordMessage(
void statusReactions.restoreInitial();
}
} else {
if (dispatchError) {
if (dispatchError || finalDeliveryFailed) {
await statusReactions.setError();
} else {
await statusReactions.setDone();
}
if (removeAckAfterReply) {
void (async () => {
await sleep(dispatchError ? DEFAULT_TIMING.errorHoldMs : DEFAULT_TIMING.doneHoldMs);
await sleep(
dispatchError || finalDeliveryFailed
? DEFAULT_TIMING.errorHoldMs
: DEFAULT_TIMING.doneHoldMs,
);
await statusReactions.clear();
})();
} else {

View File

@@ -3,7 +3,9 @@ import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { RequestClient } from "../internal/discord.js";
const deliverOutboundPayloadsMock = vi.hoisted(() => vi.fn(async () => []));
const deliverOutboundPayloadsMock = vi.hoisted(() =>
vi.fn(async () => [{ messageId: "msg-1", channelId: "channel-1" }]),
);
const sendMessageDiscordMock = vi.hoisted(() => vi.fn());
const sendVoiceMessageDiscordMock = vi.hoisted(() => vi.fn());
@@ -57,7 +59,7 @@ describe("deliverDiscordReply", () => {
beforeEach(() => {
deliverOutboundPayloadsMock.mockClear();
deliverOutboundPayloadsMock.mockResolvedValue([]);
deliverOutboundPayloadsMock.mockResolvedValue([{ messageId: "msg-1", channelId: "channel-1" }]);
sendMessageDiscordMock.mockReset().mockResolvedValue({
messageId: "msg-1",
channelId: "channel-1",
@@ -105,6 +107,22 @@ describe("deliverDiscordReply", () => {
);
});
it("fails when shared outbound accepts a final reply but delivers no Discord message", async () => {
deliverOutboundPayloadsMock.mockResolvedValueOnce([]);
await expect(
deliverDiscordReply({
replies: [{ text: "lost reply" }],
target: "channel:101",
token: "token",
accountId: "default",
runtime,
cfg,
textLimit: 2000,
}),
).rejects.toThrow("discord final reply produced no delivered message for channel:101");
});
it("strips internal execution trace lines at the final Discord send boundary", async () => {
await deliverDiscordReply({
replies: [

View File

@@ -181,7 +181,7 @@ export async function deliverDiscordReply(params: {
return;
}
await deliverOutboundPayloads({
const results = await deliverOutboundPayloads({
cfg: params.cfg,
channel: "discord",
to: delivery.to,
@@ -205,4 +205,7 @@ export async function deliverDiscordReply(params: {
requesterAccountId: params.accountId,
}),
});
if (results.length === 0) {
throw new Error(`discord final reply produced no delivered message for ${delivery.to}`);
}
}