diff --git a/extensions/telegram/src/bot-message-dispatch.test.ts b/extensions/telegram/src/bot-message-dispatch.test.ts index 08a63e0f422..fdf011b0f5c 100644 --- a/extensions/telegram/src/bot-message-dispatch.test.ts +++ b/extensions/telegram/src/bot-message-dispatch.test.ts @@ -1328,6 +1328,47 @@ describe("dispatchTelegramMessage draft streaming", () => { ); }); + it("rotates after a visible tool payload lands between compaction and the next assistant message", async () => { + const answerDraftStream = createSequencedDraftStream(1001); + const reasoningDraftStream = createDraftStream(); + createTelegramDraftStream + .mockImplementationOnce(() => answerDraftStream) + .mockImplementationOnce(() => reasoningDraftStream); + dispatchReplyWithBufferedBlockDispatcher.mockImplementation( + async ({ dispatcherOptions, replyOptions }) => { + await replyOptions?.onPartialReply?.({ text: "Message A partial" }); + await replyOptions?.onCompactionStart?.(); + await replyOptions?.onCompactionEnd?.(); + await dispatcherOptions.deliver( + { mediaUrl: "file:///tmp/tool-result.png" }, + { kind: "tool" }, + ); + await replyOptions?.onAssistantMessageStart?.(); + await replyOptions?.onPartialReply?.({ text: "Message B partial" }); + await dispatcherOptions.deliver({ text: "Message B final" }, { kind: "final" }); + return { queuedFinal: true }; + }, + ); + deliverReplies.mockResolvedValue({ delivered: true }); + editMessageTelegram.mockResolvedValue({ ok: true, chatId: "123", messageId: "1001" }); + + await dispatchWithContext({ context: createContext(), streamMode: "partial" }); + + expect(answerDraftStream.forceNewMessage).toHaveBeenCalledTimes(1); + expect(deliverReplies).toHaveBeenCalledWith( + expect.objectContaining({ + replies: [expect.objectContaining({ mediaUrl: "file:///tmp/tool-result.png" })], + }), + ); + expect(editMessageTelegram).toHaveBeenCalledTimes(1); + expect(editMessageTelegram).toHaveBeenCalledWith( + 123, + expect.any(Number), + "Message B final", + expect.any(Object), + ); + }); + it("finalizes multi-message assistant stream to matching preview messages in order", async () => { const answerDraftStream = createSequencedDraftStream(1001); const reasoningDraftStream = createDraftStream(); diff --git a/extensions/telegram/src/bot-message-dispatch.ts b/extensions/telegram/src/bot-message-dispatch.ts index 29937de7df4..9d80424da8b 100644 --- a/extensions/telegram/src/bot-message-dispatch.ts +++ b/extensions/telegram/src/bot-message-dispatch.ts @@ -10,7 +10,6 @@ import type { OpenClawConfig, ReplyToMode, TelegramAccountConfig, - TelegramDirectConfig, } from "openclaw/plugin-sdk/config-runtime"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { clearHistoryEntriesIfEnabled } from "openclaw/plugin-sdk/reply-history"; @@ -609,6 +608,11 @@ export const dispatchTelegramMessage = async ({ dispatcherOptions: { ...replyPipeline, deliver: async (payload, info) => { + const clearPendingCompactionReplayBoundaryOnVisibleBoundary = (didDeliver: boolean) => { + if (didDeliver && info.kind !== "final") { + pendingCompactionReplayBoundary = false; + } + }; if (payload.isError === true) { hadErrorReplyFailureOrSkip = true; } @@ -706,7 +710,9 @@ export const dispatchTelegramMessage = async ({ if (reply.hasMedia) { const payloadWithoutSuppressedReasoning = typeof payload.text === "string" ? { ...payload, text: "" } : payload; - await sendPayload(payloadWithoutSuppressedReasoning); + clearPendingCompactionReplayBoundaryOnVisibleBoundary( + await sendPayload(payloadWithoutSuppressedReasoning), + ); } if (info.kind === "final") { await flushBufferedFinalAnswer(); @@ -728,7 +734,7 @@ export const dispatchTelegramMessage = async ({ } return; } - await sendPayload(payload); + clearPendingCompactionReplayBoundaryOnVisibleBoundary(await sendPayload(payload)); if (info.kind === "final") { await flushBufferedFinalAnswer(); pendingCompactionReplayBoundary = false; @@ -960,8 +966,10 @@ export const dispatchTelegramMessage = async ({ const userMessage = (ctxPayload.RawBody ?? ctxPayload.Body ?? "").slice(0, 500); if (userMessage.trim()) { const agentDir = resolveAgentDir(cfg, route.agentId); - const directConfig = !isGroup ? (groupConfig as TelegramDirectConfig | undefined) : undefined; - const directAutoTopicLabel = directConfig?.autoTopicLabel; + const directAutoTopicLabel = + !isGroup && groupConfig && "autoTopicLabel" in groupConfig + ? groupConfig.autoTopicLabel + : undefined; const accountAutoTopicLabel = telegramCfg?.autoTopicLabel; const autoTopicConfig = resolveAutoTopicLabelConfig( directAutoTopicLabel,