From 5541c3d396ab366d4d5529daeae53da559e77b82 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Sun, 13 Sep 2026 21:08:45 +0530 Subject: [PATCH] fix(channels): show progress-card notes in drafts (#144790) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/tools/progress-card.md | 2 +- .../app-server/event-projector-reasoning.ts | 4 +- .../codex/src/app-server/event-projector.ts | 1 + .../codex/src/app-server/run-attempt.test.ts | 71 +++--- .../message-handler.process-progress.ts | 1 + extensions/discord/test-api.ts | 2 + .../monitor/handler-draft-controller.ts | 1 + .../mattermost/src/mattermost/monitor-turn.ts | 1 + extensions/msteams/src/reply-dispatcher.ts | 1 + .../msteams/src/reply-stream-controller.ts | 4 +- .../message-handler/dispatch-progress-card.ts | 21 +- .../message-handler/dispatch-progress.ts | 22 +- .../dispatch.preview-fallback.test.ts | 26 +++ .../src/monitor/message-handler/dispatch.ts | 6 +- extensions/slack/src/progress-blocks.ts | 54 ++++- .../src/bot-message-dispatch-progress.ts | 1 + .../src/bot-message-dispatch.types.ts | 2 +- .../telegram/src/progress-draft-preview.ts | 14 +- extensions/telegram/test-api.ts | 2 + .../markdown-core/src/html-scanner.ts | 0 .../markdown-core/src/reasoning-tag-parser.ts | 14 +- packages/markdown-core/src/strip-html.ts | 37 ++++ ...ded-agent-subscribe.handlers.tools.test.ts | 3 +- src/agents/tools/web-fetch-utils.ts | 6 +- src/agents/tools/web-fetch-visibility.ts | 2 +- src/auto-reply/get-reply-options.types.ts | 2 + .../reply/agent-runner-cli-dispatch.ts | 1 + .../reply/agent-runner-event-handler.ts | 1 + .../reply/dispatch-from-config.execute.ts | 4 + .../dispatch-from-config.prepare-execution.ts | 12 +- ...ispatch-from-config.progress.test-utils.ts | 28 +++ .../progress-draft-compositor.plan.test.ts | 47 ++++ src/channels/progress-draft-compositor.ts | 131 ++++++----- .../progress-draft-compositor.types.ts | 62 ++++++ src/channels/streaming.ts | 26 ++- .../progress-card-channel-summary.test.ts | 36 +++- .../progress-card-channel-summary.ts | 13 +- src/shared/text/escape-markdown.ts | 4 + src/shared/text/strip-markdown.test.ts | 29 +++ src/shared/text/strip-markdown.ts | 5 +- ...-card-channel-literals.integration.test.ts | 203 ++++++++++++++++++ 41 files changed, 750 insertions(+), 152 deletions(-) create mode 100644 extensions/telegram/test-api.ts rename src/agents/tools/web-fetch-html-tag.ts => packages/markdown-core/src/html-scanner.ts (100%) create mode 100644 packages/markdown-core/src/strip-html.ts create mode 100644 src/channels/progress-draft-compositor.types.ts create mode 100644 src/shared/text/escape-markdown.ts create mode 100644 src/shared/text/strip-markdown.test.ts create mode 100644 test/progress-card-channel-literals.integration.test.ts diff --git a/docs/tools/progress-card.md b/docs/tools/progress-card.md index c696629640e2..4cc51c3a18d5 100644 --- a/docs/tools/progress-card.md +++ b/docs/tools/progress-card.md @@ -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. diff --git a/extensions/codex/src/app-server/event-projector-reasoning.ts b/extensions/codex/src/app-server/event-projector-reasoning.ts index 2a7e30008fa0..c12a00eb5fa1 100644 --- a/extensions/codex/src/app-server/event-projector-reasoning.ts +++ b/extensions/codex/src/app-server/event-projector-reasoning.ts @@ -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 } : {}), }, }); diff --git a/extensions/codex/src/app-server/event-projector.ts b/extensions/codex/src/app-server/event-projector.ts index 463b316670ff..d391c1deb9a7 100644 --- a/extensions/codex/src/app-server/event-projector.ts +++ b/extensions/codex/src/app-server/event-projector.ts @@ -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"); } diff --git a/extensions/codex/src/app-server/run-attempt.test.ts b/extensions/codex/src/app-server/run-attempt.test.ts index 26abe6da4461..fcf3b7a98913 100644 --- a/extensions/codex/src/app-server/run-attempt.test.ts +++ b/extensions/codex/src/app-server/run-attempt.test.ts @@ -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: + '\n\n**Working** [results](https://example.com "', + }, + expected: { + steps: [], + explanation: "Checking results. Next step.", + explanationFormat: "plain", + }, + }, + { + name: "markup without visible text", + input: { markdown: '' }, + 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 }, diff --git a/src/session-cards/progress-card-channel-summary.ts b/src/session-cards/progress-card-channel-summary.ts index eb7aeac7a8a2..6085f8b710c6 100644 --- a/src/session-cards/progress-card-channel-summary.ts +++ b/src/session-cards/progress-card-channel-summary.ts @@ -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; diff --git a/src/shared/text/escape-markdown.ts b/src/shared/text/escape-markdown.ts new file mode 100644 index 000000000000..9df8e0c8179d --- /dev/null +++ b/src/shared/text/escape-markdown.ts @@ -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, "\\$&"); +} diff --git a/src/shared/text/strip-markdown.test.ts b/src/shared/text/strip-markdown.test.ts new file mode 100644 index 000000000000..407d4cdf9eea --- /dev/null +++ b/src/shared/text/strip-markdown.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { stripMarkdown } from "./strip-markdown.js"; + +describe("stripMarkdown HTML projection", () => { + it.each([ + ["Checking
results
Next", "Checking\nresults\nNext"], + ["Before after", "Before after"], + ["Before after", "Before after"], + ["Before `;\nAfter", "Before `;\nAfter"], + ["Before \n```", ""], + ['Checking [results](https://example.com "