fix: dedupe tool call display by toolCallId and sanitize titles#138
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.
masami-agent
left a comment
There was a problem hiding this comment.
Solid fix, well-documented, and production-tested 👍
The toolCallId-based dedup is the right approach — substring matching on title was always fragile, especially with claude-agent-acp's placeholder → refined title lifecycle. The ToolEntry / ToolState design is clean and the sanitize_title helper handles the multi-line command rendering issue nicely.
A few minor notes (non-blocking):
ToolEntryandToolStateare markedpubbut only used withindiscord.rs— could bepub(crate)or private. Not a blocker.- Backward compat with empty
toolCallIdis handled correctly (all share one slot = same behavior as before).
LGTM, good to merge.
|
LGTM — clean fix, well-structured, and production-tested. Approving and merging. One minor note for a follow-up: Thanks for the contribution! 🙏 |
thepagent
left a comment
There was a problem hiding this comment.
LGTM — visibility fix applied, squashing and merging.
…abdev#138) fix: dedupe tool call display by toolCallId and sanitize titles
Summary
Two independent rendering bugs in the streaming tool-call display, both visible in any Discord thread that runs more than a couple of bash tools:
Duplicate placeholder lines.
claude-agent-acpemits the firsttool_callevent before the input fields are streamed in, so the title falls back to"Terminal"/"Edit"/ etc. A latertool_call_updatecarries the refined real title. The current handler pushes a new line on every event without deduping, leaving orphaned placeholders that never get updated:The
ToolDonearm tries to find the entry to update viatool_lines.iter_mut().rev().find(|l| l.contains(&title)), which works for the refined entries but never matches the orphanedTerminalplaceholders.Inline-code spans break on multi-line commands. The title is rendered as
format!("🔧{title}...")— single backticks. Discord's single-backtick inline code only spans one line. Any heredoc, multi-line bash, or&&-chained command split across lines breaks the inline code span at the first\n, and the rest of the command spills out as garbled raw text. Visually it looks like the command was truncated.Fix
toolCallIdthroughAcpEvent::ToolStart/ToolDoneso the renderer can pin updates to the same entry across the streamingtool_call→tool_call_update→tool_call_update(status=completed)lifecycle.tool_lines: Vec<String>withVec<ToolEntry>(id, title, state).ToolStartupdates the slot in place if the id is already present;ToolDonefinds by id (not by substring match against an unreliable title) and preserves the existing title when the Done event omits one — which it routinely does inclaude-agent-acpupdates.sanitize_titlehelper that flattens\nto" ; "and rewrites embedded backticks so they can't break the surrounding inline-code span.ToolEntry::render(), which picks the icon from aToolStateenum (Running / Completed / Failed) — no more brittle substring lookups.Repro (before this PR)
In a Discord thread connected to a
claudepreset bot, ask:Result: every Bash invocation leaves a
🔧 \Terminal`...line in the message that never updates to the real command. Multi-line&&` commands also render with the second line falling out of the inline-code span.After this PR
Each tool call appears as a single line that evolves in place:
Multi-line commands collapse to one line:
cmd1 ; cmd2 ; cmd3— still inside the inline-code span, no markdown breakage.Files changed
src/acp/protocol.rs—AcpEvent::ToolStart/ToolDonecarry anid;classify_notificationextractstoolCallIdfrom the update payload.src/discord.rs— newToolEntry/ToolStatetypes;sanitize_titlehelper;tool_linesbecomesVec<ToolEntry>; ToolStart/ToolDone match arms dedupe by id.+108 / -15. No new dependencies.
Compatibility
toolCallIdis already part of everytool_callandtool_call_updatepayload (it's howclaude-agent-acpitself correlates the events internally — seeacp-agent.jslines 1501-1556 in v0.25.0). We just weren't reading it.tool_callwithout atoolCallId(we fall back to an empty string id, in which case all calls without ids share one slot — the same effective behavior as today, just on the empty-id path).discord.rs.Testing
Running in production for the past two days against a multi-user Discord channel doing heavy git/gh PR workflows including many multi-line bash commands. No regressions seen; the orphan-line and multi-line-cmd issues are gone.
Note: this PR is intentionally scoped to the tool-call display layer. It does not touch the streaming chunking / message-splitting code (that's #135's territory) or the thinking/agent_thought_chunk handling. A follow-up will surface thinking content as a blockquote on top of the message; that's a separate concern.