fix(telegram): mode-gate corrections, collapse-by-edit, durable 🧠 marker

Five progress-window fixes for the Telegram streaming lane, aligning it with
the Discord reference surface:

- FIX 2: /reasoning on + /verbose off no longer kills the progress window.
  forceBlockStreamingForReasoning is now scoped to non-progress modes, so
  durable reasoning removes only the 🧠 lane; commentary/tools still stream and
  the collapse bar still posts.
- FIX 3: durable thoughts render behind the 🧠 marker instead of the literal
  "Thinking" header. The core formatReasoningMessage output is rewritten
  channel-side in reasoning-lane-coordinator (no core change), keeping the
  italic body.
- FIX 4: /verbose on no longer duplicates tool calls. canPushStreamToolProgress
  now yields under verbose so the durable verbose lane owns every progress
  surface (invariant: persistent message XOR window).
- FIX 1: the progress window collapses by EDITING the existing message in place
  into the summary bar (draft-stream finalizeToPreview), then posts the final
  below — no delete + repost, which scroll-jumped the client. Falls back to
  clearing the window when there is no bar to collapse into.
- FIX 5: message_tool_only/codex finals that bypass the in-band answer path now
  post the collapse bar from a cleanup-time fallback (sawProgressFinal from the
  dispatch counts).

