feat: surface streamed thinking content as a Discord blockquote#139
Open
marvin-69-jpg wants to merge 2 commits intoopenabdev:mainfrom
Open
feat: surface streamed thinking content as a Discord blockquote#139marvin-69-jpg wants to merge 2 commits intoopenabdev:mainfrom
marvin-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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.