fix(telegram): isolate verbose status after streamed finals (#89813)

Summary:
- The branch updates Telegram dispatch so a verbose/status final arriving after a streamed final answer uses a fresh answer-lane message, with default and progress-mode regression tests.
- PR surface: Source +14, Tests +52. Total +66 across 2 files.
- Reproducibility: yes. The linked bug report gives a concrete Telegram `/reset`, `/v on`, short-prompt path, and source inspection shows current main can route a second final payload through the finalized answer lane.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(telegram): isolate verbose status after streamed finals

Validation:
- ClawSweeper review passed for head 4d476a957f.
- Required merge gates passed before the squash merge.

Prepared head SHA: 4d476a957f
Review: https://github.com/openclaw/openclaw/pull/89813#issuecomment-4612006920

Co-authored-by: kesslerio <martin@kessler.io>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
This commit is contained in:
clawsweeper[bot]
2026-06-03 12:21:08 +00:00
committed by GitHub
co-authored by kesslerio clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com> clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com> takhoffman
parent 9dc1694eb7
commit 07006943de
2 changed files with 66 additions and 0 deletions
@@ -227,6 +227,7 @@ const telegramDepsForTest: TelegramBotDeps = {
describe("dispatchTelegramMessage draft streaming", () => {
type TelegramMessageContext = Parameters<typeof dispatchTelegramMessage>[0]["context"];
const trailingFinalStatusText = "Post-final plugin status";
beforeAll(async () => {
({ dispatchTelegramMessage, resetTelegramReplyFenceForTests } =
@@ -1605,6 +1606,30 @@ describe("dispatchTelegramMessage draft streaming", () => {
expect(editMessageTelegram).not.toHaveBeenCalled();
});
it("sends trailing verbose status after streamed final answer without replacing the answer draft", async () => {
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
async ({ dispatcherOptions, replyOptions }) => {
await replyOptions?.onPartialReply?.({ text: "Normal reply" });
await dispatcherOptions.deliver({ text: "Normal reply" }, { kind: "final" });
await dispatcherOptions.deliver({ text: trailingFinalStatusText }, { kind: "final" });
return { queuedFinal: true };
},
);
await dispatchWithContext({ context: createContext() });
expect(answerDraftStream.update).toHaveBeenCalledTimes(3);
expect(answerDraftStream.update).toHaveBeenNthCalledWith(1, "Normal reply");
expect(answerDraftStream.update).toHaveBeenNthCalledWith(2, "Normal reply");
expect(answerDraftStream.update).toHaveBeenNthCalledWith(3, trailingFinalStatusText);
expect(answerDraftStream.forceNewMessage).toHaveBeenCalledTimes(1);
expect(answerDraftStream.forceNewMessage.mock.invocationCallOrder[0]).toBeLessThan(
answerDraftStream.update.mock.invocationCallOrder[2],
);
expect(deliverReplies).not.toHaveBeenCalled();
});
it("applies partial deltas while preserving the first-preview debounce", async () => {
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
@@ -2075,6 +2100,33 @@ describe("dispatchTelegramMessage draft streaming", () => {
expect(editMessageTelegram).not.toHaveBeenCalled();
});
it("sends trailing verbose status after a progress-mode 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" });
await dispatcherOptions.deliver({ text: trailingFinalStatusText }, { kind: "final" });
return { queuedFinal: true };
},
);
await dispatchWithContext({
context: createContext(),
streamMode: "progress",
telegramCfg: { streaming: { mode: "progress" } },
});
expect(answerDraftStream.update).toHaveBeenCalledTimes(2);
expect(answerDraftStream.update).toHaveBeenNthCalledWith(1, "Cracking\n\n`🛠️ Exec`");
expect(answerDraftStream.update).toHaveBeenNthCalledWith(2, trailingFinalStatusText);
expect(answerDraftStream.forceNewMessage).toHaveBeenCalledTimes(2);
expect(answerDraftStream.forceNewMessage.mock.invocationCallOrder[1]).toBeLessThan(
answerDraftStream.update.mock.invocationCallOrder[1],
);
expectDeliveredReply(0, { text: "Branch is up to date" });
});
it("does not stream text-only tool results into progress drafts", async () => {
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
@@ -1673,6 +1673,19 @@ export const dispatchTelegramMessage = async ({
buttons?: TelegramInlineButtons,
) => {
const finalText = await resolveTranscriptBackedFinalText(text);
const deliverPostFinalFollowUpText = async () => {
await prepareAnswerLaneForText();
return deliverLaneText({
laneName: "answer",
text: finalText,
payload: answerPayload,
infoKind: "final",
buttons,
});
};
if (finalAnswerDelivered) {
return deliverPostFinalFollowUpText();
}
if (streamMode === "progress") {
return deliverProgressModeFinalAnswer(answerPayload, finalText);
}
@@ -1918,6 +1931,7 @@ export const dispatchTelegramMessage = async ({
? () =>
enqueueDraftLaneEvent(async () => {
reasoningStepState.resetForNextStep();
finalAnswerDelivered = false;
if (streamMode !== "progress") {
resetProgressDraftState();
}