Tests: adapted predating dispatch/reasoning tests to the new 🧠 marker and
collapse-by-edit behavior; added coverage for FIX 2 (window alive under
/reasoning on), FIX 4 (no window tool dup under verbose), and FIX 5
(message_tool_only collapse bar). 220 green across bot-message-dispatch,
draft-stream, progress-summary.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Peter Lindsey
2026-07-02 18:03:55 +08:00
committed by Ayaan Zaidi
parent 16a07774a9
commit af0477b61d
5 changed files with 300 additions and 52 deletions
@@ -411,6 +411,18 @@ describe("dispatchTelegramMessage draft streaming", () => {
return expectRecordFields(mockCallArg(dispatchReplyWithBufferedBlockDispatcher), expected);
}
// The collapse bar edits the live window message in place (finalizeToPreview)
// instead of deleting it and reposting the bar as a new message.
function expectWindowCollapsedTo(
stream: { finalizeToPreview: { mock: { calls: unknown[][] } } },
barText: string,
) {
const calls = stream.finalizeToPreview.mock.calls;
expect(calls.length).toBeGreaterThan(0);
const preview = calls[calls.length - 1][0] as { text?: string };
expect(preview.text).toBe(barText);
}
function createContext(overrides?: Partial<TelegramMessageContext>): TelegramMessageContext {
const base = {
ctxPayload: {},
@@ -2888,11 +2900,12 @@ describe("dispatchTelegramMessage draft streaming", () => {
);
expect(answerDraftStream.update).not.toHaveBeenCalledWith("Branch is up to date");
expect(answerDraftStream.forceNewMessage).toHaveBeenCalledTimes(1);
expect(answerDraftStream.clear).toHaveBeenCalledTimes(1);
// The progress window collapses to a one-line activity summary (Discord
// parity) before the final answer posts fresh below it.
expectDeliveredReply(0, { text: "🛠️ 1 tool call · ⏱️ 1s" });
expectDeliveredReply(0, { text: "Branch is up to date" }, 1);
// The window collapses IN PLACE into the one-line activity summary (edit,
// not delete + repost — Discord parity), so clear() is never called on it.
expect(answerDraftStream.clear).not.toHaveBeenCalled();
expectWindowCollapsedTo(answerDraftStream, "🛠️ 1 tool call · ⏱️ 1s");
// The final answer then posts fresh below the collapsed bar.
expectDeliveredReply(0, { text: "Branch is up to date" });
expect(editMessageTelegram).not.toHaveBeenCalled();
});
@@ -2905,7 +2918,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
}
it("tallies reasoning bursts and tool calls into the collapse summary", async () => {
setupDraftStreams({ answerMessageId: 2001 });
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
async ({ dispatcherOptions, replyOptions }) => {
// burst 1 → tool → burst 2 → tool, then a trailing burst flushed at the
@@ -2928,8 +2941,8 @@ describe("dispatchTelegramMessage draft streaming", () => {
telegramCfg: { streaming: { mode: "progress" } },
});
expectDeliveredReply(0, { text: "🧠 3 thoughts · 🛠️ 2 tool calls · ⏱️ 1s" });
expectDeliveredReply(0, { text: "Done" }, 1);
expectWindowCollapsedTo(answerDraftStream, "🧠 3 thoughts · 🛠️ 2 tool calls · ⏱️ 1s");
expectDeliveredReply(0, { text: "Done" });
});
it("does not post a collapse summary when no progress draft started", async () => {
@@ -2974,6 +2987,88 @@ describe("dispatchTelegramMessage draft streaming", () => {
expect(texts.some((text) => text.includes("tool call · ⏱️"))).toBe(false);
});
it("keeps the progress window alive under /reasoning on so commentary and tools still stream", async () => {
// /reasoning on removes only the 🧠 lane from the window; commentary, tool
// lines, and the collapse bar must still stream (Discord parity). A prior
// regression forced block streaming in progress mode, killing the window.
loadSessionStore.mockReturnValue({ s1: { reasoningLevel: "on" } });
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
async ({ dispatcherOptions, replyOptions }) => {
await replyOptions?.onItemEvent?.({ kind: "preamble", itemId: "c1", progressText: "Note" });
await replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
await dispatcherOptions.deliver({ text: "Done" }, { kind: "final" });
return { queuedFinal: true };
},
);
await dispatchWithContext({
context: createContext({
ctxPayload: { SessionKey: "s1" } as unknown as TelegramMessageContext["ctxPayload"],
}),
streamMode: "progress",
telegramCfg: { streaming: { mode: "progress" } },
});
// The window streamed (a preview was rendered) and collapsed into a bar
// counting the note + tool — proof the window was not killed.
expect(answerDraftStream.updatePreview).toHaveBeenCalled();
expectWindowCollapsedTo(answerDraftStream, "💬 1 note · 🛠️ 1 tool call · ⏱️ 1s");
expectDeliveredReply(0, { text: "Done" });
});
it("does not duplicate tool lines into the window under verbose", async () => {
// Invariant D2 (persistent XOR window): when the durable verbose lane owns
// tool messages, the window must render no tool line and must not count it.
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
async ({ dispatcherOptions, replyOptions }) => {
replyOptions?.onVerboseProgressVisibility?.(true);
await replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
await dispatcherOptions.deliver({ text: "Done" }, { kind: "final" });
return { queuedFinal: true };
},
);
await dispatchWithContext({
context: createContext(),
streamMode: "progress",
telegramCfg: { streaming: { mode: "progress" } },
});
// No tool line ever rendered to the window (verbose owns it durably), so the
// window never streamed and there is no collapse bar to count it.
expect(answerDraftStream.updatePreview).not.toHaveBeenCalled();
expect(answerDraftStream.finalizeToPreview).not.toHaveBeenCalled();
const texts = allDeliveredReplyTexts();
expect(texts.some((text) => text.includes("tool call"))).toBe(false);
});
it("posts a collapse summary for a message_tool_only final that bypasses the answer path", async () => {
// Codex-runtime turns deliver the final out-of-band (queuedFinal), so the
// in-band collapse path never runs. The window still started, so the
// cleanup-time fallback must emit the bar (Discord parity).
setupDraftStreams({ answerMessageId: 2001 });
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ replyOptions }) => {
await replyOptions?.onItemEvent?.({ kind: "preamble", itemId: "c1", progressText: "Note" });
await replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
return {
queuedFinal: true,
counts: { block: 0, final: 1, tool: 1 },
sourceReplyDeliveryMode: "message_tool_only",
};
});
await dispatchWithContext({
context: createContext(),
streamMode: "progress",
telegramCfg: { streaming: { mode: "progress" } },
});
const texts = allDeliveredReplyTexts();
expect(texts).toContain("💬 1 note · 🛠️ 1 tool call · ⏱️ 1s");
});
it("replaces Telegram command progress items with matching command output", async () => {
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ replyOptions }) => {
@@ -3037,9 +3132,10 @@ describe("dispatchTelegramMessage draft streaming", () => {
expect(answerDraftStream.forceNewMessage.mock.invocationCallOrder[1]).toBeLessThan(
answerDraftStream.update.mock.invocationCallOrder[0],
);
// Collapse summary posts first, then the final answer below it.
expectDeliveredReply(0, { text: "🛠️ 1 tool call · ⏱️ 1s" });
expectDeliveredReply(0, { text: "Branch is up to date" }, 1);
// Window collapses in place into the summary bar; the final answer posts
// fresh below it.
expectWindowCollapsedTo(answerDraftStream, "🛠️ 1 tool call · ⏱️ 1s");
expectDeliveredReply(0, { text: "Branch is up to date" });
});
it("does not stream text-only tool results into progress drafts", async () => {
@@ -3122,8 +3218,8 @@ describe("dispatchTelegramMessage draft streaming", () => {
expect(answerDraftStream.updatePreview).toHaveBeenCalledWith(
telegramProgressPreview("Shelling\n\n🛠️ Exec", "<b>Shelling</b>\n<b>🛠️ Exec</b>"),
);
expectDeliveredReply(0, { text: "🛠️ 1 tool call · ⏱️ 1s" });
expectDeliveredReply(0, { text: "Branch is up to date" }, 1);
expectWindowCollapsedTo(answerDraftStream, "🛠️ 1 tool call · ⏱️ 1s");
expectDeliveredReply(0, { text: "Branch is up to date" });
});
it("does not restart progress drafts for command output after final answer delivery", async () => {
@@ -3153,8 +3249,8 @@ describe("dispatchTelegramMessage draft streaming", () => {
expect(answerDraftStream.updatePreview).toHaveBeenCalledWith(
telegramProgressPreview("Shelling\n\n🛠️ Exec", "<b>Shelling</b>\n<b>🛠️ Exec</b>"),
);
expectDeliveredReply(0, { text: "🛠️ 1 tool call · ⏱️ 1s" });
expectDeliveredReply(0, { text: "Branch is up to date" }, 1);
expectWindowCollapsedTo(answerDraftStream, "🛠️ 1 tool call · ⏱️ 1s");
expectDeliveredReply(0, { text: "Branch is up to date" });
});
it("does not restart progress drafts for command output while final answer delivery is pending", async () => {
@@ -3188,12 +3284,12 @@ describe("dispatchTelegramMessage draft streaming", () => {
expect(answerDraftStream.updatePreview).toHaveBeenCalledWith(
telegramProgressPreview("Shelling\n\n🛠️ Exec", "<b>Shelling</b>\n<b>🛠️ Exec</b>"),
);
expectDeliveredReply(0, { text: "🛠️ 1 tool call · ⏱️ 1s" });
expectDeliveredReply(0, { text: "Branch is up to date" }, 1);
expectWindowCollapsedTo(answerDraftStream, "🛠️ 1 tool call · ⏱️ 1s");
expectDeliveredReply(0, { text: "Branch is up to date" });
});
it("uses the transcript final when progress-mode final text is truncated", async () => {
setupDraftStreams({ answerMessageId: 2001 });
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
const fullAnswer =
"Ja. Hier nochmal sauber Schritt fuer Schritt. Einen API Key kopiert man aus der Google Cloud Console. Danach pruefst du die Projekt- und API-Einstellungen.";
const truncatedFinal =
@@ -3219,8 +3315,8 @@ describe("dispatchTelegramMessage draft streaming", () => {
telegramCfg: { streaming: { mode: "progress" } },
});
expectDeliveredReply(0, { text: "🛠️ 1 tool call · ⏱️ 1s" });
expectDeliveredReply(0, { text: fullAnswer }, 1);
expectWindowCollapsedTo(answerDraftStream, "🛠️ 1 tool call · ⏱️ 1s");
expectDeliveredReply(0, { text: fullAnswer });
});
it("streams the first long final chunk and sends follow-up chunks", async () => {
@@ -3949,7 +4045,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
await dispatchWithContext({ context: createReasoningStreamContext() });
expect(reasoningDraftStream.update).toHaveBeenCalledWith("Thinking\n\n_Thinking_");
expect(reasoningDraftStream.update).toHaveBeenCalledWith("🧠 _Thinking_");
expect(answerDraftStream.update).toHaveBeenCalledWith("Answer");
expect(deliverReplies).not.toHaveBeenCalled();
});
@@ -3969,7 +4065,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
await dispatchWithContext({ context: createReasoningForumTopicContext() });
expect(reasoningDraftStream.update).toHaveBeenCalledWith("Thinking\n\n_Thinking_");
expect(reasoningDraftStream.update).toHaveBeenCalledWith("🧠 _Thinking_");
expect(answerDraftStream.update).toHaveBeenCalledWith("Answer");
expect(answerDraftStream.stop).toHaveBeenCalled();
expect(deliverReplies).not.toHaveBeenCalled();
@@ -4018,7 +4114,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
await dispatchWithContext({ context: createReasoningStreamContext() });
expect(reasoningDraftStream.update).toHaveBeenLastCalledWith(
"Thinking\n\n_Reading_\n\n_Checking_",
"🧠 _Reading_\n\n_Checking_",
);
const updates = reasoningDraftStream.update.mock.calls.map((call) => call[0]);
expect(updates.join("\n")).not.toContain("CheckingReading");
@@ -4047,7 +4143,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
},
});
expect(reasoningDraftStream.update).toHaveBeenCalledWith("Thinking\n\n_Thinking_");
expect(reasoningDraftStream.update).toHaveBeenCalledWith("🧠 _Thinking_");
expect(answerDraftStream.update).toHaveBeenCalledWith("Answer");
});
@@ -4068,10 +4164,12 @@ describe("dispatchTelegramMessage draft streaming", () => {
const run = dispatchWithContext({ context: createReasoningStreamContext() });
await vi.waitFor(() =>
expect(reasoningDraftStream.update).toHaveBeenCalledWith("Thinking\n\n_Thinking_"),
expect(reasoningDraftStream.update).toHaveBeenCalledWith("🧠 _Thinking_"),
);
// Durable thoughts render behind the 🧠 marker; the literal "Thinking"
// header (and its streaming dot-variants) must never leak back into a lane.
expect(reasoningDraftStream.update).not.toHaveBeenCalledWith("Thinking\n\n_Thinking_");
expect(reasoningDraftStream.update).not.toHaveBeenCalledWith("Thinking.\n\n_Thinking_");
expect(reasoningDraftStream.update).not.toHaveBeenCalledWith("Thinking..\n\n_Thinking_");
expect(reasoningDraftStream.update).not.toHaveBeenCalledWith("Thinking...\n\n_Thinking_");
finishRun?.();
await run;
@@ -4158,7 +4256,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
await dispatchWithContext({ context: createReasoningStreamContext() });
expect(reasoningDraftStream.update).toHaveBeenCalledWith("Thinking\n\n_hidden_");
expect(reasoningDraftStream.update).toHaveBeenCalledWith("🧠 _hidden_");
expect(deliverReplies).not.toHaveBeenCalled();
});
@@ -4180,7 +4278,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
}),
});
const delivered = expectDeliveredReply(0, { text: "Thinking\n\n_hidden_" });
const delivered = expectDeliveredReply(0, { text: "🧠 _hidden_" });
expect(delivered).not.toHaveProperty("isReasoning");
});
+98 -22
View File
@@ -900,7 +900,14 @@ export const dispatchTelegramMessage = async ({
agentId: route.agentId,
loadFreshSessionEntry,
});
const forceBlockStreamingForReasoning = resolvedReasoningLevel === "on";
// Progress mode's ephemeral working-lane window IS the streaming mechanism and
// is independent of reasoning persistence (Discord keeps its window alive
// regardless of /reasoning). Only non-progress modes upgrade reasoning-on to
// block streaming. Forcing block streaming in progress mode killed the whole
// window (no commentary/tool lanes, no collapse bar) and suppressed all
// streamed output for message_tool_only providers.
const forceBlockStreamingForReasoning =
resolvedReasoningLevel === "on" && streamMode !== "progress";
const streamReasoningDraft = resolvedReasoningLevel === "stream";
const streamDeliveryEnabled = !isRoomEvent && streamMode !== "off";
const rawReplyQuoteText =
@@ -1087,13 +1094,15 @@ export const dispatchTelegramMessage = async ({
});
let finalAnswerDeliveryStarted = false;
let finalAnswerDelivered = false;
// While the durable verbose lane is active, the ephemeral draft yields its
// commentary lines so they render once. Tool/plan status lines keep the
// draft: they have no durable counterpart in streamed runs.
// While the durable verbose lane is active it owns EVERY progress surface
// (commentary, tool, plan, command output, patch summaries), posting each as
// its own persistent message. The ephemeral window must therefore render none
// of them, or each renders twice (invariant: persistent message XOR window).
let verboseProgressActive: () => boolean = () => false;
const canPushStreamToolProgress = () =>
Boolean(
answerLane.stream &&
!verboseProgressActive() &&
!answerLane.finalized &&
!finalAnswerDeliveryStarted &&
!finalAnswerDelivered,
@@ -1130,6 +1139,7 @@ export const dispatchTelegramMessage = async ({
};
const markProgressFinalDelivered = () => {
finalAnswerDelivered = true;
sawProgressFinal = true;
progressDraft.markFinalReplyDelivered();
};
const resetProgressDraftState = () => {
@@ -1561,6 +1571,11 @@ export const dispatchTelegramMessage = async ({
const silentErrorReplies = telegramCfg.silentErrorReplies === true;
const isDmTopic = !isGroup && threadSpec.scope === "dm" && threadSpec.id != null;
let queuedFinal = false;
// A final answer was produced this turn (in-band or out-of-band). Out-of-band
// finals (message_tool_only / codex) never flow through
// deliverProgressModeFinalAnswer, so the collapse bar must be posted from the
// cleanup fallback instead — see the finally block.
let sawProgressFinal = false;
let skippedDuplicateAnswerBlockDraftDelivery = false;
let suppressSilentReplyFallback = false;
let hadErrorReplyFailureOrSkip = false;
@@ -1901,44 +1916,82 @@ export const dispatchTelegramMessage = async ({
await emitPreviewFinalizedHook(result);
return result.kind !== "skipped";
};
// Post-turn collapse summary (Discord parity): when the progress window
// collapses at end-of-turn, post a one-line activity digest as a durable
// standalone message, then the final answer posts below it so the timeline
// reads thoughts/tools → summary → answer. Emitted at most once per turn,
// only for a non-error final, and only when the window actually rendered
// (rv mode delivers everything durably and the window stays empty — no bar).
const deliverProgressCollapseSummary = async () => {
// The one-line activity digest for the collapse bar, or undefined when the
// window never rendered (rv mode delivers everything durably — no bar) or
// the summary was already emitted this turn.
const resolveProgressCollapseSummaryLine = (): string | undefined => {
if (progressSummaryDelivered) {
return;
return undefined;
}
progressSummaryDelivered = true;
if (!progressDraftEverRendered) {
return;
return undefined;
}
const line = formatTelegramProgressSummaryLine(
progressSummary.counts(),
Date.now() - progressSummaryStartedAt,
);
return line || undefined;
};
// Post-turn collapse summary (Discord parity) as a durable standalone
// message. Used when there is no live window to collapse in place — the
// final answer posts below so the timeline reads thoughts/tools → summary →
// answer. Emitted at most once per turn.
const deliverProgressCollapseSummary = async () => {
const line = resolveProgressCollapseSummaryLine();
if (!line) {
return;
}
await sendPayload({ text: line }, { durable: true });
};
// Collapse the live window IN PLACE into the summary bar: edit the existing
// window message so its content becomes the bar line, keeping it on screen.
// Mirrors Discord — deleting the window and reposting the bar scroll-jumps
// the Telegram client and flashes the window away. Returns true when the
// window was collapsed in place; false when there is no bar (nothing
// streamed) or no live window message, so the caller tears the window down.
const collapseProgressWindowIntoSummary = async (): Promise<boolean> => {
const line = resolveProgressCollapseSummaryLine();
if (!line) {
return false;
}
const messageId = await answerLane.stream?.finalizeToPreview(renderStreamText(line));
if (typeof messageId === "number") {
return true;
}
// No live window to edit (rv mode, never rendered): keep the bar as a
// fresh durable post so the timeline still shows the collapse summary.
await sendPayload({ text: line }, { durable: true });
return false;
};
const deliverProgressModeFinalAnswer = async (
payload: ReplyPayload,
text: string,
): Promise<LaneDeliveryResult> => {
if (activeAnswerDraftIsToolProgressOnly) {
await rotateAnswerLaneAfterToolProgress();
} else {
await answerLane.stream?.clear();
resetDraftLaneState(answerLane);
}
// Collapse the window into the bar in place BEFORE resetting lane state
// (which drops the stream's message id). Error finals get no summary
// (Discord parity). When nothing collapsed in place, tear the window down
// so a stale progress box does not linger above the final answer.
const collapsedInPlace =
payload.isError === true ? false : await collapseProgressWindowIntoSummary();
if (payload.isError === true) {
// Error finals get no collapse summary (Discord parity); mark it handled.
progressSummaryDelivered = true;
}
if (!collapsedInPlace) {
if (activeAnswerDraftIsToolProgressOnly) {
await rotateAnswerLaneAfterToolProgress();
} else {
await answerLane.stream?.clear();
resetDraftLaneState(answerLane);
}
} else {
await deliverProgressCollapseSummary();
if (activeAnswerDraftIsToolProgressOnly) {
resetAnswerToolProgressDraft();
suppressProgressDraftState();
rotateAnswerLaneWhenQueuedBlocksSettle = false;
}
answerLane.stream?.forceNewMessage();
resetDraftLaneState(answerLane);
}
const delivered = await sendPayload(applyTextToPayload(payload, text), { durable: true });
if (!delivered) {
@@ -2521,7 +2574,10 @@ export const dispatchTelegramMessage = async ({
// own durable messages and must NOT also feed the bar (invariant:
// persistent message XOR bar count — D2).
if (payload.phase === "start") {
if (verboseProgressActive() || !canPushStreamToolProgress()) {
// canPushStreamToolProgress() is false under verbose, so this
// also closes bursts (never counting the tool) when the durable
// lane owns the tool message (invariant: persistent XOR window).
if (!canPushStreamToolProgress()) {
progressSummary.closeReasoningBurst();
progressSummary.closeCommentaryBurst();
} else {
@@ -2682,6 +2738,12 @@ export const dispatchTelegramMessage = async ({
return { kind: "completed" };
}
({ queuedFinal } = turnResult.dispatchResult);
// Out-of-band finals (message_tool_only) never run the in-band final-delivery
// path, so record the final from the dispatch counts for the cleanup-time
// collapse-bar fallback.
if ((turnResult.dispatchResult.counts?.final ?? 0) > 0) {
sawProgressFinal = true;
}
suppressSilentReplyFallback =
turnResult.dispatchResult.sourceReplyDeliveryMode === "message_tool_only";
} catch (err) {
@@ -2710,6 +2772,20 @@ export const dispatchTelegramMessage = async ({
await stream.clear();
}
}
// Fallback collapse summary (Discord parity): finals that bypass
// deliverProgressModeFinalAnswer — notably message_tool_only/codex turns
// whose final is delivered out-of-band — still collapse here. The internal
// once-guard and progressDraftEverRendered check keep this from
// double-posting or firing when the window never rendered.
if (
streamMode === "progress" &&
sawProgressFinal &&
!dispatchError &&
!hadErrorReplyFailureOrSkip &&
!isDispatchSuperseded()
) {
await deliverProgressCollapseSummary();
}
}
} finally {
dispatchWasSuperseded = isDispatchSuperseded();
@@ -14,6 +14,9 @@ type TestDraftStream = {
stop: ReturnType<typeof vi.fn<() => Promise<void>>>;
discard: ReturnType<typeof vi.fn<() => Promise<void>>>;
materialize: ReturnType<typeof vi.fn<() => Promise<number | undefined>>>;
finalizeToPreview: ReturnType<
typeof vi.fn<(preview: TelegramDraftPreview) => Promise<number | undefined>>
>;
forceNewMessage: ReturnType<typeof vi.fn<() => void>>;
sendMayHaveLanded: ReturnType<typeof vi.fn<() => boolean>>;
setMessageId: (value: number | undefined) => void;
@@ -66,6 +69,15 @@ export function createTestDraftStream(params?: {
await params?.onDiscard?.();
}),
materialize: vi.fn().mockImplementation(async () => messageId),
finalizeToPreview: vi.fn().mockImplementation(async (preview: TelegramDraftPreview) => {
if (messageId == null) {
return undefined;
}
previewRevision += 1;
lastDeliveredText = preview.text.trimEnd();
stopped = true;
return messageId;
}),
forceNewMessage: vi.fn().mockImplementation(() => {
stopped = false;
if (params?.clearMessageIdOnForceNew) {
@@ -113,6 +125,14 @@ export function createSequencedTestDraftStream(startMessageId = 1001): TestDraft
stop: vi.fn().mockResolvedValue(undefined),
discard: vi.fn().mockResolvedValue(undefined),
materialize: vi.fn().mockImplementation(async () => activeMessageId),
finalizeToPreview: vi.fn().mockImplementation(async (preview: TelegramDraftPreview) => {
if (activeMessageId == null) {
return undefined;
}
previewRevision += 1;
lastDeliveredText = preview.text.trimEnd();
return activeMessageId;
}),
forceNewMessage: vi.fn().mockImplementation(() => {
activeMessageId = undefined;
visibleSinceMs = undefined;
+30
View File
@@ -59,6 +59,13 @@ export type TelegramDraftStream = {
discard?: () => Promise<void>;
/** Return the current preview message id after pending updates settle. */
materialize?: () => Promise<number | undefined>;
/**
* Collapse the preview in place: edit the existing window message so its
* content becomes `preview`, then stop without deleting. Used at end-of-turn
* so the streaming window becomes the summary bar (no delete + repost, which
* scroll-jumps the client). Returns the message id if the edit landed.
*/
finalizeToPreview: (preview: TelegramDraftPreview) => Promise<number | undefined>;
/** Reset internal state so the next update creates a new message instead of editing. */
forceNewMessage: () => void;
/** True when a preview sendMessage was attempted but the response was lost. */
@@ -587,6 +594,28 @@ export function createTelegramDraftStream(params: {
return streamMessageId;
};
const finalizeToPreview = async (
preview: TelegramDraftPreview,
): Promise<number | undefined> => {
// Settle pending updates so we edit the real, current window message.
streamState.final = true;
await loop.flush();
const text = preview.text.trimEnd();
// No live window message to edit (never rendered, or already torn down):
// nothing to collapse in place — caller falls back to a fresh bar post.
if (typeof streamMessageId !== "number" || !text) {
return undefined;
}
// Replace the whole message with the bar line: edits diff from a zero
// offset, not from the streamed prefix.
deliveredTextOffset = 0;
lastSentPreviewKey = "";
lastRequestedText = text;
lastRequestedPreview = { ...preview, text };
await sendOrEditStreamMessage(text);
return streamMessageId;
};
params.log?.(`telegram stream preview ready (maxChars=${maxChars}, throttleMs=${throttleMs})`);
return {
@@ -601,6 +630,7 @@ export function createTelegramDraftStream(params: {
stop,
discard,
materialize,
finalizeToPreview,
forceNewMessage,
sendMayHaveLanded: () => messageSendAttempted && typeof streamMessageId !== "number",
};
@@ -5,8 +5,23 @@ import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coer
import { findCodeRegions, isInsideCode } from "openclaw/plugin-sdk/text-chunking";
import { stripReasoningTagsFromText } from "openclaw/plugin-sdk/text-chunking";
const REASONING_MESSAGE_RE = /^Thinking\.{0,3}\s*_/u;
// A durable reasoning message already marked channel-side: 🧠 + italic body
// (see markReasoningMessage). Detect it so a re-split passes it through
// unchanged instead of re-marking.
const REASONING_MESSAGE_RE = /^🧠\s+_/u;
// Core's formatReasoningMessage prefixes the italic body with a literal
// "Thinking" header. Telegram renders durable thoughts with the 🧠 marker
// (Discord parity), so this header must be rewritten channel-side.
const CORE_THINKING_HEADER_RE = /^Thinking\.{0,3}\s*\n+/u;
const LEGACY_REASONING_MESSAGE_PREFIX = "Reasoning:\n";
// Rewrite core's "Thinking\n\n_body_" into "🧠 _body_": strip the header word
// and prefix the first italic line with 🧠. Keeps the italic body intact so
// Telegram HTML renders it as before.
function markReasoningMessage(formatted: string): string {
const withoutHeader = formatted.replace(CORE_THINKING_HEADER_RE, "");
return withoutHeader.replace(/^_/u, "🧠 _");
}
const REASONING_TAG_PREFIXES = [
"<think",
"<thinking",
@@ -84,6 +99,11 @@ export function splitTelegramReasoningText(
if (REASONING_MESSAGE_RE.test(trimmed)) {
return { reasoningText: trimmed };
}
// Durable reasoning payloads arrive pre-formatted by core with the "Thinking"
// header; rewrite that to the 🧠 marker rather than passing it through.
if (CORE_THINKING_HEADER_RE.test(trimmed)) {
return { reasoningText: markReasoningMessage(trimmed) };
}
if (
trimmed.startsWith(LEGACY_REASONING_MESSAGE_PREFIX) &&
trimmed.length > LEGACY_REASONING_MESSAGE_PREFIX.length
@@ -94,7 +114,11 @@ export function splitTelegramReasoningText(
const taggedReasoning = extractThinkingFromTaggedStreamOutsideCode(text);
const strippedAnswer = stripReasoningTagsFromText(text, { mode: "strict", trim: "both" });
return { reasoningText: formatReasoningMessage(taggedReasoning || strippedAnswer || text) };
return {
reasoningText: markReasoningMessage(
formatReasoningMessage(taggedReasoning || strippedAnswer || text),
),
};
}
type BufferedFinalAnswer = {