From 937f8e434f65b33f88833dc63f73aabe76861403 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Mon, 16 Feb 2026 18:26:23 +0530 Subject: [PATCH] fix: preserve telegram recovered reply and one-time finalization --- CHANGELOG.md | 1 + .../run/payloads.e2e.test.ts | 23 ++++++++ src/agents/pi-embedded-runner/run/payloads.ts | 14 ++--- src/telegram/bot-message-dispatch.test.ts | 32 +++++++++++ src/telegram/bot-message-dispatch.ts | 54 +++++++++---------- 5 files changed, 86 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 87ac0076d1f..dbe479a5033 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,7 @@ Docs: https://docs.openclaw.ai - Telegram: keep draft-stream preview replies attached to the user message for `replyToMode: "all"` in groups and DMs, preserving threaded reply context from preview through finalization. (#17880) Thanks @yinghaosang. - Telegram: disable block streaming when `channels.telegram.streamMode` is `off`, preventing newline/content-block replies from splitting into multiple messages. (#17679) Thanks @saivarunk. - Telegram: route non-abort slash commands on the normal chat/topic sequential lane while keeping true abort requests (`/stop`, `stop`) on the control lane, preventing command/reply race conditions from control-lane bypass. (#17899) Thanks @obviyus. +- Telegram: prevent streaming final replies from being overwritten by later final/error payloads, and suppress fallback tool-error warnings when a recovered assistant answer already exists after tool calls. (#17883) Thanks @Marvae and @obviyus. - Discord: preserve channel session continuity when runtime payloads omit `message.channelId` by falling back to event/raw `channel_id` values for routing/session keys, so same-channel messages keep history across turns/restarts. Also align diagnostics so active Discord runs no longer appear as `sessionKey=unknown`. (#17622) Thanks @shakkernerd. - Discord: dedupe native skill commands by skill name in multi-agent setups to prevent duplicated slash commands with `_2` suffixes. (#17365) Thanks @seewhyme. - Discord: ensure role allowlist matching uses raw role IDs for message routing authorization. Thanks @xinhuagu. diff --git a/src/agents/pi-embedded-runner/run/payloads.e2e.test.ts b/src/agents/pi-embedded-runner/run/payloads.e2e.test.ts index 03a982289d0..a1457a03b3f 100644 --- a/src/agents/pi-embedded-runner/run/payloads.e2e.test.ts +++ b/src/agents/pi-embedded-runner/run/payloads.e2e.test.ts @@ -184,6 +184,29 @@ describe("buildEmbeddedRunPayloads", () => { expect(payloads[0]?.text).toContain("code 1"); }); + it("does not add tool error fallback when assistant text exists after tool calls", () => { + const payloads = buildPayloads({ + assistantTexts: ["Checked the page and recovered with final answer."], + lastAssistant: makeAssistant({ + stopReason: "toolUse", + errorMessage: undefined, + content: [ + { + type: "toolCall", + id: "toolu_01", + name: "browser", + arguments: { action: "search", query: "openclaw docs" }, + }, + ], + }), + lastToolError: { toolName: "browser", error: "connection timeout" }, + }); + + expect(payloads).toHaveLength(1); + expect(payloads[0]?.isError).toBeUndefined(); + expect(payloads[0]?.text).toContain("recovered"); + }); + it("suppresses recoverable tool errors containing 'required' for non-mutating tools", () => { const payloads = buildPayloads({ lastToolError: { toolName: "browser", error: "url required" }, diff --git a/src/agents/pi-embedded-runner/run/payloads.ts b/src/agents/pi-embedded-runner/run/payloads.ts index e7a4f74b89f..9ccbf76f972 100644 --- a/src/agents/pi-embedded-runner/run/payloads.ts +++ b/src/agents/pi-embedded-runner/run/payloads.ts @@ -218,6 +218,7 @@ export function buildEmbeddedRunPayloads(params: { : [] ).filter((text) => !shouldSuppressRawErrorText(text)); + let hasUserFacingAssistantReply = false; for (const text of answerTexts) { const { text: cleanedText, @@ -238,22 +239,13 @@ export function buildEmbeddedRunPayloads(params: { replyToTag, replyToCurrent, }); + hasUserFacingAssistantReply = true; } if (params.lastToolError) { - const lastAssistantHasToolCalls = - Array.isArray(params.lastAssistant?.content) && - params.lastAssistant?.content.some((block) => - block && typeof block === "object" - ? (block as { type?: unknown }).type === "toolCall" - : false, - ); - const lastAssistantWasToolUse = params.lastAssistant?.stopReason === "toolUse"; - const hasUserFacingReply = - replyItems.length > 0 && !lastAssistantHasToolCalls && !lastAssistantWasToolUse; const shouldShowToolError = shouldShowToolErrorWarning({ lastToolError: params.lastToolError, - hasUserFacingReply, + hasUserFacingReply: hasUserFacingAssistantReply, suppressToolErrors: Boolean(params.config?.messages?.suppressToolErrors), }); diff --git a/src/telegram/bot-message-dispatch.test.ts b/src/telegram/bot-message-dispatch.test.ts index f5f051e05c3..64bfd96967c 100644 --- a/src/telegram/bot-message-dispatch.test.ts +++ b/src/telegram/bot-message-dispatch.test.ts @@ -216,6 +216,38 @@ describe("dispatchTelegramMessage draft streaming", () => { expect(draftStream.stop).toHaveBeenCalled(); }); + it("does not overwrite finalized preview when additional final payloads are sent", async () => { + const draftStream = createDraftStream(999); + createTelegramDraftStream.mockReturnValue(draftStream); + dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { + await dispatcherOptions.deliver({ text: "Primary result" }, { kind: "final" }); + await dispatcherOptions.deliver( + { text: "⚠️ Recovered tool error details" }, + { kind: "final" }, + ); + return { queuedFinal: true }; + }); + deliverReplies.mockResolvedValue({ delivered: true }); + editMessageTelegram.mockResolvedValue({ ok: true, chatId: "123", messageId: "999" }); + + await dispatchWithContext({ context: createContext() }); + + expect(editMessageTelegram).toHaveBeenCalledTimes(1); + expect(editMessageTelegram).toHaveBeenCalledWith( + 123, + 999, + "Primary result", + expect.any(Object), + ); + expect(deliverReplies).toHaveBeenCalledWith( + expect.objectContaining({ + replies: [expect.objectContaining({ text: "⚠️ Recovered tool error details" })], + }), + ); + expect(draftStream.clear).not.toHaveBeenCalled(); + expect(draftStream.stop).toHaveBeenCalled(); + }); + it("falls back to normal delivery when preview final is too long to edit", async () => { const draftStream = createDraftStream(999); createTelegramDraftStream.mockReturnValue(draftStream); diff --git a/src/telegram/bot-message-dispatch.ts b/src/telegram/bot-message-dispatch.ts index 0663f375aa2..2741ec393ca 100644 --- a/src/telegram/bot-message-dispatch.ts +++ b/src/telegram/bot-message-dispatch.ts @@ -315,33 +315,33 @@ export const dispatchTelegramMessage = async ({ finalText.length <= draftMaxChars && !payload.isError; if (canFinalizeViaPreviewEdit) { - draftStream?.stop(); - draftStoppedForPreviewEdit = true; - if ( - currentPreviewText && - currentPreviewText.startsWith(finalText) && - finalText.length < currentPreviewText.length - ) { - // Ignore regressive final edits (e.g., "Okay." -> "Ok"), which - // can appear transiently in some provider streams. - return; - } - try { - await editMessageTelegram(chatId, previewMessageId, finalText, { - api: bot.api, - cfg, - accountId: route.accountId, - linkPreview: telegramCfg.linkPreview, - buttons: previewButtons, - }); - finalizedViaPreviewMessage = true; - deliveryState.delivered = true; - return; - } catch (err) { - logVerbose( - `telegram: preview final edit failed; falling back to standard send (${String(err)})`, - ); - } + draftStream?.stop(); + draftStoppedForPreviewEdit = true; + if ( + currentPreviewText && + currentPreviewText.startsWith(finalText) && + finalText.length < currentPreviewText.length + ) { + // Ignore regressive final edits (e.g., "Okay." -> "Ok"), which + // can appear transiently in some provider streams. + return; + } + try { + await editMessageTelegram(chatId, previewMessageId, finalText, { + api: bot.api, + cfg, + accountId: route.accountId, + linkPreview: telegramCfg.linkPreview, + buttons: previewButtons, + }); + finalizedViaPreviewMessage = true; + deliveryState.delivered = true; + return; + } catch (err) { + logVerbose( + `telegram: preview final edit failed; falling back to standard send (${String(err)})`, + ); + } } if ( !hasMedia &&