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 "