Skip to content

Commit 3a2f54e

Browse files
fix(telegram): suppress post-final tool error noise
Suppress non-actionable text-only tool/progress noise after Telegram final delivery while preserving terminal final warnings, media payloads, and exec approval prompts. Use the core nonTerminalToolErrorWarning marker for recovered final tool warnings, and cover suppression plus preservation cases with regression tests.
1 parent e5d1fad commit 3a2f54e

2 files changed

Lines changed: 84 additions & 6 deletions

File tree

extensions/telegram/src/bot-message-dispatch.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
createPluginStateSyncKeyedStoreForTests,
66
resetPluginStateStoreForTests,
77
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
8+
import { setReplyPayloadMetadata } from "openclaw/plugin-sdk/reply-payload-testing";
89
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
910
import { resolveAutoTopicLabelConfig as resolveAutoTopicLabelConfigRuntime } from "./auto-topic-label-config.js";
1011
import type { TelegramBotDeps } from "./bot-deps.js";
@@ -2612,6 +2613,73 @@ describe("dispatchTelegramMessage draft streaming", () => {
26122613
expectDeliveredReply(0, { text: "Boom" });
26132614
});
26142615

2616+
it("suppresses failed tool payloads after the final reply", async () => {
2617+
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
2618+
await dispatcherOptions.deliver({ text: "Final answer" }, { kind: "final" });
2619+
await dispatcherOptions.deliver(
2620+
{ text: "Tool failed after final", isError: true },
2621+
{ kind: "tool" },
2622+
);
2623+
return { queuedFinal: true };
2624+
});
2625+
2626+
await dispatchWithContext({ context: createContext(), streamMode: "off" });
2627+
2628+
expect(deliverReplies).toHaveBeenCalledTimes(1);
2629+
expectDeliveredReply(0, { text: "Final answer" });
2630+
});
2631+
2632+
it("preserves final error warnings after the final reply", async () => {
2633+
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
2634+
await dispatcherOptions.deliver({ text: "Final answer" }, { kind: "final" });
2635+
await dispatcherOptions.deliver({ text: "Write failed", isError: true }, { kind: "final" });
2636+
return { queuedFinal: true };
2637+
});
2638+
2639+
await dispatchWithContext({ context: createContext(), streamMode: "off" });
2640+
2641+
expect(deliverReplies).toHaveBeenCalledTimes(2);
2642+
expectDeliveredReply(0, { text: "Final answer" });
2643+
expectDeliveredReply(0, { text: "Write failed", isError: true }, 1);
2644+
});
2645+
2646+
it("suppresses non-terminal final error warnings after the final reply", async () => {
2647+
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
2648+
await dispatcherOptions.deliver({ text: "Final answer" }, { kind: "final" });
2649+
await dispatcherOptions.deliver(
2650+
setReplyPayloadMetadata(
2651+
{ text: "Post-processing failed", isError: true },
2652+
{ nonTerminalToolErrorWarning: true },
2653+
),
2654+
{ kind: "final" },
2655+
);
2656+
return { queuedFinal: true };
2657+
});
2658+
2659+
await dispatchWithContext({ context: createContext(), streamMode: "off" });
2660+
2661+
expect(deliverReplies).toHaveBeenCalledTimes(1);
2662+
expectDeliveredReply(0, { text: "Final answer" });
2663+
});
2664+
2665+
it("preserves non-terminal final error warnings before any final reply is delivered", async () => {
2666+
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
2667+
await dispatcherOptions.deliver(
2668+
setReplyPayloadMetadata(
2669+
{ text: "Post-processing failed", isError: true },
2670+
{ nonTerminalToolErrorWarning: true },
2671+
),
2672+
{ kind: "final" },
2673+
);
2674+
return { queuedFinal: true };
2675+
});
2676+
2677+
await dispatchWithContext({ context: createContext(), streamMode: "off" });
2678+
2679+
expect(deliverReplies).toHaveBeenCalledTimes(1);
2680+
expectDeliveredReply(0, { text: "Post-processing failed", isError: true });
2681+
});
2682+
26152683
it("streams button-bearing text into the same message", async () => {
26162684
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
26172685
const buttons = [[{ text: "OK", callback_data: "ok" }]];

extensions/telegram/src/bot-message-dispatch.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,10 @@ import { normalizeMessagePresentation } from "openclaw/plugin-sdk/interactive-ru
4646
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
4747
import { chunkMarkdownTextWithMode } from "openclaw/plugin-sdk/reply-chunking";
4848
import { createChannelHistoryWindow } from "openclaw/plugin-sdk/reply-history";
49-
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
49+
import {
50+
isReplyPayloadNonTerminalToolErrorWarning,
51+
resolveSendableOutboundReplyParts,
52+
} from "openclaw/plugin-sdk/reply-payload";
5053
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-payload";
5154
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
5255
import {
@@ -1681,9 +1684,6 @@ export const dispatchTelegramMessage = async ({
16811684
if (isDispatchSuperseded()) {
16821685
return;
16831686
}
1684-
if (payload.isError === true) {
1685-
hadErrorReplyFailureOrSkip = true;
1686-
}
16871687

16881688
const deduped =
16891689
info.kind === "final"
@@ -1717,14 +1717,24 @@ export const dispatchTelegramMessage = async ({
17171717
if (info.kind === "final") {
17181718
await enqueueDraftLaneEvent(async () => {});
17191719
}
1720+
// Hide handled post-answer probe failures while preserving final warnings.
1721+
// Agents may intentionally run searches/commands with no result, recover,
1722+
// and send a final answer; late text-only failures are non-actionable noise.
1723+
const isToolPayloadAfterFinal =
1724+
info.kind === "tool" && (finalAnswerDeliveryStarted || finalAnswerDelivered);
1725+
const isNonTerminalWarningAfterDeliveredFinal =
1726+
isReplyPayloadNonTerminalToolErrorWarning(effectivePayload) &&
1727+
finalAnswerDelivered;
17201728
if (
1721-
info.kind === "tool" &&
1722-
(finalAnswerDeliveryStarted || finalAnswerDelivered) &&
1729+
(isToolPayloadAfterFinal || isNonTerminalWarningAfterDeliveredFinal) &&
17231730
!reply.hasMedia &&
17241731
!hasExecApprovalPayload(effectivePayload)
17251732
) {
17261733
return;
17271734
}
1735+
if (payload.isError === true) {
1736+
hadErrorReplyFailureOrSkip = true;
1737+
}
17281738

17291739
const deliverFinalAnswerText = async (
17301740
answerPayload: ReplyPayload,

0 commit comments

Comments
 (0)