feat: surface streamed thinking content as a Discord blockquote#139
feat: surface streamed thinking content as a Discord blockquote#139marvin-69-jpg wants to merge 2 commits intoopenabdev:mainfrom
Conversation
claude-agent-acp emits multiple events for the same tool invocation as
the input fields stream in: a `tool_call` with a placeholder title
("Terminal" / "Edit" / etc.) followed by one or more `tool_call_update`
events that refine the title to the real command. The current handler
pushes a new line on every event without deduping, so the message ends
up with orphaned placeholder lines that never get updated:
🔧 `Terminal`...
❌
🔧 `cd /home/node/work && git status`...
✅
It also passes the raw title through inline-code formatting without
flattening newlines or escaping embedded backticks. Discord's
single-backtick inline-code spans render on a single line only, so
multi-line shell commands (heredocs, &&-chained commands written across
lines) appear truncated mid-render with the inline-code span breaking
on the first newline.
Fix:
* Carry the ACP toolCallId through AcpEvent::ToolStart / ToolDone so the
renderer can pin updates to the same entry.
* Replace `tool_lines: Vec<String>` with `Vec<ToolEntry>` (id, title,
state). ToolStart updates the slot in place if the id is already
present; ToolDone preserves the existing title when the Done event
omits one (which it routinely does in claude-agent-acp updates).
* Add a `sanitize_title` helper that flattens \n to " ; " and rewrites
embedded backticks so they can't break the surrounding inline-code
span.
* Each tool now renders via ToolEntry::render() which picks the icon
from the state — no more brittle substring-based line lookups against
the placeholder title.
Tested in production against a multi-user Discord channel running heavy
git/gh workflows with multi-line bash commands.
Currently the only signal Discord users get when an agent enters extended thinking mode is the 🤔 emoji on the bot's message. The actual thinking content delivered via `agent_thought_chunk` is parsed and discarded — `classify_notification` returns `AcpEvent::Thinking` with no payload, and the `Thinking` arm in stream_prompt only flips the reaction emoji. This patch surfaces that content as a blockquote at the top of the streaming Discord message, similar to how the native Claude Code CLI shows thinking blocks above tool calls and the final answer: > 🤔 Thinking > Let me start by reading the README and checking my notes. > > There's a local commit ahead of origin/main, I should reset to > origin/main first before branching. Implementation: * `AcpEvent::Thinking` carries the delta string instead of being a unit variant; `classify_notification` reads `update.content.text` (the same shape `agent_message_chunk` already uses). * `stream_prompt` accumulates thinking deltas into a `thought_buf` alongside the existing `text_buf`, and re-renders the message on each event. * `compose_display` gains a `thought` parameter and emits a blockquote-prefixed section above the tool list when thought is non-empty. claude-agent-acp emits both `thinking` (block start) and `thinking_delta` (continuation) as the same `agent_thought_chunk` shape, with no marker for block boundaries. When the model produces several thinking blocks in a row separated only by tool calls, the deltas concatenate into a wall of text like ".There's a local commit" with no whitespace between sentences. We work around this with a small heuristic in `needs_thinking_separator`: if the previous chunk ends in sentence-terminating punctuation (`. ! ? 。 ! ?`) and the new chunk starts with a letter (no leading whitespace), insert a paragraph break. This is imperfect but covers the common case without requiring upstream protocol changes. Tested in production for the past day against a multi-user Discord channel with both English and Traditional Chinese conversations; the heuristic correctly inserts breaks between distinct thinking blocks and leaves token-level deltas inside a single block alone.
chaodu-agent
left a comment
There was a problem hiding this comment.
🔴 Needs Rebase + Rework — Thinking-content feature is valuable, but the branch is stale and duplicates code already merged to main.
Baseline Check (Step 0)
| Field | Value |
|---|---|
| State | OPEN |
| Mergeable | CONFLICTING |
| Created | 2026-04-08 (23 days ago) |
| Last commit | 2026-04-08 |
| Author | @brettchien |
| Labels | closing-soon |
| Base branch | fix/dedupe-tool-call-display (PR #138) |
Main has: AcpEvent::Thinking (unit variant, no payload), ToolEntry/ToolState/sanitize_title in adapter.rs, compose_display with 4-param signature (tool_lines, text, streaming, tool_display). Thinking events trigger 🤔 emoji only — content is discarded.
Net-new: AcpEvent::Thinking(String) payload, thought_buf accumulation, blockquote rendering ("> 🤔 Thinking"), needs_thinking_separator() heuristic.
四問框架
1. 解決什麼問題
When an agent enters extended thinking mode, the only visible signal is a 🤔 emoji. The actual thinking content streamed via agent_thought_chunk events is parsed and discarded.
2. 怎麼解決
Changes AcpEvent::Thinking to carry the delta string, accumulates thinking deltas into thought_buf, renders as a blockquote above the tool list in Discord messages.
3. 考慮過什麼
Block-boundary heuristic (needs_thinking_separator) handles the case where claude-agent-acp emits both thinking and thinking_delta as the same shape with no boundary marker.
4. 最佳方案嗎
The feature is great UX, but the implementation needs rebasing — most of the tool-tracking infra it adds already landed on main.
🟢 INFO
- Surfacing thinking content as a blockquote is a great UX improvement.
needs_thinking_separator()is a thoughtful heuristic for detecting block boundaries.- Graceful fallback:
Thinking("")still triggers the 🤔 reaction. - Good defensive coding:
ToolDonewithout priorToolStartis handled.
🟡 NIT
- Hardcoded blockquote header —
"> 🤔 _Thinking_\n"is hardcoded. Main's config already hasemoji_thinking— consider using it for consistency. - No truncation on
thought_buf— Extended thinking sessions could produce very long blockquotes that hit Discord's 2000-char message limit. - Visibility mismatch —
ToolEntryandToolStateare declaredpubin discord.rs but werepub(crate)/ private in main's adapter.rs.
🔴 SUGGESTED CHANGES
-
Merge conflict — needs rebase. Main already has
ToolEntry,ToolState,sanitize_title, and id-based tool dedup insrc/adapter.rs(merged from PR #138 or similar). This PR re-introduces all of that insrc/discord.rs. After rebase, the only net-new code should be: (1)AcpEvent::Thinking(String)in protocol.rs, (2)thought_bufaccumulation +needs_thinking_separator(), (3) blockquote rendering incompose_display. -
compose_displaysignature mismatch — Main'scompose_displaytakes(tool_lines, text, streaming, tool_display). The PR's version takes(tool_lines, thought, text)— droppingstreamingandtool_displaywhich control compact/full/none tool rendering and the "typing…" indicator. These features would regress. Thethoughtparameter needs to be added to main's existing signature. -
protocol.rsduplication — TheThinking → Thinking(String)change is the core of this PR and is correct, but the diff also re-introducesToolStart { title }→ToolStart { id, title }which main already has. After rebase this should be a one-line change.
超渡法師 Review — PR #1391. What problem does it solve?When an ACP backend agent enters extended thinking mode, the only visible signal in Discord is a 🤔 emoji reaction. The actual thinking content streamed via 2. How does it solve it?
3. What was considered?
4. Is this the best approach?The feature design is solid — blockquote rendering of thinking content is the right UX pattern for Discord. The heuristic is a pragmatic workaround for a protocol gap. However, the implementation needs a rebase. Traffic Light🟢 INFO — Excellent UX improvement
🔴 SUGGESTED CHANGES
🟡 NIT
Verdict🔴 Changes requested — Great feature, needs rebase. The thinking blockquote is the right UX pattern. After rebasing onto current |
Summary
When a Claude (or any other ACP backend) agent enters extended thinking mode, the only visible signal in Discord today is the 🤔 emoji on the bot's message. The actual thinking content streamed by the agent over the ACP protocol (
agent_thought_chunkevents) is parsed and discarded:This PR surfaces that content as a blockquote at the top of the streaming Discord message, similar to how the native Claude Code CLI shows thinking blocks above tool calls and the final answer:
Discord users — especially in multi-user team channels where the bot is doing non-trivial work — get a much clearer picture of what the agent is reasoning about, before the answer arrives.
Implementation
AcpEvent::Thinkingcarries the delta string instead of being a unit variant.classify_notificationreadsupdate.content.text(the same shapeagent_message_chunkalready uses).stream_promptaccumulates thinking deltas into athought_bufalongside the existingtext_buf, and re-renders the message on each event.compose_displaygains athoughtparameter and emits a blockquote-prefixed section above the tool list whenthoughtis non-empty.The block-boundary heuristic
claude-agent-acpemits boththinking(the start of a new block) andthinking_delta(continuation) as the sameagent_thought_chunkshape with no marker for the boundary:When the model produces several thinking blocks in a row separated only by tool calls, the deltas concatenate into a wall of text like
".There's a local commit"with no whitespace between sentences. We work around this with a small heuristic inneeds_thinking_separator:If the previous chunk ends in sentence-terminating punctuation and the new chunk starts with a letter (no leading whitespace), insert a
\n\n. This is imperfect but covers the common case without requiring upstream protocol changes — token-level deltas within a single block almost always include leading whitespace, so the heuristic doesn't over-correct.A cleaner long-term fix would be for
claude-agent-acpto thread thecontent_block_startevent through toagent_thought_chunk(or add ablock_start: trueflag in the update payload). Happy to follow up upstream if maintainers prefer that route.Files changed
src/acp/protocol.rs—AcpEvent::Thinking(String);classify_notificationextracts text fromagent_thought_chunk.src/discord.rs— newthought_bufinstream_prompt; newneeds_thinking_separatorhelper;compose_displaytakesthoughtand renders blockquote.+75 / -8.
Compatibility
Thinkingvariant is just never produced;compose_displayskips the blockquote whenthought_bufis empty).discord.rs, not gated onagent.preset.Testing
Running in production for the past day against a multi-user Discord channel mixing English and Traditional Chinese conversations. The heuristic correctly inserts paragraph breaks between distinct thinking blocks and leaves token-level deltas inside a single block alone.
Note about base branch
This PR is layered on top of #138 (
fix: dedupe tool call display by toolCallId and sanitize titles) — both PRs touch theAcpEventenum and thecompose_displaysignature, and #138 is the more critical bug fix. I've based this branch onfix/dedupe-tool-call-displayso they apply cleanly together; if you'd rather merge them in the opposite order I can rebase. The diff stat above is for the thinking change alone, not cumulative.