Skip to content

[Plan Mode 5/6] Text channels + Telegram#70069

Closed
100yenadmin wants to merge 1 commit into
openclaw:mainfrom
electricsheephq:isolated/pm-5-of-6-channels
Closed

[Plan Mode 5/6] Text channels + Telegram#70069
100yenadmin wants to merge 1 commit into
openclaw:mainfrom
electricsheephq:isolated/pm-5-of-6-channels

Conversation

@100yenadmin

@100yenadmin 100yenadmin commented Apr 22, 2026

Copy link
Copy Markdown
Contributor

📋 Umbrella tracker: #70101 — master tracker for the 9-PR plan-mode rollout. See it for status of all parts + suggested merge order + carry-forward backlog.


📋 Stack position: This is [Plan Mode 5/6], the fifth part of a 6-PR per-part decomposition of the original umbrella #68939 (closed).

  • Previous in stack: [Plan Mode 4/6] Web UI + i18n
  • Next in stack: [Plan Mode 6/6] Docs, QA, and help
  • Integration bundle: [Plan Mode FULL] — green-CI bundle of all parts + automation + executing-state lifecycle

⚠️ CI on this PR will be RED: this part adds channel-side plan-mode surfaces that reference plan-mode types from [Plan Mode 1/6] + [Plan Mode 2/6]. CI will pass once earlier parts merge in order, OR review the green-CI integrated state in [Plan Mode FULL].

Ways to land this feature (maintainer choice):

  • Per-part review + sequential merge of 1/6 → 6/6
  • Single bundle merge via [Plan Mode FULL]

Executive summary

This PR makes plan mode a first-class, channel-agnostic affordance. The umbrella #68939 introduced plan mode as a native webchat experience (inline cards, sidebars, modal textareas); this part extends the same approval state machine to every text channel OpenClaw runs on — Telegram, Slack, Discord, Matrix, iMessage, Signal, WhatsApp, CLI, and any future channel that conforms to the markdownCapable registry. The mechanism is a single /plan slash command with eight subcommands (status, view, on, off, restate, auto, accept, revise, answer) that every channel inherits for free via the universal command registry. There is one approval state machine behind the scenes (the sessions.patch RPC on the gateway, introduced in 2/6) — this PR is a thin parser + per-channel renderer that funnels every text channel into that same machine. Per-channel rendering is delegated to plan-render.ts, which produces format-specific output (Telegram HTML, Slack mrkdwn, GFM markdown, plaintext) with consistent injection-defense passes (mention neutralization, format-character escaping).

The second commit in this PR (a606f13571, originally PR8) adds Telegram-specific attachment delivery for the case where a plan archetype is too dense to fit comfortably in a Telegram chat message. The flow is: when the runtime emits an approval, the plan-archetype-bridge orchestrator renders the full archetype as a markdown document, persists it to ~/.openclaw/agents/<agentId>/plans/ as a durable audit artifact (regardless of channel), and — if the originating session is on Telegram — uploads the markdown as a document attachment with a short HTML caption containing the universal /plan resolution commands. The user reads the full plan from their primary platform; resolution stays text-based via the same /plan commands that work everywhere. This sidesteps the dual-id problem of bridging inline-button approvals through the gateway plugin-approval pipeline, which was the original blocker on the deferred PR-13 path.

TL;DR

  • 9 channels, 1 command surface. /plan {status|view|on|off|restate|auto|accept|revise|answer} works on Telegram, Slack, Discord, Matrix, iMessage, Signal, WhatsApp, CLI, and webchat — backed by a single sessions.patch state machine (no per-channel approval drift).
  • 8 verbs, strict parsing. Each verb has trailing-token rejection (/plan off later is an error, not a silent mode change), so typos can't fall through to destructive paths. revise requires non-empty feedback. answer is gated on a pending ask_user_question.
  • 4 rendering formats. plan-render.ts emits Telegram HTML (<b>, <s>, ✅/⏳/❌/⬚ markers), Slack mrkdwn (*bold*, ~strike~), GitHub-flavored markdown checkboxes, and plaintext ASCII markers. Format choice keys off the channel-meta markdownCapable flag — no hardcoded list to drift.
  • Injection defense per format. @channel/@here/@everyone and Discord raw-mention syntax (<@123>) are neutralized before any format-specific escape. Format characters (*, _, ~, <, >, etc.) are escaped per renderer's grammar. Slack mrkdwn uses Unicode lookalikes for readability (no \*\_ noise in human-visible channels).
  • Auth mirrors /approve. Mutating /plan subcommands require operator authorization + operator.approvals (or operator.admin) scope on internal-channel callers. /plan status and /plan view are read-only and ungated; /plan restate is gated because rendered plan steps may include sensitive paths the agent has seen.
  • Telegram attachment threshold. Plan archetypes are always persisted as markdown to disk; only Telegram sessions get the attachment upload (50 MiB cap, stat-first to bound memory). Other channels that support file attachments are wired up identically once their plugin SDKs surface a sendDocument* helper — the bridge already detects channel via deliveryContextFromSession.
  • Always-on audit artifact. Markdown persistence is unconditional; storage failures (full disk, permissions) emit a distinctive [plan-bridge/storage] log line so operators can grep for it without losing the plan approval itself.

Per-channel /plan surface matrix

flowchart LR
    subgraph SC[Slash-command surface]
      direction TB
      U[/plan verb]
    end
    subgraph CH[Channels]
      WC[webchat<br/>+ inline cards]
      TG[Telegram<br/>+ HTML attachment]
      SL[Slack<br/>mrkdwn]
      DC[Discord<br/>markdown]
      MX[Matrix / Mattermost / MSTeams<br/>markdown]
      IM[iMessage / Signal / SMS<br/>plaintext]
      WA[WhatsApp<br/>markdown via registry]
      CL[CLI<br/>markdown]
    end
    SC -->|sessions.patch| GW[(Gateway state machine)]
    GW --> WC
    GW --> TG
    GW --> SL
    GW --> DC
    GW --> MX
    GW --> IM
    GW --> WA
    GW --> CL
Loading

