use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::mpsc;
use crate::acp::ToolKind;
use crate::event::{AgentEvent, EventSink};
use serde_json::Value;
pub fn sha256_first_12(text: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(text.as_bytes());
let result = hasher.finalize();
hex_encode(&result[..6])
}
fn hex_encode(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
#[derive(Debug)]
#[allow(dead_code)]
struct ToolCallState {
name: String,
kind: ToolKind,
}
pub fn extract_locations(tool_name: &str, arguments: &Value) -> Vec<Value> {
match tool_name {
"Read" => {
let mut locs = Vec::new();
if let Some(path) = arguments.get("path").and_then(|v| v.as_str()) {
locs.push(serde_json::json!({"path": path}));
}
locs
}
"Edit" => {
let mut locs = Vec::new();
if let Some(file_path) = arguments.get("file_path").and_then(|v| v.as_str()) {
let mut loc = serde_json::json!({"path": file_path});
if let Some(line) = arguments.get("start_line").and_then(|v| v.as_u64()) {
loc["line"] = serde_json::json!(line);
}
locs.push(loc);
}
locs
}
"Write" => {
let mut locs = Vec::new();
if let Some(file_path) = arguments.get("file_path").and_then(|v| v.as_str()) {
locs.push(serde_json::json!({"path": file_path}));
}
locs
}
"Glob" => {
let mut locs = Vec::new();
if let Some(pattern) = arguments.get("pattern").and_then(|v| v.as_str()) {
locs.push(serde_json::json!({"path": pattern}));
}
locs
}
"WebFetch" => {
let mut locs = Vec::new();
if let Some(url) = arguments.get("url").and_then(|v| v.as_str()) {
locs.push(serde_json::json!({"path": url}));
}
locs
}
"Bash" => {
let mut locs = Vec::new();
if let Some(cwd) = arguments.get("cwd").and_then(|v| v.as_str()) {
locs.push(serde_json::json!({"path": cwd}));
}
locs
}
_ => Vec::new(),
}
}
pub struct AcpBridge {
session_id: String,
tx: mpsc::UnboundedSender<Value>,
partials: tokio::sync::Mutex<Vec<(String, usize)>>,
kind_map: HashMap<String, ToolKind>,
pending_calls: tokio::sync::Mutex<HashMap<String, ToolCallState>>,
turn: u64,
cancelled: tokio::sync::Mutex<bool>,
tool_call_notifications_enabled: bool,
}
impl AcpBridge {
pub fn new(
session_id: String,
kind_map: HashMap<String, ToolKind>,
turn: u64,
tool_call_notifications_enabled: bool,
) -> (Arc<Self>, mpsc::UnboundedReceiver<Value>) {
let (tx, rx) = mpsc::unbounded_channel();
let bridge = Arc::new(Self {
session_id,
tx,
partials: tokio::sync::Mutex::new(Vec::new()),
kind_map,
pending_calls: tokio::sync::Mutex::new(HashMap::new()),
cancelled: tokio::sync::Mutex::new(false),
turn,
tool_call_notifications_enabled,
});
(bridge, rx)
}
pub async fn synthesize_cancelled_tool_results(&self) {
let pending = {
let mut pending = self.pending_calls.lock().await;
std::mem::take(&mut *pending)
};
{
let mut cancelled = self.cancelled.lock().await;
*cancelled = true;
}
for (call_id, state) in &pending {
let result_notif = serde_json::json!({
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionId": self.session_id,
"update": {
"sessionUpdate": "tool_call_update",
"toolCallId": call_id,
"status": "failed",
"content": {
"type": "content",
"content": {
"type": "text",
"text": format!("Cancelled: tool call `{}` was interrupted by session/cancel", state.name),
}
}
}
}
});
let _ = self.tx.send(result_notif);
}
}
}
#[async_trait::async_trait]
impl EventSink for AcpBridge {
async fn emit(&self, event: AgentEvent) {
match event {
AgentEvent::PartialToken { text, step } => {
let mut partials = self.partials.lock().await;
partials.push((text, step));
}
AgentEvent::AssistantText { text, step } => {
let mut partials = self.partials.lock().await;
if partials.is_empty() {
partials.push((text, step));
}
}
AgentEvent::TurnFinished { reason, steps: _ } => {
let partials: Vec<(String, usize)> = {
let mut guard = self.partials.lock().await;
std::mem::take(&mut *guard)
};
let full_text: String = partials.iter().map(|(t, _)| t.as_str()).collect();
let message_id = sha256_first_12(&full_text);
for (delta, _step) in &partials {
let notif = serde_json::json!({
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionId": self.session_id,
"update": {
"sessionUpdate": "agent_message_chunk",
"content": {
"type": "text",
"text": delta,
},
"messageId": message_id,
}
}
});
let _ = self.tx.send(notif);
}
let stop_reason = match reason.as_str() {
"no_more_tool_calls" => "end_turn",
"cancelled" => "cancelled",
"budget_exceeded" => "max_turns",
s if s.starts_with("stuck:")
|| s.starts_with("transcript_limit:")
|| s.starts_with("provider_stop:")
|| s == "permission_denial_limit" =>
{
"error"
}
_ => "end_turn",
};
let mut notif = serde_json::json!({
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionId": self.session_id,
"update": {
"sessionUpdate": "end_turn",
"stopReason": stop_reason,
"messageId": message_id,
"turnId": self.turn.to_string(),
}
}
});
if !full_text.is_empty() {
notif["params"]["update"]["content"] = serde_json::json!({
"type": "text",
"text": full_text,
});
}
let _ = self.tx.send(notif);
}
AgentEvent::ToolCall {
name,
id,
arguments,
step: _step,
} => {
if !self.tool_call_notifications_enabled {
return;
}
let args: Value = serde_json::from_str(&arguments).unwrap_or_default();
let kind = self.kind_map.get(&name).copied().unwrap_or(ToolKind::Other);
let locations = extract_locations(&name, &args);
{
let mut pending = self.pending_calls.lock().await;
pending.insert(
id.clone(),
ToolCallState {
name: name.clone(),
kind,
},
);
}
let mut pending_notif = serde_json::json!({
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionId": self.session_id,
"update": {
"sessionUpdate": "tool_call",
"toolCallId": id,
"title": name,
"kind": kind,
"status": "pending",
}
}
});
if !locations.is_empty() {
pending_notif["params"]["update"]["locations"] = serde_json::json!(locations);
}
let _ = self.tx.send(pending_notif);
let in_progress_notif = serde_json::json!({
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionId": self.session_id,
"update": {
"sessionUpdate": "tool_call_update",
"toolCallId": id,
"status": "in_progress",
}
}
});
let _ = self.tx.send(in_progress_notif);
}
AgentEvent::ToolResult {
id,
name: _name,
output,
step: _step,
is_error,
} => {
if !self.tool_call_notifications_enabled {
return;
}
let _state = {
let mut pending = self.pending_calls.lock().await;
pending.remove(&id)
};
let status = if is_error { "failed" } else { "completed" };
let mut result_notif = serde_json::json!({
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionId": self.session_id,
"update": {
"sessionUpdate": "tool_call_update",
"toolCallId": id,
"status": status,
}
}
});
if !output.is_empty() {
result_notif["params"]["update"]["content"] = serde_json::json!({
"type": "content",
"content": {
"type": "text",
"text": output,
}
});
}
let _ = self.tx.send(result_notif);
}
_ => {}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sha256_first_12_known_value() {
let result = sha256_first_12("Hello, world!");
assert_eq!(result, "315f5bdb76d0");
}
#[test]
fn sha256_first_12_consistent() {
let a = sha256_first_12("test message");
let b = sha256_first_12("test message");
assert_eq!(a, b, "same input must produce same output");
}
#[test]
fn sha256_first_12_different_inputs_differ() {
let a = sha256_first_12("hello");
let b = sha256_first_12("world");
assert_ne!(a, b);
}
#[test]
fn sha256_first_12_empty_string() {
let result = sha256_first_12("");
assert_eq!(result, "e3b0c44298fc");
}
fn make_bridge(
kind_map: HashMap<String, ToolKind>,
) -> (Arc<AcpBridge>, mpsc::UnboundedReceiver<Value>) {
AcpBridge::new("sess-1".into(), kind_map, 1, true)
}
fn default_kind_map() -> HashMap<String, ToolKind> {
HashMap::new()
}
#[test]
fn extract_locations_read_extracts_path() {
let locs = extract_locations("Read", &serde_json::json!({"path": "/tmp/f.txt"}));
assert_eq!(locs.len(), 1);
assert_eq!(locs[0]["path"], "/tmp/f.txt");
}
#[test]
fn extract_locations_edit_extracts_file_path() {
let locs = extract_locations(
"Edit",
&serde_json::json!({"file_path": "/tmp/f.txt", "old_string": "x", "new_string": "y"}),
);
assert_eq!(locs.len(), 1);
assert_eq!(locs[0]["path"], "/tmp/f.txt");
}
#[test]
fn extract_locations_edit_with_start_line() {
let locs = extract_locations(
"Edit",
&serde_json::json!({"file_path": "/tmp/f.txt", "start_line": 42, "old_string": "x", "new_string": "y"}),
);
assert_eq!(locs.len(), 1);
assert_eq!(locs[0]["path"], "/tmp/f.txt");
assert_eq!(locs[0]["line"], 42);
}
#[test]
fn extract_locations_write_extracts_file_path() {
let locs = extract_locations(
"Write",
&serde_json::json!({"file_path": "/tmp/new.txt", "contents": "hello"}),
);
assert_eq!(locs.len(), 1);
assert_eq!(locs[0]["path"], "/tmp/new.txt");
}
#[test]
fn extract_locations_glob_extracts_pattern() {
let locs = extract_locations("Glob", &serde_json::json!({"pattern": "/src/**/*.rs"}));
assert_eq!(locs.len(), 1);
assert_eq!(locs[0]["path"], "/src/**/*.rs");
}
#[test]
fn extract_locations_web_fetch_extracts_url() {
let locs = extract_locations(
"WebFetch",
&serde_json::json!({"url": "https://example.com"}),
);
assert_eq!(locs.len(), 1);
assert_eq!(locs[0]["path"], "https://example.com");
}
#[test]
fn extract_locations_bash_with_cwd() {
let locs = extract_locations(
"Bash",
&serde_json::json!({"command": "ls", "cwd": "/tmp/work"}),
);
assert_eq!(locs.len(), 1);
assert_eq!(locs[0]["path"], "/tmp/work");
}
#[test]
fn extract_locations_bash_without_cwd() {
let locs = extract_locations("Bash", &serde_json::json!({"command": "ls"}));
assert!(locs.is_empty());
}
#[test]
fn extract_locations_malformed_args_no_panic() {
let locs = extract_locations("Read", &serde_json::json!({}));
assert!(locs.is_empty());
let locs = extract_locations("Read", &serde_json::json!({"path": 42}));
assert!(locs.is_empty());
}
#[test]
fn extract_locations_unknown_tool_returns_empty() {
let locs = extract_locations("UnknownTool", &serde_json::json!({"some_arg": "value"}));
assert!(locs.is_empty());
}
#[tokio::test]
async fn bridge_buffers_partial_tokens_and_emits_on_turn_finished() {
let (bridge, mut rx) = make_bridge(default_kind_map());
bridge
.emit(AgentEvent::PartialToken {
text: "Hello".into(),
step: 0,
})
.await;
bridge
.emit(AgentEvent::PartialToken {
text: ", world!".into(),
step: 0,
})
.await;
assert!(rx.try_recv().is_err(), "partials are buffered");
bridge
.emit(AgentEvent::TurnFinished {
reason: "no_more_tool_calls".into(),
steps: 3,
})
.await;
let notif1 = rx.try_recv().expect("chunk 1");
assert_eq!(notif1["method"], "session/update");
assert_eq!(notif1["params"]["sessionId"], "sess-1");
assert_eq!(
notif1["params"]["update"]["sessionUpdate"],
"agent_message_chunk"
);
assert_eq!(notif1["params"]["update"]["content"]["type"], "text");
assert_eq!(notif1["params"]["update"]["content"]["text"], "Hello");
let notif2 = rx.try_recv().expect("chunk 2");
assert_eq!(notif2["params"]["update"]["content"]["text"], ", world!");
let mid1 = notif1["params"]["update"]["messageId"]
.as_str()
.unwrap()
.to_string();
let mid2 = notif2["params"]["update"]["messageId"]
.as_str()
.unwrap()
.to_string();
assert_eq!(mid1, mid2, "all chunks must share same messageId");
assert_eq!(mid1, "315f5bdb76d0");
let notif3 = rx.try_recv().expect("final notification");
assert_eq!(notif3["method"], "session/update");
assert_eq!(notif3["params"]["update"]["stopReason"], "end_turn");
assert_eq!(
notif3["params"]["update"]["messageId"].as_str().unwrap(),
"315f5bdb76d0"
);
assert_eq!(
notif3["params"]["update"]["content"]["text"]
.as_str()
.unwrap(),
"Hello, world!"
);
assert!(rx.try_recv().is_err());
}
#[tokio::test]
async fn bridge_emits_stop_reason_on_turn_finished_empty() {
let (bridge, mut rx) = make_bridge(default_kind_map());
bridge
.emit(AgentEvent::TurnFinished {
reason: "no_more_tool_calls".into(),
steps: 3,
})
.await;
let notif = rx.try_recv().expect("should receive notification");
assert_eq!(notif["method"], "session/update");
assert_eq!(notif["params"]["update"]["stopReason"], "end_turn");
assert_eq!(
notif["params"]["update"]["messageId"].as_str().unwrap(),
"e3b0c44298fc"
);
assert!(
notif["params"]["update"]["content"].is_null()
|| notif["params"]["update"].get("content").is_none()
);
}
#[tokio::test]
async fn bridge_message_id_is_content_hash_of_full_text() {
let (bridge, mut rx) = make_bridge(default_kind_map());
bridge
.emit(AgentEvent::PartialToken {
text: "Hello".into(),
step: 0,
})
.await;
bridge
.emit(AgentEvent::PartialToken {
text: ", world!".into(),
step: 0,
})
.await;
bridge
.emit(AgentEvent::TurnFinished {
reason: "no_more_tool_calls".into(),
steps: 2,
})
.await;
let _n1 = rx.try_recv().unwrap();
let _n2 = rx.try_recv().unwrap();
let n3 = rx.try_recv().unwrap();
let mid = n3["params"]["update"]["messageId"].as_str().unwrap();
assert_eq!(mid, "315f5bdb76d0");
assert_eq!(
n3["params"]["update"]["content"]["text"].as_str().unwrap(),
"Hello, world!"
);
}
#[tokio::test]
async fn bridge_message_id_stable_across_sessions_same_content() {
let (bridge1, mut rx1) = make_bridge(default_kind_map());
let (bridge2, mut rx2) = make_bridge(default_kind_map());
for bridge in [&bridge1, &bridge2] {
bridge
.emit(AgentEvent::PartialToken {
text: "Hello".into(),
step: 0,
})
.await;
bridge
.emit(AgentEvent::TurnFinished {
reason: "no_more_tool_calls".into(),
steps: 2,
})
.await;
}
let _c1 = rx1.try_recv().unwrap();
let f1 = rx1.try_recv().unwrap();
let _c2 = rx2.try_recv().unwrap();
let f2 = rx2.try_recv().unwrap();
assert_eq!(
f1["params"]["update"]["messageId"], f2["params"]["update"]["messageId"],
"identical content must produce identical messageId across sessions"
);
}
#[tokio::test]
async fn bridge_assistant_text_fallback() {
let (bridge, mut rx) = make_bridge(default_kind_map());
bridge
.emit(AgentEvent::AssistantText {
text: "full response".into(),
step: 0,
})
.await;
bridge
.emit(AgentEvent::TurnFinished {
reason: "no_more_tool_calls".into(),
steps: 1,
})
.await;
let chunk = rx.try_recv().expect("chunk");
assert_eq!(
chunk["params"]["update"]["content"]["text"]
.as_str()
.unwrap(),
"full response"
);
assert_eq!(
chunk["params"]["update"]["messageId"].as_str().unwrap(),
sha256_first_12("full response")
);
let final_notif = rx.try_recv().expect("final");
assert_eq!(final_notif["params"]["update"]["stopReason"], "end_turn");
assert_eq!(
final_notif["params"]["update"]["content"]["text"]
.as_str()
.unwrap(),
"full response"
);
}
#[tokio::test]
async fn tool_call_emits_pending_with_kind_and_locations() {
let mut km = HashMap::new();
km.insert("Read".to_string(), ToolKind::Read);
let (bridge, mut rx) = make_bridge(km);
bridge
.emit(AgentEvent::ToolCall {
name: "Read".into(),
id: "tc-1".into(),
arguments: r#"{"path":"/tmp/f.txt"}"#.into(),
step: 0,
})
.await;
let notif = rx.try_recv().expect("pending tool_call");
assert_eq!(notif["method"], "session/update");
assert_eq!(notif["params"]["sessionId"], "sess-1");
assert_eq!(notif["params"]["update"]["sessionUpdate"], "tool_call");
assert_eq!(notif["params"]["update"]["toolCallId"], "tc-1");
assert_eq!(notif["params"]["update"]["title"], "Read");
assert_eq!(notif["params"]["update"]["kind"], "read");
assert_eq!(notif["params"]["update"]["status"], "pending");
assert_eq!(
notif["params"]["update"]["locations"][0]["path"],
"/tmp/f.txt"
);
let notif2 = rx.try_recv().expect("in_progress tool_call_update");
assert_eq!(
notif2["params"]["update"]["sessionUpdate"],
"tool_call_update"
);
assert_eq!(notif2["params"]["update"]["toolCallId"], "tc-1");
assert_eq!(notif2["params"]["update"]["status"], "in_progress");
assert!(notif2["params"]["update"].get("locations").is_none());
}
#[tokio::test]
async fn tool_call_unknown_tool_uses_other_kind() {
let (bridge, mut rx) = make_bridge(default_kind_map());
bridge
.emit(AgentEvent::ToolCall {
name: "UnknownTool".into(),
id: "tc-x".into(),
arguments: "{}".into(),
step: 0,
})
.await;
let notif = rx.try_recv().expect("pending");
assert_eq!(notif["params"]["update"]["kind"], "other");
}
#[tokio::test]
async fn tool_call_lifecycle_pending_in_progress_completed() {
let mut km = HashMap::new();
km.insert("Read".to_string(), ToolKind::Read);
let (bridge, mut rx) = make_bridge(km);
bridge
.emit(AgentEvent::ToolCall {
name: "Read".into(),
id: "tc-1".into(),
arguments: r#"{"path":"/tmp/f.txt"}"#.into(),
step: 0,
})
.await;
bridge
.emit(AgentEvent::ToolResult {
id: "tc-1".into(),
name: "Read".into(),
output: "file contents".into(),
step: 0,
is_error: false,
})
.await;
let n1 = rx.try_recv().expect("pending");
assert_eq!(n1["params"]["update"]["status"], "pending");
assert_eq!(n1["params"]["update"]["sessionUpdate"], "tool_call");
let n2 = rx.try_recv().expect("in_progress");
assert_eq!(n2["params"]["update"]["status"], "in_progress");
assert_eq!(n2["params"]["update"]["sessionUpdate"], "tool_call_update");
let n3 = rx.try_recv().expect("completed");
assert_eq!(n3["params"]["update"]["status"], "completed");
assert_eq!(n3["params"]["update"]["sessionUpdate"], "tool_call_update");
assert_eq!(n3["params"]["update"]["toolCallId"], "tc-1");
assert_eq!(n3["params"]["update"]["content"]["type"], "content");
assert_eq!(
n3["params"]["update"]["content"]["content"]["text"],
"file contents"
);
assert!(n3["params"]["update"].get("locations").is_none());
}
#[tokio::test]
async fn tool_call_lifecycle_failed_on_is_error() {
let mut km = HashMap::new();
km.insert("Bash".to_string(), ToolKind::Execute);
let (bridge, mut rx) = make_bridge(km);
bridge
.emit(AgentEvent::ToolCall {
name: "Bash".into(),
id: "tc-err".into(),
arguments: r#"{"command":"bad"}"#.into(),
step: 0,
})
.await;
bridge
.emit(AgentEvent::ToolResult {
id: "tc-err".into(),
name: "Bash".into(),
output: "command not found".into(),
step: 0,
is_error: true,
})
.await;
let _n1 = rx.try_recv().expect("pending");
let _n2 = rx.try_recv().expect("in_progress");
let n3 = rx.try_recv().expect("failed");
assert_eq!(n3["params"]["update"]["status"], "failed");
assert_eq!(n3["params"]["update"]["sessionUpdate"], "tool_call_update");
assert_eq!(n3["params"]["update"]["toolCallId"], "tc-err");
assert_eq!(
n3["params"]["update"]["content"]["content"]["text"],
"command not found"
);
}
#[tokio::test]
async fn concurrent_tool_calls_independent_lifecycle() {
let mut km = HashMap::new();
km.insert("Read".to_string(), ToolKind::Read);
km.insert("Bash".to_string(), ToolKind::Execute);
let (bridge, mut rx) = make_bridge(km);
bridge
.emit(AgentEvent::ToolCall {
name: "Read".into(),
id: "tc-1".into(),
arguments: r#"{"path":"/tmp/a.txt"}"#.into(),
step: 0,
})
.await;
bridge
.emit(AgentEvent::ToolCall {
name: "Bash".into(),
id: "tc-2".into(),
arguments: r#"{"command":"echo hi"}"#.into(),
step: 0,
})
.await;
bridge
.emit(AgentEvent::ToolResult {
id: "tc-2".into(),
name: "Bash".into(),
output: "hi".into(),
step: 0,
is_error: false,
})
.await;
bridge
.emit(AgentEvent::ToolResult {
id: "tc-1".into(),
name: "Read".into(),
output: "content".into(),
step: 0,
is_error: true,
})
.await;
let n1 = rx.try_recv().expect("tc-1 pending");
assert_eq!(n1["params"]["update"]["toolCallId"], "tc-1");
assert_eq!(n1["params"]["update"]["status"], "pending");
let n2 = rx.try_recv().expect("tc-1 in_progress");
assert_eq!(n2["params"]["update"]["toolCallId"], "tc-1");
assert_eq!(n2["params"]["update"]["status"], "in_progress");
let n3 = rx.try_recv().expect("tc-2 pending");
assert_eq!(n3["params"]["update"]["toolCallId"], "tc-2");
assert_eq!(n3["params"]["update"]["status"], "pending");
let n4 = rx.try_recv().expect("tc-2 in_progress");
assert_eq!(n4["params"]["update"]["toolCallId"], "tc-2");
assert_eq!(n4["params"]["update"]["status"], "in_progress");
let n5 = rx.try_recv().expect("tc-2 completed");
assert_eq!(n5["params"]["update"]["toolCallId"], "tc-2");
assert_eq!(n5["params"]["update"]["status"], "completed");
let n6 = rx.try_recv().expect("tc-1 failed");
assert_eq!(n6["params"]["update"]["toolCallId"], "tc-1");
assert_eq!(n6["params"]["update"]["status"], "failed");
}
#[tokio::test]
async fn non_tool_events_are_silenced() {
let (bridge, mut rx) = make_bridge(default_kind_map());
bridge
.emit(AgentEvent::Latency {
step: 0,
llm_ms: 100,
})
.await;
bridge
.emit(AgentEvent::Usage {
input_tokens: 10,
output_tokens: 5,
cache_hit_tokens: 0,
cache_miss_tokens: 0,
step: 0,
})
.await;
assert!(rx.try_recv().is_err());
}
#[tokio::test]
async fn tool_call_without_locations() {
let mut km = HashMap::new();
km.insert("Bash".to_string(), ToolKind::Execute);
let (bridge, mut rx) = make_bridge(km);
bridge
.emit(AgentEvent::ToolCall {
name: "Bash".into(),
id: "tc-noloc".into(),
arguments: r#"{"command":"ls"}"#.into(),
step: 0,
})
.await;
let n1 = rx.try_recv().expect("pending");
assert_eq!(n1["params"]["update"]["toolCallId"], "tc-noloc");
assert!(n1["params"]["update"]
.get("locations")
.map_or(true, |l| l.is_null()
|| l.as_array().map_or(true, |a| a.is_empty())));
}
#[tokio::test]
async fn tool_result_empty_output_no_content_block() {
let (bridge, mut rx) = make_bridge(default_kind_map());
bridge
.emit(AgentEvent::ToolCall {
name: "Read".into(),
id: "tc-empty".into(),
arguments: "{}".into(),
step: 0,
})
.await;
bridge
.emit(AgentEvent::ToolResult {
id: "tc-empty".into(),
name: "Read".into(),
output: "".into(),
step: 0,
is_error: false,
})
.await;
let _n1 = rx.try_recv().expect("pending");
let _n2 = rx.try_recv().expect("in_progress");
let n3 = rx.try_recv().expect("completed");
assert_eq!(n3["params"]["update"]["status"], "completed");
assert!(
n3["params"]["update"].get("content").is_none()
|| n3["params"]["update"]["content"].is_null()
);
}
#[tokio::test]
async fn pending_notification_arrives_before_turn_finished() {
let mut km = HashMap::new();
km.insert("Read".to_string(), ToolKind::Read);
let (bridge, mut rx) = make_bridge(km);
bridge
.emit(AgentEvent::ToolCall {
name: "Read".into(),
id: "tc-before".into(),
arguments: r#"{"path":"/tmp/f.txt"}"#.into(),
step: 0,
})
.await;
bridge
.emit(AgentEvent::ToolResult {
id: "tc-before".into(),
name: "Read".into(),
output: "data".into(),
step: 0,
is_error: false,
})
.await;
bridge
.emit(AgentEvent::TurnFinished {
reason: "no_more_tool_calls".into(),
steps: 1,
})
.await;
let first = rx.try_recv().expect("first");
assert_eq!(first["params"]["update"]["sessionUpdate"], "tool_call");
assert_eq!(first["params"]["update"]["status"], "pending");
let _in_prog = rx.try_recv().expect("in_progress");
let _completed = rx.try_recv().expect("completed");
let final_notif = rx.try_recv().expect("turn finished");
let update = &final_notif["params"]["update"];
let session_update = update["sessionUpdate"].as_str().unwrap_or("");
assert!(
session_update == "agent_message_chunk" || session_update == "end_turn",
"unexpected final sessionUpdate: {session_update}"
);
assert_eq!(update["stopReason"], "end_turn");
assert!(rx.try_recv().is_err());
}
#[tokio::test]
async fn synthesize_cancelled_tool_results_emits_failed_notifications() {
let mut km = HashMap::new();
km.insert("Read".to_string(), ToolKind::Read);
km.insert("Bash".to_string(), ToolKind::Execute);
let (bridge, mut rx) = make_bridge(km);
bridge
.emit(AgentEvent::ToolCall {
name: "Read".into(),
id: "tc-1".into(),
arguments: r#"{"path":"/tmp/f.txt"}"#.into(),
step: 0,
})
.await;
bridge
.emit(AgentEvent::ToolCall {
name: "Bash".into(),
id: "tc-2".into(),
arguments: r#"{"command":"sleep 10"}"#.into(),
step: 0,
})
.await;
for _ in 0..4 {
let _ = rx.try_recv().expect("pending+in_progress notifications");
}
bridge.synthesize_cancelled_tool_results().await;
let mut cancelled_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
for _ in 0..2 {
let notif = rx.try_recv().expect("failed notification");
assert_eq!(notif["params"]["update"]["status"], "failed");
assert_eq!(
notif["params"]["update"]["sessionUpdate"],
"tool_call_update"
);
let id = notif["params"]["update"]["toolCallId"]
.as_str()
.unwrap()
.to_string();
cancelled_ids.insert(id);
assert!(notif["params"]["update"]["content"]["content"]["text"]
.as_str()
.unwrap()
.contains("Cancelled"));
}
assert_eq!(
cancelled_ids,
["tc-1", "tc-2"].iter().map(|s| s.to_string()).collect()
);
assert!(
rx.try_recv().is_err(),
"no more notifications after synthetic cancel"
);
}
#[tokio::test]
async fn synthesize_cancelled_tool_results_no_pending_calls_is_noop() {
let (bridge, mut rx) = make_bridge(default_kind_map());
bridge.synthesize_cancelled_tool_results().await;
assert!(
rx.try_recv().is_err(),
"no pending calls should produce no notifications"
);
}
}