Skip to content

Commit 48299a5

Browse files
fix: dedupe tool call display by toolCallId and sanitize titles (#138)
fix: dedupe tool call display by toolCallId and sanitize titles
1 parent 668219d commit 48299a5

2 files changed

Lines changed: 108 additions & 15 deletions

File tree

src/acp/protocol.rs

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,8 @@ impl std::fmt::Display for JsonRpcError {
6060
pub enum AcpEvent {
6161
Text(String),
6262
Thinking,
63-
ToolStart { title: String },
64-
ToolDone { title: String, status: String },
63+
ToolStart { id: String, title: String },
64+
ToolDone { id: String, title: String, status: String },
6565
Status,
6666
}
6767

@@ -70,6 +70,19 @@ pub fn classify_notification(msg: &JsonRpcMessage) -> Option<AcpEvent> {
7070
let update = params.get("update")?;
7171
let session_update = update.get("sessionUpdate")?.as_str()?;
7272

73+
// toolCallId is the stable identity across tool_call → tool_call_update
74+
// events for the same tool invocation. claude-agent-acp emits the first
75+
// event before the input fields are streamed in (so the title falls back
76+
// to "Terminal" / "Edit" / etc.) and refines them in a later
77+
// tool_call_update; without the id we can't tell those events belong to
78+
// the same call and end up rendering placeholder + refined as two
79+
// separate lines.
80+
let tool_id = update
81+
.get("toolCallId")
82+
.and_then(|v| v.as_str())
83+
.unwrap_or("")
84+
.to_string();
85+
7386
match session_update {
7487
"agent_message_chunk" => {
7588
let text = update.get("content")?.get("text")?.as_str()?;
@@ -80,15 +93,15 @@ pub fn classify_notification(msg: &JsonRpcMessage) -> Option<AcpEvent> {
8093
}
8194
"tool_call" => {
8295
let title = update.get("title").and_then(|v| v.as_str()).unwrap_or("").to_string();
83-
Some(AcpEvent::ToolStart { title })
96+
Some(AcpEvent::ToolStart { id: tool_id, title })
8497
}
8598
"tool_call_update" => {
8699
let title = update.get("title").and_then(|v| v.as_str()).unwrap_or("").to_string();
87100
let status = update.get("status").and_then(|v| v.as_str()).unwrap_or("").to_string();
88101
if status == "completed" || status == "failed" {
89-
Some(AcpEvent::ToolDone { title, status })
102+
Some(AcpEvent::ToolDone { id: tool_id, title, status })
90103
} else {
91-
Some(AcpEvent::ToolStart { title })
104+
Some(AcpEvent::ToolStart { id: tool_id, title })
92105
}
93106
}
94107
"plan" => Some(AcpEvent::Status),

src/discord.rs

Lines changed: 90 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -407,7 +407,14 @@ async fn stream_prompt(
407407
let (buf_tx, buf_rx) = watch::channel(initial);
408408

409409
let mut text_buf = String::new();
410-
let mut tool_lines: Vec<String> = Vec::new();
410+
// Tool calls indexed by toolCallId. Vec preserves first-seen
411+
// order. We store id + title + state separately so a ToolDone
412+
// event that arrives without a refreshed title (claude-agent-acp's
413+
// update events don't always re-send the title field) can still
414+
// reuse the title we already learned from a prior
415+
// tool_call_update — only the icon flips 🔧 → ✅ / ❌. Rendering
416+
// happens on the fly in compose_display().
417+
let mut tool_lines: Vec<ToolEntry> = Vec::new();
411418
let current_msg_id = msg_id;
412419

413420
if reset {
@@ -474,16 +481,53 @@ async fn stream_prompt(
474481
AcpEvent::Thinking => {
475482
reactions.set_thinking().await;
476483
}
477-
AcpEvent::ToolStart { title, .. } if !title.is_empty() => {
484+
AcpEvent::ToolStart { id, title } if !title.is_empty() => {
478485
reactions.set_tool(&title).await;
479-
tool_lines.push(format!("🔧 `{title}`..."));
486+
let title = sanitize_title(&title);
487+
// Dedupe by toolCallId: replace if we've already
488+
// seen this id, otherwise append a new entry.
489+
// claude-agent-acp emits a placeholder title
490+
// ("Terminal", "Edit", etc.) on the first event
491+
// and refines it via tool_call_update; without
492+
// dedup the placeholder and refined version
493+
// appear as two separate orphaned lines.
494+
if let Some(slot) = tool_lines.iter_mut().find(|e| e.id == id) {
495+
slot.title = title;
496+
slot.state = ToolState::Running;
497+
} else {
498+
tool_lines.push(ToolEntry {
499+
id,
500+
title,
501+
state: ToolState::Running,
502+
});
503+
}
480504
let _ = buf_tx.send(compose_display(&tool_lines, &text_buf));
481505
}
482-
AcpEvent::ToolDone { title, status, .. } => {
506+
AcpEvent::ToolDone { id, title, status } => {
483507
reactions.set_thinking().await;
484-
let icon = if status == "completed" { "✅" } else { "❌" };
485-
if let Some(line) = tool_lines.iter_mut().rev().find(|l| l.contains(&title)) {
486-
*line = format!("{icon} `{title}`");
508+
let new_state = if status == "completed" {
509+
ToolState::Completed
510+
} else {
511+
ToolState::Failed
512+
};
513+
// Find by id (the title is unreliable — substring
514+
// match against the placeholder "Terminal" would
515+
// never find the refined entry). Preserve the
516+
// existing title if the Done event omits it.
517+
if let Some(slot) = tool_lines.iter_mut().find(|e| e.id == id) {
518+
if !title.is_empty() {
519+
slot.title = sanitize_title(&title);
520+
}
521+
slot.state = new_state;
522+
} else if !title.is_empty() {
523+
// Done arrived without a prior Start (rare
524+
// race) — record it so we still show
525+
// something.
526+
tool_lines.push(ToolEntry {
527+
id,
528+
title: sanitize_title(&title),
529+
state: new_state,
530+
});
487531
}
488532
let _ = buf_tx.send(compose_display(&tool_lines, &text_buf));
489533
}
@@ -529,11 +573,47 @@ async fn stream_prompt(
529573
.await
530574
}
531575

532-
fn compose_display(tool_lines: &[String], text: &str) -> String {
576+
/// Flatten a tool-call title into a single line that's safe to render
577+
/// inside Discord inline-code spans. Discord renders single-backtick
578+
/// code on a single line only, so multi-line shell commands (heredocs,
579+
/// `&&`-chained commands split across lines) appear truncated; we
580+
/// collapse newlines to ` ; ` and rewrite embedded backticks so they
581+
/// don't break the wrapping span.
582+
fn sanitize_title(title: &str) -> String {
583+
title.replace('\r', "").replace('\n', " ; ").replace('`', "'")
584+
}
585+
586+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
587+
enum ToolState {
588+
Running,
589+
Completed,
590+
Failed,
591+
}
592+
593+
#[derive(Debug, Clone)]
594+
struct ToolEntry {
595+
id: String,
596+
title: String,
597+
state: ToolState,
598+
}
599+
600+
impl ToolEntry {
601+
fn render(&self) -> String {
602+
let icon = match self.state {
603+
ToolState::Running => "🔧",
604+
ToolState::Completed => "✅",
605+
ToolState::Failed => "❌",
606+
};
607+
let suffix = if self.state == ToolState::Running { "..." } else { "" };
608+
format!("{icon} `{}`{}", self.title, suffix)
609+
}
610+
}
611+
612+
fn compose_display(tool_lines: &[ToolEntry], text: &str) -> String {
533613
let mut out = String::new();
534614
if !tool_lines.is_empty() {
535-
for line in tool_lines {
536-
out.push_str(line);
615+
for entry in tool_lines {
616+
out.push_str(&entry.render());
537617
out.push('\n');
538618
}
539619
out.push('\n');

0 commit comments

Comments
 (0)