diff --git a/CHANGELOG.md b/CHANGELOG.md index eccfe3fad68..0eab864e594 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -399,6 +399,7 @@ Docs: https://docs.openclaw.ai - CLI/models: make `openclaw models scan` fall back to public OpenRouter free-model metadata when no `OPENROUTER_API_KEY` is configured, avoid config secret resolution for explicit `--no-probe` scans, and apply the scan timeout to the OpenRouter catalog request. - Feishu: keep streaming cards to one live card per turn, flush throttled card edits after meaningful text boundaries, and skip exact block/partial repeats so tool-heavy replies do not duplicate card output. Thanks @allan0509. - Feishu: finish the streaming-card duplicate closeout by stripping leaked reasoning tags, preserving cross-block partial snapshots, enabling topic-thread streaming cards, omitting the generic `main` card header, surfacing transient tool/compaction status, and cleaning streaming state after close failures. Thanks @sesame437, @Vicky-v7, @maoku-family, @Pengxiao-Wang, and @Maple778. +- Telegram: keep final-only answers on the normal final-send path instead of creating synthetic draft previews, while preserving real partial preview finalization. Credited from #39213. Thanks @chalawbot. - Telegram: recover incomplete partial-stream previews by falling back to a final send when an ambiguous final edit failure would otherwise retain a strict prefix of the answer. Fixes #71525. (#71554) Thanks @sahilsatralkar. - Control UI/chat: collapse assistant token/model context details behind an explicit Context disclosure and show full dates in message footers, making historical transcript timing clear without noisy default metadata. (#71337) Thanks @BunsDev. - OpenAI/Codex OAuth: explain `unsupported_country_region_territory` token-exchange failures with a proxy/region hint instead of surfacing a generic OAuth error. Fixes #51175. (#71501) Thanks @vincentkoc and @wulala-xjj. diff --git a/extensions/telegram/src/bot-message-dispatch.test.ts b/extensions/telegram/src/bot-message-dispatch.test.ts index 50c294b84d9..d30e37dc66e 100644 --- a/extensions/telegram/src/bot-message-dispatch.test.ts +++ b/extensions/telegram/src/bot-message-dispatch.test.ts @@ -740,6 +740,31 @@ describe("dispatchTelegramMessage draft streaming", () => { ); }); + it("does not materialize native draft tool progress before final-only text", async () => { + const draftStream = createTestDraftStream({ previewMode: "draft" }); + draftStream.materialize.mockResolvedValue(321); + createTelegramDraftStream.mockReturnValue(draftStream); + dispatchReplyWithBufferedBlockDispatcher.mockImplementation( + async ({ dispatcherOptions, replyOptions }) => { + await replyOptions?.onToolStart?.({ name: "exec", phase: "start" }); + await dispatcherOptions.deliver({ text: "Done" }, { kind: "final" }); + return { queuedFinal: true }; + }, + ); + + await dispatchWithContext({ context: createContext(), streamMode: "partial" }); + + expect(draftStream.update).toHaveBeenCalledWith("Working…\n• `tool: exec`"); + expect(draftStream.update).not.toHaveBeenCalledWith("Done"); + expect(draftStream.materialize).not.toHaveBeenCalled(); + expect(deliverReplies).toHaveBeenCalledWith( + expect.objectContaining({ + replies: [expect.objectContaining({ text: "Done" })], + }), + ); + expect(draftStream.clear).toHaveBeenCalledTimes(1); + }); + it("suppresses Telegram tool progress when explicitly disabled", async () => { const draftStream = createDraftStream(); createTelegramDraftStream.mockReturnValue(draftStream); @@ -1201,12 +1226,14 @@ describe("dispatchTelegramMessage draft streaming", () => { await replyOptions?.onPartialReply?.({ text: "Message A partial" }); await dispatcherOptions.deliver({ text: "Message A final" }, { kind: "final" }); const startPromise = replyOptions?.onAssistantMessageStart?.(); + const partialPromise = replyOptions?.onPartialReply?.({ text: "Message B partial" }); const finalPromise = dispatcherOptions.deliver( { text: "Message B final" }, { kind: "final" }, ); resolveMaterialize?.(1001); await startPromise; + await partialPromise; await finalPromise; return { queuedFinal: true }; }, @@ -1368,7 +1395,7 @@ describe("dispatchTelegramMessage draft streaming", () => { expect(boundaryRotationOrder).toBeLessThan(secondUpdateOrder); }); - it("keeps final-only preview lane finalized until a real boundary rotation happens", async () => { + it("sends final-only text without creating a synthetic preview before real partials", async () => { const answerDraftStream = createSequencedDraftStream(1001); const reasoningDraftStream = createDraftStream(); createTelegramDraftStream @@ -1392,17 +1419,16 @@ describe("dispatchTelegramMessage draft streaming", () => { await dispatchWithContext({ context: createContext(), streamMode: "partial" }); expect(answerDraftStream.forceNewMessage).toHaveBeenCalledTimes(1); + expect(deliverReplies).toHaveBeenCalledWith( + expect.objectContaining({ + replies: [expect.objectContaining({ text: "Message A final" })], + }), + ); + expect(editMessageTelegram).toHaveBeenCalledTimes(1); expect(editMessageTelegram).toHaveBeenNthCalledWith( 1, 123, 1001, - "Message A final", - expect.any(Object), - ); - expect(editMessageTelegram).toHaveBeenNthCalledWith( - 2, - 123, - 1002, "Message B final", expect.any(Object), ); diff --git a/extensions/telegram/src/draft-stream.test.ts b/extensions/telegram/src/draft-stream.test.ts index cd82809cffe..c5fa46a7ec4 100644 --- a/extensions/telegram/src/draft-stream.test.ts +++ b/extensions/telegram/src/draft-stream.test.ts @@ -151,6 +151,9 @@ describe("createTelegramDraftStream", () => { expect(api.editMessageText).not.toHaveBeenCalled(); await stream.clear(); + expect(api.sendMessageDraft).toHaveBeenLastCalledWith(123, expect.any(Number), "", { + message_thread_id: 42, + }); expect(api.deleteMessage).not.toHaveBeenCalled(); }); diff --git a/extensions/telegram/src/draft-stream.ts b/extensions/telegram/src/draft-stream.ts index 802442f74ba..19511a224a3 100644 --- a/extensions/telegram/src/draft-stream.ts +++ b/extensions/telegram/src/draft-stream.ts @@ -1,7 +1,7 @@ import type { Bot } from "grammy"; import { - clearFinalizableDraftMessage, createFinalizableDraftStreamControlsForState, + takeMessageIdAfterStop, } from "openclaw/plugin-sdk/channel-lifecycle"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { buildTelegramThreadParams, type TelegramThreadSpec } from "./bot/helpers.js"; @@ -380,23 +380,32 @@ export function createTelegramDraftStream(params: { }); const clear = async () => { - await clearFinalizableDraftMessage({ + const messageId = await takeMessageIdAfterStop({ stopForClear, readMessageId: () => streamMessageId, clearMessageId: () => { streamMessageId = undefined; }, - isValidMessageId: (value): value is number => - typeof value === "number" && Number.isFinite(value), - deleteMessage: async (messageId) => { - await params.api.deleteMessage(chatId, messageId); - }, - onDeleteSuccess: (messageId) => { - params.log?.(`telegram stream preview deleted (chat=${chatId}, message=${messageId})`); - }, - warn: params.warn, - warnPrefix: "telegram stream preview cleanup failed", }); + if (typeof messageId === "number" && Number.isFinite(messageId)) { + try { + await params.api.deleteMessage(chatId, messageId); + params.log?.(`telegram stream preview deleted (chat=${chatId}, message=${messageId})`); + } catch (err) { + params.warn?.(`telegram stream preview cleanup failed: ${formatErrorMessage(err)}`); + } + return; + } + if (previewTransport !== "draft" || resolvedDraftApi == null || streamDraftId == null) { + return; + } + const clearDraftId = streamDraftId; + streamDraftId = undefined; + try { + await resolvedDraftApi(chatId, clearDraftId, "", threadParams); + } catch (err) { + params.warn?.(`telegram stream preview cleanup failed: ${formatErrorMessage(err)}`); + } }; const discard = async () => { diff --git a/extensions/telegram/src/lane-delivery-text-deliverer.ts b/extensions/telegram/src/lane-delivery-text-deliverer.ts index ae1d83c065f..8fb0a42f411 100644 --- a/extensions/telegram/src/lane-delivery-text-deliverer.ts +++ b/extensions/telegram/src/lane-delivery-text-deliverer.ts @@ -225,6 +225,7 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { ) => { const hasPreviewButtons = Boolean(previewButtons && previewButtons.length > 0); return ( + lane.hasStreamedMessage && isDraftPreviewLane(lane) && !hasPreviewButtons && typeof lane.stream?.materialize === "function" @@ -412,7 +413,7 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { stopBeforeEdit, context, }); - if (previewTargetBeforeStop.stopCreatesFirstPreview) { + if (previewTargetBeforeStop.stopCreatesFirstPreview && lane.hasStreamedMessage) { // Final stop() can create the first visible preview message. // Prime pending text so the stop flush sends the final text snapshot. lane.stream.update(text); diff --git a/extensions/telegram/src/lane-delivery.test.ts b/extensions/telegram/src/lane-delivery.test.ts index 174c73c9ddd..adbabaa20e4 100644 --- a/extensions/telegram/src/lane-delivery.test.ts +++ b/extensions/telegram/src/lane-delivery.test.ts @@ -1,6 +1,9 @@ import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import { describe, expect, it, vi } from "vitest"; -import { createTestDraftStream } from "./draft-stream.test-helpers.js"; +import { + createSequencedTestDraftStream, + createTestDraftStream, +} from "./draft-stream.test-helpers.js"; import { type ArchivedPreview, createLaneTextDeliverer, @@ -173,7 +176,10 @@ describe("createLaneTextDeliverer", () => { }); it("primes stop-created previews with final text before editing", async () => { - const harness = createHarness({ answerMessageIdAfterStop: 777 }); + const harness = createHarness({ + answerMessageIdAfterStop: 777, + answerHasStreamedMessage: true, + }); harness.lanes.answer.lastPartialText = "no"; const result = await harness.deliverLaneText({ @@ -196,7 +202,10 @@ describe("createLaneTextDeliverer", () => { }); it("keeps stop-created preview when follow-up final edit fails", async () => { - const harness = createHarness({ answerMessageIdAfterStop: 777 }); + const harness = createHarness({ + answerMessageIdAfterStop: 777, + answerHasStreamedMessage: true, + }); harness.editPreview.mockRejectedValue(new Error("500: edit failed after stop flush")); const result = await harness.deliverLaneText({ @@ -314,6 +323,29 @@ describe("createLaneTextDeliverer", () => { ); }); + it("does not create a synthetic preview for final-only text", async () => { + const answerStream = createSequencedTestDraftStream(777); + const harness = createHarness({ + answerStream: answerStream as DraftLaneState["stream"], + answerHasStreamedMessage: false, + }); + + const result = await harness.deliverLaneText({ + laneName: "answer", + text: "Final only", + payload: { text: "Final only" }, + infoKind: "final", + }); + + expect(result.kind).toBe("sent"); + expect(answerStream.update).not.toHaveBeenCalled(); + expect(answerStream.materialize).not.toHaveBeenCalled(); + expect(harness.editPreview).not.toHaveBeenCalled(); + expect(harness.sendPayload).toHaveBeenCalledWith( + expect.objectContaining({ text: "Final only" }), + ); + }); + it("keeps existing preview when final text regresses", async () => { const harness = createHarness({ answerMessageId: 999 }); harness.lanes.answer.lastPartialText = "Recovered final answer."; @@ -485,6 +517,53 @@ describe("createLaneTextDeliverer", () => { expect(harness.markDelivered).toHaveBeenCalledTimes(1); }); + it("does not materialize a native draft for final-only text", async () => { + const answerStream = createTestDraftStream({ previewMode: "draft" }); + answerStream.materialize.mockResolvedValue(321); + const harness = createHarness({ + answerStream: answerStream as DraftLaneState["stream"], + answerHasStreamedMessage: false, + }); + + const result = await harness.deliverLaneText({ + laneName: "answer", + text: "Final only", + payload: { text: "Final only" }, + infoKind: "final", + }); + + expect(result.kind).toBe("sent"); + expect(answerStream.update).not.toHaveBeenCalled(); + expect(answerStream.materialize).not.toHaveBeenCalled(); + expect(harness.sendPayload).toHaveBeenCalledWith( + expect.objectContaining({ text: "Final only" }), + ); + }); + + it("does not materialize native draft tool-progress preview before final-only text", async () => { + const answerStream = createTestDraftStream({ previewMode: "draft" }); + answerStream.materialize.mockResolvedValue(321); + const harness = createHarness({ + answerStream: answerStream as DraftLaneState["stream"], + answerHasStreamedMessage: false, + answerLastPartialText: "Working...\n- tool: exec", + }); + + const result = await harness.deliverLaneText({ + laneName: "answer", + text: "Final only", + payload: { text: "Final only" }, + infoKind: "final", + }); + + expect(result.kind).toBe("sent"); + expect(answerStream.update).not.toHaveBeenCalledWith("Final only"); + expect(answerStream.materialize).not.toHaveBeenCalled(); + expect(harness.sendPayload).toHaveBeenCalledWith( + expect.objectContaining({ text: "Final only" }), + ); + }); + it("materializes DM draft streaming final when revision changes", async () => { let previewRevision = 3; const answerStream = createTestDraftStream({ previewMode: "draft", messageId: 654 });