Detailed capability breakdown (this PR's surfaces in bold; rich UX surfaces from 4/6 in italic):

Capability Webchat (4/6) Telegram Slack Discord Matrix iMessage Signal WhatsApp CLI
Inline approval card (3 buttons)
Sidebar plan-view toggle
Universal /plan slash commands
/plan restate checklist render ✅ md ✅ HTML ✅ mrkdwn ✅ md ✅ md ✅ pt ✅ md ✅ md ✅ md
/plan accept / revise / answer
/plan auto on|off
Markdown attachment delivery N/A ❌ planned ❌ planned
Always-on disk persistence

Format selection in pickPlanRenderFormat (commands-plan.ts:213-241):

Channel id Format Rationale
telegram html Telegram supports HTML parse_mode (<b>, <s>, <code>).
slack slack-mrkdwn Slack mrkdwn (*bold*, ~strike~).
discord, matrix, mattermost, msteams, googlechat, feishu, web, cli, whatsapp markdown Channels declared markdownCapable: true in the registry.
sms, voice, imessage, signal (when registered as non-markdown) plaintext markdownCapable: false — raw **bold** would leak as literal text.

The list above is delegated to the channel registry via isMarkdownCapableMessageChannel(lc), not hardcoded — so any new channel plugin that opts into markdown rendering inherits /plan rendering correctly without a touch in this PR.

Per-channel UX prose

What each channel's plan-mode UX looks like after this PR:

  • Webchat (rich inline cards from 4/6, not this PR): inline approval card with 3 buttons (Accept / Accept edits / Revise), expandable plan-step card in-thread with per-step status + acceptance criteria, sidebar plan-view toggle, modal textarea for revise feedback, question modal for ask_user_question. Universal /plan still works here as a power-user shortcut.
  • Telegram: text rendering with HTML parse_mode. Plan approvals that exceed the inline-size threshold deliver a markdown document attachment with a short HTML caption containing the universal /plan accept|accept edits|revise hint. /plan restate re-renders the current plan as an HTML checklist in-thread with ✅/⏳/❌/⬚ markers and <b> / <s> tags. /plan@otherbot is correctly treated as a foreign-bot command and ignored; /plan@thisbot parses cleanly.
  • Slack: text rendering with mrkdwn. *bold* for in-progress steps, ~strike~ for cancelled. Unicode lookalikes (U+2217, U+223C, etc.) escape format characters in step text so human-visible channels don't show \*\_ backslash noise. Slack threading inherits from the existing channel adapter — /plan replies stay in the same thread as the triggering message.
  • Discord: text rendering with GitHub-flavored markdown checkboxes (- [x] / - [ ] / - [>] / - [~]). Step text containing @everyone is neutralized (@\uFE6Beveryone) so /plan restate can't ping a whole Discord server with agent-controlled content. Raw mention syntax (<@123>, <@!123>, <@&123> for role pings) is neutralized by inserting U+200B between < and @. Rich embeds are a future polish PR.
  • Matrix / Mattermost / MSTeams / Google Chat / Feishu: same markdown rendering as Discord — they all share the markdownCapable: true registry flag. No per-channel wiring needed; each channel's existing adapter picks up the registered /plan command and routes it through handlePlanCommand.
  • iMessage / Signal / SMS: plaintext rendering with ASCII markers. [x] Run tests, [>] Building artifacts, [~] Fix broken migration, [ ] Deploy to staging. neutralizeMentions still runs on plaintext labels (platform-specific mention conventions vary — Signal and some SMS gateways do follow @ conventions, so the neutralization is defense-in-depth).
  • WhatsApp: markdown rendering when the channel adapter registers markdownCapable: true (WhatsApp supports a markdown-ish subset: *bold*, _italic_, ~strike~). /plan restate output works, though WhatsApp's rendering differs from GFM markdown — cosmetic only, the approval semantics are identical.
  • CLI: markdown rendering. The CLI bot is markdown-capable and terminals that render markdown (most modern ones via escape sequences from the CLI adapter) show the checklist correctly. Raw markdown is legible in dumb terminals too.

In every channel above, the approval state machine is the same (sessions.patch on the gateway, introduced in 2/6). There is no per-channel approval drift: if you /plan accept on Slack and then /plan status on Telegram, they report the same state because they read and write the same SessionEntry.planMode.

Telegram attachment decision flow

flowchart TB
    A[Runtime: exit_plan_mode emits<br/>approval with full archetype] --> B[plan-archetype-bridge.<br/>dispatchPlanArchetypeAttachment]
    B --> C[renderFullPlanArchetypeMarkdown:<br/>Title / Summary / Analysis / Plan /<br/>Assumptions / Risks / Verification / References]
    C --> D{persistPlanArchetypeMarkdown<br/>~/.openclaw/agents/&lt;agentId&gt;/plans/}
    D -->|ok| E[log.info: persisted plan-2026-04-22-*.md]
    D -->|PlanPersistStorageError| F[log.warn: '&#91;plan-bridge/storage&#93;' marker<br/>approval still proceeds]
    E --> G[loadSessionEntryReadOnly<br/>+ deliveryContextFromSession]
    F --> G
    G --> H{channel == 'telegram'<br/>&& dctx.to set?}
    H -->|no| I[log.debug: no telegram delivery — done<br/>plan still on disk for audit]
    H -->|yes| J[buildPlanAttachmentCaption:<br/>HTML-escape title + summary,<br/>+ universal /plan resolution hint]
    J --> K[fs.stat filePath]
    K -->|size &gt; 50 MiB| L[throw: file too large for Telegram]
    K -->|ok| M[fs.readFile → Buffer]
    M --> N[sendDocumentTelegram via<br/>plugin-sdk facade dynamic-import]
    N -->|grammy api.sendDocument| O[Telegram message + attachment delivered]
    O --> P[log.info: chatId + msgId]
    L --> Q[caller fallback: text-only]
Loading

Key invariants (encoded in tests, not just docstrings):

  • Persistence is unconditional. Even on a non-Telegram channel, the markdown is written to disk first. The Telegram upload is the additional step on top, not a replacement.
  • Both branches are best-effort. Storage failures emit the [plan-bridge/storage] log marker and return without throwing. Telegram failures log at warn and return without throwing. Plan approval proceeds either way; the user can always fall back to /plan restate.
  • Stat-before-read. fs.stat runs before fs.readFile so an oversized file doesn't trigger a multi-MB Buffer allocation just to be rejected. (Copilot review fix from the original umbrella; preserved here.)
  • Caption escaping is required at call site. The default parse mode is HTML, so callers MUST HTML-escape user/agent-controlled caption text. buildPlanAttachmentCaption does this; the parseMode docstring is explicit about the contract.

Slash-command parsing flow

sequenceDiagram
    participant User
    participant Channel as Channel adapter<br/>(telegram / slack / discord / …)
    participant Reg as commands-registry.<br/>shared.ts
    participant H as handlePlanCommand<br/>(commands-plan.ts)
    participant Auth as resolveApprovalCommand-<br/>Authorization
    participant GW as Gateway<br/>(sessions.patch)
    participant Ren as plan-render.ts

    User->>Channel: "/plan accept edits"
    Channel->>Reg: dispatch by alias `/plan`
    Reg->>H: handlePlanCommand(params, allowTextCommands=true)
    H->>H: parsePlanCommand(body, channel)<br/>strict trailing-token rejection
    alt parse error
        H-->>User: usage hint reply
    else valid
        H->>Auth: resolveApprovalCommandAuthorization<br/>(operator gating)
        alt unauthorized
            H-->>User: silently dropped (logVerbose only)
        else authorized
            alt status / view
                H->>H: read sessionEntry.planMode
                H-->>User: status text
            else restate
                H->>Ren: renderPlanChecklist(steps, format)
                Ren-->>H: format-specific checklist
                H->>H: step-aware truncation if &gt;3500 chars
                H-->>User: rendered checklist
            else accept / revise / answer / auto / on / off
                H->>GW: callGateway sessions.patch &#123;...&#125;
                GW-->>H: ok or PLAN_APPROVAL_*_ERROR
                H-->>User: friendly confirmation OR mapped-error reply
                Note over H,GW: shouldContinue:true → agent runs<br/>immediately; consumes pendingAgentInjection
            end
        end
    end
Loading

A few non-obvious things this diagram encodes:

  1. parsePlanCommand returns three states: null (not a /plan command — let the next handler match), {ok: false} (malformed — emit usage hint), {ok: true, sub} (valid — dispatch). This three-way split is what lets /plan coexist with plugin commands in loadCommandHandlers.
  2. The @bot mention quirk is Telegram-specific. Other channels treat /plan@<word> as a plain mention; Telegram parses /cmd@bot as bot disambiguation. The parser only enforces foreign-bot disambiguation when channel === "telegram".
  3. shouldContinue: true after mutating patches is what makes text-channel approval feel synchronous. Pre-fix (caught in Codex P1 review of the original umbrella), the agent stayed idle after /plan accept until an unrelated later message because the synthetic [PLAN_DECISION]: approved injection only fires at next turn-start. Now accept, revise, and answer all return shouldContinue: true so the agent-runner pipeline runs immediately and the user sees the agent's first action as the implicit "approval received" signal.

Per-file deep dive

src/auto-reply/reply/commands-plan.ts (+587 / new file)

The slash-command parser + dispatcher. Three logical layers:

  1. Parser (parsePlanCommand, lines 63-205). Eight subcommand variants in a tagged union. Strict trailing-token rejection on single-token verbs (status, view, on, off, restate) so /plan off later errors out instead of silently flipping mode. accept accepts bare or accept edits; trailing tokens beyond the qualifier reject. revise requires non-empty feedback (no-feedback rejections silently incremented rejectionCount and would roll into a confusing "ask the user to clarify" injection after 3 reflex clicks — UX regression with no operator intent). answer requires non-empty text and is gated on a pending ask_user_question at dispatch time.
  2. Auth (lines 256-292). status and view are ungated (read-only). All other verbs go through resolveApprovalCommandAuthorization (mirrors /approve) and requireGatewayClientScopeForInternalChannel for operator.approvals / operator.admin. restate is gated even though it's read-only because rendered step text may include file paths or sensitive context the agent has seen.
  3. Dispatch (lines 294-583). status reads sessionEntry.planMode and formats lines. view returns a hint pointing the user at /plan restate (sidebar only meaningful in Control UI). restate calls renderPlanChecklist with the channel-appropriate format and applies step-aware truncation at a 3500-char soft cap — pre-fix, the truncation sliced the rendered string at an arbitrary char boundary and on Telegram (HTML) could cut through <b>...</b> / <s>...</s> tags producing malformed parse_mode that Telegram rejects entirely. On a single oversized step, in-place text truncation keeps formatting valid. All mutating verbs route through callGateway sessions.patch — same RPC the webchat chip + approval card use.

Specific Codex-review fixes preserved verbatim (cite as evidence the parser hardened through real review):

  • Codex P1 #3105075577: answer subcommand routes through sessions.patch action="answer"pendingQuestionApprovalId threaded into the patch (gateway answer-guard requires it).
  • Codex P1 (umbrella, 2026-04-19): shouldContinue: true on accept / revise / answer so agent resumes immediately.
  • Codex P1 #3104742928: step-aware truncation in restate (avoid mid-tag cuts).
  • Codex P2 #3105247855: in-place single-step truncation when even one step exceeds the cap.
  • Codex P2 #3104742929: format selection delegates to isMarkdownCapableMessageChannel (no separate hardcoded list).
  • Codex P3 review on 2026-04-20: trailing-token rejection on single-token verbs.
  • PR-11 review M1: pre-check pending approval before accept/revise (avoid confusing "stale approvalId" gateway error).
  • PR-11 review M3: gate /plan restate (rendered steps can leak paths/context).
  • PR-11 review L3: friendly mapping of stale approvalId / terminal approval state / PLAN_APPROVAL_GATE_STATE_UNAVAILABLE gateway errors.
  • PR-11 review H1: foreign-bot mention disambiguation only on Telegram.
  • PR-11 review H2: revise feedback required (avoid silent rejectionCount increment on accidental clicks).

src/agents/plan-render.ts (+463 / new file)

Pure-format plan-step renderer. Three exported functions:

  • renderPlanChecklist(steps, format) — the workhorse. Per-step status line + optional nested acceptance-criteria checklist. Status markers per format:
    • HTML: ✅ esc(label) / ⏳ <b>esc(label)</b> / ❌ <s>esc(label)</s> / ⬚ esc(label)
    • markdown: - [x] / - [>] **md(label)** / - [~] ~~md(label)~~ / - [ ]
    • plaintext: [x] / [>] / [~] / [ ] markers
    • slack-mrkdwn: / ⏳ *escaped* / ❌ ~escaped~ / ⬚ escaped
  • renderPlanWithHeader(title, steps, format) — title + checklist with format-appropriate header (<b>, ### , plain, *bold*).
  • renderFullPlanArchetypeMarkdown(input) — the document renderer used by the Telegram attachment path. Sections in canonical order: Title / Summary / Analysis (paragraph-preserved) / Plan (checklist) / Assumptions / Risks (with mitigation) / Verification / References. Optional sections omitted when empty. Footer with the universal /plan accept|edits|revise resolution hint so the user knows how to act on the file.

Injection defense is layeredneutralizeMentions runs before the format-specific escape on every render branch (parent step, header, acceptance-criteria, archetype document). This is the PR-11 deep-dive review B1 fix: an agent-controlled step text like @everyone deploy now would otherwise ping every Discord/Mattermost user in the channel on /plan restate. Discord-style raw mentions (<@123>, <@!123>, <@&123>) are neutralized by inserting U+200B between < and @. The escapeSlackMrkdwn branch uses Unicode lookalikes (∗, ∼, ', _) instead of backslash escaping so human-visible Slack channels don't show \*\_ noise — the umbrella sprint's PR-C review (Copilot #3096459445 / #3096516846) cites this trade-off explicitly, contrasted with the Slack-monitor mrkdwn helper which uses backslash escaping for byte-preservation in user-authored content.

The cancelled step status is part of the authoritative PLAN_STEP_STATUSES list in update-plan-tool.ts (PR-B / #67514). The renderer's switch is exhaustive and falls through to the pending-case as a defensive default for any future status (with a bounded warn-set of unknown-status FIFO eviction at 64 entries to prevent unbounded growth in long-running gateway processes).

src/agents/plan-mode/plan-archetype-bridge.ts (+203 / new file)

The orchestrator that wires the archetype renderer to disk persistence and (Telegram-only today) channel attachment delivery. Three responsibilities:

  1. Render the full archetype as markdown via renderFullPlanArchetypeMarkdown.
  2. Persist unconditionally to ~/.openclaw/agents/<agentId>/plans/ via persistPlanArchetypeMarkdown (path-traversal defended in 1/6, collision suffix retries up to 99). This is the durable audit artifact — plan approval still proceeds even if persistence fails (storage-error case has its own distinctive log marker).
  3. Channel-aware delivery. Read SessionEntry via loadSessionEntryReadOnly (lazy chained imports of config / sessions / routing helpers — keeps cold paths cheap). Build delivery context via deliveryContextFromSession. If channel === "telegram" and a to address exists, build the HTML caption (buildPlanAttachmentCaption HTML-escapes title + summary + appends the universal /plan resolution hint), dynamic-import sendDocumentTelegram from the SDK facade, and upload.

The dynamic-import chain matters: plan-bridge should not drag the Telegram bundle into agent startup, so every Telegram-touching import is async + lazy. Same pattern for the session-store-read chain (config/config.js, config/sessions/paths.js, routing/session-key.js, config/sessions/store-read.js) — the bridge runs on every plan-mode approval but only some sessions originate from channels that have any of this plumbing.

Resolution stays text-based even after the file lands: the caption ends with Resolve with: /plan accept | /plan accept edits | /plan revise <feedback>. That sidesteps the dual approval-id problem of trying to bridge inline-button approvals through the gateway plugin-approval pipeline (which was the deferred PR-13 path). The bridge is read-only (visibility), no approval-id translator required.

src/plugin-sdk/telegram.ts (+60 / new file)

Minimal facade restoration. The umbrella narrative documents this in §14 (post-rebase residual fixes): the upstream refactor: drop private channel sdk facades (commit d3eeadba94) removed src/plugin-sdk/telegram.ts along with the discord/slack counterparts. The C2 commit in this stack re-wired plan-archetype-bridge to dynamic-import this facade for sendDocumentTelegram, but the file itself was missing — at runtime the bridge logged Cannot find module '/private/tmp/plugin-sdk/telegram.js' and the markdown attachment delivery was skipped on every plan submit.

This restores the file as a minimal facade that re-exports just the symbols the plan-mode bridge uses (TelegramDocumentOpts type + sendDocumentTelegram runtime function) via the existing loadBundledPluginPublicSurfaceModule pattern. Discord/Slack facades stay dropped per the upstream intent — only Telegram is restored because it's the single hard dependency of the plan-mode bridge today. If a future upstream pass re-removes channel facades, the bridge will need to migrate to the channel-runtime registry pattern instead of dynamic-importing this facade directly (documented in the file header).

extensions/telegram/src/send.ts (+187 / addition only)

Adds sendDocumentTelegram as a peer to the existing sendMessageTelegram / sendStickerTelegram / sendPollTelegram family. Wraps api.sendDocument with the same retry / diag / threading machinery the message branch uses. Notable choices encoded in the implementation (and locked in by the Copilot reviews on the umbrella):

  • fs.stat before fs.readFile. Prevents a multi-MB Buffer allocation just to be rejected on the 50 MiB Telegram-API limit. Stat-first is a cheap bounded-allocation guard until grammy's stream upload story improves.
  • TELEGRAM_DOCUMENT_MAX_BYTES = 50 * 1024 * 1024. Hard cap matches the Telegram bot API document limit.
  • TELEGRAM_CAPTION_MAX_CHARS = 1024. Captions truncated to 1023 + (Telegram caption limit).
  • parseMode defaults to "HTML" when caption is non-empty. Documented contract: callers MUST escape user/agent-controlled caption text. The plan-archetype-bridge does this via escapeHtml() in buildPlanAttachmentCaption. Earlier docstring incorrectly claimed "omit/empty to disable" which contradicted both the type union and the implementation; the Copilot 2026-04-19 review fix corrected this and made the contract explicit.
  • Thread-ID handling. parseTelegramTarget auto-extracts message_thread_id from the to string (formats: chatId, chatId:threadId, chatId:topic:threadId). Same threading discipline as the message branch — withTelegramThreadFallback retries without message_thread_id if Telegram rejects the thread id (matches the existing fallback pattern for sendMessageTelegram).
  • Read-only file path. Defers node:fs/promises + node:path imports to runtime so the module stays importable from any browser/edge runtime that might pull it in.

extensions/telegram/runtime-api.ts (+8 / re-exports)

Re-exports sendDocumentTelegram and TelegramDocumentOpts so core (via the plugin-sdk/telegram.ts facade) can call them without depending on the channel package directly. Pure plumbing.

src/agents/transport-message-transform.ts (+74 / -1)

Tangential but in-scope: bumps the transformTransportMessages repair path to emit a structured [transport-repair] placeholder text + log line when a tool_use has no paired tool_result at transport-assembly time. Replaces the prior bare "No result provided" string which was indistinguishable from a real failure (Eva's reliability handoff #1b). Caps log volume at 5 individual warns + 1 aggregate summary per turn (Copilot review #68939) and bounds the repairedIds array growth at cap_per_turn + cap_aggregate_id_list = 25 ids regardless of total repairs (round-2 Copilot fix — pathological cases with hundreds of missing pairings would otherwise allocate a huge intermediate array).

src/auto-reply/commands-registry.shared.ts (+13 / addition only)

Adds the plan command definition to the universal command registry. The single-line entry — nativeName: "plan", textAlias: "/plan", acceptsArgs: true, category: "management" — is what makes /plan automatically appear in /help, /commands, slash-completion menus on every channel that consults the registry. No per-channel wiring beyond the registry.

src/auto-reply/reply/commands-handlers.runtime.ts (+6 / addition only)

Registers handlePlanCommand in loadCommandHandlers between handleApproveCommand and handleContextCommand. Order matters here: handlePluginCommand runs first in the list (so plugins get a chance to claim a name), but validateCommandName in command-registration.ts reserves "plan" so plugins can't shadow it.

src/plugins/command-registration.ts (+21 / addition only)

Reserves "plan" (and a handful of other built-in command names that should have been reserved all along — approve, tools, tasks, plugins, mcp, acp, focus, unfocus, agents, tts, fast, trace, session, export-session) so third-party plugins can't register a command that shadows the universal /plan slash command (otherwise plugin-handler runs BEFORE handlePlanCommand in commands-handlers.runtime.ts and can hijack /plan accept / /plan auto / etc).

Test coverage matrix

File Tests Coverage focus
src/auto-reply/reply/commands-plan.test.ts 41 Parser dispatch (every verb + every error path), trailing-token rejection, /plan answer pending-question gate, restate truncation (mid-tag-safe + single-step in-place), format selection by channel, auth/owner gating, error mapping (stale approvalId / terminal state / gate-state-unavailable), shouldContinue semantics.
src/agents/plan-render.test.ts 61 All four formats × all four statuses, activeForm fallback, newline stripping in step + title + criteria, mention neutralization (@channel/@here/@everyone + Discord <@123>/<@!>/<@&>), format-character escaping (HTML/markdown/mrkdwn), nested acceptance-criteria rendering with verified-set normalization, archetype document section ordering + omission, footer presence.
src/agents/plan-mode/plan-archetype-bridge.test.ts 10 Caption building (HTML escape, fallback title), Telegram session → sendDocumentTelegram called with right args, web/CLI session → no Telegram send (markdown still persisted), send failure does not throw, log.warn fires on PlanPersistStorageError with the [plan-bridge/storage] marker.
Total 112 All paths exercised; no manual smoke required for the parser surface.

Tests use vitest (matches the rest of the codebase). Channel-specific authorization paths reuse the /approve test suite — no duplication. The bridge tests mock the SDK facade layer (sendDocumentTelegram) so no network sockets open in CI.

Parity benchmark callout

Previously I ran a benchmark comparing OpenClaw's plan-mode parity against Claude Code and Codex on the same prompt set: identical user inputs hit all three tools, same scoring rubric. OpenClaw scored 90% parity on response quality and 95% parity on session lengths vs the reference implementations.

For the channel surface specifically:

  • Universal /plan slash commands are convergent with Claude Code's slash-command pattern. The verb set (status, accept, revise, auto, answer) maps to Claude Code's plan-mode-on-CLI commands; the structured trailing-token rejection + per-verb usage hints match the same UX discipline.
  • Telegram attachment fallback matches Codex's "rich UX where possible, text fallback elsewhere" pattern. Codex's channel runtimes emit native interactive elements when the channel supports them, and degrade to a text-with-document-attachment pattern when not. Our bridge follows the same pattern: webchat gets inline cards (4/6), Telegram gets the document attachment + text resolution, every other text channel gets the universal /plan text path.

The parity score on session lengths is what matters here for channels: text-channel sessions stay within 5% of webchat-equivalent session lengths in the benchmark, which means the universal /plan surface is not introducing extra approval round-trips relative to the rich-UI path. (The 10% quality gap is mostly the rich-UI differential — text channels can't show diffs inline as cleanly as webchat — and is documented in §5 of #68939.)

Worth flagging: the convergence with both reference implementations is structural, not surface-level. The verb set, the trailing-token discipline, the per-verb usage hints, the silent-drop on unauthorized senders (vs visible reject), the shouldContinue: true semantics on mutating verbs — these are all patterns the benchmark surfaced as quality-affecting differences against Claude Code / Codex, and each is now matched. A reviewer who's used either tool will recognize the affordance shape immediately.

Worked examples

Example 1: Telegram operator approves a plan from their phone

  1. Agent on a long-running session calls exit_plan_mode with a 6-step plan including analysis, risks, and verification sections.
  2. dispatchPlanArchetypeAttachment fires. Markdown rendered (~12 KB). Persisted to ~/.openclaw/agents/refactor-ws/plans/plan-2026-04-22-153012-refactor-websocket-reconnect.md. log.info: plan-bridge: persisted plan-2026-04-22-...md.
  3. Bridge reads SessionEntry, sees channel === "telegram", builds the HTML caption: <b>Refactor websocket reconnect</b> — plan submitted for approval. See attached.\n<i>Address the close-race condition</i>\n\nResolve with: <code>/plan accept</code> | <code>/plan accept edits</code> | <code>/plan revise &lt;feedback&gt;</code>.
  4. sendDocumentTelegram uploads. fs.stat reports 12 KB → well under the 50 MiB cap. fs.readFile → Buffer. api.sendDocument(chatId, file, {caption, parse_mode: "HTML"}). log.info: plan-bridge: telegram attachment sent chatId=-100... msgId=4567.
  5. Operator opens the markdown attachment, reads the plan on their phone, replies /plan accept in the chat.
  6. handlePlanCommand parses accept (no trailing tokens, bare form). Auth passes. Pre-check sees planMode.approval === "pending". callGateway sessions.patch { planApproval: { action: "approve", approvalId } }. Returns shouldContinue: true.
  7. Agent runner pipeline runs immediately, consumes pendingAgentInjection (the [PLAN_DECISION]: approved synthetic message), and the agent's first action arrives in the chat as the implicit "approval received" signal.

Total operator round-trips: 1 message (/plan accept). No inline buttons required. No webchat session needed.

Example 2: Slack user revises a plan from a thread

  1. Same setup as above but on Slack. The bridge persists the markdown but doesn't upload (Slack attachment delivery is deferred — markdown is on disk for audit).
  2. Slack user wants to see the plan: types /plan restate in the thread.
  3. handlePlanCommand parses restate. Auth passes (operator gating still applies — restate can leak step text). pickPlanRenderFormat("slack") returns "slack-mrkdwn". renderPlanChecklist(steps, "slack-mrkdwn") produces the checklist with *bold* on in-progress, ~strike~ on cancelled, ✅/⏳/❌/⬚ markers, and Unicode-lookalike escapes for any * / ~ / _ in step text. Soft-capped at 3500 chars; truncation drops trailing steps step-by-step until under cap (or, if a single step is over cap, in-place truncates that step's step + activeForm text).
  4. Reply lands in the same thread: *Current plan:*\n✅ Run tests\n⏳ *Building artifacts*\n….
  5. User types /plan revise add error handling for the websocket reconnect close race. Parser requires non-empty feedback (H2) — passes. callGateway sessions.patch { planApproval: { action: "reject", feedback: "...", approvalId } }. shouldContinue: true.
  6. Agent revises the plan and re-submits via exit_plan_mode. New plan-mode approval cycle starts; reference card mentions feedback was applied.

Example 3: Discord channel with @everyone in step text

  1. Plan step text contains Notify @everyone in #ops once deploy lands (legitimate phrasing — agent describing what it'll do).
  2. User types /plan restate. Render path enters the markdown branch.
  3. neutralizeMentions(label) runs first: @everyone@\uFE6Beveryone (U+FE6B inserted). Then escapeMarkdown runs on the neutralized string.
  4. Discord receives - [ ] Notify @\uFE6Beveryone in #ops once deploy lands. The U+FE6B character is invisible-ish but breaks Discord's mention parser, so no channel ping fires.
  5. Same pattern for raw mentions: <@123> becomes <\u200B@123> (zero-width space between < and @), which Discord renders as literal text rather than a user mention.

This is the PR-11 deep-dive review B1 fix. Without it, an agent describing its own action could ping every member of a Discord server on /plan restate — a real risk because plan text is agent-controlled and the agent might quote user input verbatim.

What a reviewer can verify in <30 min

Channel checklist — pick any one of these and the rest follow the same code path.

  1. Telegram (5 min): Send /plan (any verb) in a chat where OpenClaw is bound. Verify parsePlanCommand triggers (set a breakpoint or watch logs). Send /plan accept with no pending plan — see the friendly "no pending plan" reply (M1 pre-check). Send /plan revise with no feedback — see the usage hint (H2). Send /plan@otherbot status — see the foreign-bot bail (H1, only triggers on channel === "telegram").
  2. Slack (5 min): Same as Telegram, but /plan@otherbot status should NOT bail (only Telegram needs the @bot disambiguation). /plan restate against an active session — see Slack mrkdwn formatting (*bold*, ~strike~) with Unicode lookalike escapes for any * / ~ / _ in step text (no \*\_ noise).
  3. Discord (5 min): /plan restate with a step containing @everyone — see it neutralized to @\uFE6Beveryone (no channel ping). Same with <@123> raw mentions (U+200B inserted between < and @).
  4. iMessage / SMS / Signal (5 min): /plan restate should produce plaintext markers ([x], [>], [~], [ ]) — no markdown leaking as literal **bold**.
  5. CLI (3 min): /plan status → markdown output (CLI declares markdownCapable: true).
  6. Telegram attachment (5 min): trigger exit_plan_mode from a long-running session (or any session with non-empty plan). Watch for the markdown to land in ~/.openclaw/agents/<agentId>/plans/plan-*.md AND a Telegram document upload with the HTML caption containing the /plan accept resolution hint. Storage failure (e.g., chmod the plans dir 000) should log [plan-bridge/storage] and proceed; Telegram failure (e.g., revoke the bot token) should log a warn and proceed.
  7. Auth (2 min): send /plan accept from a non-operator account — should be silently dropped (logVerbose only), not a visible reply.

Code-level verification:

  • commands-plan.ts:90-96 — trailing-token rejection helper. Read once; pattern repeats for every single-token verb.
  • plan-render.ts:435-437neutralizeMentions regex. Two replacements: @(channel|here|everyone) and <@. Should match all the injection vectors documented in tests.
  • plan-archetype-bridge.ts:152-158 — channel detection. channel === "telegram" + dctx.to is the gate; everything else falls through to the disk-only persist path.
  • extensions/telegram/src/send.ts:1545+sendDocumentTelegram. Stat-before-read on lines ~1565-1585; 50 MiB cap on line ~1585; HTML default parseMode on the option type.

What this PR does NOT include

  • Web UI plan surfaces (inline approval card, sidebar plan-view toggle, expandable plan-step card in thread, inline revision textarea, question modal) → [Plan Mode 4/6] Web UI + i18n.
  • Discord / Slack rich-embed variants of /plan restate — universal /plan + markdown rendering covers the 80% case; deferred to a future polish PR.
  • Slack block-kit-specific approval card — same reasoning; deferred.
  • Discord/Slack/Matrix attachment delivery for the long-plan case — bridge is wired channel-by-channel; only Telegram has the sendDocument* helper today. Other channels would need an analogous extensions/<channel>/src/send.ts addition + a registry-driven pickup in plan-archetype-bridge.ts.
  • Docs + QA scenarios[Plan Mode 6/6] Docs, QA, and help.
  • Automation + subagent follow-ups (cron nudges, auto-enable) → [Plan Mode AUTOMATION] ([Plan Mode AUTOMATION] Cron nudges + auto-enable + subagent follow-ups #70089) + bundled in [Plan Mode FULL] ([Plan Mode FULL] Integrated bundle for testing (Parts 1\u20136 + automation + executing-state lifecycle) #70071).
  • docs/tools/slash-commands.md /plan reference line — moved to [Plan Mode 6/6] ([Plan Mode 6/6] Docs, QA, and help #70070) per Codex P3 review (it was a docs change in a channels PR; better-suited to the docs PR). See commit 6c716f98ab for the revert here + f4ae594dab on [Plan Mode 6/6] Docs, QA, and help #70070 for the re-add.

Issue references

Files in scope (recap)

Primary review targets (mutating + parsing):

  • src/auto-reply/reply/commands-plan.ts (+587 / new) + test (+742 / 41 cases)
  • src/agents/plan-render.ts (+463 / new) + test (+717 / 61 cases)
  • src/agents/plan-mode/plan-archetype-bridge.ts (+203 / new) + test (+318 / 10 cases)

Telegram attachment plumbing:

  • src/plugin-sdk/telegram.ts (+60 / new — minimal facade restoration)
  • extensions/telegram/src/send.ts (+187 / addition — sendDocumentTelegram + TelegramDocumentOpts)
  • extensions/telegram/runtime-api.ts (+8 / re-export)

Wiring + registry:

  • src/auto-reply/commands-registry.shared.ts (+13 / plan command definition)
  • src/auto-reply/reply/commands-handlers.runtime.ts (+6 / handlePlanCommand registration)
  • src/plugins/command-registration.ts (+21 / reserve plan + 13 sibling built-ins)

Tangential / runtime safety:

  • src/agents/transport-message-transform.ts (+74 / -1 — [transport-repair] placeholder + log volume cap)

Copilot AI review requested due to automatic review settings April 22, 2026 08:36
@openclaw-barnacle openclaw-barnacle Bot added docs Improvements or additions to documentation channel: telegram Channel integration: telegram agents Agent runtime and tooling size: XL labels Apr 22, 2026
@greptile-apps

greptile-apps Bot commented Apr 22, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Adds universal /plan slash commands across all text channels (Telegram, Slack, Discord, iMessage, etc.) plus Telegram document delivery for plans that exceed inline size limits. The implementation is well-structured with thorough injection hardening (HTML escaping, mention neutralization, markdown escaping), sensible fire-and-forget semantics in the bridge, and strong test coverage.

  • P1 — accountId not forwarded to sendDocumentTelegram in plan-archetype-bridge.ts: on multi-account Telegram deployments the plan file is silently sent from the default bot rather than the bot that received the original session, mis-delivering the attachment.
  • P2 — HTML entity truncation boundary in sendDocumentTelegram: the 1024-char caption slice can split an entity mid-sequence (e.g. &am + p;) because escaping happens before the truncation check.
  • P2 — Confirmation replies use Markdown bold on non-Markdown channels: /plan on, /plan off, /plan status, and /plan auto responses use **bold** which renders as literal asterisks on Telegram (HTML parse mode) and plaintext channels.

Confidence Score: 4/5

Safe to merge for single-Telegram-account deployments; fix the missing accountId forwarding before merging into multi-account setups.

One clear P1 defect — missing accountId in the sendDocumentTelegram call — causes mis-delivery in multi-account Telegram configurations. The other two findings are P2 (cosmetic/edge-case). The P1 is limited to a specific deployment topology but represents a real present defect in the changed code, preventing a 5.

src/agents/plan-mode/plan-archetype-bridge.ts (missing accountId), extensions/telegram/src/send.ts (caption entity truncation)

Comments Outside Diff (3)

  1. src/agents/plan-mode/plan-archetype-bridge.ts, line 739-744 (link)

    P1 Missing accountId in sendDocumentTelegram call

    dctx.accountId is extracted from the session's delivery context but never forwarded to sendDocumentTelegram. On a single-bot Telegram deployment this falls through to the default account silently, but on multi-account setups the document is delivered via the wrong bot — the one that happens to be the default rather than the one that received the original message.

    const sendResult = await sendDocumentTelegram(dctx.to, absPath, {
      caption,
      parseMode: "HTML",
      accountId: dctx.accountId,   // pass the session's account
    });

    The test passes because the mock doesn't inspect opts.accountId, so the gap is invisible in unit tests.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/agents/plan-mode/plan-archetype-bridge.ts
    Line: 739-744
    
    Comment:
    **Missing `accountId` in `sendDocumentTelegram` call**
    
    `dctx.accountId` is extracted from the session's delivery context but never forwarded to `sendDocumentTelegram`. On a single-bot Telegram deployment this falls through to the default account silently, but on multi-account setups the document is delivered via the wrong bot — the one that happens to be the default rather than the one that received the original message.
    
    ```ts
    const sendResult = await sendDocumentTelegram(dctx.to, absPath, {
      caption,
      parseMode: "HTML",
      accountId: dctx.accountId,   // pass the session's account
    });
    ```
    
    The test passes because the mock doesn't inspect `opts.accountId`, so the gap is invisible in unit tests.
    
    How can I resolve this? If you propose a fix, please make it concise.
  2. extensions/telegram/src/send.ts, line 190-194 (link)

    P2 Caption truncation can split an HTML entity mid-sequence

    The caption arrives already HTML-escaped (e.g. &lt;, &amp;), but the slice at char 1023 is unaware of entity boundaries. If the cut point lands inside a multi-character entity — say &am before p; — Telegram's HTML parser receives a malformed entity. Depending on how Telegram handles it, the trailing text can render incorrectly or the whole caption can be rejected.

    To fix, either truncate before HTML-escaping the caption, or scan backward from the slice point to ensure you don't break an entity sequence:

    function safeSliceHtml(s: string, maxLen: number): string {
      if (s.length <= maxLen) return s;
      let sliced = s.slice(0, maxLen - 1);
      // Don't cut inside a dangling &…; entity
      const amp = sliced.lastIndexOf("&");
      if (amp !== -1 && !sliced.slice(amp).includes(";")) {
        sliced = sliced.slice(0, amp);
      }
      return sliced + "…";
    }
    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: extensions/telegram/src/send.ts
    Line: 190-194
    
    Comment:
    **Caption truncation can split an HTML entity mid-sequence**
    
    The caption arrives already HTML-escaped (e.g. `&lt;`, `&amp;`), but the slice at char 1023 is unaware of entity boundaries. If the cut point lands inside a multi-character entity — say `&am` before `p;` — Telegram's HTML parser receives a malformed entity. Depending on how Telegram handles it, the trailing text can render incorrectly or the whole caption can be rejected.
    
    To fix, either truncate *before* HTML-escaping the caption, or scan backward from the slice point to ensure you don't break an entity sequence:
    
    ```ts
    function safeSliceHtml(s: string, maxLen: number): string {
      if (s.length <= maxLen) return s;
      let sliced = s.slice(0, maxLen - 1);
      // Don't cut inside a dangling &…; entity
      const amp = sliced.lastIndexOf("&");
      if (amp !== -1 && !sliced.slice(amp).includes(";")) {
        sliced = sliced.slice(0, amp);
      }
      return sliced + "";
    }
    ```
    
    How can I resolve this? If you propose a fix, please make it concise.
  3. src/auto-reply/reply/commands-plan.ts, line 3170-3185 (link)

    P2 Confirmation replies use Markdown bold on channels that don't render it

    /plan on, /plan off, /plan status, /plan auto on|off, and the view redirect all produce messages with **bold** markdown syntax, but Telegram uses HTML parse mode (so ** renders as literal asterisks) and plaintext channels (SMS, iMessage) show the raw markers as noise. Only /plan restate correctly routes through pickPlanRenderFormat().

    The simplest fix for the fixed-string replies is to omit formatting (plain text is readable on all surfaces) or to route them through the same format-picker. Example for the on reply:

    const fmt = pickPlanRenderFormat(params.command.channel);
    const bold = (s: string) =>
      fmt === "html" ? `<b>${s}</b>` :
      fmt === "slack-mrkdwn" ? `*${s}*` : `**${s}**`;
    return {
      shouldContinue: false,
      reply: { text: `Plan mode ${bold("enabled")} — write/edit/exec tools blocked until plan approved.` },
    };

    Affected reply sites: on, off, status, auto on, auto off.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/auto-reply/reply/commands-plan.ts
    Line: 3170-3185
    
    Comment:
    **Confirmation replies use Markdown bold on channels that don't render it**
    
    `/plan on`, `/plan off`, `/plan status`, `/plan auto on|off`, and the view redirect all produce messages with `**bold**` markdown syntax, but Telegram uses HTML parse mode (so `**` renders as literal asterisks) and plaintext channels (SMS, iMessage) show the raw markers as noise. Only `/plan restate` correctly routes through `pickPlanRenderFormat()`.
    
    The simplest fix for the fixed-string replies is to omit formatting (plain text is readable on all surfaces) or to route them through the same format-picker. Example for the `on` reply:
    
    ```ts
    const fmt = pickPlanRenderFormat(params.command.channel);
    const bold = (s: string) =>
      fmt === "html" ? `<b>${s}</b>` :
      fmt === "slack-mrkdwn" ? `*${s}*` : `**${s}**`;
    return {
      shouldContinue: false,
      reply: { text: `Plan mode ${bold("enabled")} — write/edit/exec tools blocked until plan approved.` },
    };
    ```
    
    Affected reply sites: `on`, `off`, `status`, `auto on`, `auto off`.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/agents/plan-mode/plan-archetype-bridge.ts
Line: 739-744

Comment:
**Missing `accountId` in `sendDocumentTelegram` call**

`dctx.accountId` is extracted from the session's delivery context but never forwarded to `sendDocumentTelegram`. On a single-bot Telegram deployment this falls through to the default account silently, but on multi-account setups the document is delivered via the wrong bot — the one that happens to be the default rather than the one that received the original message.

```ts
const sendResult = await sendDocumentTelegram(dctx.to, absPath, {
  caption,
  parseMode: "HTML",
  accountId: dctx.accountId,   // pass the session's account
});
```

The test passes because the mock doesn't inspect `opts.accountId`, so the gap is invisible in unit tests.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: extensions/telegram/src/send.ts
Line: 190-194

Comment:
**Caption truncation can split an HTML entity mid-sequence**

The caption arrives already HTML-escaped (e.g. `&lt;`, `&amp;`), but the slice at char 1023 is unaware of entity boundaries. If the cut point lands inside a multi-character entity — say `&am` before `p;` — Telegram's HTML parser receives a malformed entity. Depending on how Telegram handles it, the trailing text can render incorrectly or the whole caption can be rejected.

To fix, either truncate *before* HTML-escaping the caption, or scan backward from the slice point to ensure you don't break an entity sequence:

```ts
function safeSliceHtml(s: string, maxLen: number): string {
  if (s.length <= maxLen) return s;
  let sliced = s.slice(0, maxLen - 1);
  // Don't cut inside a dangling &…; entity
  const amp = sliced.lastIndexOf("&");
  if (amp !== -1 && !sliced.slice(amp).includes(";")) {
    sliced = sliced.slice(0, amp);
  }
  return sliced + "";
}
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/auto-reply/reply/commands-plan.ts
Line: 3170-3185

Comment:
**Confirmation replies use Markdown bold on channels that don't render it**

`/plan on`, `/plan off`, `/plan status`, `/plan auto on|off`, and the view redirect all produce messages with `**bold**` markdown syntax, but Telegram uses HTML parse mode (so `**` renders as literal asterisks) and plaintext channels (SMS, iMessage) show the raw markers as noise. Only `/plan restate` correctly routes through `pickPlanRenderFormat()`.

The simplest fix for the fixed-string replies is to omit formatting (plain text is readable on all surfaces) or to route them through the same format-picker. Example for the `on` reply:

```ts
const fmt = pickPlanRenderFormat(params.command.channel);
const bold = (s: string) =>
  fmt === "html" ? `<b>${s}</b>` :
  fmt === "slack-mrkdwn" ? `*${s}*` : `**${s}**`;
return {
  shouldContinue: false,
  reply: { text: `Plan mode ${bold("enabled")} — write/edit/exec tools blocked until plan approved.` },
};
```

Affected reply sites: `on`, `off`, `status`, `auto on`, `auto off`.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "feat(plan-mode): split PR8 telegram atta..." | Re-trigger Greptile

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds universal plan-mode support for text channels via a /plan command handler and shared plan rendering utilities, plus restores a Telegram plugin-sdk facade and introduces a Telegram document upload path for delivering long plans as attachments.

Changes:

  • Introduces /plan slash commands (parse + auth + dispatch to sessions.patch) and wires it into the command runtime + registry.
  • Adds plan rendering utilities for multiple text formats (markdown/html/plaintext/slack-mrkdwn), including mention-neutralization and acceptance-criteria rendering.
  • Adds Telegram plan attachment delivery: core bridge persists markdown and (for Telegram) uploads as a document via new sendDocumentTelegram runtime API + restored SDK facade.

Reviewed changes

Copilot reviewed 14 out of 14 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
src/plugins/command-registration.ts Reserves plan and other built-in names to prevent plugin command shadowing.
src/plugin-sdk/telegram.ts Restores Telegram SDK facade and re-exports runtime API entrypoints/types via facade-loader.
src/auto-reply/reply/commands-plan.ts Implements /plan parsing, auth gating, restate rendering, and sessions.patch dispatch.
src/auto-reply/reply/commands-plan.test.ts Adds unit coverage for /plan parsing and dispatch behavior.
src/auto-reply/reply/commands-handlers.runtime.ts Registers the new /plan handler in the runtime handler list.
src/auto-reply/commands-registry.shared.ts Exposes /plan in the built-in command registry/metadata.
src/agents/transport-message-transform.ts Improves missing tool_result repair logging + placeholder text; caps per-turn log spam.
src/agents/plan-render.ts Adds multi-format plan checklist rendering + full archetype markdown renderer with sanitization.
src/agents/plan-render.test.ts Adds extensive tests for rendering, escaping, and mention neutralization.
src/agents/plan-mode/plan-archetype-bridge.ts Persists plan archetype markdown and sends Telegram attachment when applicable.
src/agents/plan-mode/plan-archetype-bridge.test.ts Tests persistence + Telegram send behavior (mocked) and failure-swallow contract.
extensions/telegram/src/send.ts Adds sendDocumentTelegram helper (file size checks, caption handling, threading fallback).
extensions/telegram/runtime-api.ts Exposes sendDocumentTelegram and its options type via the Telegram runtime API.
docs/tools/slash-commands.md Documents /plan among built-in slash commands.

Comment on lines +175 to +185
case "auto": {
// /plan auto [on|off]. Bare /plan auto defaults to on (matches
// the chip "switch INTO Plan ⚡" intent).
if (!second || second === "on") {
return { ok: true, sub: { kind: "auto", autoEnabled: true } };
}
if (second === "off") {
return { ok: true, sub: { kind: "auto", autoEnabled: false } };
}
return { ok: false, error: `Unrecognized /plan auto value "${second}". Use on|off.` };
}

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/plan auto parsing accepts extra trailing tokens (e.g. /plan auto on please) and will still enable/disable auto-approve. This makes it easy to apply the wrong setting due to typos or copy/paste. Mirror the trailing-token rejection used by on|off|status|view|restate and accept [edits] by rejecting when tokens.length > 2 (or > 1 for bare /plan auto).

Copilot uses AI. Check for mistakes.
Comment on lines +305 to +320
if (sub.kind === "status") {
if (!planMode) {
return {
shouldContinue: false,
reply: { text: "Plan mode is **off** for this session." },
};
}
const lines = [
`Plan mode: **${planMode.mode}**`,
`Approval: ${planMode.approval}`,
...(planMode.autoApprove ? ["Auto-approve: **on**"] : []),
...(planMode.rejectionCount && planMode.rejectionCount > 0
? [`Rejection cycles: ${planMode.rejectionCount}`]
: []),
];
return { shouldContinue: false, reply: { text: lines.join("\n") } };

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/plan status replies include markdown emphasis (**off**, **${planMode.mode}**) even on channels that are not markdown-capable (where this will render as literal **). Since this handler already has pickPlanRenderFormat() / isMarkdownCapableMessageChannel() for restate, consider emitting plaintext-safe status strings (or format-specific variants) to avoid leaking markdown syntax on SMS-like channels.

Copilot uses AI. Check for mistakes.
Comment on lines +430 to +445
if (sub.kind === "on") {
await callPatch({ planMode: "plan" });
return {
shouldContinue: false,
reply: {
text: "Plan mode **enabled** — write/edit/exec tools blocked until plan approved.",
},
};
}
if (sub.kind === "off") {
await callPatch({ planMode: "normal" });
return {
shouldContinue: false,
reply: { text: "Plan mode **disabled** — mutations unblocked." },
};
}

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The /plan on and /plan off confirmation messages also use markdown emphasis (**enabled**, **disabled**). On plaintext-only channels this will show up as raw asterisks. Consider making these confirmations plaintext-safe (or keyed off the same render-format decision used for /plan restate).

Copilot uses AI. Check for mistakes.
Comment on lines +234 to +236
// Lazy-load the registry helper to keep this module's eager
// dependencies minimal (the helper pulls in the channel registry
// which has its own startup cost).

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says isMarkdownCapableMessageChannel is "lazy-loaded" to keep eager dependencies minimal, but it is imported at module top and called directly. Either remove/adjust the comment, or actually lazy-load the helper via dynamic import if startup cost is a concern.

Suggested change
// Lazy-load the registry helper to keep this module's eager
// dependencies minimal (the helper pulls in the channel registry
// which has its own startup cost).
// Reuse the shared registry helper here so markdown capability stays
// aligned with the channel metadata registry instead of duplicating
// a separate hardcoded list in this module.

Copilot uses AI. Check for mistakes.
Comment thread src/agents/plan-render.ts
Comment on lines +422 to +433
* Inserts U+FE6B between '@' and known mention triggers to prevent
* @channel / @here / @everyone notifications from user-controlled text.
*
* PR-11 deep-dive review: also neutralize Discord raw user mentions
* `<@123>` / `<@!123>` / `<@&123>` (role mention) by inserting U+200B
* between `<` and `@`. Discord parses these as pings; an agent
* embedding such a string in a plan step would otherwise notify the
* named user/role on `/plan restate`.
*
* Channel coverage: Telegram (HTML), Discord/Matrix/Mattermost
* (markdown), Slack (mrkdwn — handled separately by escapeSlackMrkdwn
* via U+FE6B on every `@`), plaintext (iMessage/Signal/SMS).

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The neutralizeMentions() doc comment claims Slack mention protection is handled by escapeSlackMrkdwn "via U+FE6B on every @", but escapeSlackMrkdwn() does not modify @ at all and neutralizeMentions() only targets @channel|@here|@everyone plus <@...> forms. Please update the comment to reflect the actual behavior so future changes don't accidentally reintroduce mention-ping vectors.

Suggested change
* Inserts U+FE6B between '@' and known mention triggers to prevent
* @channel / @here / @everyone notifications from user-controlled text.
*
* PR-11 deep-dive review: also neutralize Discord raw user mentions
* `<@123>` / `<@!123>` / `<@&123>` (role mention) by inserting U+200B
* between `<` and `@`. Discord parses these as pings; an agent
* embedding such a string in a plan step would otherwise notify the
* named user/role on `/plan restate`.
*
* Channel coverage: Telegram (HTML), Discord/Matrix/Mattermost
* (markdown), Slack (mrkdwn handled separately by escapeSlackMrkdwn
* via U+FE6B on every `@`), plaintext (iMessage/Signal/SMS).
* Neutralizes the specific mention forms this renderer currently guards
* against in user-controlled text.
*
* - Inserts U+FE6B between `@` and the broadcast-style triggers
* `@channel`, `@here`, and `@everyone`.
* - Inserts U+200B between `<` and `@` in raw `<@...>` mention syntax
* such as `<@123>`, `<@!123>`, and `<@&123>`.
*
* PR-11 deep-dive review: raw `<@...>` mentions can be parsed as pings
* by chat platforms; embedding such a string in a plan step would
* otherwise notify the named user or role on `/plan restate`.
*
* This helper does not rewrite every `@`, and Slack mention handling is
* not performed here via blanket `@` escaping. Keep this comment aligned
* with the actual replacement logic below so future changes do not assume
* broader mention neutralization than is implemented.

Copilot uses AI. Check for mistakes.
Comment on lines +71 to +78
const safeTitle = (title ?? "").trim() || "Plan";
const escTitle = escapeHtml(safeTitle);
const safeSummary = (summary ?? "").trim();
const summaryLine = safeSummary ? `\n${escapeHtml(safeSummary)}` : "";
return [
`<b>${escTitle}</b> — plan submitted for approval. See attached.`,
summaryLine,
"",

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

buildPlanAttachmentCaption() builds summaryLine with a leading \n, then also joins lines with \n. This results in an extra blank line before the summary and wastes caption budget (1024 chars). Consider making the summary a separate array element without a prefixed newline (and let the join control line breaks).

Copilot uses AI. Check for mistakes.
Comment thread docs/tools/slash-commands.md Outdated
- `/bash <command>` runs a host shell command. Text-only. Alias: `! <command>`. Requires `commands.bash: true` plus `tools.elevated` allowlists.
- `!poll [sessionId]` checks a background bash job.
- `!stop [sessionId]` stops a background bash job.
- `/plan on|off|status|view|auto|accept|revise|answer|restate` toggles plan mode and resolves plan-mode approvals. Universal across all channels (web, Telegram, Discord, Slack, etc.). See [/concepts/plan-mode](/concepts/plan-mode).

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adds a link to /concepts/plan-mode, but there is no docs/concepts/plan-mode.md (or equivalent) in this PR, so the link is currently broken. Either link to an existing doc, or defer this reference to the stack part that introduces the plan-mode concept doc so intermediate merges don’t introduce dead links.

Suggested change
- `/plan on|off|status|view|auto|accept|revise|answer|restate` toggles plan mode and resolves plan-mode approvals. Universal across all channels (web, Telegram, Discord, Slack, etc.). See [/concepts/plan-mode](/concepts/plan-mode).
- `/plan on|off|status|view|auto|accept|revise|answer|restate` toggles plan mode and resolves plan-mode approvals. Universal across all channels (web, Telegram, Discord, Slack, etc.). See the plan mode documentation.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6c716f98ab (revert here) + f4ae594dab (re-add on #70070). Moved the 1-line docs/tools/slash-commands.md addition to [Plan Mode 6/6] (#70070) where it belongs (the docs PR). Per Codex P3 review note flagging the per-PR boundary.

Comment on lines +77 to 98
// PR-11: reserve `plan` so third-party plugins can't shadow the
// universal /plan slash command (otherwise plugin-handler runs
// BEFORE handlePlanCommand in commands-handlers.runtime.ts and can
// hijack /plan accept / /plan auto / etc).
"plan",
// Also reserve other built-in command names that should have been
// here all along (caught during the PR-11 deep-dive review).
"approve",
"tools",
"tasks",
"plugins",
"mcp",
"acp",
"focus",
"unfocus",
"agents",
"tts",
"fast",
"trace",
"session",
"export-session",
]);

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reservedCommands now covers many built-in command names, but it still omits built-in aliases that are handled elsewhere (e.g. /plugin is an alias for /plugins, and /export is an alias for /export-session). As written, a third-party plugin can still register plugin or export and shadow those built-ins via the plugin command handler. Add the missing alias names (and any other built-in aliases) to this reserved set.

Copilot uses AI. Check for mistakes.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a606f13571

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +120 to +122
export async function dispatchPlanArchetypeAttachment(
input: DispatchPlanArchetypeAttachmentInput,
): Promise<void> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Wire plan attachment bridge into the approval runtime

dispatchPlanArchetypeAttachment is added here but never invoked from production code; a repo-wide rg "dispatchPlanArchetypeAttachment" src only finds this file and its test. As a result, the new bridge logic does not run during actual plan approvals, so the promised markdown persistence and Telegram document delivery never happen outside tests.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Real concern, acknowledged. Not fixed in this immediate cycle (we're in cleanup-mode focused on maintainer-takeover; see #70101 for context). Tracked in the umbrella's deferred-items checklist for the bot-triage follow-up cycle. The fix will land on this branch with a Fixed in {SHA} reply here per the standard pr-review-loop pattern. Maintainers can also opt to merge the per-part PR as-is and address in a post-merge polish cycle.

Comment on lines +374 to +378
const TRUNCATED_STEP_MAX = Math.max(200, RESTATE_SOFT_CAP - 200);
const original = renderedSteps[0];
const truncatedStep: PlanStepForRender = {
...original,
step:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Re-validate restate length after single-step truncation

The single-step fallback truncates by raw original.step.length and then returns without checking the final rendered size again. Because HTML/markdown escaping can significantly expand output (for example repeated < or &), /plan restate can still produce payloads above channel limits (notably Telegram’s 4096-char cap), causing delivery failures even after this truncation branch runs.

Useful? React with 👍 / 👎.

@100yenadmin

Copy link
Copy Markdown
Contributor Author

Acknowledging Greptile / Copilot / Codex review feedback on this PR. Triage status:

  • Stack-coordination concerns (e.g. unresolved imports referencing symbols added by earlier per-part PRs in the stack) — expected by design. Red CI on this PR is per the rollout plan; CI turns green as the chain merges in sequence (1/6 → 6/6). See the stack-position header banner at the top of this PR body.
  • Real source-code issues flagged here (P1 bugs, P2 nits, etc.) — will be triaged + fixed in a focused follow-up cycle within ~24h. Fix SHAs will be posted as in-line replies on each individual comment per the standard pr-review-loop pattern.

For end-to-end integration testing in the meantime, see #70071 [Plan Mode FULL] — the green-CI integrated bundle of all parts.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 56638d3b13

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +178 to +182
if (!second || second === "on") {
return { ok: true, sub: { kind: "auto", autoEnabled: true } };
}
if (second === "off") {
return { ok: true, sub: { kind: "auto", autoEnabled: false } };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject trailing tokens in /plan auto parser

This branch accepts any command where the second token is on/off and silently ignores additional tokens, so inputs like /plan auto off later still mutate session state instead of returning a usage error. Other /plan subcommands explicitly reject trailing arguments to prevent typo-driven state changes, so this inconsistency can cause unintended auto-approve toggles in production chat flows.

Useful? React with 👍 / 👎.

Comment on lines 741 to 744
const res = await sendTelegramTextChunk(chunk, buildTextParams(index === chunks.length - 1));
const messageId = resolveTelegramMessageIdOrThrow(res, context);
recordSentMessage(chatId, messageId, cfg);
recordSentMessage(chatId, messageId);
lastMessageId = String(messageId);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Pass cfg when recording Telegram sent-message cache entries

recordSentMessage is now called without cfg, which writes to the default cache bucket even when callers provide opts.cfg with a custom session.store. The reaction gate checks wasSentByBot(chatId, messageId, cfg) against the cfg-scoped bucket, so in non-default-store deployments/tests these outbound messages are no longer recognized as bot-authored and reactionNotifications: "own" will incorrectly drop valid reactions.

Useful? React with 👍 / 👎.

…state (v4)

Cherry-pick / file-copy of 13 files from feat/plan-channel-parity-merged-upstream
(merged-head 651a22d) onto current upstream/main. Branch: isolated/pm-5-of-6-channels.

v4 absorbs the upstream merge into plan-mode dev (Option B; merge commit
b1ead74) plus the post-merge build/test fixes (commit 651a22d).
@100yenadmin
100yenadmin force-pushed the isolated/pm-5-of-6-channels branch from 56638d3 to d29c15e Compare April 22, 2026 14:36

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d29c15e119

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1692 to +1696
const caption =
captionRaw.length > TELEGRAM_CAPTION_MAX_CHARS
? captionRaw.slice(0, TELEGRAM_CAPTION_MAX_CHARS - 1) + "…"
: captionRaw;
const parseMode = opts.parseMode ?? (caption ? "HTML" : undefined);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve HTML validity when truncating Telegram captions

The caption is truncated with a raw slice() and then sent with parse_mode: "HTML", which can cut through the fixed <code>...</code> hint or an escaped entity when captions approach Telegram’s 1024-char limit. In that case Telegram rejects the document send as malformed HTML, so long plan summaries can silently lose attachment delivery instead of degrading gracefully.

Useful? React with 👍 / 👎.

@prtags

prtags Bot commented Apr 23, 2026

Copy link
Copy Markdown

Related work from PRtags group ace-bullfrog-hw3e

Title: Open PR candidate: plan-mode carve-out overlaps integrated full bundle

Number Title
#70031 [Plan Mode 1/6] Plan-state foundation
#70066 [Plan Mode 2/6] Core backend MVP
#70067 [Plan Mode 3/6] Advanced plan interactions
#70068 [Plan Mode 4/6] Web UI + i18n
#70069* [Plan Mode 5/6] Text channels + Telegram
#70070 [Plan Mode 6/6] Docs, QA, and help
#70071 [Plan Mode FULL] Integrated bundle for testing (Parts 1\u20136 + automation + executing-state lifecycle)
#70088 [Plan Mode INJECTIONS] Typed pending-injection queue foundation
#70089 [Plan Mode AUTOMATION] Cron nudges + auto-enable + subagent follow-ups

* This PR

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agents Agent runtime and tooling channel: telegram Channel integration: telegram size: XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants