Compare commits

...

4 Commits

Author SHA1 Message Date
Ayaan Zaidi
367d1e7263 fix(telegram): reset cleared progress drafts 2026-05-30 22:31:30 +05:30
Ayaan Zaidi
e4a6bd7dc3 docs(telegram): expose commentary progress option 2026-05-30 22:31:30 +05:30
Ayaan Zaidi
fb3463b337 feat(telegram): show commentary progress drafts 2026-05-30 22:31:30 +05:30
Ayaan Zaidi
ad826c80a5 refactor(channels): share commentary progress drafts 2026-05-30 22:31:30 +05:30
11 changed files with 322 additions and 52 deletions

View File

@@ -318,10 +318,11 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
- `channels.telegram.streaming` is `off | partial | block | progress` (default: `partial`)
- `progress` keeps one editable status draft for tool progress, clears it at completion, and sends the final answer as a normal message
- `streaming.preview.toolProgress` controls whether tool/progress updates reuse the same edited preview message (default: `true` when preview streaming is active)
- `streaming.progress.commentary` (default `false`) opts into Codex preamble/commentary text in the temporary progress draft. Commentary is cleaned before display, stays transient, and does not change final answer delivery.
- `streaming.preview.commandText` controls command/exec detail inside those tool-progress lines: `raw` (default, preserves released behavior) or `status` (tool label only)
- legacy `channels.telegram.streamMode` and boolean `streaming` values are detected; run `openclaw doctor --fix` to migrate them to `channels.telegram.streaming.mode`
Tool-progress preview updates are the short status lines shown while tools run, for example command execution, file reads, planning updates, patch summaries, or Codex preamble/commentary text in Codex app-server mode. Telegram keeps these enabled by default to match released OpenClaw behavior from `v2026.4.22` and later.
Tool-progress preview updates are the short status lines shown while tools run, for example command execution, file reads, planning updates, and patch summaries. Telegram keeps these enabled by default to match released OpenClaw behavior from `v2026.4.22` and later.
Direct chats can use native Telegram drafts for these tool-progress lines without persisting tool chatter into chat history. Native drafts stop before answer text starts; final answers stay on the normal persistent delivery path. This lane is off by default and should be gated to trusted DM IDs first:

View File

