diff --git a/CHANGELOG.md b/CHANGELOG.md index 836205daf18..085b4f89818 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/extensions/discord/src/monitor/message-handler.process.test.ts b/extensions/discord/src/monitor/message-handler.process.test.ts index 0411c41fba2..c90cf6ddbf5 100644 --- a/extensions/discord/src/monitor/message-handler.process.test.ts +++ b/extensions/discord/src/monitor/message-handler.process.test.ts @@ -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) => { diff --git a/extensions/discord/src/monitor/message-handler.process.ts b/extensions/discord/src/monitor/message-handler.process.ts index a3a50320778..ed587a56856 100644 --- a/extensions/discord/src/monitor/message-handler.process.ts +++ b/extensions/discord/src/monitor/message-handler.process.ts @@ -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 { diff --git a/extensions/discord/src/monitor/reply-delivery.test.ts b/extensions/discord/src/monitor/reply-delivery.test.ts index 100d5c46b8a..99a709a7445 100644 --- a/extensions/discord/src/monitor/reply-delivery.test.ts +++ b/extensions/discord/src/monitor/reply-delivery.test.ts @@ -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: [ diff --git a/extensions/discord/src/monitor/reply-delivery.ts b/extensions/discord/src/monitor/reply-delivery.ts index c1b34574c91..8384dcf2da5 100644 --- a/extensions/discord/src/monitor/reply-delivery.ts +++ b/extensions/discord/src/monitor/reply-delivery.ts @@ -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}`); + } } diff --git a/src/auto-reply/dispatch.test.ts b/src/auto-reply/dispatch.test.ts index 484f2a640a9..7a039a5f266 100644 --- a/src/auto-reply/dispatch.test.ts +++ b/src/auto-reply/dispatch.test.ts @@ -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([]), diff --git a/src/auto-reply/dispatch.ts b/src/auto-reply/dispatch.ts index 0111adc57f6..a8608cefe88 100644 --- a/src/auto-reply/dispatch.ts +++ b/src/auto-reply/dispatch.ts @@ -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 } : {}), }; } diff --git a/src/auto-reply/reply/dispatch-from-config.types.ts b/src/auto-reply/reply/dispatch-from-config.types.ts index 81de6ef935d..91e9f68d95f 100644 --- a/src/auto-reply/reply/dispatch-from-config.types.ts +++ b/src/auto-reply/reply/dispatch-from-config.types.ts @@ -8,6 +8,7 @@ import type { ReplyDispatchKind, ReplyDispatcher } from "./reply-dispatcher.type export type DispatchFromConfigResult = { queuedFinal: boolean; counts: Record; + failedCounts?: Partial>; sourceReplyDeliveryMode?: SourceReplyDeliveryMode; };