diff --git a/CHANGELOG.md b/CHANGELOG.md index efafe944df13..97d1e4b61f54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -294,6 +294,7 @@ Docs: https://docs.openclaw.ai - ACPX/Windows: hide the MCP proxy target child process window on Windows so ACP-backed agents do not flash or fail because of terminal window handling. Fixes #60672. (#60678) Thanks @KChow-ctrl. - Agents: abort generic repeated no-progress tool loops at the critical threshold when identical calls keep returning identical outcomes. (#80668) Thanks @frankekn. - Exec approvals: omit generated command highlights for non-POSIX Windows and shell-wrapper approval commands until those command languages have native highlighting support. (#80566) Thanks @jesse-merhi. +- Telegram: keep verbose tool progress and result drafts separate from the final assistant answer so tool output no longer blends into the final Telegram message. (#80294) Thanks @jalehman. ## 2026.5.9 diff --git a/extensions/telegram/src/bot-message-dispatch.test.ts b/extensions/telegram/src/bot-message-dispatch.test.ts index e616d70d2aa6..eb83921d288a 100644 --- a/extensions/telegram/src/bot-message-dispatch.test.ts +++ b/extensions/telegram/src/bot-message-dispatch.test.ts @@ -1118,6 +1118,49 @@ describe("dispatchTelegramMessage draft streaming", () => { expect(deliverReplies).not.toHaveBeenCalled(); }); + it("rotates a tool-progress-only answer draft before streaming the final answer", async () => { + const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 }); + dispatchReplyWithBufferedBlockDispatcher.mockImplementation( + async ({ dispatcherOptions, replyOptions }) => { + await replyOptions?.onToolStart?.({ name: "exec", phase: "start" }); + await dispatcherOptions.deliver({ text: "Branch is up to date" }, { kind: "final" }); + return { queuedFinal: true }; + }, + ); + + await dispatchWithContext({ context: createContext() }); + + expect(answerDraftStream.update).toHaveBeenNthCalledWith( + 1, + expect.stringMatching(/`šŸ› ļø Exec`$/), + ); + expect(answerDraftStream.update).toHaveBeenNthCalledWith(2, "Branch is up to date"); + expect(answerDraftStream.forceNewMessage).toHaveBeenCalledTimes(1); + expect(answerDraftStream.clear).not.toHaveBeenCalled(); + const rotationOrder = answerDraftStream.forceNewMessage.mock.invocationCallOrder[0]; + const finalUpdateOrder = answerDraftStream.update.mock.invocationCallOrder[1]; + expect(rotationOrder).toBeLessThan(finalUpdateOrder); + }); + + it("rotates a verbose tool result draft before streaming the final answer", async () => { + const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 }); + dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { + await dispatcherOptions.deliver({ text: "šŸ› ļø Exec: pnpm test" }, { kind: "tool" }); + await dispatcherOptions.deliver({ text: "Tests passed" }, { kind: "final" }); + return { queuedFinal: true }; + }); + + await dispatchWithContext({ context: createContext() }); + + expect(answerDraftStream.update).toHaveBeenNthCalledWith(1, "šŸ› ļø Exec: pnpm test"); + expect(answerDraftStream.update).toHaveBeenNthCalledWith(2, "Tests passed"); + expect(answerDraftStream.forceNewMessage).toHaveBeenCalledTimes(1); + expect(answerDraftStream.clear).not.toHaveBeenCalled(); + const rotationOrder = answerDraftStream.forceNewMessage.mock.invocationCallOrder[0]; + const finalUpdateOrder = answerDraftStream.update.mock.invocationCallOrder[1]; + expect(rotationOrder).toBeLessThan(finalUpdateOrder); + }); + it("keeps progress updates in a draft and sends the final answer normally", async () => { const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 }); dispatchReplyWithBufferedBlockDispatcher.mockImplementation( @@ -1143,7 +1186,8 @@ describe("dispatchTelegramMessage draft streaming", () => { "Cracking...\n`šŸ› ļø Exec`\n`šŸ› ļø git rev-parse --abbrev-ref HEAD`", ); expect(answerDraftStream.update).not.toHaveBeenCalledWith("Branch is up to date"); - expect(answerDraftStream.clear).toHaveBeenCalledTimes(1); + expect(answerDraftStream.forceNewMessage).toHaveBeenCalledTimes(1); + expect(answerDraftStream.clear).not.toHaveBeenCalled(); expectDeliveredReply(0, { text: "Branch is up to date" }); expect(editMessageTelegram).not.toHaveBeenCalled(); }); @@ -1264,9 +1308,9 @@ describe("dispatchTelegramMessage draft streaming", () => { expect(draftStream.update).toHaveBeenCalledWith( "Shelling\n`šŸ”Ž Web Search: docs lookup`\n• `tests passed`", ); - expect(draftStream.forceNewMessage).not.toHaveBeenCalled(); + expect(draftStream.forceNewMessage).toHaveBeenCalledTimes(1); expect(draftStream.materialize).not.toHaveBeenCalled(); - expect(draftStream.clear).toHaveBeenCalledTimes(1); + expect(draftStream.clear).not.toHaveBeenCalled(); expectDeliveredReply(0, { text: "Final after tool" }); expect(editMessageTelegram).not.toHaveBeenCalled(); }); diff --git a/extensions/telegram/src/bot-message-dispatch.ts b/extensions/telegram/src/bot-message-dispatch.ts index 4f844df1d23c..20c6c1ed4fa6 100644 --- a/extensions/telegram/src/bot-message-dispatch.ts +++ b/extensions/telegram/src/bot-message-dispatch.ts @@ -588,6 +588,19 @@ export const dispatchTelegramMessage = async ({ let streamToolProgressSuppressed = false; let streamToolProgressLines: string[] = []; let lastAnswerPartialText = ""; + let activeAnswerDraftIsToolProgressOnly = false; + function resetAnswerToolProgressDraft() { + activeAnswerDraftIsToolProgressOnly = false; + } + async function prepareAnswerLaneForToolProgress() { + if (activeAnswerDraftIsToolProgressOnly) { + return; + } + if (answerLane.hasStreamedMessage) { + await rotateLaneForNewMessage(answerLane); + } + activeAnswerDraftIsToolProgressOnly = true; + } const renderProgressDraft = async (options?: { flush?: boolean }) => { if (!answerLane.stream || streamMode !== "progress") { return; @@ -601,6 +614,7 @@ export const dispatchTelegramMessage = async ({ if (!streamText || streamText === answerLane.lastPartialText) { return; } + await prepareAnswerLaneForToolProgress(); answerLane.lastPartialText = streamText; answerLane.hasStreamedMessage = true; answerLane.finalized = false; @@ -640,6 +654,7 @@ export const dispatchTelegramMessage = async ({ seed: progressSeed, formatLine: formatProgressAsMarkdownCode, }); + await prepareAnswerLaneForToolProgress(); answerLane.lastPartialText = streamText; answerLane.hasStreamedMessage = true; answerLane.finalized = false; @@ -732,6 +747,9 @@ export const dispatchTelegramMessage = async ({ } lane.hasStreamedMessage = false; lane.finalized = false; + if (lane === answerLane) { + resetAnswerToolProgressDraft(); + } }; const rotateLaneForNewMessage = async (lane: DraftLaneState) => { if (!lane.hasStreamedMessage && typeof lane.stream?.messageId() !== "number") { @@ -742,7 +760,21 @@ export const dispatchTelegramMessage = async ({ lane.stream?.forceNewMessage(); resetDraftLaneState(lane); }; + const rotateAnswerLaneAfterToolProgress = async () => { + if (!activeAnswerDraftIsToolProgressOnly) { + return false; + } + await answerLane.stream?.stop(); + answerLane.stream?.forceNewMessage(); + resetDraftLaneState(answerLane); + streamToolProgressSuppressed = true; + streamToolProgressLines = []; + return true; + }; const prepareAnswerLaneForText = async () => { + if (await rotateAnswerLaneAfterToolProgress()) { + return; + } if (!answerLane.finalized) { return; } @@ -762,6 +794,7 @@ export const dispatchTelegramMessage = async ({ if (streamMode === "progress") { return; } + resetAnswerToolProgressDraft(); streamToolProgressSuppressed = true; streamToolProgressLines = []; } @@ -1092,8 +1125,12 @@ export const dispatchTelegramMessage = async ({ payload: ReplyPayload, text: string, ): Promise => { - await answerLane.stream?.clear(); - resetDraftLaneState(answerLane); + if (activeAnswerDraftIsToolProgressOnly) { + await rotateAnswerLaneAfterToolProgress(); + } else { + await answerLane.stream?.clear(); + resetDraftLaneState(answerLane); + } const delivered = await sendPayload(applyTextToPayload(payload, text), { durable: true }); answerLane.finalized = true; return delivered ? { kind: "sent" } : { kind: "skipped" }; @@ -1216,6 +1253,24 @@ export const dispatchTelegramMessage = async ({ const segments = split.segments; const reply = resolveSendableOutboundReplyParts(effectivePayload); + const deliverFinalAnswerText = async ( + answerPayload: ReplyPayload, + text: string, + buttons?: TelegramInlineButtons, + ) => { + if (streamMode === "progress") { + return deliverProgressModeFinalAnswer(answerPayload, text); + } + await rotateAnswerLaneAfterToolProgress(); + return deliverLaneText({ + laneName: "answer", + text, + payload: answerPayload, + infoKind: "final", + buttons, + }); + }; + const flushBufferedFinalAnswer = async () => { const buffered = reasoningStepState.takeBufferedFinalAnswer(replyFenceGeneration); @@ -1227,13 +1282,11 @@ export const dispatchTelegramMessage = async ({ | { buttons?: TelegramInlineButtons } | undefined )?.buttons; - await deliverLaneText({ - laneName: "answer", - text: buffered.text, - payload: buffered.payload, - infoKind: "final", - buttons: bufferedButtons, - }); + await deliverFinalAnswerText( + buffered.payload, + buffered.text, + bufferedButtons, + ); reasoningStepState.resetForNextStep(); }; @@ -1254,13 +1307,15 @@ export const dispatchTelegramMessage = async ({ if (segment.lane === "reasoning") { reasoningStepState.noteReasoningHint(); } + if (segment.lane === "answer" && info.kind === "tool") { + await prepareAnswerLaneForToolProgress(); + } const result = - streamMode === "progress" && - segment.lane === "answer" && - info.kind === "final" - ? await deliverProgressModeFinalAnswer( + segment.lane === "answer" && info.kind === "final" + ? await deliverFinalAnswerText( effectivePayload, segment.update.text, + telegramButtons, ) : await deliverLaneText({ laneName: segment.lane, @@ -1315,6 +1370,7 @@ export const dispatchTelegramMessage = async ({ } if (info.kind === "final") { + await rotateAnswerLaneAfterToolProgress(); await answerLane.stream?.stop(); await reasoningLane.stream?.stop(); reasoningStepState.resetForNextStep();