@@ -1,11 +1,14 @@
import { EmbeddedBlockChunker } from "openclaw/plugin-sdk/agent-runtime";
import {
buildChannelCommentaryProgressDraftLine,
createChannelProgressDraftGate,
type ChannelProgressDraftLine,
formatChannelProgressDraftText,
isChannelProgressDraftWorkToolName,
mergeChannelProgressDraftLine,
normalizeChannelProgressDraftLineIdentity,
removeChannelProgressDraftLine,
resolveChannelCommentaryProgressLineId,
resolveChannelProgressDraftMaxLines,
resolveChannelStreamingBlockEnabled,
resolveChannelStreamingProgressCommentary,
@@ -123,10 +126,8 @@ export function createDiscordDraftPreviewController(params: {
});
const clearProgressDraftLine = async (lineId: string) => {
const nextLines = previewToolProgressLines.filter(
(line) => typeof line !== "object" || line.id?.trim() !== lineId,
);
if (nextLines.length === previewToolProgressLines.length) {
const nextLines = removeChannelProgressDraftLine(previewToolProgressLines, lineId);
if (nextLines === previewToolProgressLines) {
return;
}
previewToolProgressLines = nextLines;
@@ -307,25 +308,20 @@ export function createDiscordDraftPreviewController(params: {
if (finalReplyStarted || finalReplyDelivered) {
return;
}
const itemId = options?.itemId?.trim();
if (!text && !itemId) {
return;
}
const normalized = normalizeCommentaryProgressText(text ?? "");
const lineId = itemId ? `commentary:${itemId}` : normalized ? `commentary:${normalized}` : "";
if (!normalized) {
const line = buildChannelCommentaryProgressDraftLine({
text,
itemId: options?.itemId,
});
if (!line) {
const lineId = resolveChannelCommentaryProgressLineId({
text,
itemId: options?.itemId,
});
if (lineId) {
await clearProgressDraftLine(lineId);
}
return;
}
const line: ChannelProgressDraftLine = {
id: lineId,
kind: "item",
text: normalized,
label: "Commentary",
prefix: false,
};
previewToolProgressLines = mergeChannelProgressDraftLine(previewToolProgressLines, line, {
maxLines: resolveChannelProgressDraftMaxLines(params.discordConfig),
});
@@ -469,24 +465,6 @@ function normalizeReasoningProgressLine(text: string): string {
.trim();
}
function normalizeCommentaryProgressText(text: string): string {
const cleaned = stripInlineDirectiveTagsForDelivery(text).text.trim();
if (!cleaned || isSilentCommentaryProgressText(cleaned)) {
return "";
}
return cleaned
.split(/\r?\n/u)
.map((line) => line.replace(/\s+/g, " ").trim())
.filter(Boolean)
.map((line) => `_${line}_`)
.join("\n");
}
function isSilentCommentaryProgressText(text: string): boolean {
const normalized = text.replace(/^[\s*_`~]+|[\s*_`~]+$/gu, "").trim();
return /^NO_REPLY$/iu.test(normalized);
}
function mergeReasoningProgressText(
current: string,
incoming: string,

View File

@@ -2076,6 +2076,118 @@ describe("dispatchTelegramMessage draft streaming", () => {
expect(deliverReplies).not.toHaveBeenCalled();
});
it("hides Telegram commentary progress unless explicitly enabled", async () => {
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ replyOptions }) => {
await replyOptions?.onItemEvent?.({
itemId: "preamble-1",
kind: "preamble",
progressText: "Checking private context before replying.",
});
return { queuedFinal: false };
});
await dispatchWithContext({
context: createContext(),
streamMode: "progress",
telegramCfg: { streaming: { mode: "progress", progress: { label: false } } },
});
expect(answerDraftStream.update).not.toHaveBeenCalled();
});
it("shows opt-in Telegram commentary progress through the shared progress draft", async () => {
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ replyOptions }) => {
await replyOptions?.onItemEvent?.({
itemId: "preamble-1",
kind: "preamble",
progressText: "Checking the current weather source before summarizing.",
});
await replyOptions?.onItemEvent?.({
itemId: "preamble-1",
kind: "preamble",
progressText: "Checking the current weather source before summarizing clearly.",
});
await replyOptions?.onItemEvent?.({
itemId: "preamble-2",
kind: "preamble",
progressText: "[[reply_to_current]] Checking route impacts.",
});
await replyOptions?.onItemEvent?.({
itemId: "preamble-2",
kind: "preamble",
progressText: "NO_REPLY",
});
await replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
return { queuedFinal: false };
});
await dispatchWithContext({
context: createContext(),
streamMode: "progress",
telegramCfg: {
streaming: {
mode: "progress",
progress: {
label: false,
toolProgress: false,
commentary: true,
},
},
},
});
expect(answerDraftStream.update).toHaveBeenLastCalledWith(
"_Checking the current weather source before summarizing clearly._",
);
const updates = answerDraftStream.update.mock.calls.map((call) => call[0]).join("\n");
expect(updates).not.toContain("Exec");
expect(updates).not.toContain("reply_to_current");
expect(updates).not.toContain("NO_REPLY");
});
it("keeps Telegram progress drafts usable after the last commentary line becomes silent", async () => {
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ replyOptions }) => {
await replyOptions?.onItemEvent?.({
itemId: "preamble-1",
kind: "preamble",
progressText: "Temporary note.",
});
await replyOptions?.onItemEvent?.({
itemId: "preamble-1",
kind: "preamble",
progressText: "NO_REPLY",
});
await replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
return { queuedFinal: false };
});
await dispatchWithContext({
context: createContext(),
streamMode: "progress",
telegramCfg: {
streaming: {
mode: "progress",
progress: {
label: false,
commentary: true,
},
},
},
});
expect(answerDraftStream.clear).toHaveBeenCalled();
expect(answerDraftStream.forceNewMessage).toHaveBeenCalled();
const clearOrder = answerDraftStream.clear.mock.invocationCallOrder[0];
const forceNewMessageOrder = answerDraftStream.forceNewMessage.mock.invocationCallOrder[0];
const lastUpdateOrder = answerDraftStream.update.mock.invocationCallOrder.at(-1)!;
expect(clearOrder).toBeLessThan(forceNewMessageOrder);
expect(forceNewMessageOrder).toBeLessThan(lastUpdateOrder);
expect(answerDraftStream.update).toHaveBeenLastCalledWith("`🛠️ Exec`");
});
it("does not restart progress drafts after final answer delivery", async () => {
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(

View File

@@ -23,6 +23,7 @@ import {
projectOutboundPayloadPlanForDelivery,
} from "openclaw/plugin-sdk/channel-outbound";
import {
buildChannelCommentaryProgressDraftLine,
buildChannelProgressDraftLineForEntry,
createChannelProgressDraftGate,
type ChannelProgressDraftLine,
@@ -31,8 +32,11 @@ import {
formatChannelProgressDraftText,
isChannelProgressDraftWorkToolName,
mergeChannelProgressDraftLine,
removeChannelProgressDraftLine,
resolveChannelCommentaryProgressLineId,
resolveChannelProgressDraftMaxLines,
resolveChannelStreamingBlockEnabled,
resolveChannelStreamingProgressCommentary,
resolveChannelStreamingPreviewNativeToolProgress,
resolveChannelStreamingPreviewNativeToolProgressAllowFrom,
resolveChannelStreamingPreviewToolProgress,
@@ -920,6 +924,8 @@ export const dispatchTelegramMessage = async ({
const reasoningLane = lanes.reasoning;
const streamToolProgressEnabled =
Boolean(answerLane.stream) && resolveChannelStreamingPreviewToolProgress(telegramCfg);
const commentaryProgressEnabled =
Boolean(answerLane.stream) && resolveChannelStreamingProgressCommentary(telegramCfg);
const nativeToolProgressDraft =
streamToolProgressEnabled &&
!isRoomEvent &&
@@ -982,6 +988,26 @@ export const dispatchTelegramMessage = async ({
});
let finalAnswerDeliveryStarted = false;
let finalAnswerDelivered = false;
const clearStreamProgressDraftLine = async (lineId: string) => {
const nextLines = removeChannelProgressDraftLine(streamToolProgressLines, lineId);
if (nextLines === streamToolProgressLines) {
return false;
}
streamToolProgressLines = nextLines;
if (!progressDraftGate.hasStarted) {
return true;
}
if (await renderProgressDraft()) {
return true;
}
answerLane.lastPartialText = "";
answerLane.hasStreamedMessage = false;
answerLane.finalized = false;
resetAnswerToolProgressDraft();
await answerLane.stream?.clear();
answerLane.stream?.forceNewMessage();
return true;
};
const pushStreamToolProgress = async (
line?: string | ChannelProgressDraftLine,
options?: { toolName?: string; startImmediately?: boolean },
@@ -1062,6 +1088,34 @@ export const dispatchTelegramMessage = async ({
}
return false;
};
const pushCommentaryProgress = async (text?: string, options?: { itemId?: string }) => {
if (!answerLane.stream || streamMode !== "progress" || !commentaryProgressEnabled) {
return false;
}
if (answerLane.finalized || finalAnswerDeliveryStarted || finalAnswerDelivered) {
return false;
}
const line = buildChannelCommentaryProgressDraftLine({
text,
itemId: options?.itemId,
});
if (!line) {
const lineId = resolveChannelCommentaryProgressLineId({
text,
itemId: options?.itemId,
});
return lineId ? await clearStreamProgressDraftLine(lineId) : false;
}
const nextLines = mergeChannelProgressDraftLine(streamToolProgressLines, line, {
maxLines: resolveChannelProgressDraftMaxLines(telegramCfg),
});
if (nextLines === streamToolProgressLines) {
return false;
}
streamToolProgressLines = nextLines;
await progressDraftGate.startNow();
return await renderProgressDraft();
};
let splitReasoningOnNextStream = false;
let draftLaneEventQueue = Promise.resolve();
const reasoningStepState = createTelegramReasoningStepState();
@@ -2003,6 +2057,12 @@ export const dispatchTelegramMessage = async ({
await progressPromise;
},
onItemEvent: async (payload) => {
if (payload.kind === "preamble") {
await pushCommentaryProgress(payload.progressText, {
itemId: payload.itemId,
});
return;
}
await pushStreamToolProgress(
buildChannelProgressDraftLineForEntry(telegramCfg, {
event: "item",

View File

@@ -89,6 +89,10 @@ export const telegramChannelConfigUiHints = {
label: "Telegram Progress Tool Lines",
help: "Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery.",
},
"streaming.progress.commentary": {
label: "Telegram Progress Commentary",
help: "Show assistant commentary/preamble text in the temporary progress draft. Final answer delivery is unchanged.",
},
"streaming.progress.commandText": {
label: "Telegram Progress Command Text",
help: 'Command/exec detail in progress draft lines: "raw" preserves released behavior; "status" shows only the tool label.',

View File

@@ -12,6 +12,7 @@ import type {
import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
import { normalizeTrimmedStringList } from "../shared/string-normalization.js";
import { asBoolean } from "../utils/boolean.js";
import { stripInlineDirectiveTagsForDelivery } from "../utils/directive-tags.js";
export type {
ChannelDeliveryStreamingConfig,
@@ -275,6 +276,7 @@ export type ChannelProgressDraftLine = {
status?: string;
toolName?: string;
prefix?: boolean;
format?: boolean;
};
function compactStrings(values: readonly (string | undefined | null)[]): string[] {
@@ -513,6 +515,70 @@ export function buildChannelProgressDraftLine(
return undefined;
}
export function normalizeChannelCommentaryProgressText(text: string): string {
const cleaned = stripInlineDirectiveTagsForDelivery(text).text.trim();
if (!cleaned || isSilentChannelCommentaryProgressText(cleaned)) {
return "";
}
return cleaned
.split(/\r?\n/u)
.map((line) => line.replace(/\s+/g, " ").trim())
.filter(Boolean)
.map((line) => `_${line}_`)
.join("\n");
}
export function resolveChannelCommentaryProgressLineId(params: {
text?: string;
itemId?: string;
}): string | undefined {
const itemId = params.itemId?.trim();
if (itemId) {
return `commentary:${itemId}`;
}
const normalized = normalizeChannelCommentaryProgressText(params.text ?? "");
return normalized ? `commentary:${normalized}` : undefined;
}
export function buildChannelCommentaryProgressDraftLine(params: {
text?: string;
itemId?: string;
}): ChannelProgressDraftLine | undefined {
const normalized = normalizeChannelCommentaryProgressText(params.text ?? "");
const itemId = params.itemId?.trim();
const lineId = itemId ? `commentary:${itemId}` : normalized ? `commentary:${normalized}` : "";
if (!normalized || !lineId) {
return undefined;
}
return {
id: lineId,
kind: "item",
text: normalized,
label: "Commentary",
prefix: false,
format: false,
};
}
export function removeChannelProgressDraftLine<TLine extends string | ChannelProgressDraftLine>(
lines: TLine[],
lineId: string,
): TLine[] {
const normalizedLineId = lineId.trim();
if (!normalizedLineId) {
return lines;
}
const nextLines = lines.filter(
(line) => typeof line !== "object" || line.id?.trim() !== normalizedLineId,
);
return nextLines.length === lines.length ? lines : nextLines;
}
function isSilentChannelCommentaryProgressText(text: string): boolean {
const normalized = text.replace(/^[\s*_`~]+|[\s*_`~]+$/gu, "").trim();
return /^NO_REPLY$/iu.test(normalized);
}
export function createChannelProgressDraftGate(params: {
onStart: () => void | Promise<void>;
initialDelayMs?: number;
@@ -989,14 +1055,17 @@ export function formatChannelProgressDraftText(params: {
? line
: getProgressDraftLineText(line);
const text = compactChannelProgressDraftLine(rawText, maxLineChars);
return text ? { text, isLabelLine, prefix } : undefined;
const format =
!isLabelLine && typeof line === "object" && line !== null ? line.format !== false : true;
return text ? { text, isLabelLine, prefix, format } : undefined;
})
.filter((line): line is { text: string; isLabelLine: boolean; prefix: boolean } =>
Boolean(line),
.filter(
(line): line is { text: string; isLabelLine: boolean; prefix: boolean; format: boolean } =>
Boolean(line),
)
.slice(-maxLines)
.map(({ text, isLabelLine, prefix }) => {
const formatted = isLabelLine ? text : formatLine(text);
.map(({ text, isLabelLine, prefix, format }) => {
const formatted = isLabelLine || !format ? text : formatLine(text);
return {
text:
!isLabelLine && prefix && shouldPrefixProgressLine(text)

File diff suppressed because one or more lines are too long

View File

@@ -238,7 +238,7 @@ describe("config schema", () => {
expect(progressPropsFor("discord")).not.toHaveProperty("nativeTaskCards");
expect(progressPropsFor("telegram")).not.toHaveProperty("nativeTaskCards");
expect(progressPropsFor("discord")).toHaveProperty("commentary");
expect(progressPropsFor("telegram")).not.toHaveProperty("commentary");
expect(progressPropsFor("telegram")).toHaveProperty("commentary");
expect(res.uiHints["channels.matrix"]?.label).toBe("Matrix");
expect(res.uiHints["channels.matrix.accessToken"]?.sensitive).toBe(true);
expect(res.uiHints["channels.matrix.streaming.progress.label"]?.label).toBe(
@@ -252,7 +252,9 @@ describe("config schema", () => {
expect(res.uiHints["channels.discord.streaming.progress.toolProgress"]?.label).toBe(
"Discord Progress Tool Lines",
);
expect(res.uiHints["channels.telegram.streaming.progress.commentary"]).toBeUndefined();
expect(res.uiHints["channels.telegram.streaming.progress.commentary"]?.label).toBe(
"Telegram Progress Commentary",
);
expect(res.uiHints["channels.mattermost.streaming.progress.label"]?.label).toBe(
"Mattermost Progress Label",
);
@@ -410,7 +412,7 @@ describe("config schema", () => {
).toBe(false);
});
it("accepts progress commentary only for Discord streaming config", () => {
it("accepts progress commentary for channels that render commentary drafts", () => {
expect(
DiscordConfigSchema.safeParse({
streaming: {
@@ -427,7 +429,7 @@ describe("config schema", () => {
progress: { commentary: true },
},
}).success,
).toBe(false);
).toBe(true);
});
it("keeps per-agent model overrides limited to model selection", () => {

View File

@@ -1,5 +1,6 @@
import type {
ChannelPreviewStreamingConfig,
ChannelStreamingProgressConfig,
ChannelStreamingPreviewConfig,
ContextVisibilityMode,
DmPolicy,
@@ -72,6 +73,12 @@ export type TelegramStreamingPreviewConfig = ChannelStreamingPreviewConfig & {
export type TelegramPreviewStreamingConfig = Omit<ChannelPreviewStreamingConfig, "preview"> & {
preview?: TelegramStreamingPreviewConfig;
progress?: TelegramStreamingProgressConfig;
};
export type TelegramStreamingProgressConfig = ChannelStreamingProgressConfig & {
/** Include assistant commentary/preamble text in the progress draft. Default: false. */
commentary?: boolean;
};
export type TelegramExecApprovalConfig = {

View File

@@ -102,7 +102,7 @@ const ChannelStreamingProgressSchema = z
commandText: z.enum(["raw", "status"]).optional(),
})
.strict();
const DiscordStreamingProgressSchema = ChannelStreamingProgressSchema.extend({
const ChannelCommentaryStreamingProgressSchema = ChannelStreamingProgressSchema.extend({
commentary: z.boolean().optional(),
}).strict();
const SlackStreamingProgressSchema = ChannelStreamingProgressSchema.extend({
@@ -119,9 +119,10 @@ const ChannelPreviewStreamingConfigSchema = z
.strict();
const TelegramPreviewStreamingConfigSchema = ChannelPreviewStreamingConfigSchema.extend({
preview: TelegramStreamingPreviewSchema.optional(),
progress: ChannelCommentaryStreamingProgressSchema.optional(),
}).strict();
const DiscordPreviewStreamingConfigSchema = ChannelPreviewStreamingConfigSchema.extend({
progress: DiscordStreamingProgressSchema.optional(),
progress: ChannelCommentaryStreamingProgressSchema.optional(),
}).strict();
const SlackStreamingConfigSchema = ChannelPreviewStreamingConfigSchema.extend({
nativeTransport: z.boolean().optional(),

View File

@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
buildChannelCommentaryProgressDraftLine,
buildChannelProgressDraftLine,
createChannelProgressDraftGate,
DEFAULT_PROGRESS_DRAFT_LABELS,
@@ -10,6 +11,8 @@ import {
isChannelProgressDraftWorkToolName,
isPotentialTruncatedFinal,
mergeChannelProgressDraftLine,
removeChannelProgressDraftLine,
resolveChannelCommentaryProgressLineId,
resolveChannelPreviewStreamMode,
resolveChannelProgressDraftMaxLineChars,
resolveChannelProgressDraftLabel,
@@ -117,6 +120,39 @@ describe("channel-streaming", () => {
).toBe(false);
});
it("builds transient commentary progress lines", () => {
const line = buildChannelCommentaryProgressDraftLine({
itemId: "preamble-1",
text: "[[reply_to_current]] Checking\n source data.",
});
expect(line).toEqual({
id: "commentary:preamble-1",
kind: "item",
text: "_Checking_\n_source data._",
label: "Commentary",
prefix: false,
format: false,
});
expect(
formatChannelProgressDraftText({
entry: { streaming: { progress: { label: false } } },
lines: [line!],
formatLine: (text) => `\`${text}\``,
}),
).toBe("_Checking_ _source data._");
expect(
buildChannelCommentaryProgressDraftLine({
itemId: "preamble-1",
text: "**NO_REPLY",
}),
).toBeUndefined();
expect(resolveChannelCommentaryProgressLineId({ itemId: "preamble-1" })).toBe(
"commentary:preamble-1",
);
expect(removeChannelProgressDraftLine([line!], "commentary:preamble-1")).toEqual([]);
});
it("falls back to legacy flat fields when the canonical object is absent", () => {
const entry = {
chunkMode: "newline",