use std::sync::Arc;
use tokio::sync::broadcast;
use bamboo_agent_core::AgentEvent;
use super::platform::{OutboundMessage, Platform, ReplyCtx};
pub const MAX_MESSAGE_CHARS: usize = 4096;
const TOOL_LINE_MAX_CHARS: usize = 300;
pub fn chunk_message(text: &str, limit: usize) -> Vec<String> {
if text.is_empty() {
return Vec::new();
}
let chars: Vec<char> = text.chars().collect();
chars
.chunks(limit.max(1))
.map(|chunk| chunk.iter().collect())
.collect()
}
fn truncate_chars(text: &str, max: usize) -> String {
if text.chars().count() <= max {
return text.to_string();
}
let mut out: String = text.chars().take(max).collect();
out.push('…');
out
}
fn summarize_arguments(arguments: &serde_json::Value) -> String {
let Some(obj) = arguments.as_object() else {
return arguments.to_string();
};
for key in ["command", "file_path", "path", "query", "pattern", "url"] {
if let Some(value) = obj.get(key).and_then(|v| v.as_str()) {
return value.to_string();
}
}
serde_json::to_string(arguments).unwrap_or_default()
}
fn format_tool_line(tool_name: &str, arguments: &serde_json::Value) -> String {
let summary = summarize_arguments(arguments);
truncate_chars(&format!("⚙ {tool_name}: {summary}"), TOOL_LINE_MAX_CHARS)
}
async fn send_chunks(platform: &Arc<dyn Platform>, ctx: &ReplyCtx, text: &str) {
for chunk in chunk_message(text, MAX_MESSAGE_CHARS) {
if let Err(error) = platform.reply(ctx, OutboundMessage::text(chunk)).await {
tracing::warn!("connect: failed to deliver reply: {error}");
}
}
}
pub async fn stream_execution(
platform: Arc<dyn Platform>,
reply_ctx: ReplyCtx,
mut rx: broadcast::Receiver<AgentEvent>,
) {
let mut final_text = String::new();
let mut terminal_note: Option<String> = None;
loop {
match rx.recv().await {
Ok(AgentEvent::ToolStart {
tool_name,
arguments,
..
}) => {
let line = format_tool_line(&tool_name, &arguments);
send_chunks(&platform, &reply_ctx, &line).await;
}
Ok(AgentEvent::Token { content }) => final_text.push_str(&content),
Ok(AgentEvent::Complete { .. }) => break,
Ok(AgentEvent::Cancelled { message }) => {
terminal_note = Some(message.unwrap_or_else(|| "Cancelled.".to_string()));
break;
}
Ok(AgentEvent::Error { message }) => {
terminal_note = Some(format!("Error: {message}"));
break;
}
Ok(_) => continue,
Err(broadcast::error::RecvError::Lagged(_)) => continue,
Err(broadcast::error::RecvError::Closed) => break,
}
}
let body = terminal_note.unwrap_or(final_text);
if !body.trim().is_empty() {
send_chunks(&platform, &reply_ctx, &body).await;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn chunk_message_splits_on_char_boundaries_not_bytes() {
let text = "\u{4e2d}\u{6587}\u{6d4b}\u{8bd5}"; let chunks = chunk_message(text, 2);
assert_eq!(chunks, vec!["\u{4e2d}\u{6587}", "\u{6d4b}\u{8bd5}"]);
}
#[test]
fn chunk_message_respects_the_4096_limit() {
let text = "a".repeat(10_000);
let chunks = chunk_message(&text, MAX_MESSAGE_CHARS);
assert_eq!(chunks.len(), 3);
assert_eq!(chunks[0].chars().count(), MAX_MESSAGE_CHARS);
assert_eq!(chunks[1].chars().count(), MAX_MESSAGE_CHARS);
assert_eq!(chunks[2].chars().count(), 10_000 - 2 * MAX_MESSAGE_CHARS);
}
#[test]
fn chunk_message_empty_text_yields_no_chunks() {
assert!(chunk_message("", MAX_MESSAGE_CHARS).is_empty());
}
#[test]
fn format_tool_line_prefers_command_field_and_truncates() {
let args = serde_json::json!({ "command": "cargo test --workspace" });
let line = format_tool_line("Bash", &args);
assert_eq!(line, "⚙ Bash: cargo test --workspace");
}
#[test]
fn format_tool_line_truncates_long_summaries() {
let long_command = "x".repeat(1000);
let args = serde_json::json!({ "command": long_command });
let line = format_tool_line("Bash", &args);
assert!(line.chars().count() <= TOOL_LINE_MAX_CHARS + 1);
assert!(line.ends_with('…'));
}
#[test]
fn format_tool_line_falls_back_to_json_for_unknown_shape() {
let args = serde_json::json!({ "foo": "bar" });
let line = format_tool_line("CustomTool", &args);
assert!(line.starts_with("⚙ CustomTool: "));
assert!(line.contains("foo"));
}
struct RecordingPlatform {
sent: tokio::sync::Mutex<Vec<String>>,
}
#[async_trait::async_trait]
impl Platform for RecordingPlatform {
fn name(&self) -> &str {
"recording"
}
fn capabilities(&self) -> super::super::platform::Capabilities {
Default::default()
}
async fn start(
&self,
_inbound: tokio::sync::mpsc::Sender<super::super::platform::InboundMessage>,
) -> super::super::platform::PlatformResult<()> {
Ok(())
}
async fn reply(
&self,
_ctx: &ReplyCtx,
msg: OutboundMessage,
) -> super::super::platform::PlatformResult<super::super::platform::MessageRef> {
self.sent.lock().await.push(msg.text);
Ok(super::super::platform::MessageRef(serde_json::Value::Null))
}
async fn edit(
&self,
_msg_ref: &super::super::platform::MessageRef,
_new: OutboundMessage,
) -> super::super::platform::PlatformResult<()> {
Ok(())
}
async fn stop(&self) -> super::super::platform::PlatformResult<()> {
Ok(())
}
}
#[tokio::test]
async fn stream_execution_renders_tool_lines_and_final_text() {
let (tx, rx) = broadcast::channel(16);
let platform = Arc::new(RecordingPlatform {
sent: tokio::sync::Mutex::new(Vec::new()),
});
let ctx = ReplyCtx(serde_json::json!({"chat_id": "1"}));
tx.send(AgentEvent::ToolStart {
tool_call_id: "1".to_string(),
tool_name: "Bash".to_string(),
arguments: serde_json::json!({ "command": "cargo test" }),
})
.unwrap();
tx.send(AgentEvent::Token {
content: "Hello ".to_string(),
})
.unwrap();
tx.send(AgentEvent::Token {
content: "world.".to_string(),
})
.unwrap();
tx.send(AgentEvent::Complete {
usage: bamboo_agent_core::TokenUsage {
prompt_tokens: 1,
completion_tokens: 1,
total_tokens: 2,
},
})
.unwrap();
stream_execution(platform.clone(), ctx, rx).await;
let sent = platform.sent.lock().await;
assert_eq!(sent.len(), 2);
assert_eq!(sent[0], "⚙ Bash: cargo test");
assert_eq!(sent[1], "Hello world.");
}
#[tokio::test]
async fn stream_execution_renders_error_note_instead_of_partial_text() {
let (tx, rx) = broadcast::channel(16);
let platform = Arc::new(RecordingPlatform {
sent: tokio::sync::Mutex::new(Vec::new()),
});
let ctx = ReplyCtx(serde_json::json!({"chat_id": "1"}));
tx.send(AgentEvent::Token {
content: "partial".to_string(),
})
.unwrap();
tx.send(AgentEvent::Error {
message: "boom".to_string(),
})
.unwrap();
stream_execution(platform.clone(), ctx, rx).await;
let sent = platform.sent.lock().await;
assert_eq!(sent.len(), 1);
assert_eq!(sent[0], "Error: boom");
}
#[tokio::test]
async fn stream_execution_returns_when_channel_closes_without_terminal_event() {
let (tx, rx) = broadcast::channel(16);
let platform = Arc::new(RecordingPlatform {
sent: tokio::sync::Mutex::new(Vec::new()),
});
let ctx = ReplyCtx(serde_json::json!({"chat_id": "1"}));
drop(tx);
tokio::time::timeout(
std::time::Duration::from_secs(5),
stream_execution(platform.clone(), ctx, rx),
)
.await
.expect("stream_execution must not hang on a closed channel");
assert!(platform.sent.lock().await.is_empty());
}
}