Telegram: keep verbose tool results separate from final answers (#80294)

* Telegram: keep tool progress separate from final answers

* Telegram: separate verbose tool results from final answers

* fix: separate Telegram verbose tool drafts
This commit is contained in:
Josh Lehman
2026-05-11 15:21:19 -07:00
committed by GitHub
parent d3edbaabcb
commit 24ffd4d2f8
3 changed files with 117 additions and 16 deletions
+1
View File
@@ -294,6 +294,7 @@ Docs: https://docs.openclaw.ai
- ACPX/Windows: hide the MCP proxy target child process window on Windows so ACP-backed agents do not flash or fail because of terminal window handling. Fixes #60672. (#60678) Thanks @KChow-ctrl.
- Agents: abort generic repeated no-progress tool loops at the critical threshold when identical calls keep returning identical outcomes. (#80668) Thanks @frankekn.
- Exec approvals: omit generated command highlights for non-POSIX Windows and shell-wrapper approval commands until those command languages have native highlighting support. (#80566) Thanks @jesse-merhi.
- Telegram: keep verbose tool progress and result drafts separate from the final assistant answer so tool output no longer blends into the final Telegram message. (#80294) Thanks @jalehman.
## 2026.5.9
@@ -1118,6 +1118,49 @@ describe("dispatchTelegramMessage draft streaming", () => {
expect(deliverReplies).not.toHaveBeenCalled();
});
it("rotates a tool-progress-only answer draft before streaming the 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" });
return { queuedFinal: true };
},
);
await dispatchWithContext({ context: createContext() });
expect(answerDraftStream.update).toHaveBeenNthCalledWith(
1,
expect.stringMatching(/`🛠️ Exec`$/),
);
expect(answerDraftStream.update).toHaveBeenNthCalledWith(2, "Branch is up to date");
expect(answerDraftStream.forceNewMessage).toHaveBeenCalledTimes(1);
expect(answerDraftStream.clear).not.toHaveBeenCalled();
const rotationOrder = answerDraftStream.forceNewMessage.mock.invocationCallOrder[0];
const finalUpdateOrder = answerDraftStream.update.mock.invocationCallOrder[1];
expect(rotationOrder).toBeLessThan(finalUpdateOrder);
});
it("rotates a verbose tool result draft before streaming the final answer", async () => {
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
await dispatcherOptions.deliver({ text: "🛠️ Exec: pnpm test" }, { kind: "tool" });
await dispatcherOptions.deliver({ text: "Tests passed" }, { kind: "final" });
return { queuedFinal: true };
});
await dispatchWithContext({ context: createContext() });
expect(answerDraftStream.update).toHaveBeenNthCalledWith(1, "🛠️ Exec: pnpm test");
expect(answerDraftStream.update).toHaveBeenNthCalledWith(2, "Tests passed");
expect(answerDraftStream.forceNewMessage).toHaveBeenCalledTimes(1);
expect(answerDraftStream.clear).not.toHaveBeenCalled();
const rotationOrder = answerDraftStream.forceNewMessage.mock.invocationCallOrder[0];
const finalUpdateOrder = answerDraftStream.update.mock.invocationCallOrder[1];
expect(rotationOrder).toBeLessThan(finalUpdateOrder);
});
it("keeps progress updates in a draft and sends the final answer normally", async () => {
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
@@ -1143,7 +1186,8 @@ describe("dispatchTelegramMessage draft streaming", () => {
"Cracking...\n`🛠️ Exec`\n`🛠️ git rev-parse --abbrev-ref HEAD`",
);
expect(answerDraftStream.update).not.toHaveBeenCalledWith("Branch is up to date");
expect(answerDraftStream.clear).toHaveBeenCalledTimes(1);
expect(answerDraftStream.forceNewMessage).toHaveBeenCalledTimes(1);
expect(answerDraftStream.clear).not.toHaveBeenCalled();
expectDeliveredReply(0, { text: "Branch is up to date" });
expect(editMessageTelegram).not.toHaveBeenCalled();
});
@@ -1264,9 +1308,9 @@ describe("dispatchTelegramMessage draft streaming", () => {
expect(draftStream.update).toHaveBeenCalledWith(
"Shelling\n`🔎 Web Search: docs lookup`\n• `tests passed`",
);
expect(draftStream.forceNewMessage).not.toHaveBeenCalled();
expect(draftStream.forceNewMessage).toHaveBeenCalledTimes(1);
expect(draftStream.materialize).not.toHaveBeenCalled();
expect(draftStream.clear).toHaveBeenCalledTimes(1);
expect(draftStream.clear).not.toHaveBeenCalled();
expectDeliveredReply(0, { text: "Final after tool" });
expect(editMessageTelegram).not.toHaveBeenCalled();
});
+69 -13
View File
@@ -588,6 +588,19 @@ export const dispatchTelegramMessage = async ({
let streamToolProgressSuppressed = false;
let streamToolProgressLines: string[] = [];
let lastAnswerPartialText = "";
let activeAnswerDraftIsToolProgressOnly = false;
function resetAnswerToolProgressDraft() {
activeAnswerDraftIsToolProgressOnly = false;
}
async function prepareAnswerLaneForToolProgress() {
if (activeAnswerDraftIsToolProgressOnly) {
return;
}
if (answerLane.hasStreamedMessage) {
await rotateLaneForNewMessage(answerLane);
}
activeAnswerDraftIsToolProgressOnly = true;
}
const renderProgressDraft = async (options?: { flush?: boolean }) => {
if (!answerLane.stream || streamMode !== "progress") {
return;
@@ -601,6 +614,7 @@ export const dispatchTelegramMessage = async ({
if (!streamText || streamText === answerLane.lastPartialText) {
return;
}
await prepareAnswerLaneForToolProgress();
answerLane.lastPartialText = streamText;
answerLane.hasStreamedMessage = true;
answerLane.finalized = false;
@@ -640,6 +654,7 @@ export const dispatchTelegramMessage = async ({
seed: progressSeed,
formatLine: formatProgressAsMarkdownCode,
});
await prepareAnswerLaneForToolProgress();
answerLane.lastPartialText = streamText;
answerLane.hasStreamedMessage = true;
answerLane.finalized = false;
@@ -732,6 +747,9 @@ export const dispatchTelegramMessage = async ({
}
lane.hasStreamedMessage = false;
lane.finalized = false;
if (lane === answerLane) {
resetAnswerToolProgressDraft();
}
};
const rotateLaneForNewMessage = async (lane: DraftLaneState) => {
if (!lane.hasStreamedMessage && typeof lane.stream?.messageId() !== "number") {
@@ -742,7 +760,21 @@ export const dispatchTelegramMessage = async ({
lane.stream?.forceNewMessage();
resetDraftLaneState(lane);
};
const rotateAnswerLaneAfterToolProgress = async () => {
if (!activeAnswerDraftIsToolProgressOnly) {
return false;
}
await answerLane.stream?.stop();
answerLane.stream?.forceNewMessage();
resetDraftLaneState(answerLane);
streamToolProgressSuppressed = true;
streamToolProgressLines = [];
return true;
};
const prepareAnswerLaneForText = async () => {
if (await rotateAnswerLaneAfterToolProgress()) {
return;
}
if (!answerLane.finalized) {
return;
}
@@ -762,6 +794,7 @@ export const dispatchTelegramMessage = async ({
if (streamMode === "progress") {
return;
}
resetAnswerToolProgressDraft();
streamToolProgressSuppressed = true;
streamToolProgressLines = [];
}
@@ -1092,8 +1125,12 @@ export const dispatchTelegramMessage = async ({
payload: ReplyPayload,
text: string,
): Promise<LaneDeliveryResult> => {
await answerLane.stream?.clear();
resetDraftLaneState(answerLane);
if (activeAnswerDraftIsToolProgressOnly) {
await rotateAnswerLaneAfterToolProgress();
} else {
await answerLane.stream?.clear();
resetDraftLaneState(answerLane);
}
const delivered = await sendPayload(applyTextToPayload(payload, text), { durable: true });
answerLane.finalized = true;
return delivered ? { kind: "sent" } : { kind: "skipped" };
@@ -1216,6 +1253,24 @@ export const dispatchTelegramMessage = async ({
const segments = split.segments;
const reply = resolveSendableOutboundReplyParts(effectivePayload);
const deliverFinalAnswerText = async (
answerPayload: ReplyPayload,
text: string,
buttons?: TelegramInlineButtons,
) => {
if (streamMode === "progress") {
return deliverProgressModeFinalAnswer(answerPayload, text);
}
await rotateAnswerLaneAfterToolProgress();
return deliverLaneText({
laneName: "answer",
text,
payload: answerPayload,
infoKind: "final",
buttons,
});
};
const flushBufferedFinalAnswer = async () => {
const buffered =
reasoningStepState.takeBufferedFinalAnswer(replyFenceGeneration);
@@ -1227,13 +1282,11 @@ export const dispatchTelegramMessage = async ({
| { buttons?: TelegramInlineButtons }
| undefined
)?.buttons;
await deliverLaneText({
laneName: "answer",
text: buffered.text,
payload: buffered.payload,
infoKind: "final",
buttons: bufferedButtons,
});
await deliverFinalAnswerText(
buffered.payload,
buffered.text,
bufferedButtons,
);
reasoningStepState.resetForNextStep();
};
@@ -1254,13 +1307,15 @@ export const dispatchTelegramMessage = async ({
if (segment.lane === "reasoning") {
reasoningStepState.noteReasoningHint();
}
if (segment.lane === "answer" && info.kind === "tool") {
await prepareAnswerLaneForToolProgress();
}
const result =
streamMode === "progress" &&
segment.lane === "answer" &&
info.kind === "final"
? await deliverProgressModeFinalAnswer(
segment.lane === "answer" && info.kind === "final"
? await deliverFinalAnswerText(
effectivePayload,
segment.update.text,
telegramButtons,
)
: await deliverLaneText({
laneName: segment.lane,
@@ -1315,6 +1370,7 @@ export const dispatchTelegramMessage = async ({
}
if (info.kind === "final") {
await rotateAnswerLaneAfterToolProgress();
await answerLane.stream?.stop();
await reasoningLane.stream?.stop();
reasoningStepState.resetForNextStep();