mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:20:43 +00:00
fix(discord): fail dropped final reply delivery
This commit is contained in:
committed by
Peter Steinberger
parent
1a4c078399
commit
9e97cdb213
@@ -69,6 +69,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Plugins/runtime-deps: include `json5` in the memory-core plugin runtime dependency set so packaged `memory_search` sandboxes can resolve generated OpenClaw runtime chunks that parse JSON5 config. Fixes #77461.
|
||||
- Codex harness: preserve app-server usage-limit reset details and deliver OpenClaw-owned runtime failure notices through tool-only source-reply mode, so Telegram and other chat channels tell users when Codex subscription limits or API failures block a turn instead of going silent. (#77557) Thanks @pashpashpash.
|
||||
- Agents/OpenAI: default direct OpenAI Responses models to the SSE transport instead of WebSocket auto-selection, preventing pi runtime chat turns from hanging on servers where the WebSocket path stalls while the OpenAI HTTP stream works. Thanks @vincentkoc.
|
||||
- Discord/replies: treat failed final reply delivery as a failed turn instead of counting it as a delivered automatic visible reply, so guild/channel turns no longer show done when the final message was dropped. Fixes #77520.
|
||||
- Discord: prefer IPv4 for Discord REST and gateway WebSocket startup paths so IPv4-only networks no longer stall before Gateway READY and inbound message dispatch. Fixes #77398; refs #77526. Thanks @Beandon13.
|
||||
- Channels/plugins: key bundled package-state probes, env/config presence, and read-only command defaults by channel id instead of manifest plugin id, preserving setup and native-command detection for channel plugins whose package id differs from the channel alias. Thanks @vincentkoc.
|
||||
- Docker: prune package-excluded plugin dist directories from runtime images unless the build explicitly opts that plugin in, so official external plugins such as Feishu stay install-on-demand instead of shipping partial metadata without compiled runtime output. Fixes #77424. Thanks @vincentkoc.
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,6 +283,36 @@ describe("withReplyDispatcher", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("reconciles queuedFinal and counts after dispatcher-side delivery failure", async () => {
|
||||
const dispatcher = {
|
||||
sendToolResult: () => true,
|
||||
sendBlockReply: () => true,
|
||||
sendFinalReply: () => true,
|
||||
getQueuedCounts: () => ({ tool: 0, block: 0, final: 0 }),
|
||||
getCancelledCounts: () => ({ tool: 0, block: 0, final: 0 }),
|
||||
getFailedCounts: () => ({ tool: 0, block: 0, final: 1 }),
|
||||
markComplete: () => undefined,
|
||||
waitForIdle: async () => undefined,
|
||||
} satisfies ReplyDispatcher;
|
||||
hoisted.dispatchReplyFromConfigMock.mockResolvedValueOnce({
|
||||
queuedFinal: true,
|
||||
counts: { tool: 0, block: 0, final: 1 },
|
||||
});
|
||||
|
||||
const result = await dispatchInboundMessage({
|
||||
ctx: buildTestCtx(),
|
||||
cfg: {} as OpenClawConfig,
|
||||
dispatcher,
|
||||
replyResolver: async () => ({ text: "ok" }),
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
queuedFinal: false,
|
||||
counts: { tool: 0, block: 0, final: 0 },
|
||||
failedCounts: { tool: 0, block: 0, final: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it("uses CommandTargetSessionKey for silent-reply policy on native command turns", async () => {
|
||||
hoisted.createReplyDispatcherWithTypingMock.mockReturnValueOnce({
|
||||
dispatcher: createDispatcher([]),
|
||||
|
||||
@@ -103,19 +103,36 @@ function finalizeDispatchResult(
|
||||
dispatcher: ReplyDispatcher,
|
||||
): DispatchFromConfigResult {
|
||||
const cancelledCounts = dispatcher.getCancelledCounts?.();
|
||||
if (!cancelledCounts) {
|
||||
const failedCounts = dispatcher.getFailedCounts?.();
|
||||
if (!cancelledCounts && !failedCounts) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const counts = {
|
||||
tool: Math.max(0, result.counts.tool - cancelledCounts.tool),
|
||||
block: Math.max(0, result.counts.block - cancelledCounts.block),
|
||||
final: Math.max(0, result.counts.final - cancelledCounts.final),
|
||||
const resultCounts = {
|
||||
tool: result.counts?.tool ?? 0,
|
||||
block: result.counts?.block ?? 0,
|
||||
final: result.counts?.final ?? 0,
|
||||
};
|
||||
const counts = {
|
||||
tool: Math.max(0, resultCounts.tool - (cancelledCounts?.tool ?? 0) - (failedCounts?.tool ?? 0)),
|
||||
block: Math.max(
|
||||
0,
|
||||
resultCounts.block - (cancelledCounts?.block ?? 0) - (failedCounts?.block ?? 0),
|
||||
),
|
||||
final: Math.max(
|
||||
0,
|
||||
resultCounts.final - (cancelledCounts?.final ?? 0) - (failedCounts?.final ?? 0),
|
||||
),
|
||||
};
|
||||
const hasFailedCounts =
|
||||
(failedCounts?.tool ?? 0) > 0 ||
|
||||
(failedCounts?.block ?? 0) > 0 ||
|
||||
(failedCounts?.final ?? 0) > 0;
|
||||
return {
|
||||
...result,
|
||||
queuedFinal: result.queuedFinal && counts.final > 0,
|
||||
counts,
|
||||
...(hasFailedCounts ? { failedCounts } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { ReplyDispatchKind, ReplyDispatcher } from "./reply-dispatcher.type
|
||||
export type DispatchFromConfigResult = {
|
||||
queuedFinal: boolean;
|
||||
counts: Record<ReplyDispatchKind, number>;
|
||||
failedCounts?: Partial<Record<ReplyDispatchKind, number>>;
|
||||
sourceReplyDeliveryMode?: SourceReplyDeliveryMode;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user