mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 20:05:46 -06:00
fix(telegram): render progress drafts as rich previews
This commit is contained in:
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
|
||||
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 })),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user