Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions src/acp/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@ impl std::fmt::Display for JsonRpcError {
pub enum AcpEvent {
Text(String),
Thinking,
ToolStart { title: String },
ToolDone { title: String, status: String },
ToolStart { id: String, title: String },
ToolDone { id: String, title: String, status: String },
Status,
}

Expand All @@ -79,16 +79,18 @@ pub fn classify_notification(msg: &JsonRpcMessage) -> Option<AcpEvent> {
Some(AcpEvent::Thinking)
}
"tool_call" => {
let id = update.get("toolCallId").and_then(|v| v.as_str()).unwrap_or("").to_string();
let title = update.get("title").and_then(|v| v.as_str()).unwrap_or("").to_string();
Some(AcpEvent::ToolStart { title })
Some(AcpEvent::ToolStart { id, title })
}
"tool_call_update" => {
let id = update.get("toolCallId").and_then(|v| v.as_str()).unwrap_or("").to_string();
let title = update.get("title").and_then(|v| v.as_str()).unwrap_or("").to_string();
let status = update.get("status").and_then(|v| v.as_str()).unwrap_or("").to_string();
if status == "completed" || status == "failed" {
Some(AcpEvent::ToolDone { title, status })
Some(AcpEvent::ToolDone { id, title, status })
} else {
Some(AcpEvent::ToolStart { title })
Some(AcpEvent::ToolStart { id, title })
}
}
"plan" => Some(AcpEvent::Status),
Expand Down
41 changes: 25 additions & 16 deletions src/discord.rs
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ async fn stream_prompt(

let mut text_buf = String::new();
let mut tool_lines: Vec<String> = Vec::new();
let mut tool_ids: Vec<String> = Vec::new();
let current_msg_id = msg_id;

if reset {
Expand All @@ -210,19 +211,16 @@ async fn stream_prompt(
if buf_rx.has_changed().unwrap_or(false) {
let content = buf_rx.borrow_and_update().clone();
if content != last_content {
if content.len() > 1900 {
let chunks = format::split_message(&content, 1900);
if let Some(first) = chunks.first() {
let _ = edit(&ctx, channel, current_edit_msg, first).await;
}
for chunk in chunks.iter().skip(1) {
if let Ok(new_msg) = channel.say(&ctx.http, chunk).await {
current_edit_msg = new_msg.id;
}
}
// During streaming, only edit the single placeholder message.
// Truncate if over Discord's limit — the final edit handles
// proper multi-message splitting after streaming completes.
let display = if content.len() > 1900 {
let end = content.floor_char_boundary(1900);
format!("{}…", &content[..end])
} else {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ &content[..1900] will panic if byte 1900 falls in the middle of a multi-byte UTF-8 character (e.g. Chinese/Japanese text is 3 bytes per char). This is likely to hit in production since many openab users write in zh-TW.

Suggestion:

let end = (1900..content.len())
    .find(|&i| content.is_char_boundary(i))
    .unwrap_or(content.len());
format!("{}…", &content[..end])

Or if targeting Rust 1.80+: content.floor_char_boundary(1900)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3cbc865 — both the streaming truncation and the split_message() hard-split path now use floor_char_boundary() (stable since Rust 1.80).

  • discord.rs: &content[..1900]content.floor_char_boundary(1900)
  • format.rs: as_bytes().chunks(limit) + from_utf8_lossyfloor_char_boundary loop, no more replacement chars

Thanks for catching this.

let _ = edit(&ctx, channel, current_edit_msg, &content).await;
}
content.clone()
};
let _ = edit(&ctx, channel, current_edit_msg, &display).await;
last_content = content;
}
}
Expand Down Expand Up @@ -253,16 +251,27 @@ async fn stream_prompt(
AcpEvent::Thinking => {
reactions.set_thinking().await;
}
AcpEvent::ToolStart { title, .. } if !title.is_empty() => {
AcpEvent::ToolStart { id, title, .. } if !title.is_empty() => {
reactions.set_tool(&title).await;
tool_ids.push(id);
tool_lines.push(format!("🔧 `{title}`..."));
let _ = buf_tx.send(compose_display(&tool_lines, &text_buf));
}
AcpEvent::ToolDone { title, status, .. } => {
AcpEvent::ToolDone { id, title, status, .. } => {
reactions.set_thinking().await;
let icon = if status == "completed" { "✅" } else { "❌" };
if let Some(line) = tool_lines.iter_mut().rev().find(|l| l.contains(&title)) {
*line = format!("{icon} `{title}`");
// Match by toolCallId instead of title text.
// On match, swap the icon but keep the original ToolStart title,
// since ToolDone often returns a different or empty title.
let idx = tool_ids.iter().rposition(|tid| !tid.is_empty() && tid == &id);
if let Some(i) = idx {
let kept_title = tool_lines[i]
.split('`').nth(1)
.unwrap_or(&title);
tool_lines[i] = format!("{icon} `{kept_title}`");
} else if !title.is_empty() {
tool_ids.push(id);
tool_lines.push(format!("{icon} `{title}`"));
}
let _ = buf_tx.send(compose_display(&tool_lines, &text_buf));
}
Expand Down
7 changes: 5 additions & 2 deletions src/format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,14 @@ pub fn split_message(text: &str, limit: usize) -> Vec<String> {
}
// If a single line exceeds limit, hard-split it
if line.len() > limit {
for chunk in line.as_bytes().chunks(limit) {
let mut pos = 0;
while pos < line.len() {
let end = line.floor_char_boundary((pos + limit).min(line.len()));
if !current.is_empty() {
chunks.push(current);
}
current = String::from_utf8_lossy(chunk).to_string();
current = line[pos..end].to_string();
pos = end;
}
} else {
current.push_str(line);
Expand Down