mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:30:42 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user