Skip to content

feat: surface streamed thinking content as a Discord blockquote#139

Open
marvin-69-jpg wants to merge 2 commits intoopenabdev:mainfrom
marvin-69-jpg:feat/surface-thinking-content
Open

feat: surface streamed thinking content as a Discord blockquote#139
marvin-69-jpg wants to merge 2 commits intoopenabdev:mainfrom
marvin-69-jpg:feat/surface-thinking-content

Conversation

@marvin-69-jpg
Copy link
Copy Markdown
Contributor

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_chunk events) is parsed and discarded:

"agent_thought_chunk" => {
    Some(AcpEvent::Thinking)   // ← unit variant, no payload
}
AcpEvent::Thinking => {
    reactions.set_thinking().await;   // ← only the emoji
}

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:

> 🤔 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.

🔧 `git fetch && git checkout main && git pull --ff-only`...

(final answer streams in here)

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::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.

The block-boundary heuristic

claude-agent-acp emits both thinking (the start of a new block) and thinking_delta (continuation) as the same agent_thought_chunk shape with no marker for the boundary:

// claude-agent-acp/dist/acp-agent.js, around line 1463
case "thinking":
case "thinking_delta":
    update = {
        sessionUpdate: "agent_thought_chunk",
        content: { type: "text", text: chunk.thinking },
    };

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:

fn needs_thinking_separator(prev: &str, new: &str) -> bool {
    let last = prev.chars().last()?;
    let first = new.chars().next()?;
    let sentence_end = matches!(last, '.' | '!' | '?' | '。' | '!' | '?');
    let starts_word = first.is_alphabetic();
    sentence_end && starts_word
}

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-acp to thread the content_block_start event through to agent_thought_chunk (or add a block_start: true flag in the update payload). Happy to follow up upstream if maintainers prefer that route.

Files changed

  • src/acp/protocol.rsAcpEvent::Thinking(String); classify_notification extracts text from agent_thought_chunk.
  • src/discord.rs — new thought_buf in stream_prompt; new needs_thinking_separator helper; compose_display takes thought and renders blockquote.

+75 / -8.

Compatibility

  • Backwards compatible with any ACP backend that doesn't emit thinking events at all (the Thinking variant is just never produced; compose_display skips the blockquote when thought_buf is empty).
  • Preset-agnostic: this is a render-side change inside discord.rs, not gated on agent.preset.
  • No new dependencies.

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 the AcpEvent enum and the compose_display signature, and #138 is the more critical bug fix. I've based this branch on fix/dedupe-tool-call-display so 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.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants