Providers stream commentary as cumulative snapshots, and the draft line id was derived from the commentary text, so each snapshot minted a new id and stacked another line instead of replacing the open one. Id-less commentary now remembers its open line and reuses that id when the next snapshot continues it; a snapshot sanitized to nothing no longer clears the line, since only an explicit item id retracts.
Also fixes activation: the commentary gate guessed "partial" when streaming.mode was unset, which made progress.commentary a silent no-op on channels whose own default is progress (Telegram, Discord). It now takes the caller's resolved mode, matching resolveChannelStreamingPreviewToolProgress. Proven live on Telegram: three overlapping commentary lines collapse to one.
The mock streamed pre-tool assistant text on the Responses path without `phase: "commentary"`, the field the API sets and the transport reads, so the draft-proof scenario ran green while never exercising the preamble lane on that transport. Proven live on Telegram: the preamble now renders as the status headline, and as a commentary line when progress.commentary is on.
* fix(systemd): clear start-limit latch before gateway start
systemd latches a unit into failed/start-limit-hit after a crash loop,
then refuses systemctl start until reset-failed clears the latch. The
reset-failed step only ran for the restart action, so the natural
recovery command `openclaw gateway start` failed on a crash-looped
gateway while `openclaw gateway restart` recovered it.
Run reset-failed before both start and restart (stop is unchanged),
mirroring launchd's startLaunchAgent which calls launchctl enable before
kickstart. reset-failed is idempotent and a no-op on a healthy unit.
* test(systemd): cover start-limit latch clearing on system-scope start
Mirrors the existing system-scope restart test: when startSystemdService
targets a system unit as root, it must emit reset-failed before start so
a crash-looped gateway recovers via `openclaw gateway start` too.
* test(systemd): assert healthy start tolerates reset-failed no-op
reset-failed is idempotent on a non-failed unit (systemd returns success
without changing unit state), so prepending it to start cannot regress a
healthy gateway. Covers the merge-risk concern that every start now
depends on an extra reset-failed call.
* test(systemd): cover reset failure and stop controls
---------
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
A progress draft only exists while the agent is working, and OpenClaw's exec
returns instantly for a mock tool call, so the whole turn finished in ~86ms —
inside the 1.5s draft gate. No draft was created, which is correct behavior but
proves nothing about rendering.
The draft-proof scenario now holds its final answer (MOCK_DRAFTPROOF_FINAL_DELAY_MS,
default 6s) so the turn straddles the gate. That is what made the live Discord
recording possible: draft created, edited with the status headline above the
tool line, replaced by the final answer, then deleted.
resolveChannelStreamingPreviewToolProgress guessed a mode when streaming.mode
was unset, so it could never be right for every channel: guessing "partial"
dropped Discord and Telegram's explicit progress.toolProgress opt-out, and my
earlier switch to "progress" broke it the other way for Slack, Mattermost, and
Teams, whose real default is partial. With both keys configured and disagreeing,
the wrong one won.
The helper has no channel identity, so it stops guessing and takes the mode the
caller already resolved. All nine call sites pass theirs. Omitting it keeps the
previous configured-mode reading, so untouched callers behave exactly as before.
Reported by ClawSweeper on #116143.
Three gates my own changes broke:
- docs/channels/telegram.md needed a blank line before </Note>; I formatted the
TypeScript but never ran the docs formatter over the pages I edited.
- The mock harness matched a literal with a regex, which oxlint's prefer-includes
rejects.
- Widening resolveChannelPreviewStreamMode's defaultMode to StreamingMode and
adding rendersRollingLinesNatively changes the exported plugin-SDK surface, so
the API baseline hash had to be regenerated.
Proving headline-plus-tool-line composition needs assistant text emitted
*before* a tool call: that leading text is what gets tagged as commentary and
rendered as the draft's status headline. The mock could only echo a single token,
so the draft path was unreachable without a live model.
Trigger `OPENCLAW_E2E_DRAFTPROOF` now serves two turns — preamble plus a shell
call, then the final answer — on both /v1/responses and /v1/chat/completions.
Only the completions transport tags commentary today, which is why the
completions path matters here.
Used to record the draft live on real Telegram: one message carrying the
headline above the tool line, the final answer, then the draft edited to its
receipt.
ClawSweeper's accepted-migration path requires operator-facing docs for the
default change: state what an existing install sees after upgrading and the
one-line setting that restores streamed answer text.
The label was smuggled into the rolling line array as a `{draftLabel}`
sentinel so it would participate in the maxLines window, then special-cased
in three places plus an index lookup to pull it back out for its own block.
A label is not a line; it is a block that yields its slot once real work
fills the window. Saying that directly removes the sentinel, every
`isLabelLine` check, and the double map over the line list, while keeping the
documented scroll-away behavior and the plan/tool budget split.
Adds the missing boundary test for a checklist that consumes the whole line
budget: `slice(-0)` returns every element, so that case needs the explicit
empty-window branch rather than falling out of the arithmetic.
A status headline replaced the rolling tool lines instead of sitting above
them, so a default Discord draft showed one preamble sentence for an entire
tool-heavy turn. Operators reached for `/verbose` to see any activity, which
delivers durable per-tool-call messages and floods the channel.
- Render the headline above the lines; both stay visible in one message.
- Shorten the start gate from 5s to 1.5s. The gate only creates the draft
when the timer fires and finalize cancels it, so quick answers still post
no draft while a 3s tool turn stops being silent.
- Drop Discord's label-gated tool-progress default so
resolveChannelStreamingPreviewToolProgress is the single owner. An explicit
`toolProgress: false` still silences the lines.
- Resolve that toggle against a "progress" mode guess when `streaming.mode`
is unset, so the progress-draft channels stop ignoring an explicit
`progress.toolProgress` opt-out.
Telegram now defaults to `streaming.mode: "progress"` like Discord; set
`"partial"` to keep streamed answer text. Its renderer draws work lines from
the compositor's structured lines, so `rendersRollingLinesNatively` keeps
them out of the composed text rather than printing every line twice.