fix(channels): show progress-card notes in drafts (#144790)

Closes #143431.

## What Problem This Solves
Markdown-only progress cards showed “Progress updated” in channel drafts, hiding the note.

## Why This Change Was Made
The shared Markdown parser removes formatting and authored HTML while preserving code literals and link metadata. This refines #139206 while retaining its HTML boundary. The card store remains the sole writer; transactions order updates and reset clears.

## User Impact
Notes are readable within existing headline limits. Checklist counts, replacement, clearing, and stored Markdown keep their behavior.

## Evidence
The registered-tool-to-final-renderer regression covers four literal inputs across three output variants. It fails in ten cases on the previous head and passes all twelve after the correction. Real channel recordings confirm literal characters, inactive links, replacement, clearing, and final delivery.

A Slack dispatch regression confirms that a fresh “Checking results” preamble and the matching projected note produce one native title. It fails with the duplicate title before the fix and passes after. The corrected branch passed 263 tests; the corrected merged content passed 266, including all twelve literal cases.

## Compatibility
No stored format, configuration key, protocol version, or SDK entrypoint changed. Existing callback and snapshot types gained optional presentation metadata.

## Consumers
Both agent execution paths share the note projection. The final channel renderers must treat the prepared note as literal text while preserving authored formatting in narration and commentary. Speech, session preambles, and other plain-text callers retain their existing defaults. Web-fetch extraction and visibility use the same unchanged scanner at its new shared location. Existing code-region readers retain their prior contract.

## Invalidation
Each update replaces the draft plan and note; empty input clears them.

## Tests
The prepared-text contract is forwarded through the existing event and draft boundaries. Authored narration keeps its previous formatting. The broader merged selection passed 782 tests; later formatter changes passed the recorded focused selections, including 120 tests on the final merged content. These are overlapping selections, not a summed test total. Live recordings precede equivalent encoder centralization; final focused tests cover that change.

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
This commit is contained in:
Ayaan Zaidi 2026-09-13 21:08:45 +05:30 • committed by GitHub
parent f765199d9c
commit 5541c3d396
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 750 additions and 152 deletions

View file

@ -89,7 +89,7 @@ A full in-place conversation reset (`/reset` without `soft`, or `sessions.reset`
## Where the card appears
Channels with progress drafts show the latest checklist in active `partial`, `block`, and `progress` previews, subject to their preview settings and line limits. Card updates supply a completion count, or `Progress updated` for a note without steps; they do not copy the note's Markdown or HTML into tool summaries. Telegram uses native checkboxes with `channels.telegram.richMessages: true` and readable HTML checklists otherwise. See [Streaming and chunking](/concepts/streaming#progress-draft-rendering).
Channels with progress drafts show the latest checklist in active `partial`, `block`, and `progress` previews, subject to their preview settings and line limits. Cards with steps supply a completion count. Notes without steps supply readable text with Markdown formatting and authored HTML removed, subject to the existing headline limit. A note without readable text supplies `Progress updated`. The full Markdown remains in the durable card. Telegram uses native checkboxes with `channels.telegram.richMessages: true` and readable HTML checklists otherwise. See [Streaming and chunking](/concepts/streaming#progress-draft-rendering).
The current chat keeps exactly one live card, in the collapsible surface inside the composer, at every width. Opening a side panel does not move it out of the conversation. The dashboard widget and the session hovercard are separate read-only placements: hover a session row in the sidebar or a session-reference link in chat to see the same card for that session. All card placements read the same Gateway-backed state and refresh after `progressCard.changed` notifications. A notification is a refresh hint, including a null revision; clients confirm a removal with a read or clear response for that session and agent.

View file

@ -115,6 +115,7 @@ export class CodexReasoningProjection {
this.emitPlanUpdate(
{
explanation,
...(params.explanationFormat === "plain" ? { explanationFormat: "plain" as const } : {}),
steps: plan,
},
source,
@ -153,7 +154,7 @@ export class CodexReasoningProjection {
}
private emitPlanUpdate(
params: { explanation?: string | null; steps?: AgentPlanStep[] },
params: { explanation?: string | null; explanationFormat?: "plain"; steps?: AgentPlanStep[] },
source: PlanUpdateSource = "codex-app-server",
): void {
if (!params.explanation && params.steps === undefined) {
@ -166,6 +167,7 @@ export class CodexReasoningProjection {
title: "Plan updated",
source,
...(params.explanation ? { explanation: params.explanation } : {}),
...(params.explanationFormat ? { explanationFormat: params.explanationFormat } : {}),
...(params.steps ? { steps: params.steps } : {}),
},
});

View file

@ -309,6 +309,7 @@ export class CodexAppServerEventProjector extends CodexTurnProjection {
const projected: JsonObject = {
plan: update.steps,
...(update.explanation ? { explanation: update.explanation } : {}),
...(update.explanationFormat ? { explanationFormat: update.explanationFormat } : {}),
};
await this.reasoningProjection.handleTurnPlanUpdated(projected, "openclaw");
}

View file

@ -2943,6 +2943,34 @@ describe("runCodexAppServerAttempt", () => {
],
});
const noteResponse = await harness.handleServerRequest({
id: "request-plan-note",
method: "item/tool/call",
params: {
threadId: "thread-1",
turnId: "turn-1",
callId: "call-plan-note",
namespace: null,
tool: "progress_card",
arguments: {
markdown:
'<progress aria-label="private" value="1" max="2"></progress>\n\n**Working** [results](https://example.com "<script>").',
},
},
});
expect(noteResponse).toMatchObject({ success: true });
expect(onAgentEvent).toHaveBeenCalledWith({
stream: "plan",
data: {
phase: "update",
title: "Plan updated",
source: "openclaw",
explanation: "Working results.",
explanationFormat: "plain",
steps: [],
},
});
const clearResponse = await harness.handleServerRequest({
id: "request-plan-clear",
method: "item/tool/call",
@ -2956,7 +2984,7 @@ describe("runCodexAppServerAttempt", () => {
},
});
expect(clearResponse).toMatchObject({ success: true });
expect(executeProgressCard).toHaveBeenCalledTimes(3);
expect(executeProgressCard).toHaveBeenCalledTimes(4);
expect(onAgentEvent).toHaveBeenCalledWith({
stream: "plan",
data: {
@ -3200,47 +3228,6 @@ describe("runCodexAppServerAttempt", () => {
expect(closeAndWait).not.toHaveBeenCalled();
});
it("projects dynamic progress cards through the shared safe status contract", async () => {
const params = createRunParams();
const onAgentEvent = vi.fn();
params.onAgentEvent = onAgentEvent;
const projector = new CodexAppServerEventProjector(params, "thread-1", "turn-1");
await projector.recordDynamicProgressCardUpdate({
markdown: '<progress aria-label="private" value="1" max="2"></progress>',
plan: [{ step: "Ship", status: "completed" }],
});
await projector.recordDynamicProgressCardUpdate({ markdown: "Working" });
await projector.recordDynamicProgressCardUpdate({});
expect(onAgentEvent).toHaveBeenNthCalledWith(1, {
stream: "plan",
data: {
phase: "update",
title: "Plan updated",
source: "openclaw",
explanation: "1/1 complete",
steps: [{ step: "Ship", status: "completed" }],
},
});
expect(onAgentEvent).toHaveBeenNthCalledWith(2, {
stream: "plan",
data: {
phase: "update",
title: "Plan updated",
source: "openclaw",
explanation: "Progress updated",
steps: [],
},
});
expect(onAgentEvent).toHaveBeenNthCalledWith(3, {
stream: "plan",
data: { phase: "update", title: "Plan updated", source: "openclaw", steps: [] },
});
expect(JSON.stringify(onAgentEvent.mock.calls)).not.toContain("<progress");
expect(JSON.stringify(onAgentEvent.mock.calls)).not.toContain("private");
});
it("keeps searchable Codex dynamic tools canonical in mirrored transcript snapshots", async () => {
const params = createRunParams();
const projector = new CodexAppServerEventProjector(params, "thread-1", "turn-1");

View file

@ -151,6 +151,7 @@ export function createDiscordMessageProgressRuntime(params: {
if (payload.phase === "update") {
return await draftPreview.pushPlanProgress(payload.steps, {
explanation: payload.explanation,
explanationFormat: payload.explanationFormat,
});
}
return false;

View file

@ -1,4 +1,6 @@
// Discord test API exposes transcript-provider fixtures without deep extension imports.
export { createDiscordDraftPreviewController } from "./src/monitor/message-handler.draft-preview.js";
export { RequestClient } from "./src/internal/discord.js";
export {
discordVoiceTranscriptsSourceProvider,
setDiscordTranscriptsVoiceManager,

View file

@ -111,6 +111,7 @@ export async function createMatrixDraftController(params: {
}
return await progressDraft.pushPlanProgress(payload.steps, {
explanation: payload.explanation,
explanationFormat: payload.explanationFormat,
});
},
onApprovalEvent: async (payload) => {

View file

@ -522,6 +522,7 @@ export async function dispatchMattermostInboundTurn(
const boundarySettled = enterBlockPreviewActivity("tool");
const progressSettled = progressDraft.pushPlanProgress(payloadValue.steps, {
explanation: payloadValue.explanation,
explanationFormat: payloadValue.explanationFormat,
});
previewBoundaryController.noteUpdate();
const [, visible] = await Promise.all([boundarySettled, progressSettled]);

View file

@ -640,6 +640,7 @@ export function createMSTeamsReplyDispatcher(params: {
}
await streamController.pushPlanProgress(normalizeAgentPlanSteps(payload.steps), {
explanation: typeof payload.explanation === "string" ? payload.explanation : undefined,
explanationFormat: payload.explanationFormat === "plain" ? "plain" : undefined,
});
return false;
},

View file

@ -233,6 +233,8 @@ export function createTeamsReplyStreamController(params: {
// Teams cannot delete an empty interim card; final delivery settles it.
const progressDraft = createChannelProgressDraftCompositor({
// Informative Teams activities are already plain text, unlike Markdown draft transports.
formatPlainText: (text) => text,
entry: params.msteamsConfig,
mode: streamMode,
active: Boolean(stream) && streamMode === "progress",
@ -368,7 +370,7 @@ export function createTeamsReplyStreamController(params: {
async pushPlanProgress(
steps?: AgentPlanStep[],
options?: { explanation?: string },
options?: { explanation?: string; explanationFormat?: "plain" },
): Promise<void> {
await progressDraft.pushPlanProgress(steps, options);
},

View file

@ -69,6 +69,7 @@ export function createSlackDraftProgressCardRuntime(params: {
seed: params.progressSeed,
formatLine: formatSlackProgressDraftLine,
narration: snapshot.statusHeadline,
narrationFormat: snapshot.statusHeadlineFormat,
plan: snapshot.plan,
diffStat: snapshot.diffStat,
});
@ -78,16 +79,30 @@ export function createSlackDraftProgressCardRuntime(params: {
state: DraftProgressCardState,
) => {
const title = params.explicitTitle ?? snapshot.statusHeadline ?? "Working";
const titleFormat = params.explicitTitle ? undefined : snapshot.statusHeadlineFormat;
const narration = params.explicitTitle
? combineProgressHeadlineAndExplanation(snapshot.statusHeadline, snapshot.planExplanation)
: snapshot.planExplanation && snapshot.planExplanation !== title
? snapshot.planExplanation
? snapshot.statusHeadlineFormat === "plain" || snapshot.planExplanationFormat === "plain"
? [
...(snapshot.statusHeadline &&
(snapshot.statusHeadline !== snapshot.planExplanation ||
snapshot.statusHeadlineFormat !== snapshot.planExplanationFormat)
? [{ text: snapshot.statusHeadline, format: snapshot.statusHeadlineFormat }]
: []),
...(snapshot.planExplanation
? [{ text: snapshot.planExplanation, format: snapshot.planExplanationFormat }]
: []),
]
: combineProgressHeadlineAndExplanation(snapshot.statusHeadline, snapshot.planExplanation)
: snapshot.planExplanation &&
(snapshot.planExplanation !== title || snapshot.planExplanationFormat !== titleFormat)
? [{ text: snapshot.planExplanation, format: snapshot.planExplanationFormat }]
: undefined;
const workCounter = state === "working" ? params.progressWorkCounter : undefined;
const sessionUrl = state === "working" ? undefined : resolveSessionUrl();
return buildSlackProgressCardBlocks({
state,
title,
titleFormat,
narration,
plan: snapshot.plan,
lines: resolveStructuredProgressLines(snapshot.lines),

View file

@ -16,6 +16,7 @@ import { formatSlackError } from "../../errors.js";
import { SLACK_EDIT_TEXT_MAX_BYTES, SLACK_TEXT_LIMIT } from "../../limits.js";
import {
buildSlackProgressStreamChunks,
buildSlackProgressTextBlocks,
reconcileSlackNativeTaskChunks,
EMPTY_SLACK_NATIVE_STREAM_SNAPSHOT,
type SlackNativeStreamSnapshot,
@ -201,7 +202,14 @@ export function createSlackProgressRuntime(runtimeParams: {
if (!candidate) {
return false;
}
const title = normalizeProgressText(resolveNativeProgressTitle(progressDraft.getSnapshot()));
const snapshot = progressDraft.getSnapshot();
const title = normalizeProgressText(
combineProgressHeadlineAndExplanation(
explicitProgressTitle ??
(snapshot.statusHeadlineFormat === "plain" ? undefined : snapshot.statusHeadline),
snapshot.planExplanationFormat === "plain" ? undefined : snapshot.planExplanation,
),
);
return title.length > 0 && title.includes(candidate);
};
@ -368,7 +376,9 @@ export function createSlackProgressRuntime(runtimeParams: {
text: previewText,
blocks: progressCard.resolvePresentation(snapshot, "working"),
}
: previewText,
: snapshot.preparedBlocks
? { text: previewText, blocks: buildSlackProgressTextBlocks(snapshot.preparedBlocks) }
: previewText,
);
if (options?.flush) {
await draftStream.flush();
@ -477,11 +487,15 @@ export function createSlackProgressRuntime(runtimeParams: {
delivery.streamFailed = false;
};
const pushPlanProgress = async (steps?: AgentPlanStep[], explanation?: string) => {
const pushPlanProgress = async (
steps?: AgentPlanStep[],
explanation?: string,
explanationFormat?: "plain",
) => {
if (isProgressMode && slackProgressStyle === "compact") {
return false;
}
return await progressDraft.pushPlanProgress(steps, { explanation });
return await progressDraft.pushPlanProgress(steps, { explanation, explanationFormat });
};
const updateDraftFromPartial = (text?: string) => {

View file

@ -1,4 +1,5 @@
// Slack tests cover dispatch.preview fallback plugin behavior.
import { projectProgressCardChannelUpdate } from "openclaw/plugin-sdk/agent-harness-runtime";
import {
createTestRegistry,
resetPluginRuntimeStateForTest,
@ -171,6 +172,7 @@ let mockedReplyOptionEvents: Array<
kind: "plan";
phase?: string;
explanation?: string;
explanationFormat?: "plain";
steps: Array<{ step: string; status: "pending" | "in_progress" | "completed" }>;
}
| { kind: "concurrent_items"; progressTexts: string[] }
@ -1079,6 +1081,7 @@ vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => {
await params.replyOptions?.onPlanUpdate?.({
phase: entry.phase,
explanation: entry.explanation,
explanationFormat: entry.explanationFormat,
steps: entry.steps,
});
} else if (entry.kind === "concurrent_items") {
@ -3526,6 +3529,29 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
taskUpdate("plan_step_1", "Patch", "in_progress"),
],
},
{
name: "deduplicates a native title shared by a fresh preamble and a prepared note",
events: [
{
kind: "item",
itemKind: "preamble",
itemId: "preamble-1",
progressText: "Checking results",
},
{
kind: "plan",
phase: "update",
...projectProgressCardChannelUpdate({ markdown: "**Checking** results" }),
steps: [],
},
],
updates: [
planUpdate("Checking results"),
taskUpdate(expect.any(String), "Update Plan", "in_progress", {
details: "Checking results",
}),
],
},
{
name: "starts native Slack progress from an explanation-only plan",
events: [

View file

@ -603,7 +603,11 @@ async function dispatchSlackMessageWithSetup(
if (payload.phase !== "update") {
return false;
}
return await progress.pushPlanProgress(payload.steps, payload.explanation);
return await progress.pushPlanProgress(
payload.steps,
payload.explanation,
payload.explanationFormat,
);
},
onApprovalEvent: async (payload) => {
return await progress.progressDraft.pushApprovalEvent(payload);

View file

@ -40,6 +40,35 @@ function field(text: string) {
return { type: "mrkdwn" as const, text: truncateSlackText(text, SLACK_PROGRESS_FIELD_MAX) };
}
type SlackProgressText = { text: string; format?: "plain" };
function progressTextSection(value: SlackProgressText, style?: "italic"): Block | KnownBlock {
if (value.format === "plain") {
return {
type: "section",
text: {
type: "plain_text",
text: truncateSlackText(value.text, SLACK_PROGRESS_FIELD_MAX),
emoji: false,
},
};
}
const rendered = renderProgressCardText(value.text, style);
const marker = style === "italic" ? "_" : "";
return { type: "section", text: field(`${marker}${rendered}${marker}`) };
}
export function buildSlackProgressTextBlocks(
blocks: NonNullable<ChannelProgressDraftCompositorSnapshot["preparedBlocks"]>,
): (Block | KnownBlock)[] {
return blocks.map((block) =>
progressTextSection({
text: block.text,
format: block.format === "plain" ? "plain" : undefined,
}),
);
}
function resolveMaxLineChars(value: number | undefined, fallback: number): number {
return value && value > 0 ? Math.floor(value) : fallback;
}
@ -352,9 +381,10 @@ function buildActivityText(lines: readonly ChannelProgressDraftLine[], maxLineCh
export function buildSlackProgressCardBlocks(params: {
state: SlackProgressCardState;
title: string;
titleFormat?: "plain";
lines: readonly ChannelProgressDraftLine[];
plan?: readonly AgentPlanStep[];
narration?: string;
narration?: string | readonly SlackProgressText[];
maxLineChars?: number;
toolCalls?: number;
elapsedSeconds?: number;
@ -369,7 +399,14 @@ export function buildSlackProgressCardBlocks(params: {
maxLines: SLACK_MAX_BLOCKS,
maxLineChars,
});
const narration = params.narration?.replace(/\s+/g, " ").trim();
const narration = (
typeof params.narration === "string" ? [{ text: params.narration }] : (params.narration ?? [])
)
.map(({ text, format }: SlackProgressText) => ({
text: text.replace(/\s+/g, " ").trim(),
format,
}))
.filter((part) => part.text);
const diffStat = formatChannelProgressDraftDiffStat(params.diffStat);
const workingFooter = [
...(params.toolCalls && params.toolCalls > 0 ? [`🛠️ ${params.toolCalls} tools`] : []),
@ -387,8 +424,6 @@ export function buildSlackProgressCardBlocks(params: {
return title === undefined ? [] : [escapeSlackMrkdwn(title)];
});
const sections = [
`${icon} *${renderProgressCardText(params.title.trim() || "Working", "bold")}*`,
narration ? `_${renderProgressCardText(narration, "italic")}_` : "",
planLines.map((line) => renderProgressCardText(line)).join("\n"),
buildActivityText(
params.lines.filter((line) => line.kind !== "approval" && lineTaskStatus(line) !== "error"),
@ -397,9 +432,14 @@ export function buildSlackProgressCardBlocks(params: {
// Attention has its own bounded section so activity truncation cannot hide it.
joinRecentProgressRows(attention),
];
const blocks: (Block | KnownBlock)[] = sections
.filter(Boolean)
.map((text) => ({ type: "section", text: field(text) }));
const title = params.title.trim() || "Working";
const blocks: (Block | KnownBlock)[] = [
params.titleFormat === "plain"
? progressTextSection({ text: `${icon} ${title}`, format: "plain" })
: { type: "section", text: field(`${icon} *${renderProgressCardText(title, "bold")}*`) },
...narration.map((part) => progressTextSection(part, "italic")),
...sections.filter(Boolean).map((text) => ({ type: "section" as const, text: field(text) })),
];
if (footer) {
blocks.push({ type: "context", elements: [field(footer)] });
}

View file

@ -277,6 +277,7 @@ export async function handlePlanUpdate(
return payload.phase === "update" && canPushToolProgress(turn)
? await turn.progressCompositor.pushPlanProgress(payload.steps, {
explanation: payload.explanation,
explanationFormat: payload.explanationFormat,
})
: false;
}

View file

@ -156,7 +156,7 @@ type TelegramProgressCompositor = {
pushCommentaryProgress: (text?: string, options?: { itemId?: string }) => Promise<boolean>;
pushPlanProgress: (
steps?: AgentPlanStep[],
options?: { explanation?: string },
options?: { explanation?: string; explanationFormat?: "plain" },
) => Promise<boolean>;
pushPreambleHeadline: (text?: string, options?: { itemId?: string }) => Promise<boolean>;
pushToolEvent: (payload: CallbackPayload<"onToolStart">) => Promise<boolean>;

View file

@ -31,8 +31,12 @@ function isTelegramProgressPriorityLine(line: ChannelProgressDraftCompositorLine
// Each row has one content decision; both Telegram transports use that row.
type ProgressText = { html: string; rich: RichText };
function literalProgressText(text: string, style?: "bold" | "italic"): ProgressText {
function literalProgressText(text: string, style?: "bold" | "italic" | "code"): ProgressText {
const escaped = escapeTelegramHtml(text);
if (style === "code") {
// Telegram also detects bare URLs in HTML text; code entities keep prepared notes inert.
return { html: `<code>${escaped}</code>`, rich: { type: "code", text } };
}
return style === "bold"
? { html: `<b>${escaped}</b>`, rich: boldRichText(text) }
: style === "italic"
@ -122,11 +126,11 @@ export function renderTelegramProgressDraftPreview(
addParagraph(literalProgressText(compactChannelProgressDraftLine(label, maxLineChars), "bold"));
}
if (snapshot.statusHeadline) {
const status = markdownProgressText(
compactChannelProgressDraftLine(snapshot.statusHeadline, maxLineChars),
);
const text = compactChannelProgressDraftLine(snapshot.statusHeadline, maxLineChars);
const plain = snapshot.statusHeadlineFormat === "plain";
const status = plain ? literalProgressText(text, "code") : markdownProgressText(text);
addParagraph(
label
label || plain
? status
: {
html: `<b>${status.html}</b>`,

View file

@ -0,0 +1,2 @@
export { renderTelegramProgressDraftPreview } from "./src/progress-draft-preview.js";
export { telegramHtmlToPlainTextFallback } from "./src/format.js";

View file

@ -195,8 +195,9 @@ type MarkdownInlineSource = {
offsets: number[];
};
type MarkdownCodeOptions = {
type MarkdownOwnershipOptions = {
includeSource?: boolean;
includeText?: boolean;
syntax?: "commonmark" | "gfm";
};
@ -262,9 +263,9 @@ function captureInlineSources(text: string, sources: Map<number, MarkdownInlineS
};
}
export function parseMarkdownOwnership(text: string, options?: MarkdownCodeOptions) {
export function parseMarkdownOwnership(text: string, options?: MarkdownOwnershipOptions) {
if (!text) {
return { regions: [], codeSpans: [], retainStart: 0 };
return { regions: [], codeSpans: [], textSpans: [], retainStart: 0 };
}
const sources = new Map<number, MarkdownInlineSource>();
const tables = options?.syntax !== "commonmark";
@ -276,11 +277,15 @@ export function parseMarkdownOwnership(text: string, options?: MarkdownCodeOptio
],
}) as PositionedNode;
const regions: MarkdownCodeRegion[] = [];
const textSpans: Array<[number, number]> = [];
const pending: PositionedNode[] = [tree];
while (pending.length > 0) {
const node = expectDefined(pending.pop(), "Markdown ownership node");
const start = node.position?.start?.offset;
const end = node.position?.end?.offset;
if (options?.includeText && node.type === "text" && start !== undefined && end !== undefined) {
textSpans.push([start, end]);
}
if (
(node.type === "code" || node.type === "inlineCode") &&
start !== undefined &&
@ -302,6 +307,7 @@ export function parseMarkdownOwnership(text: string, options?: MarkdownCodeOptio
return {
regions,
codeSpans: regions.map(({ start, end }): [number, number] => [start, end]),
textSpans,
retainStart: tree.children?.at(-1)?.position?.start?.offset ?? text.length,
};
}
@ -309,7 +315,7 @@ export function parseMarkdownOwnership(text: string, options?: MarkdownCodeOptio
/** Returns parser-owned CommonMark/GFM code ranges with block ownership. */
export function findMarkdownCodeRegions(
text: string,
options?: MarkdownCodeOptions,
options?: MarkdownOwnershipOptions,
): MarkdownCodeRegion[] {
return /[`~\t]| {4}/u.test(text) ? parseMarkdownOwnership(text, options).regions : [];
}

View file

@ -0,0 +1,37 @@
import { RAW_TEXT_TAGS, readRawTextBounds } from "./html-scanner.js";
import { matchMarkdownHtmlTag, tokenizeHtmlTags } from "./html-tags.js";
import { parseMarkdownOwnership } from "./reasoning-tag-parser.js";
/** Removes authored HTML before block parsing while preserving code and escaped literals. */
export function stripHtmlFromMarkdown(markdown: string): string {
const { textSpans } = parseMarkdownOwnership(markdown, { includeText: true });
let output = "";
let cursor = 0;
for (let start = markdown.indexOf("<"); start !== -1; start = markdown.indexOf("<", start + 1)) {
if (!textSpans.some(([begin, end]) => start >= begin && start < end)) {
continue;
}
let escaped = false;
for (let index = start - 1; markdown[index] === "\\"; index -= 1) {
escaped = !escaped;
}
if (escaped) {
continue;
}
const raw = matchMarkdownHtmlTag(markdown.slice(start));
if (!raw) {
continue;
}
const tag = tokenizeHtmlTags(raw).next().value;
output += markdown.slice(cursor, start);
cursor =
tag && !tag.closing && RAW_TEXT_TAGS.has(tag.name)
? readRawTextBounds(markdown, tag.name, start + raw.length).end
: start + raw.length;
if (tag && (tag.name === "br" || tag.name === "hr")) {
output += "\n";
}
start = cursor - 1;
}
return output + markdown.slice(cursor);
}

View file

@ -351,7 +351,8 @@ describe("progress_card compatibility plan events", () => {
phase: "update",
title: "Plan updated",
source: "openclaw",
explanation: "Progress updated",
explanation: "Checking safe candidates.",
explanationFormat: "plain",
steps: [],
},
});

View file

@ -4,8 +4,6 @@
* Converts lightweight HTML into bounded markdown/text without pulling in a full renderer.
*/
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { stripInvisibleUnicode } from "../../infra/unicode-visibility.js";
import { decodeHtmlEntities } from "../../shared/html-entities.js";
import {
RAW_TEXT_TAGS,
isAsciiWhitespace,
@ -16,7 +14,9 @@ import {
readTagToken,
readRawTextBounds,
skipRawTextElement,
} from "./web-fetch-html-tag.js";
} from "../../../packages/markdown-core/src/html-scanner.js";
import { stripInvisibleUnicode } from "../../infra/unicode-visibility.js";
import { decodeHtmlEntities } from "../../shared/html-entities.js";
import { sanitizeHtml } from "./web-fetch-visibility.js";
/** Output mode requested by web_fetch extraction. */

View file

@ -13,7 +13,7 @@ import {
readTagToken,
skipHtmlComment,
startsLikeHtmlTag,
} from "./web-fetch-html-tag.js";
} from "../../../packages/markdown-core/src/html-scanner.js";
// Compile property matchers once: this list is checked for every styled element.
const HIDDEN_STYLE_PATTERNS = (

View file

@ -316,6 +316,8 @@ export type GetReplyOptions = {
phase?: string;
title?: string;
explanation?: string;
/** Prepared literal text; unmarked explanations retain authored Markdown. */
explanationFormat?: "plain";
steps?: AgentPlanStep[];
source?: string;
}) => Promise<ProgressCallbackResult> | ProgressCallbackResult;

View file

@ -361,6 +361,7 @@ function createPlanUpdateBridge(params: {
phase: normalizeOptionalString(evt.data.phase),
title: normalizeOptionalString(evt.data.title),
explanation: normalizeOptionalString(evt.data.explanation),
...(evt.data.explanationFormat === "plain" ? { explanationFormat: "plain" as const } : {}),
steps: normalizeAgentPlanSteps(evt.data.steps),
source: normalizeOptionalString(evt.data.source),
};

View file

@ -183,6 +183,7 @@ export function createAgentRunEventHandler(params: {
phase: readStringValue(evt.data.phase),
title: readStringValue(evt.data.title),
explanation: readStringValue(evt.data.explanation),
...(evt.data.explanationFormat === "plain" ? { explanationFormat: "plain" as const } : {}),
steps: normalizeAgentPlanSteps(evt.data.steps),
source: readStringValue(evt.data.source),
});

View file

@ -381,6 +381,9 @@ export async function executeDispatch(state: PrepareDispatchExecutionReadyState)
phase: payload.phase,
title: payload.title,
explanation: payload.explanation,
...(payload.explanationFormat
? { explanationFormat: payload.explanationFormat }
: {}),
steps,
source: payload.source,
};
@ -408,6 +411,7 @@ export async function executeDispatch(state: PrepareDispatchExecutionReadyState)
}
await state.sendPlanUpdate({
explanation: normalized.explanation,
explanationFormat: normalized.explanationFormat,
steps,
});
},

View file

@ -51,7 +51,11 @@ export async function prepareDispatchExecution(state: ChooseDispatchRouteReadySt
}
let didSendPlanStatusNotice = false;
const formatPlanUpdateText = (payload: { explanation?: string; steps?: AgentPlanStep[] }) => {
const formatPlanUpdateText = (payload: {
explanation?: string;
explanationFormat?: "plain";
steps?: AgentPlanStep[];
}) => {
const explanation = payload.explanation?.replace(/\s+/g, " ").trim();
const steps = (payload.steps ?? [])
.map((entry) => ({ step: entry.step.replace(/\s+/g, " ").trim(), status: entry.status }))
@ -62,10 +66,14 @@ export async function prepareDispatchExecution(state: ChooseDispatchRouteReadySt
maxLineChars: 120,
}).join("\n");
}
return explanation || "Planning next steps.";
// Generic notices retain their shipped receipt; prepared notes belong to literal-capable drafts.
return payload.explanationFormat === "plain"
? "Progress updated"
: explanation || "Planning next steps.";
};
const sendPlanUpdate = async (payload: {
explanation?: string;
explanationFormat?: "plain";
steps?: AgentPlanStep[];
}): Promise<void> => {
if (

View file

@ -962,6 +962,34 @@ describe("dispatchReplyFromConfig", () => {
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({ text: "done" });
});
it("keeps prepared notes in drafts and the generic receipt in verbose notices", async () => {
setNoAbort();
const cfg = {
...emptyConfig,
agents: { defaults: { verboseDefault: "on" } },
} satisfies OpenClawConfig;
const dispatcher = createDispatcher();
await dispatchReplyFromConfig({
ctx: buildTestCtx({ Provider: "telegram", ChatType: "direct" }),
cfg,
dispatcher,
replyResolver: async (_ctx, opts) => {
await opts?.onPlanUpdate?.({
phase: "update",
explanation: "Use **literal** and [label](https://example.com).",
explanationFormat: "plain",
steps: [],
});
return { text: "done" };
},
});
expect(firstToolResultPayload(dispatcher)).toMatchObject({
text: "Progress updated",
isStatusNotice: true,
});
expect(dispatcher.sendToolResult).toHaveBeenCalledTimes(1);
});
it("sends only one plan status notice per reply run", async () => {
setNoAbort();
const cfg = {

View file

@ -2,6 +2,53 @@ import { describe, expect, it, vi } from "vitest";
import { createChannelProgressDraftCompositor } from "./progress-draft-compositor.js";
describe("progress draft plan lifecycle", () => {
it("keeps the native update callback's unformatted text contract", async () => {
const tryNativeUpdate = vi.fn(() => Promise.resolve(true));
const progress = createChannelProgressDraftCompositor({
entry: { streaming: { mode: "progress", progress: { label: false, toolProgress: true } } },
mode: "progress",
active: true,
seed: "native-literal",
update: () => true,
tryNativeUpdate,
});
try {
await progress.pushPlanProgress([], {
explanation: "Use **literal**.",
explanationFormat: "plain",
});
await progress.pushToolProgress("Reading", { startImmediately: true });
expect(tryNativeUpdate).toHaveBeenCalledWith(expect.stringContaining("Use **literal**."));
} finally {
progress.cancel();
}
});
it("repaints identical text when authored Markdown replaces a prepared note", async () => {
const update = vi.fn(() => true);
const progress = createChannelProgressDraftCompositor({
entry: { streaming: { mode: "progress", progress: { label: false, commentary: false } } },
mode: "progress",
active: true,
seed: "literal-handoff",
formatPlainText: (text) => text,
update,
});
try {
await progress.pushPlanProgress([], {
explanation: "**literal**",
explanationFormat: "plain",
});
expect(progress.getSnapshot().statusHeadlineFormat).toBe("plain");
await progress.pushPreambleHeadline("**literal**");
expect(update).toHaveBeenCalledTimes(2);
expect(progress.getSnapshot().statusHeadline).toBe("**literal**");
expect(progress.getSnapshot().statusHeadlineFormat).toBeUndefined();
expect(progress.getSnapshot().planExplanationFormat).toBe("plain");
} finally {
progress.cancel();
}
});
it.each(["partial", "block", "progress"] as const)(
"preserves the plan across message and answer boundaries in %s mode",
async (mode) => {

View file

@ -1,12 +1,13 @@
import type {
ChannelProgressDraftCompositorLine,
ChannelProgressDraftCompositorSnapshot,
ChannelProgressDraftCompositorParams,
} from "./progress-draft-compositor.types.js";
import {
createProgressDraftDiffStatTracker,
formatChannelProgressDraftDiffStat,
type ChannelProgressDraftDiffStat,
} from "./progress-draft-diffstat.js";
import {
createChannelProgressDraftEventHandlers,
type ChannelProgressDraftEventLineBuilder,
} from "./progress-draft-events.js";
import { createChannelProgressDraftEventHandlers } from "./progress-draft-events.js";
import { removeChannelProgressDraftLine } from "./progress-draft-lines.js";
import {
formatReasoningProgressDisplayLine,
@ -32,8 +33,6 @@ import {
resolveChannelStreamingProgressCommentary,
resolveChannelStreamingPreviewToolProgress,
resolveChannelStreamingSuppressDefaultToolProgressMessages,
type StreamingCompatEntry,
type StreamingMode,
} from "./streaming.js";
export { createChannelProgressWorkCounter } from "./progress-work-counter.js";
@ -43,55 +42,12 @@ export { createChannelProgressWorkCounter } from "./progress-work-counter.js";
// the narrator, deliberately not re-exported through the SDK barrels.
export const PROGRESS_STATUS_PREAMBLE_FRESH_MS = 20_000;
export type ChannelProgressDraftCompositorLine = string | ChannelProgressDraftLine;
export type ChannelProgressDraftCompositorSnapshot = Readonly<{
lines: readonly ChannelProgressDraftCompositorLine[];
label?: string;
statusHeadline?: string;
plan?: readonly AgentPlanStep[];
planExplanation?: string;
diffStat?: ChannelProgressDraftDiffStat;
}>;
export type {
ChannelProgressDraftCompositorLine,
ChannelProgressDraftCompositorSnapshot,
} from "./progress-draft-compositor.types.js";
type ChannelProgressDraftUpdateOptions = {
flush?: boolean;
lines: readonly ChannelProgressDraftCompositorLine[];
snapshot: ChannelProgressDraftCompositorSnapshot;
};
export function createChannelProgressDraftCompositor(params: {
/** @deprecated v2026.9.1 SDK presentation; retain until a breaking SDK release. */
presentation?: "summary";
entry: StreamingCompatEntry | null | undefined;
mode: StreamingMode;
active: boolean;
seed: string;
update: (
text: string,
options: ChannelProgressDraftUpdateOptions,
) => Promise<boolean | void> | boolean | void;
deleteCurrent?: () => Promise<void> | void;
tryNativeUpdate?: (text: string) => Promise<boolean> | boolean;
/** Publish when structured lines change even if the rendered text does not. */
updateOnLineChange?: boolean;
/**
* Set when the channel renders `update`'s structured `lines` itself, so the
* composed text carries only the status block (label, headline, checklist).
*/
rendersRollingLinesNatively?: boolean;
formatLine?: (line: string) => string;
isEmptyLine?: (line: ChannelProgressDraftCompositorLine | undefined) => boolean;
shouldStartNow?: (line: ChannelProgressDraftCompositorLine | undefined) => boolean;
reasoningLinePrefix?: string;
commentaryLinePrefix?: string;
reasoningGate?: boolean;
commentaryItalics?: boolean;
now?: () => number;
setTimeoutFn?: typeof setTimeout;
clearTimeoutFn?: typeof clearTimeout;
/** Channel-specific formatter policy; event/lifecycle ownership remains in the compositor. */
buildProgressEventLine?: ChannelProgressDraftEventLineBuilder;
}) {
export function createChannelProgressDraftCompositor(params: ChannelProgressDraftCompositorParams) {
const now = params.now ?? Date.now;
const setTimeoutFn = params.setTimeoutFn ?? setTimeout;
const clearTimeoutFn = params.clearTimeoutFn ?? clearTimeout;
@ -126,6 +82,7 @@ export function createChannelProgressDraftCompositor(params: {
let lines: ChannelProgressDraftCompositorLine[] = [];
let renderGeneration = 0;
let lastRenderedText = "";
let lastRenderedStatusFormat: "plain" | undefined;
let lastRenderedLines = lines;
let lastRenderedDiffStatKey = "";
let reasoningRawText = "";
@ -143,6 +100,7 @@ export function createChannelProgressDraftCompositor(params: {
let narrationText = "";
let planSteps: AgentPlanStep[] | undefined;
let planExplanation = "";
let planExplanationFormat: "plain" | undefined;
let finalReplyStarted = false;
let finalReplyDelivered = false;
const diffStatTracker = createProgressDraftDiffStatTracker({
@ -176,14 +134,23 @@ export function createChannelProgressDraftCompositor(params: {
const resolveStatusText = () => {
const preambleIsFresh =
preambleAt !== undefined && now() - preambleAt < PROGRESS_STATUS_PREAMBLE_FRESH_MS;
const effectiveNarration = narrationText || planExplanation;
return preambleText && (preambleIsFresh || !effectiveNarration)
? preambleText
: effectiveNarration;
if (preambleText && (preambleIsFresh || !(narrationText || planExplanation))) {
return { text: preambleText };
}
return narrationText
? { text: narrationText }
: { text: planExplanation, format: planExplanationFormat };
};
const formatDraftText = (draftLines = lines, options?: { formatted?: boolean }) => {
const narration = resolveStatusText() || undefined;
const formatDraftText = (
draftLines = lines,
options?: {
formatted?: boolean;
onPreparedBlocks?: (blocks: Array<{ text: string; format: "plain" | "markdown" }>) => void;
},
) => {
const status = resolveStatusText();
const narration = status.text || undefined;
// Channels that render the rolling lines themselves (from `update`'s
// `lines`) would print them twice if they also appeared in this text.
const linesRenderedByChannel =
@ -194,7 +161,10 @@ export function createChannelProgressDraftCompositor(params: {
lines: linesRenderedByChannel ? [] : draftLines,
seed: params.seed,
formatLine: options?.formatted === false ? undefined : params.formatLine,
formatPlainText: options?.formatted === false ? (text) => text : params.formatPlainText,
onPreparedBlocks: options?.onPreparedBlocks,
narration,
narrationFormat: status.format,
plan: planSteps,
diffStat: resolveDiffStat(),
});
@ -203,7 +173,8 @@ export function createChannelProgressDraftCompositor(params: {
const resolveDiffStat = diffStatTracker.resolve;
const getSnapshot = (): ChannelProgressDraftCompositorSnapshot => {
const statusHeadline = resolveStatusText();
const status = resolveStatusText();
const statusHeadline = status.text;
const diffStat = resolveDiffStat();
const label = resolveChannelProgressDraftLabel({
entry: params.entry,
@ -214,8 +185,10 @@ export function createChannelProgressDraftCompositor(params: {
lines: lines.map((line) => (typeof line === "string" ? line : { ...line })),
...(label ? { label } : {}),
...(statusHeadline ? { statusHeadline } : {}),
...(statusHeadline && status.format ? { statusHeadlineFormat: status.format } : {}),
...(planSteps ? { plan: planSteps.map((entry) => ({ ...entry })) } : {}),
...(planExplanation ? { planExplanation } : {}),
...(planExplanation && planExplanationFormat ? { planExplanationFormat } : {}),
...(diffStat ? { diffStat } : {}),
};
};
@ -226,6 +199,7 @@ export function createChannelProgressDraftCompositor(params: {
lines = [];
renderGeneration += 1;
lastRenderedText = "";
lastRenderedStatusFormat = undefined;
lastRenderedLines = lines;
lastRenderedDiffStatKey = "";
reasoningRawText = "";
@ -243,24 +217,44 @@ export function createChannelProgressDraftCompositor(params: {
clearActivityState(suppressed);
planSteps = undefined;
planExplanation = "";
planExplanationFormat = undefined;
};
const publish = async (options?: { flush?: boolean }): Promise<boolean> => {
const text = formatDraftText();
let blocks: Array<{ text: string; format: "plain" | "markdown" }> = [];
const text = formatDraftText(lines, {
onPreparedBlocks: (prepared) => {
blocks = prepared;
},
});
const statusFormat = resolveStatusText().format;
const diffStatKey = JSON.stringify(resolveDiffStat() ?? null);
const structuredStateChanged =
params.updateOnLineChange === true &&
(lines !== lastRenderedLines || diffStatKey !== lastRenderedDiffStatKey);
if (!text || (text === lastRenderedText && !structuredStateChanged)) {
if (
!text ||
(text === lastRenderedText &&
statusFormat === lastRenderedStatusFormat &&
!structuredStateChanged)
) {
return false;
}
const generation = renderGeneration;
const observed = await settleProgressVisibilityCallbackResult(
params.update(text, { ...options, lines: [...lines], snapshot: getSnapshot() }),
params.update(text, {
...options,
lines: [...lines],
snapshot: {
...getSnapshot(),
...(blocks.some((block) => block.format === "plain") ? { preparedBlocks: blocks } : {}),
},
}),
);
// A retired publication can finish after its replacement; it no longer owns dedupe state.
if (observed.visible && generation === renderGeneration) {
lastRenderedText = text;
lastRenderedStatusFormat = statusFormat;
lastRenderedLines = lines;
lastRenderedDiffStatKey = diffStatKey;
}
@ -332,7 +326,7 @@ export function createChannelProgressDraftCompositor(params: {
// Labels decorate activity; they must not keep a retracted card alive.
if (
lines.length ||
resolveStatusText() ||
resolveStatusText().text ||
planSteps?.length ||
formatChannelProgressDraftDiffStat(resolveDiffStat())
) {
@ -484,7 +478,7 @@ export function createChannelProgressDraftCompositor(params: {
return Boolean(lastRenderedText) && !finalReplyStarted && !finalReplyDelivered;
},
get hasStatusHeadline() {
return Boolean(resolveStatusText());
return Boolean(resolveStatusText().text);
},
get hasPlanProgress() {
return Boolean(planSteps?.length);
@ -573,7 +567,7 @@ export function createChannelProgressDraftCompositor(params: {
},
async pushPlanProgress(
steps?: AgentPlanStep[],
options?: { explanation?: string },
options?: { explanation?: string; explanationFormat?: "plain" },
): Promise<boolean> {
if (!params.active || progressSuppressed || finalReplyStarted || finalReplyDelivered) {
return false;
@ -583,6 +577,7 @@ export function createChannelProgressDraftCompositor(params: {
}
planSteps = steps && steps.length > 0 ? steps.map((entry) => ({ ...entry })) : undefined;
planExplanation = options?.explanation?.replace(/\s+/g, " ").trim() ?? "";
planExplanationFormat = options?.explanationFormat;
if (!planSteps && !planExplanation) {
return await renderAfterRetraction();
}

View file

@ -0,0 +1,62 @@
import type { ChannelProgressDraftDiffStat } from "./progress-draft-diffstat.js";
import type { ChannelProgressDraftEventLineBuilder } from "./progress-draft-events.js";
import type {
AgentPlanStep,
ChannelProgressDraftLine,
StreamingCompatEntry,
StreamingMode,
} from "./streaming.js";
export type ChannelProgressDraftCompositorLine = string | ChannelProgressDraftLine;
export type ChannelProgressDraftCompositorSnapshot = Readonly<{
lines: readonly ChannelProgressDraftCompositorLine[];
label?: string;
statusHeadline?: string;
statusHeadlineFormat?: "plain";
plan?: readonly AgentPlanStep[];
planExplanation?: string;
planExplanationFormat?: "plain";
preparedBlocks?: readonly { text: string; format: "plain" | "markdown" }[];
diffStat?: ChannelProgressDraftDiffStat;
}>;
type ChannelProgressDraftUpdateOptions = {
flush?: boolean;
lines: readonly ChannelProgressDraftCompositorLine[];
snapshot: ChannelProgressDraftCompositorSnapshot;
};
export type ChannelProgressDraftCompositorParams = {
/** @deprecated v2026.9.1 SDK presentation; retain until a breaking SDK release. */
presentation?: "summary";
entry: StreamingCompatEntry | null | undefined;
mode: StreamingMode;
active: boolean;
seed: string;
update: (
text: string,
options: ChannelProgressDraftUpdateOptions,
) => Promise<boolean | void> | boolean | void;
deleteCurrent?: () => Promise<void> | void;
tryNativeUpdate?: (text: string) => Promise<boolean> | boolean;
/** Publish when structured lines change even if the rendered text does not. */
updateOnLineChange?: boolean;
/**
* Set when the channel renders `update`'s structured `lines` itself, so the
* composed text carries only the status block (label, headline, checklist).
*/
rendersRollingLinesNatively?: boolean;
formatLine?: (line: string) => string;
formatPlainText?: (text: string) => string;
isEmptyLine?: (line: ChannelProgressDraftCompositorLine | undefined) => boolean;
shouldStartNow?: (line: ChannelProgressDraftCompositorLine | undefined) => boolean;
reasoningLinePrefix?: string;
commentaryLinePrefix?: string;
reasoningGate?: boolean;
commentaryItalics?: boolean;
now?: () => number;
setTimeoutFn?: typeof setTimeout;
clearTimeoutFn?: typeof clearTimeout;
/** Channel-specific formatter policy; event/lifecycle ownership remains in the compositor. */
buildProgressEventLine?: ChannelProgressDraftEventLineBuilder;
};

View file

@ -22,6 +22,7 @@ import type {
import { isAgentPlanProgressToolName } from "../session-cards/progress-card-channel-summary.js";
import { DEFAULT_PROGRESS_DRAFT_LABELS, selectProgressLabel } from "../shared/progress-labels.js";
import { compactProgressText } from "../shared/text-truncate.js";
import { escapeMarkdownText } from "../shared/text/escape-markdown.js";
import { asBoolean } from "../utils/boolean.js";
import {
formatChannelProgressDraftDiffStat,
@ -1406,10 +1407,15 @@ type ChannelProgressDraftTextParams = {
random?: () => number;
/** Optional formatter applied after line compaction. */
formatLine?: (line: string) => string;
/** Literal transport encoding, applied after compaction to prepared plain text. */
formatPlainText?: (text: string) => string;
/** Exposes the same ordered blocks to native renderers without parsing composed text. */
onPreparedBlocks?: (blocks: Array<{ text: string; format: "plain" | "markdown" }>) => void;
/** Prefix used for plain progress lines that lack their own icon. */
bullet?: string;
/** Status headline rendered above the plan and activity rows. */
narration?: string;
narrationFormat?: "plain";
/** Latest full plan snapshot, rendered independently from rolling tool lines. */
plan?: readonly AgentPlanStep[];
diffStat?: ChannelProgressDraftDiffStat;
@ -1450,7 +1456,11 @@ function formatProgressDraftText(
});
// The status headline sits above the rolling lines instead of replacing them:
// a headline-only draft reads as "the agent is quiet" even while tools run.
const statusHeadline = narration ? formatLine(narration) : "";
const statusHeadline = narration
? params.narrationFormat === "plain"
? (params.formatPlainText ?? escapeMarkdownText)(narration)
: formatLine(narration)
: "";
const bullet = params.bullet ?? "•";
const toolLineBudget = planLines.length > 0 ? Math.max(0, maxLines - planLines.length) : maxLines;
// Attention owns capacity before plans and routine progress consume the window.
@ -1500,6 +1510,20 @@ function formatProgressDraftText(
...planLines,
...(diffStat ? [formatLine(compactChannelProgressDraftLine(diffStat, maxLineChars))] : []),
].join("\n");
const blocks: Array<{ text: string; format: "plain" | "markdown" }> = [];
if (labelBlock) {
blocks.push({ text: labelBlock, format: "markdown" });
}
if (statusHeadline) {
blocks.push({
text: params.narrationFormat === "plain" ? narration : statusHeadline,
format: params.narrationFormat === "plain" ? "plain" : "markdown",
});
}
if (rollingBlock) {
blocks.push({ text: rollingBlock, format: "markdown" });
}
params.onPreparedBlocks?.(blocks);
return [labelBlock, statusHeadline, rollingBlock].filter(Boolean).join("\n\n");
}
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */

View file

@ -13,8 +13,40 @@ describe("projectProgressCardChannelUpdate", () => {
},
{
name: "markdown-only",
input: { markdown: "Working" },
expected: { steps: [], explanation: "Progress updated" },
input: { markdown: "Checking recent plan submissions and the tool's selection guidance." },
expected: {
steps: [],
explanation: "Checking recent plan submissions and the tool's selection guidance.",
explanationFormat: "plain",
},
},
{
name: "formatted note with renderer markup",
input: {
markdown:
'<progress aria-label="Checks, 1/2" value="1" max="2"></progress>\n\n**Checking** [results](https://example.com).<br>Next step.<script>ignored()</script>',
},
expected: {
steps: [],
explanation: "Checking results. Next step.",
explanationFormat: "plain",
},
},
{
name: "markup without visible text",
input: { markdown: '<progress value="1" max="2"></progress>' },
expected: { steps: [], explanation: "Progress updated", explanationFormat: "plain" },
},
{
name: "checklist with a note",
input: {
markdown: "Checking the next task.",
plan: [{ step: "Ship", status: "completed" }],
},
expected: {
steps: [{ step: "Ship", status: "completed" }],
explanation: "1/1 complete",
},
},
{ name: "clear", input: {}, expected: { steps: [] } },
{ name: "invalid array", input: [], expected: undefined },

View file

@ -1,4 +1,5 @@
import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce";
import { stripMarkdown } from "../shared/text/strip-markdown.js";
import { normalizeProgressCardInput, ProgressCardInputError } from "./progress-card-input.js";
const PLAN_PROGRESS_TOOL_NAMES = new Set(["progress_card", "update_plan"]);
@ -7,7 +8,7 @@ export function isAgentPlanProgressToolName(name: string | undefined): boolean {
return PLAN_PROGRESS_TOOL_NAMES.has(name?.trim().toLowerCase() ?? "");
}
/** Projects durable card state without interpreting renderer-owned Markdown or HTML. */
/** Projects checklist counts or readable notes through the shared Markdown owner. */
export function projectProgressCardChannelUpdate(input: unknown) {
const record = asOptionalRecord(input);
if (!record) {
@ -20,9 +21,15 @@ export function projectProgressCardChannelUpdate(input: unknown) {
const explanation = steps.length
? `${completed}/${steps.length} complete`
: normalized.markdown
? "Progress updated"
? stripMarkdown(normalized.markdown, { linkStyle: "label", stripHtml: true })
.replace(/\s+/g, " ")
.trim() || "Progress updated"
: undefined;
return { steps, ...(explanation ? { explanation } : {}) };
return {
steps,
...(explanation ? { explanation } : {}),
...(!steps.length && explanation ? { explanationFormat: "plain" as const } : {}),
};
} catch (error) {
if (error instanceof ProgressCardInputError) {
return undefined;

View file

@ -0,0 +1,4 @@
/** Encodes prepared text as literal CommonMark, including URL and HTML punctuation. */
export function escapeMarkdownText(text: string): string {
return text.replace(/[!-/:-@[-`{-~]/g, "\\$&");
}

View file

@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { stripMarkdown } from "./strip-markdown.js";
describe("stripMarkdown HTML projection", () => {
it.each([
["<b>Checking</b><br>results<hr>Next", "Checking\nresults\nNext"],
["Before <!-- hidden <b>note</b> --> after", "Before after"],
["Before <script>ignored()</script><style>hidden</style> after", "Before after"],
["Before <script>\nconst tag = `</script>`;\nAfter", "Before `;\nAfter"],
["Before <script>unfinished", "Before"],
["Checking<style>\n\n.hidden { color: red }\n\n</style> results", "Checking results"],
["Checking <!-- hidden\n\nprivate renderer note\n\n--> results", "Checking results"],
["Use `<progress>` and &lt;progress&gt; literally", "Use <progress> and <progress> literally"],
["Use \\<progress> literally", "Use <progress> literally"],
["```html\n<script>example()</script>\n```", "<script>example()</script>"],
['Checking [results](https://example.com "<script>") next.', "Checking results next."],
[
'Checking [results][r] next.\n\n[r]: https://example.com "<script>"',
"Checking results next.",
],
[
'Checking ![<progress>](https://example.com/image.png "<script>") next.',
"Checking <progress> next.",
],
["Checking [<b>results</b>](https://example.com) next.", "Checking results next."],
])("omits authored HTML while preserving visible text: %s", (input, expected) => {
expect(stripMarkdown(input, { stripHtml: true, linkStyle: "label" })).toBe(expected);
});
});

View file

@ -2,6 +2,7 @@ import { findAssistantTranscriptRoleHeaderSpans } from "../../../packages/markdo
import { applyConstructFallbacks } from "../../../packages/markdown-core/src/construct-fallbacks.js";
import type { FormatCapabilityProfile } from "../../../packages/markdown-core/src/format-capabilities.js";
import { markdownToIR, type MarkdownIR } from "../../../packages/markdown-core/src/ir.js";
import { stripHtmlFromMarkdown } from "../../../packages/markdown-core/src/strip-html.js";
type StripMarkdownOptions = {
/** Mark parsed assistant transcript-role headers in transports without rich text. */
@ -12,6 +13,8 @@ type StripMarkdownOptions = {
linkStyle?: "label" | "label-and-url";
/** Plain-text cleanup target. Speech removes decorative symbol and punctuation runs. */
mode?: "plain-text" | "speech";
/** Omit authored HTML tags and raw-text content while preserving code literals. */
stripHtml?: boolean;
};
type PlainTextInsertion = {
@ -115,7 +118,7 @@ export function stripMarkdown(
// The IR parser preserves links when role annotations are enabled so this
// plain-text projection can still append explicit destinations. Direct rich
// renderers suppress overlapping active links later at their own boundary.
const ir = markdownToIR(text, {
const ir = markdownToIR(options.stripHtml ? stripHtmlFromMarkdown(text) : text, {
assistantTranscriptRoleHeaders: options.assistantTranscriptRoleHeaders,
autolink: false,
blockquotePrefix: "",

View file

@ -0,0 +1,203 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
createDiscordDraftPreviewController,
RequestClient,
} from "../extensions/discord/test-api.js";
import {
renderTelegramProgressDraftPreview,
telegramHtmlToPlainTextFallback,
} from "../extensions/telegram/test-api.js";
import { markdownToIR } from "../packages/markdown-core/src/ir.js";
import { createSubscribedSessionHarness } from "../src/agents/embedded-agent-subscribe.e2e-harness.js";
import { createOpenClawTools } from "../src/agents/openclaw-tools.js";
import type { InProcessGatewayCaller } from "../src/agents/tools/in-process-gateway.js";
import { createChannelProgressDraftCompositor } from "../src/channels/progress-draft-compositor.js";
import { normalizeAgentPlanSteps } from "../src/channels/streaming.js";
const gatewayCall = vi.hoisted(() => vi.fn<InProcessGatewayCaller>());
vi.mock("../src/agents/tools/in-process-gateway.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../src/agents/tools/in-process-gateway.js")>()),
callInProcessGatewayTool: gatewayCall,
}));
afterEach(() => vi.clearAllMocks());
const cases = [
{
markdown: "Use `**literal**` now.",
text: "Use **literal** now.",
html: "Use **literal** now.",
},
{
markdown: "Use `[label](https://example.com)` literally.",
text: "Use [label](https://example.com) literally.",
html: "Use [label](https://example.com) literally.",
},
{
markdown: "Use `<b>literal</b>` now.",
text: "Use <b>literal</b> now.",
html: "Use &lt;b&gt;literal&lt;/b&gt; now.",
},
{
markdown: "Use &lt;b&gt;literal&lt;/b&gt; now.",
text: "Use <b>literal</b> now.",
html: "Use &lt;b&gt;literal&lt;/b&gt; now.",
},
];
async function runRegisteredCard(
markdown: string,
pushPlanProgress: ReturnType<typeof createChannelProgressDraftCompositor>["pushPlanProgress"],
) {
gatewayCall.mockResolvedValue({
card: { sessionKey: "agent:main:literal-test", revision: 1, updatedAt: 1, markdown },
});
const tool = createOpenClawTools({
agentSessionKey: "agent:main:literal-test",
config: { tools: { allow: ["progress_card"] } },
}).find((entry) => entry.name === "progress_card");
expect(tool).toBeDefined();
if (!tool) {
throw new Error("progress_card is not registered");
}
const { emit, subscription } = createSubscribedSessionHarness({
runId: "literal-test",
sessionPersistence: "detached",
onAgentEvent: async (event) => {
if (event.stream === "plan") {
await pushPlanProgress(normalizeAgentPlanSteps(event.data.steps), {
explanation:
typeof event.data.explanation === "string" ? event.data.explanation : undefined,
explanationFormat: event.data.explanationFormat === "plain" ? "plain" : undefined,
});
}
},
});
try {
emit({
type: "tool_execution_start",
toolName: tool.name,
toolCallId: "card-1",
args: { markdown },
});
await subscription.waitForPendingEvents();
const result = await tool.execute("card-1", { markdown });
emit({
type: "tool_execution_end",
toolName: tool.name,
toolCallId: "card-1",
result,
isError: false,
});
await subscription.waitForPendingEvents();
expect(gatewayCall).toHaveBeenCalledWith(
"progressCard.put",
expect.objectContaining({ markdown, sessionKey: "agent:main:literal-test" }),
);
} finally {
subscription.unsubscribe();
}
}
describe("registered progress cards at the final channel renderer", () => {
it.each(cases)(
"keeps Discord code and decoded text literal: $markdown",
async ({ markdown, text }) => {
const messages: string[] = [];
const rest = new RequestClient("test-token", {
queueRequests: false,
fetch: async (_input, init) => {
if (init?.method === "DELETE") {
return new Response(null, { status: 204 });
}
if (typeof init?.body !== "string") {
throw new Error("Expected a serialized message");
}
const body: unknown = JSON.parse(init.body);
if (
!body ||
typeof body !== "object" ||
!("content" in body) ||
typeof body.content !== "string"
) {
throw new Error("Expected Discord message content");
}
messages.push(body.content);
return Response.json({ id: "literal-draft" });
},
});
const controller = createDiscordDraftPreviewController({
cfg: {},
discordConfig: { streaming: { mode: "progress", progress: { label: false } } },
accountId: "default",
sourceRepliesAreToolOnly: false,
textLimit: 2000,
deliveryRest: rest,
deliverChannelId: "test-channel",
replyReference: { peek: () => undefined },
tableMode: "off",
maxLinesPerMessage: undefined,
chunkMode: "length",
log: () => {},
});
try {
await runRegisteredCard(markdown, controller.pushPlanProgress);
await controller.flush();
expect(messages).toHaveLength(1);
const rendered = markdownToIR(messages[0] ?? "", { autolink: true, linkify: true });
expect(rendered.text.trim()).toBe(text);
expect(rendered.styles).toEqual([]);
expect(rendered.links).toEqual([]);
expect(messages[0]).not.toContain("https://");
} finally {
await controller.cleanup();
}
},
);
it.each(
cases.flatMap(({ markdown, text, html }) =>
[false, true].map((richMessages) => ({ markdown, text, html, richMessages })),
),
)(
"keeps Telegram literal text and inactive links (rich=$richMessages): $markdown",
async ({ markdown, text, html, richMessages }) => {
const previews: ReturnType<typeof renderTelegramProgressDraftPreview>[] = [];
const progress = createChannelProgressDraftCompositor({
entry: { streaming: { mode: "progress", progress: { label: false } } },
mode: "progress",
active: true,
seed: "literal-test",
update: (_text, { snapshot }) => {
previews.push(
renderTelegramProgressDraftPreview(snapshot, {
richMessages,
toolProgress: true,
maxLines: 8,
maxLineChars: 240,
}),
);
return true;
},
});
try {
await runRegisteredCard(markdown, progress.pushPlanProgress);
expect(previews).toHaveLength(1);
const preview = previews[0];
if (richMessages) {
expect(preview?.text).toBe(text);
expect(preview?.richMessage).toEqual({
blocks: [{ type: "paragraph", text: { type: "code", text } }],
skip_entity_detection: true,
});
} else {
expect(preview?.text).toBe(`<code>${html}</code>`);
expect(telegramHtmlToPlainTextFallback(preview?.text ?? "")).toBe(text);
expect(preview?.text).not.toContain("href=");
}
} finally {
progress.cancel();
}
},
);
});