fix(telegram): avoid materializing tool-progress drafts

Address Clownfish follow-up on Telegram native draft finalization. Requires real streamed assistant partials before materializing drafts, clears stale native draft previews, and keeps media/buttons on normal send path.
This commit is contained in:
Vincent Koc
2026-04-26 19:43:23 -07:00
committed by GitHub
parent 6d0e84aadb
commit d5063d5b16
6 changed files with 143 additions and 24 deletions

View File

@@ -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.

View File

@@ -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),
);

View File

@@ -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();
});

View File

@@ -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 () => {

View File

@@ -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);

View File

@@ -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 });