fix(telegram): render progress drafts as rich previews

This commit is contained in:
Ayaan Zaidi
2026-06-15 08:07:10 +05:30
parent c847db550f
commit 663fabbe30
8 changed files with 329 additions and 99 deletions
@@ -1812,9 +1812,9 @@ describe("dispatchTelegramMessage draft streaming", () => {
telegramCfg: { streaming: { mode: "partial" } },
});
expect(mockCallArg(answerDraftStream.update)).toContain("Exec");
expect(answerDraftStream.update).toHaveBeenNthCalledWith(2, "Done ");
expect(answerDraftStream.update).toHaveBeenNthCalledWith(3, "Done answer");
expect(mockCallArg(answerDraftStream.updatePreview).text).toContain("Exec");
expect(answerDraftStream.update).toHaveBeenNthCalledWith(1, "Done ");
expect(answerDraftStream.update).toHaveBeenNthCalledWith(2, "Done answer");
expect(answerDraftStream.update).toHaveBeenLastCalledWith("Done answer.");
expect(deliverReplies).not.toHaveBeenCalled();
});
@@ -2219,15 +2219,14 @@ describe("dispatchTelegramMessage draft streaming", () => {
expect(answerDraftStream.update).toHaveBeenNthCalledWith(1, "Site A shows X.");
expect(answerDraftStream.update).toHaveBeenNthCalledWith(2, "Site A shows X.");
expect(answerDraftStream.update).toHaveBeenNthCalledWith(
3,
expect.stringMatching(/`🛠️ Exec`$/),
expect(answerDraftStream.updatePreview).toHaveBeenCalledWith(
expect.objectContaining({ text: expect.stringMatching(/`🛠️ Exec`$/) }),
);
expect(answerDraftStream.update).toHaveBeenNthCalledWith(4, "Final answer");
expect(answerDraftStream.update).toHaveBeenNthCalledWith(3, "Final answer");
expect(answerDraftStream.clear).toHaveBeenCalledTimes(1);
expect(answerDraftStream.forceNewMessage).toHaveBeenCalledTimes(2);
const progressResetOrder = answerDraftStream.forceNewMessage.mock.invocationCallOrder[0];
const progressUpdateOrder = answerDraftStream.update.mock.invocationCallOrder[2];
const progressUpdateOrder = answerDraftStream.updatePreview.mock.invocationCallOrder[0];
expect(progressResetOrder).toBeLessThan(progressUpdateOrder);
expect(deliverReplies).not.toHaveBeenCalled();
});
@@ -2247,12 +2246,11 @@ describe("dispatchTelegramMessage draft streaming", () => {
await dispatchWithContext({ context: createContext() });
expect(answerDraftStream.update).toHaveBeenNthCalledWith(1, "Site A shows X.");
expect(answerDraftStream.update).toHaveBeenNthCalledWith(
2,
expect.stringMatching(/`🛠️ Exec`$/),
expect(answerDraftStream.updatePreview).toHaveBeenCalledWith(
expect.objectContaining({ text: expect.stringMatching(/`🛠️ Exec`$/) }),
);
expect(answerDraftStream.update).toHaveBeenNthCalledWith(3, "Site B shows Y.");
expect(answerDraftStream.update).toHaveBeenNthCalledWith(4, "Final answer");
expect(answerDraftStream.update).toHaveBeenNthCalledWith(2, "Site B shows Y.");
expect(answerDraftStream.update).toHaveBeenNthCalledWith(3, "Final answer");
expect(answerDraftStream.forceNewMessage).toHaveBeenCalledTimes(2);
expect(answerDraftStream.clear).toHaveBeenCalledTimes(1);
expect(deliverReplies).not.toHaveBeenCalled();
@@ -2290,16 +2288,15 @@ describe("dispatchTelegramMessage draft streaming", () => {
await dispatchWithContext({ context: createContext() });
expect(answerDraftStream.update).toHaveBeenNthCalledWith(
1,
expect.stringMatching(/`🛠️ Exec`$/),
expect(answerDraftStream.updatePreview).toHaveBeenCalledWith(
expect.objectContaining({ text: expect.stringMatching(/`🛠️ Exec`$/) }),
);
expect(answerDraftStream.update).toHaveBeenNthCalledWith(2, "Branch is up to date");
expect(answerDraftStream.update).toHaveBeenNthCalledWith(1, "Branch is up to date");
expect(answerDraftStream.forceNewMessage).toHaveBeenCalledTimes(1);
expect(answerDraftStream.clear).toHaveBeenCalledTimes(1);
const clearOrder = answerDraftStream.clear.mock.invocationCallOrder[0];
const rotationOrder = answerDraftStream.forceNewMessage.mock.invocationCallOrder[0];
const finalUpdateOrder = answerDraftStream.update.mock.invocationCallOrder[1];
const finalUpdateOrder = answerDraftStream.update.mock.invocationCallOrder[0];
expect(clearOrder).toBeLessThan(rotationOrder);
expect(rotationOrder).toBeLessThan(finalUpdateOrder);
});
@@ -2317,16 +2314,15 @@ describe("dispatchTelegramMessage draft streaming", () => {
await dispatchWithContext({ context: createContext() });
expect(answerDraftStream.update).toHaveBeenNthCalledWith(
1,
expect.stringMatching(/`🛠️ Exec`$/),
expect(answerDraftStream.updatePreview).toHaveBeenCalledWith(
expect.objectContaining({ text: expect.stringMatching(/`🛠️ Exec`$/) }),
);
expect(answerDraftStream.update).toHaveBeenNthCalledWith(2, "Branch is up to date");
expect(answerDraftStream.update).toHaveBeenNthCalledWith(1, "Branch is up to date");
expect(answerDraftStream.forceNewMessage).toHaveBeenCalledTimes(1);
expect(answerDraftStream.clear).toHaveBeenCalledTimes(1);
const clearOrder = answerDraftStream.clear.mock.invocationCallOrder[0];
const rotationOrder = answerDraftStream.forceNewMessage.mock.invocationCallOrder[0];
const finalUpdateOrder = answerDraftStream.update.mock.invocationCallOrder[1];
const finalUpdateOrder = answerDraftStream.update.mock.invocationCallOrder[0];
expect(clearOrder).toBeLessThan(rotationOrder);
expect(rotationOrder).toBeLessThan(finalUpdateOrder);
});
@@ -2373,9 +2369,13 @@ describe("dispatchTelegramMessage draft streaming", () => {
telegramCfg: { streaming: { mode: "progress" } },
});
expect(answerDraftStream.update).toHaveBeenCalledWith(
"Cracking\n\n`🛠️ Exec`\n\n`🛠️ git rev-parse --abbrev-ref HEAD`",
);
expect(answerDraftStream.updatePreview).toHaveBeenCalledWith({
text: "Cracking\n\n`🛠️ Exec`\n`🛠️ git rev-parse --abbrev-ref HEAD`",
richMessage: {
html: "<b>Cracking</b><br><b>🛠️ Exec</b><br><b>🛠️ Exec</b> <code>git rev-parse --abbrev-ref HEAD</code>",
skip_entity_detection: true,
},
});
expect(answerDraftStream.update).not.toHaveBeenCalledWith("Branch is up to date");
expect(answerDraftStream.forceNewMessage).toHaveBeenCalledTimes(1);
expect(answerDraftStream.clear).toHaveBeenCalledTimes(1);
@@ -2400,12 +2400,18 @@ describe("dispatchTelegramMessage draft streaming", () => {
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.updatePreview).toHaveBeenCalledWith({
text: "Cracking\n\n`🛠️ Exec`",
richMessage: {
html: "<b>Cracking</b><br><b>🛠️ Exec</b>",
skip_entity_detection: true,
},
});
expect(answerDraftStream.update).toHaveBeenCalledTimes(1);
expect(answerDraftStream.update).toHaveBeenNthCalledWith(1, trailingFinalStatusText);
expect(answerDraftStream.forceNewMessage).toHaveBeenCalledTimes(2);
expect(answerDraftStream.forceNewMessage.mock.invocationCallOrder[1]).toBeLessThan(
answerDraftStream.update.mock.invocationCallOrder[1],
answerDraftStream.update.mock.invocationCallOrder[0],
);
expectDeliveredReply(0, { text: "Branch is up to date" });
});
@@ -2430,11 +2436,13 @@ describe("dispatchTelegramMessage draft streaming", () => {
telegramCfg: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
});
expect(answerDraftStream.update).not.toHaveBeenCalledWith(
expect(answerDraftStream.updatePreview).not.toHaveBeenCalledWith(
expect.stringContaining("stdout line one"),
);
expect(answerDraftStream.update).toHaveBeenLastCalledWith(
"Shelling\n\n`🛠️ Exec`\n\n`🔎 Web Search: docs lookup`",
expect(answerDraftStream.updatePreview).toHaveBeenLastCalledWith(
expect.objectContaining({
text: "Shelling\n\n`🛠️ Exec`\n`🔎 Web Search: docs lookup`",
}),
);
expect(deliverReplies).not.toHaveBeenCalled();
});
@@ -2456,8 +2464,10 @@ describe("dispatchTelegramMessage draft streaming", () => {
telegramCfg: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
});
expect(answerDraftStream.update).toHaveBeenCalledTimes(1);
expect(answerDraftStream.update).toHaveBeenCalledWith("Shelling\n\n`🛠️ Exec`");
expect(answerDraftStream.updatePreview).toHaveBeenCalledTimes(1);
expect(answerDraftStream.updatePreview).toHaveBeenCalledWith(
expect.objectContaining({ text: "Shelling\n\n`🛠️ Exec`" }),
);
expectDeliveredReply(0, { text: "Branch is up to date" });
});
@@ -2484,8 +2494,10 @@ describe("dispatchTelegramMessage draft streaming", () => {
telegramCfg: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
});
expect(answerDraftStream.update).toHaveBeenCalledTimes(1);
expect(answerDraftStream.update).toHaveBeenCalledWith("Shelling\n\n`🛠️ Exec`");
expect(answerDraftStream.updatePreview).toHaveBeenCalledTimes(1);
expect(answerDraftStream.updatePreview).toHaveBeenCalledWith(
expect.objectContaining({ text: "Shelling\n\n`🛠️ Exec`" }),
);
expectDeliveredReply(0, { text: "Branch is up to date" });
});
@@ -2516,8 +2528,10 @@ describe("dispatchTelegramMessage draft streaming", () => {
telegramCfg: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
});
expect(answerDraftStream.update).toHaveBeenCalledTimes(1);
expect(answerDraftStream.update).toHaveBeenCalledWith("Shelling\n\n`🛠️ Exec`");
expect(answerDraftStream.updatePreview).toHaveBeenCalledTimes(1);
expect(answerDraftStream.updatePreview).toHaveBeenCalledWith(
expect.objectContaining({ text: "Shelling\n\n`🛠️ Exec`" }),
);
expectDeliveredReply(0, { text: "Branch is up to date" });
});
@@ -2648,7 +2662,13 @@ describe("dispatchTelegramMessage draft streaming", () => {
telegramCfg: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
});
expect(draftStream.update).toHaveBeenCalledWith("Shelling\n\n`🛠️ Exec`");
expect(draftStream.updatePreview).toHaveBeenCalledWith({
text: "Shelling\n\n`🛠️ Exec`",
richMessage: {
html: "<b>Shelling</b><br><b>🛠️ Exec</b>",
skip_entity_detection: true,
},
});
expect(draftStream.flush).toHaveBeenCalled();
});
@@ -2670,7 +2690,13 @@ describe("dispatchTelegramMessage draft streaming", () => {
});
expect(createTelegramDraftStream).toHaveBeenCalledTimes(1);
expect(draftStream.update).toHaveBeenCalledWith("Shelling\n\n`🛠️ Exec`\n\n• _Checking files_");
expect(draftStream.updatePreview).toHaveBeenCalledWith({
text: "Shelling\n\n`🛠️ Exec`\n• _Checking files_",
richMessage: {
html: "<b>Shelling</b><br><b>🛠️ Exec</b><br><i>Checking files</i>",
skip_entity_detection: true,
},
});
});
it("renders configured Telegram commentary progress from preamble item events", async () => {
@@ -2697,7 +2723,13 @@ describe("dispatchTelegramMessage draft streaming", () => {
},
});
expect(draftStream.update).toHaveBeenCalledWith("Shelling\n\n_Checking recent context_");
expect(draftStream.updatePreview).toHaveBeenCalledWith({
text: "Shelling\n\n_Checking recent context_",
richMessage: {
html: "<b>Shelling</b><br><i>Checking recent context</i>",
skip_entity_detection: true,
},
});
});
it("suppresses Telegram preamble progress when commentary is disabled", async () => {
@@ -2724,7 +2756,9 @@ describe("dispatchTelegramMessage draft streaming", () => {
},
});
expect(draftStream.update).not.toHaveBeenCalledWith(expect.stringContaining("Checking recent"));
expect(draftStream.updatePreview).not.toHaveBeenCalledWith(
expect.objectContaining({ text: expect.stringContaining("Checking recent") }),
);
});
it("keeps the progress draft label when tool progress lines are hidden", async () => {
@@ -2748,7 +2782,10 @@ describe("dispatchTelegramMessage draft streaming", () => {
},
});
expect(draftStream.update).toHaveBeenCalledWith("Shelling");
expect(draftStream.updatePreview).toHaveBeenCalledWith({
text: "Shelling",
richMessage: { html: "<b>Shelling</b>", skip_entity_detection: true },
});
expect(draftStream.flush).toHaveBeenCalled();
});
@@ -2777,10 +2814,20 @@ describe("dispatchTelegramMessage draft streaming", () => {
},
});
await vi.waitFor(() => expect(draftStream.update).toHaveBeenCalledWith("Working"));
expect(draftStream.update).not.toHaveBeenCalledWith("Working.");
expect(draftStream.update).not.toHaveBeenCalledWith("Working..");
expect(draftStream.update).not.toHaveBeenCalledWith("Working...");
await vi.waitFor(() =>
expect(draftStream.updatePreview).toHaveBeenCalledWith(
expect.objectContaining({ text: "Working" }),
),
);
expect(draftStream.updatePreview).not.toHaveBeenCalledWith(
expect.objectContaining({ text: "Working." }),
);
expect(draftStream.updatePreview).not.toHaveBeenCalledWith(
expect.objectContaining({ text: "Working.." }),
);
expect(draftStream.updatePreview).not.toHaveBeenCalledWith(
expect.objectContaining({ text: "Working..." }),
);
finishRun?.();
await run;
});
@@ -2800,10 +2847,10 @@ describe("dispatchTelegramMessage draft streaming", () => {
const pendingToolStart = replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
await Promise.resolve();
await Promise.resolve();
const updateBeforeStatusReaction = draftStream.update.mock.calls.at(-1)?.[0];
const updateBeforeStatusReaction = draftStream.updatePreview.mock.calls.at(-1)?.[0]?.text;
releaseSetTool?.();
await pendingToolStart;
expect(updateBeforeStatusReaction).toMatch(/^Shelling\n`🛠️ Exec`$/);
expect(updateBeforeStatusReaction).toBe("Shelling\n\n`🛠️ Exec`");
return { queuedFinal: false };
});
@@ -2839,9 +2886,13 @@ describe("dispatchTelegramMessage draft streaming", () => {
telegramCfg: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
});
expect(draftStream.update).toHaveBeenCalledWith(
"Shelling\n\n`🔎 Web Search: docs lookup`\n\n• `tests passed`",
);
expect(draftStream.updatePreview).toHaveBeenCalledWith({
text: "Shelling\n\n`🔎 Web Search: docs lookup`\n• `tests passed`",
richMessage: {
html: "<b>Shelling</b><br><b>🔎 Web Search</b> <code>docs lookup</code><br><b>Update</b> <code>tests passed</code>",
skip_entity_detection: true,
},
});
expect(draftStream.forceNewMessage).toHaveBeenCalledTimes(1);
expect(draftStream.materialize).not.toHaveBeenCalled();
expect(draftStream.clear).toHaveBeenCalledTimes(1);
@@ -3984,7 +4035,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
telegramCfg: { streaming: { mode: "partial" } },
});
expect(mockCallArg(answerDraftStream.update)).toContain("Exec");
expect(mockCallArg(answerDraftStream.updatePreview).text).toContain("Exec");
expect(answerDraftStream.update).toHaveBeenLastCalledWith("block after progress");
});
+69 -13
View File
@@ -25,11 +25,11 @@ import {
projectOutboundPayloadPlanForDelivery,
} from "openclaw/plugin-sdk/channel-outbound";
import {
buildChannelProgressDraftLine,
buildChannelProgressDraftLineForEntry,
type ChannelProgressDraftLine,
type ChannelProgressDraftCompositorLine,
createChannelProgressDraftCompositor,
formatChannelProgressDraftLine,
formatChannelProgressDraftLineForEntry,
resolveChannelStreamingBlockEnabled,
resolveTranscriptBackedChannelFinalText,
} from "openclaw/plugin-sdk/channel-outbound";
@@ -100,7 +100,7 @@ import {
import type { TelegramStreamMode } from "./bot/types.js";
import { resolveTelegramInlineButtons, type TelegramInlineButtons } from "./button-types.js";
import { resolveTelegramDraftStreamingChunking } from "./draft-chunking.js";
import { createTelegramDraftStream } from "./draft-stream.js";
import { createTelegramDraftStream, type TelegramDraftPreview } from "./draft-stream.js";
import {
buildTelegramErrorScopeKey,
isSilentErrorPolicy,
@@ -122,6 +122,7 @@ import {
splitTelegramReasoningText,
} from "./reasoning-lane-coordinator.js";
import {
buildTelegramRichHtml,
buildTelegramRichMarkdown,
splitTelegramRichMarkdownChunks,
TELEGRAM_RICH_TEXT_LIMIT,
@@ -405,6 +406,63 @@ function formatTelegramProgressLine(text: string): string {
: formatProgressAsMarkdownCode(text);
}
function escapeTelegramProgressHtml(text: string): string {
return text
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
function renderTelegramProgressStringLine(text: string): string {
const clipped = clipProgressMarkdownText(text.trim());
const italic = clipped.match(/^_(.*)_$/u);
if (italic) {
return `<i>${escapeTelegramProgressHtml(italic[1] ?? "")}</i>`;
}
return `<code>${escapeTelegramProgressHtml(clipped)}</code>`;
}
function renderTelegramProgressLine(line: ChannelProgressDraftCompositorLine): string {
if (typeof line === "string") {
return line.split(/\r?\n/u).map(renderTelegramProgressStringLine).filter(Boolean).join("<br>");
}
if (!line.icon && line.label === "Commentary") {
return renderTelegramProgressStringLine(line.text);
}
const label = [line.icon, line.label].filter(Boolean).join(" ");
const parts = [`<b>${escapeTelegramProgressHtml(label)}</b>`];
const detail = line.detail && line.detail !== line.label ? line.detail : undefined;
if (detail) {
parts.push(`<code>${escapeTelegramProgressHtml(clipProgressMarkdownText(detail))}</code>`);
} else {
const text = line.text.trim();
if (text && text !== label) {
parts.push(renderTelegramProgressStringLine(text));
}
}
if (line.status && line.status !== line.detail) {
parts.push(`<i>${escapeTelegramProgressHtml(line.status)}</i>`);
}
return parts.join(" ");
}
function renderTelegramProgressDraftPreview(
text: string,
lines: readonly ChannelProgressDraftCompositorLine[],
): TelegramDraftPreview {
const trimmed = text.trimEnd();
const [heading] = trimmed.split(/\r?\n/u, 1);
const renderedLines = lines.map(renderTelegramProgressLine).filter(Boolean);
const htmlParts = heading?.trim()
? [`<b>${escapeTelegramProgressHtml(heading.trim())}</b>`, ...renderedLines]
: renderedLines;
return {
text: trimmed,
richMessage: buildTelegramRichHtml(htmlParts.join("<br>"), { skipEntityDetection: true }),
};
}
function normalizeTelegramThreadId(value: unknown): number | undefined {
return parseStrictPositiveInteger(value);
}
@@ -967,16 +1025,14 @@ export const dispatchTelegramMessage = async ({
active: Boolean(answerLane.stream),
seed: progressSeed,
formatLine: formatTelegramProgressLine,
// Telegram's rich-markdown renderer collapses a lone "\n" to a space, so
// tool-progress lines need a blank line between them to stay on their own
// line (it renders "\n\n" as a single break, not a double).
lineSeparator: "\n\n",
update: async (streamText, options) => {
await prepareAnswerLaneForToolProgress();
answerLane.lastPartialText = streamText;
answerLane.hasStreamedMessage = true;
answerLane.finalized = false;
answerLane.stream?.update(streamText);
answerLane.stream?.updatePreview(
renderTelegramProgressDraftPreview(streamText, options?.lines ?? []),
);
if (options?.flush) {
await answerLane.stream?.flush();
}
@@ -2280,7 +2336,7 @@ export const dispatchTelegramMessage = async ({
onToolStart: async (payload) => {
const toolName = payload.name?.trim();
const progressPromise = pushStreamToolProgress(
formatChannelProgressDraftLineForEntry(
buildChannelProgressDraftLineForEntry(
telegramCfg,
{
event: "tool",
@@ -2327,7 +2383,7 @@ export const dispatchTelegramMessage = async ({
return;
}
await pushStreamToolProgress(
formatChannelProgressDraftLine({
buildChannelProgressDraftLine({
event: "plan",
phase: payload.phase,
title: payload.title,
@@ -2341,7 +2397,7 @@ export const dispatchTelegramMessage = async ({
return;
}
await pushStreamToolProgress(
formatChannelProgressDraftLine({
buildChannelProgressDraftLine({
event: "approval",
phase: payload.phase,
title: payload.title,
@@ -2356,7 +2412,7 @@ export const dispatchTelegramMessage = async ({
return;
}
await pushStreamToolProgress(
formatChannelProgressDraftLine({
buildChannelProgressDraftLine({
event: "command-output",
phase: payload.phase,
title: payload.title,
@@ -2371,7 +2427,7 @@ export const dispatchTelegramMessage = async ({
return;
}
await pushStreamToolProgress(
formatChannelProgressDraftLine({
buildChannelProgressDraftLine({
event: "patch",
phase: payload.phase,
title: payload.title,
@@ -3,6 +3,7 @@ import { vi } from "vitest";
type TestDraftStream = {
update: ReturnType<typeof vi.fn<(text: string) => void>>;
updatePreview: ReturnType<typeof vi.fn<(preview: { text: string }) => void>>;
flush: ReturnType<typeof vi.fn<() => Promise<void>>>;
messageId: ReturnType<typeof vi.fn<() => number | undefined>>;
visibleSinceMs: ReturnType<typeof vi.fn<() => number | undefined>>;
@@ -40,6 +41,14 @@ export function createTestDraftStream(params?: {
lastDeliveredText = text.trimEnd();
params?.onUpdate?.(text);
}),
updatePreview: vi.fn().mockImplementation((preview: { text: string }) => {
if (stopped) {
return;
}
previewRevision += 1;
lastDeliveredText = preview.text.trimEnd();
params?.onUpdate?.(preview.text);
}),
flush: vi.fn().mockResolvedValue(undefined),
messageId: vi.fn().mockImplementation(() => messageId),
visibleSinceMs: vi.fn().mockImplementation(() => visibleSinceMs),
@@ -86,6 +95,14 @@ export function createSequencedTestDraftStream(startMessageId = 1001): TestDraft
previewRevision += 1;
lastDeliveredText = text.trimEnd();
}),
updatePreview: vi.fn().mockImplementation((preview: { text: string }) => {
if (activeMessageId == null) {
activeMessageId = nextMessageId++;
visibleSinceMs = Date.now();
}
previewRevision += 1;
lastDeliveredText = preview.text.trimEnd();
}),
flush: vi.fn().mockResolvedValue(undefined),
messageId: vi.fn().mockImplementation(() => activeMessageId),
visibleSinceMs: vi.fn().mockImplementation(() => visibleSinceMs),
@@ -582,6 +582,46 @@ describe("createTelegramDraftStream", () => {
});
});
it("uses caller-provided rich previews", async () => {
const api = createMockDraftApi();
const stream = createDraftStream(api);
stream.updatePreview({
text: "Shelling\n\n`🛠️ Exec`",
richMessage: {
html: "<b>Shelling</b><br><b>🛠️ Exec</b>",
skip_entity_detection: true,
},
});
await stream.flush();
expect(api.raw.sendRichMessage).toHaveBeenCalledWith({
chat_id: 123,
rich_message: {
html: "<b>Shelling</b><br><b>🛠️ Exec</b>",
skip_entity_detection: true,
},
});
stream.updatePreview({
text: "Shelling\n\n`🛠️ Exec`\n• _Checking files_",
richMessage: {
html: "<b>Shelling</b><br><b>🛠️ Exec</b><br><i>Checking files</i>",
skip_entity_detection: true,
},
});
await stream.flush();
expect(api.raw.editMessageText).toHaveBeenCalledWith({
chat_id: 123,
message_id: 17,
rich_message: {
html: "<b>Shelling</b><br><b>🛠️ Exec</b><br><i>Checking files</i>",
skip_entity_detection: true,
},
});
});
it("keeps rich rendered previews above the old text-message limit", async () => {
const richApi = {
sendRichMessage: vi.fn(async () => ({ message_id: 17 })),
+23 -3
View File
@@ -35,6 +35,7 @@ const MAX_PREVIEW_FLOOD_SUSPEND_MS = 60_000;
export type TelegramDraftStream = {
update: (text: string) => void;
updatePreview: (preview: TelegramDraftPreview) => void;
flush: () => Promise<void>;
messageId: () => number | undefined;
visibleSinceMs?: () => number | undefined;
@@ -52,7 +53,7 @@ export type TelegramDraftStream = {
sendMayHaveLanded?: () => boolean;
};
type TelegramDraftPreview = {
export type TelegramDraftPreview = {
text: string;
richMessage: TelegramInputRichMessage;
};
@@ -144,6 +145,7 @@ export function createTelegramDraftStream(params: {
let lastSentPreviewKey = "";
let lastDeliveredText = "";
let lastRequestedText = "";
let lastRequestedPreview: TelegramDraftPreview | undefined;
let previewRevision = 0;
let generation = 0;
let deliveredTextOffset = 0;
@@ -230,7 +232,10 @@ export function createTelegramDraftStream(params: {
if (!currentText) {
return false;
}
const rendered = renderTelegramDraftPreview(currentText, params.renderText);
const rendered =
deliveredTextOffset === 0 && lastRequestedPreview?.text === trimmed
? lastRequestedPreview
: renderTelegramDraftPreview(currentText, params.renderText);
const renderedText = rendered.text.trimEnd();
const renderedPreview = { ...rendered, text: renderedText };
const renderedPreviewKey = telegramDraftPreviewKey(renderedPreview);
@@ -344,14 +349,27 @@ export function createTelegramDraftStream(params: {
sendOrEditStreamMessage,
});
const update = (text: string) => {
const requestDraftUpdate = (text: string, preview?: TelegramDraftPreview) => {
if (streamState.stopped || streamState.final) {
return;
}
lastRequestedPreview = preview;
lastRequestedText = text;
updateDraft(text);
};
const update = (text: string) => {
requestDraftUpdate(text);
};
const updatePreview = (preview: TelegramDraftPreview) => {
const text = preview.text.trimEnd();
if (!text) {
return;
}
requestDraftUpdate(text, { ...preview, text });
};
const stop = async () => {
streamState.final = true;
await loop.flush();
@@ -383,6 +401,7 @@ export function createTelegramDraftStream(params: {
}
if (!options?.keepPending) {
loop.resetPending();
lastRequestedPreview = undefined;
}
loop.resetThrottleWindow();
};
@@ -422,6 +441,7 @@ export function createTelegramDraftStream(params: {
return {
update,
updatePreview,
flush: loop.flush,
messageId: () => streamMessageId,
visibleSinceMs: () => streamVisibleSinceMs,
+61 -9
View File
@@ -18,7 +18,32 @@ describe("createChannelProgressDraftCompositor", () => {
await progress.pushToolProgress("🛠️ Exec", { startImmediately: true });
expect(update).toHaveBeenCalledWith("Shelling", { flush: true });
expect(update).toHaveBeenCalledWith("Shelling", { flush: true, lines: [] });
});
it("passes structured progress lines to draft updates", async () => {
const update = vi.fn();
const progress = createChannelProgressDraftCompositor({
entry: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
mode: "progress",
active: true,
seed: "test",
update,
});
const line = {
kind: "tool" as const,
text: "🛠️ Exec: git status",
label: "Exec",
icon: "🛠️",
detail: "git status",
};
await progress.pushToolProgress(line, { startImmediately: true });
expect(update).toHaveBeenCalledWith("Shelling\n\n🛠️ Exec: git status", {
flush: true,
lines: [line],
});
});
it("keeps reasoning details hidden when tool progress lines are hidden", async () => {
@@ -36,8 +61,8 @@ describe("createChannelProgressDraftCompositor", () => {
await progress.pushToolProgress("🛠️ Exec", { startImmediately: true });
await progress.pushReasoningProgress("Reading files");
expect(update).toHaveBeenCalledWith("Shelling", { flush: true });
expect(update).not.toHaveBeenCalledWith(expect.stringContaining("Reading"), undefined);
expect(update).toHaveBeenCalledWith("Shelling", { flush: true, lines: [] });
expect(update.mock.calls.every(([text]) => !String(text).includes("Reading"))).toBe(true);
});
it("does not resurrect progress after suppression", async () => {
@@ -70,7 +95,12 @@ describe("createChannelProgressDraftCompositor", () => {
await progress.pushReasoningProgress("Reading");
await progress.pushReasoningProgress(" files");
expect(update).toHaveBeenLastCalledWith("Shelling\n\n🛠️ Exec\n• _Reading files_", undefined);
expect(update).toHaveBeenLastCalledWith(
"Shelling\n\n🛠️ Exec\n• _Reading files_",
expect.objectContaining({
lines: ["🛠️ Exec", "_Reading files_"],
}),
);
});
it("resets reasoning deltas without clearing tool progress", async () => {
@@ -88,7 +118,12 @@ describe("createChannelProgressDraftCompositor", () => {
progress.resetReasoningProgress();
await progress.pushReasoningProgress("Now testing");
expect(update).toHaveBeenLastCalledWith("Shelling\n\n🛠️ Exec\n• _Now testing_", undefined);
expect(update).toHaveBeenLastCalledWith(
"Shelling\n\n🛠️ Exec\n• _Now testing_",
expect.objectContaining({
lines: ["🛠️ Exec", "_Now testing_"],
}),
);
});
it("preserves tagged reasoning content without leaking tags", async () => {
@@ -104,7 +139,12 @@ describe("createChannelProgressDraftCompositor", () => {
await progress.pushToolProgress("🛠️ Exec", { startImmediately: true });
await progress.pushReasoningProgress("<think>Checking files</think>Final answer prose");
expect(update).toHaveBeenLastCalledWith("Shelling\n\n🛠️ Exec\n• _Checking files_", undefined);
expect(update).toHaveBeenLastCalledWith(
"Shelling\n\n🛠️ Exec\n• _Checking files_",
expect.objectContaining({
lines: ["🛠️ Exec", "_Checking files_"],
}),
);
});
it("waits for complete reasoning tags before showing tagged progress", async () => {
@@ -138,7 +178,12 @@ describe("createChannelProgressDraftCompositor", () => {
await progress.pushReasoningProgress("<thin");
await progress.pushReasoningProgress("k>Checking files</think>Final answer prose");
expect(update).toHaveBeenLastCalledWith("Shelling\n\n🛠️ Exec\n• _Checking files_", undefined);
expect(update).toHaveBeenLastCalledWith(
"Shelling\n\n🛠️ Exec\n• _Checking files_",
expect.objectContaining({
lines: ["🛠️ Exec", "_Checking files_"],
}),
);
});
it("keeps literal reasoning tags inside code blocks", async () => {
@@ -156,7 +201,9 @@ describe("createChannelProgressDraftCompositor", () => {
expect(update).toHaveBeenLastCalledWith(
"Shelling\n\n🛠️ Exec\n• _```html <think>literal</think> ```_",
undefined,
expect.objectContaining({
lines: ["🛠️ Exec", "_```html <think>literal</think> ```_"],
}),
);
});
@@ -174,7 +221,12 @@ describe("createChannelProgressDraftCompositor", () => {
await progress.pushReasoningProgress("Thinking\n\n_Reading_");
await progress.pushReasoningProgress("Thinking\n\n_Reading files_");
expect(update).toHaveBeenLastCalledWith("Shelling\n\n🛠️ Exec\n• _Reading files_", undefined);
expect(update).toHaveBeenLastCalledWith(
"Shelling\n\n🛠️ Exec\n• _Reading files_",
expect.objectContaining({
lines: ["🛠️ Exec", "_Reading files_"],
}),
);
});
it("logs a timer-fired start failure via the gate's default boundary logger", async () => {
+12 -12
View File
@@ -27,7 +27,11 @@ export type ChannelProgressDraftMode = StreamingMode;
export type ChannelProgressDraftCompositor = ReturnType<
typeof createChannelProgressDraftCompositor
>;
type ProgressDraftLine = string | ChannelProgressDraftLine;
export type ChannelProgressDraftCompositorLine = string | ChannelProgressDraftLine;
export type ChannelProgressDraftUpdateOptions = {
flush?: boolean;
lines?: readonly ChannelProgressDraftCompositorLine[];
};
/** Creates a stateful compositor for one streaming channel reply. */
export function createChannelProgressDraftCompositor(params: {
@@ -35,15 +39,12 @@ export function createChannelProgressDraftCompositor(params: {
mode: ChannelProgressDraftMode;
active: boolean;
seed: string;
update: (text: string, options?: { flush?: boolean }) => Promise<void> | void;
update: (text: string, options?: ChannelProgressDraftUpdateOptions) => Promise<void> | void;
deleteCurrent?: () => Promise<void> | void;
tryNativeUpdate?: (text: string) => Promise<boolean> | boolean;
formatLine?: (line: string) => string;
/** Separator between rendered draft lines; forwarded to the draft formatter.
* Telegram passes "\n\n" because its renderer collapses a lone newline. */
lineSeparator?: string;
isEmptyLine?: (line: ProgressDraftLine | undefined) => boolean;
shouldStartNow?: (line: ProgressDraftLine | undefined) => boolean;
isEmptyLine?: (line: ChannelProgressDraftCompositorLine | undefined) => boolean;
shouldStartNow?: (line: ChannelProgressDraftCompositorLine | undefined) => boolean;
}) {
const previewToolProgressEnabled =
params.active && resolveChannelStreamingPreviewToolProgress(params.entry);
@@ -56,7 +57,7 @@ export function createChannelProgressDraftCompositor(params: {
previewToolProgressEnabled,
});
let progressSuppressed = false;
let lines: ProgressDraftLine[] = [];
let lines: ChannelProgressDraftCompositorLine[] = [];
let lastRenderedText = "";
let reasoningRawText = "";
let lastReasoningLine: string | undefined;
@@ -69,7 +70,6 @@ export function createChannelProgressDraftCompositor(params: {
lines: draftLines,
seed: params.seed,
formatLine: options?.formatted === false ? undefined : params.formatLine,
lineSeparator: params.lineSeparator,
});
const clearProgressState = (suppressed: boolean) => {
@@ -89,7 +89,7 @@ export function createChannelProgressDraftCompositor(params: {
return false;
}
lastRenderedText = text;
await params.update(text, options);
await params.update(text, { ...options, lines: [...lines] });
return true;
};
@@ -118,7 +118,7 @@ export function createChannelProgressDraftCompositor(params: {
};
const noteProgress = async (
line?: ProgressDraftLine,
line?: ChannelProgressDraftCompositorLine,
options?: { toolName?: string; startImmediately?: boolean },
) => {
if (!params.active || finalReplyStarted || finalReplyDelivered) {
@@ -167,7 +167,7 @@ export function createChannelProgressDraftCompositor(params: {
return false;
}
lastRenderedText = text;
await params.update(text);
await params.update(text, { lines: [...lines] });
return true;
}
if (options?.startImmediately || params.shouldStartNow?.(line)) {
+2 -8
View File
@@ -1047,11 +1047,6 @@ export function formatChannelProgressDraftText(params: {
formatLine?: (line: string) => string;
/** Prefix used for plain progress lines that lack their own icon. */
bullet?: string;
/** Separator between rendered progress lines. Defaults to a single newline.
* Telegram's rich-markdown parser treats a lone "\n" as a soft break (space),
* so its draft passes "\n\n"; channels where a single newline already breaks
* the line (Discord) keep the default. */
lineSeparator?: string;
}): string {
const rawLabel = resolveChannelProgressDraftLabel({
entry: params.entry,
@@ -1063,7 +1058,6 @@ export function formatChannelProgressDraftText(params: {
const maxLineChars = resolveChannelProgressDraftMaxLineChars(params.entry);
const formatLine = params.formatLine ?? ((line: string) => line);
const bullet = params.bullet ?? "•";
const lineSeparator = params.lineSeparator ?? "\n";
const rawLines: Array<string | ChannelProgressDraftLine | { draftLabel: string }> = resolvedLabel
? [{ draftLabel: resolvedLabel }, ...params.lines]
: params.lines;
@@ -1096,7 +1090,7 @@ export function formatChannelProgressDraftText(params: {
});
const renderedLines = lines.map((line) => line.text).filter((line) => Boolean(line));
if (renderedLines.length > 1 && lines[0]?.isLabelLine) {
return `${renderedLines[0]}\n\n${renderedLines.slice(1).join(lineSeparator)}`;
return `${renderedLines[0]}\n\n${renderedLines.slice(1).join("\n")}`;
}
return renderedLines.join(lineSeparator);
return renderedLines.join("\n");
}