1use sha2::{Digest, Sha256};
33use std::collections::HashMap;
34use std::sync::Arc;
35use tokio::sync::mpsc;
36
37use crate::acp::ToolKind;
38use crate::event::{AgentEvent, EventSink};
39use serde_json::Value;
40
41pub fn sha256_first_12(text: &str) -> String {
48 let mut hasher = Sha256::new();
49 hasher.update(text.as_bytes());
50 let result = hasher.finalize();
51 hex_encode(&result[..6])
53}
54
55fn hex_encode(bytes: &[u8]) -> String {
57 bytes.iter().map(|b| format!("{b:02x}")).collect()
58}
59
60#[derive(Debug)]
67#[allow(dead_code)]
68struct ToolCallState {
69 name: String,
71 kind: ToolKind,
73}
74
75pub fn extract_locations(tool_name: &str, arguments: &Value) -> Vec<Value> {
90 match tool_name {
91 "Read" => {
92 let mut locs = Vec::new();
93 if let Some(path) = arguments.get("path").and_then(|v| v.as_str()) {
94 locs.push(serde_json::json!({"path": path}));
95 }
96 locs
97 }
98 "Edit" => {
99 let mut locs = Vec::new();
100 if let Some(file_path) = arguments.get("file_path").and_then(|v| v.as_str()) {
101 let mut loc = serde_json::json!({"path": file_path});
102 if let Some(line) = arguments.get("start_line").and_then(|v| v.as_u64()) {
103 loc["line"] = serde_json::json!(line);
104 }
105 locs.push(loc);
106 }
107 locs
108 }
109 "Write" => {
110 let mut locs = Vec::new();
111 if let Some(file_path) = arguments.get("file_path").and_then(|v| v.as_str()) {
112 locs.push(serde_json::json!({"path": file_path}));
113 }
114 locs
115 }
116 "Glob" => {
117 let mut locs = Vec::new();
118 if let Some(pattern) = arguments.get("pattern").and_then(|v| v.as_str()) {
119 locs.push(serde_json::json!({"path": pattern}));
120 }
121 locs
122 }
123 "WebFetch" => {
124 let mut locs = Vec::new();
125 if let Some(url) = arguments.get("url").and_then(|v| v.as_str()) {
126 locs.push(serde_json::json!({"path": url}));
127 }
128 locs
129 }
130 "Bash" => {
131 let mut locs = Vec::new();
132 if let Some(cwd) = arguments.get("cwd").and_then(|v| v.as_str()) {
133 locs.push(serde_json::json!({"path": cwd}));
134 }
135 locs
136 }
137 _ => Vec::new(),
138 }
139}
140
141pub struct AcpBridge {
156 session_id: String,
157 tx: mpsc::UnboundedSender<Value>,
158 partials: tokio::sync::Mutex<Vec<(String, usize)>>,
160 kind_map: HashMap<String, ToolKind>,
163 pending_calls: tokio::sync::Mutex<HashMap<String, ToolCallState>>,
165 turn: u64,
167 cancelled: tokio::sync::Mutex<bool>,
170 tool_call_notifications_enabled: bool,
173}
174
175impl AcpBridge {
176 pub fn new(
182 session_id: String,
183 kind_map: HashMap<String, ToolKind>,
184 turn: u64,
185 tool_call_notifications_enabled: bool,
186 ) -> (Arc<Self>, mpsc::UnboundedReceiver<Value>) {
187 let (tx, rx) = mpsc::unbounded_channel();
188 let bridge = Arc::new(Self {
189 session_id,
190 tx,
191 partials: tokio::sync::Mutex::new(Vec::new()),
192 kind_map,
193 pending_calls: tokio::sync::Mutex::new(HashMap::new()),
194 cancelled: tokio::sync::Mutex::new(false),
195 turn,
196 tool_call_notifications_enabled,
197 });
198 (bridge, rx)
199 }
200
201 pub async fn synthesize_cancelled_tool_results(&self) {
216 let pending = {
217 let mut pending = self.pending_calls.lock().await;
218 std::mem::take(&mut *pending)
219 };
220 {
221 let mut cancelled = self.cancelled.lock().await;
222 *cancelled = true;
223 }
224
225 for (call_id, state) in &pending {
226 let result_notif = serde_json::json!({
227 "jsonrpc": "2.0",
228 "method": "session/update",
229 "params": {
230 "sessionId": self.session_id,
231 "update": {
232 "sessionUpdate": "tool_call_update",
233 "toolCallId": call_id,
234 "status": "failed",
235 "content": {
236 "type": "content",
237 "content": {
238 "type": "text",
239 "text": format!("Cancelled: tool call `{}` was interrupted by session/cancel", state.name),
240 }
241 }
242 }
243 }
244 });
245 let _ = self.tx.send(result_notif);
246 }
247 }
248}
249
250#[async_trait::async_trait]
251impl EventSink for AcpBridge {
252 async fn emit(&self, event: AgentEvent) {
253 match event {
254 AgentEvent::PartialToken { text, step } => {
255 let mut partials = self.partials.lock().await;
257 partials.push((text, step));
258 }
259 AgentEvent::AssistantText { text, step } => {
260 let mut partials = self.partials.lock().await;
262 if partials.is_empty() {
263 partials.push((text, step));
264 }
265 }
266 AgentEvent::TurnFinished { reason, steps: _ } => {
267 let partials: Vec<(String, usize)> = {
269 let mut guard = self.partials.lock().await;
270 std::mem::take(&mut *guard)
271 };
272
273 let full_text: String = partials.iter().map(|(t, _)| t.as_str()).collect();
274 let message_id = sha256_first_12(&full_text);
275
276 for (delta, _step) in &partials {
279 let notif = serde_json::json!({
280 "jsonrpc": "2.0",
281 "method": "session/update",
282 "params": {
283 "sessionId": self.session_id,
284 "update": {
285 "sessionUpdate": "agent_message_chunk",
286 "content": {
287 "type": "text",
288 "text": delta,
289 },
290 "messageId": message_id,
291 }
292 }
293 });
294 let _ = self.tx.send(notif);
295 }
296
297 let stop_reason = match reason.as_str() {
300 "no_more_tool_calls" => "end_turn",
301 "cancelled" => "cancelled",
302 "budget_exceeded" => "max_turns",
303 s if s.starts_with("stuck:")
304 || s.starts_with("transcript_limit:")
305 || s.starts_with("provider_stop:")
306 || s == "permission_denial_limit" =>
307 {
308 "error"
309 }
310 _ => "end_turn",
311 };
312
313 let mut notif = serde_json::json!({
314 "jsonrpc": "2.0",
315 "method": "session/update",
316 "params": {
317 "sessionId": self.session_id,
318 "update": {
319 "sessionUpdate": "end_turn",
320 "stopReason": stop_reason,
321 "messageId": message_id,
322 "turnId": self.turn.to_string(),
323 }
324 }
325 });
326
327 if !full_text.is_empty() {
328 notif["params"]["update"]["content"] = serde_json::json!({
329 "type": "text",
330 "text": full_text,
331 });
332 }
333
334 let _ = self.tx.send(notif);
335 }
336
337 AgentEvent::ToolCall {
339 name,
340 id,
341 arguments,
342 step: _step,
343 } => {
344 if !self.tool_call_notifications_enabled {
346 return;
347 }
348 let args: Value = serde_json::from_str(&arguments).unwrap_or_default();
350 let kind = self.kind_map.get(&name).copied().unwrap_or(ToolKind::Other);
351 let locations = extract_locations(&name, &args);
352
353 {
355 let mut pending = self.pending_calls.lock().await;
356 pending.insert(
357 id.clone(),
358 ToolCallState {
359 name: name.clone(),
360 kind,
361 },
362 );
363 }
364
365 let mut pending_notif = serde_json::json!({
367 "jsonrpc": "2.0",
368 "method": "session/update",
369 "params": {
370 "sessionId": self.session_id,
371 "update": {
372 "sessionUpdate": "tool_call",
373 "toolCallId": id,
374 "title": name,
375 "kind": kind,
376 "status": "pending",
377 }
378 }
379 });
380 if !locations.is_empty() {
381 pending_notif["params"]["update"]["locations"] = serde_json::json!(locations);
382 }
383 let _ = self.tx.send(pending_notif);
384
385 let in_progress_notif = serde_json::json!({
387 "jsonrpc": "2.0",
388 "method": "session/update",
389 "params": {
390 "sessionId": self.session_id,
391 "update": {
392 "sessionUpdate": "tool_call_update",
393 "toolCallId": id,
394 "status": "in_progress",
395 }
396 }
397 });
398 let _ = self.tx.send(in_progress_notif);
399 }
400
401 AgentEvent::ToolResult {
402 id,
403 name: _name,
404 output,
405 step: _step,
406 is_error,
407 } => {
408 if !self.tool_call_notifications_enabled {
410 return;
411 }
412 let _state = {
415 let mut pending = self.pending_calls.lock().await;
416 pending.remove(&id)
417 };
418
419 let status = if is_error { "failed" } else { "completed" };
421 let mut result_notif = serde_json::json!({
422 "jsonrpc": "2.0",
423 "method": "session/update",
424 "params": {
425 "sessionId": self.session_id,
426 "update": {
427 "sessionUpdate": "tool_call_update",
428 "toolCallId": id,
429 "status": status,
430 }
431 }
432 });
433
434 if !output.is_empty() {
436 result_notif["params"]["update"]["content"] = serde_json::json!({
437 "type": "content",
438 "content": {
439 "type": "text",
440 "text": output,
441 }
442 });
443 }
444 let _ = self.tx.send(result_notif);
445 }
446
447 _ => {}
449 }
450 }
451}
452
453#[cfg(test)]
458mod tests {
459 use super::*;
460
461 #[test]
464 fn sha256_first_12_known_value() {
465 let result = sha256_first_12("Hello, world!");
466 assert_eq!(result, "315f5bdb76d0");
467 }
468
469 #[test]
470 fn sha256_first_12_consistent() {
471 let a = sha256_first_12("test message");
472 let b = sha256_first_12("test message");
473 assert_eq!(a, b, "same input must produce same output");
474 }
475
476 #[test]
477 fn sha256_first_12_different_inputs_differ() {
478 let a = sha256_first_12("hello");
479 let b = sha256_first_12("world");
480 assert_ne!(a, b);
481 }
482
483 #[test]
484 fn sha256_first_12_empty_string() {
485 let result = sha256_first_12("");
486 assert_eq!(result, "e3b0c44298fc");
487 }
488
489 fn make_bridge(
492 kind_map: HashMap<String, ToolKind>,
493 ) -> (Arc<AcpBridge>, mpsc::UnboundedReceiver<Value>) {
494 AcpBridge::new("sess-1".into(), kind_map, 1, true)
495 }
496
497 fn default_kind_map() -> HashMap<String, ToolKind> {
498 HashMap::new()
499 }
500
501 #[test]
502 fn extract_locations_read_extracts_path() {
503 let locs = extract_locations("Read", &serde_json::json!({"path": "/tmp/f.txt"}));
504 assert_eq!(locs.len(), 1);
505 assert_eq!(locs[0]["path"], "/tmp/f.txt");
506 }
507
508 #[test]
509 fn extract_locations_edit_extracts_file_path() {
510 let locs = extract_locations(
511 "Edit",
512 &serde_json::json!({"file_path": "/tmp/f.txt", "old_string": "x", "new_string": "y"}),
513 );
514 assert_eq!(locs.len(), 1);
515 assert_eq!(locs[0]["path"], "/tmp/f.txt");
516 }
517
518 #[test]
519 fn extract_locations_edit_with_start_line() {
520 let locs = extract_locations(
521 "Edit",
522 &serde_json::json!({"file_path": "/tmp/f.txt", "start_line": 42, "old_string": "x", "new_string": "y"}),
523 );
524 assert_eq!(locs.len(), 1);
525 assert_eq!(locs[0]["path"], "/tmp/f.txt");
526 assert_eq!(locs[0]["line"], 42);
527 }
528
529 #[test]
530 fn extract_locations_write_extracts_file_path() {
531 let locs = extract_locations(
532 "Write",
533 &serde_json::json!({"file_path": "/tmp/new.txt", "contents": "hello"}),
534 );
535 assert_eq!(locs.len(), 1);
536 assert_eq!(locs[0]["path"], "/tmp/new.txt");
537 }
538
539 #[test]
540 fn extract_locations_glob_extracts_pattern() {
541 let locs = extract_locations("Glob", &serde_json::json!({"pattern": "/src/**/*.rs"}));
542 assert_eq!(locs.len(), 1);
543 assert_eq!(locs[0]["path"], "/src/**/*.rs");
544 }
545
546 #[test]
547 fn extract_locations_web_fetch_extracts_url() {
548 let locs = extract_locations(
549 "WebFetch",
550 &serde_json::json!({"url": "https://example.com"}),
551 );
552 assert_eq!(locs.len(), 1);
553 assert_eq!(locs[0]["path"], "https://example.com");
554 }
555
556 #[test]
557 fn extract_locations_bash_with_cwd() {
558 let locs = extract_locations(
559 "Bash",
560 &serde_json::json!({"command": "ls", "cwd": "/tmp/work"}),
561 );
562 assert_eq!(locs.len(), 1);
563 assert_eq!(locs[0]["path"], "/tmp/work");
564 }
565
566 #[test]
567 fn extract_locations_bash_without_cwd() {
568 let locs = extract_locations("Bash", &serde_json::json!({"command": "ls"}));
569 assert!(locs.is_empty());
570 }
571
572 #[test]
573 fn extract_locations_malformed_args_no_panic() {
574 let locs = extract_locations("Read", &serde_json::json!({}));
575 assert!(locs.is_empty());
576 let locs = extract_locations("Read", &serde_json::json!({"path": 42}));
577 assert!(locs.is_empty());
578 }
579
580 #[test]
581 fn extract_locations_unknown_tool_returns_empty() {
582 let locs = extract_locations("UnknownTool", &serde_json::json!({"some_arg": "value"}));
583 assert!(locs.is_empty());
584 }
585
586 #[tokio::test]
589 async fn bridge_buffers_partial_tokens_and_emits_on_turn_finished() {
590 let (bridge, mut rx) = make_bridge(default_kind_map());
591
592 bridge
593 .emit(AgentEvent::PartialToken {
594 text: "Hello".into(),
595 step: 0,
596 })
597 .await;
598 bridge
599 .emit(AgentEvent::PartialToken {
600 text: ", world!".into(),
601 step: 0,
602 })
603 .await;
604
605 assert!(rx.try_recv().is_err(), "partials are buffered");
607
608 bridge
609 .emit(AgentEvent::TurnFinished {
610 reason: "no_more_tool_calls".into(),
611 steps: 3,
612 })
613 .await;
614
615 let notif1 = rx.try_recv().expect("chunk 1");
617 assert_eq!(notif1["method"], "session/update");
618 assert_eq!(notif1["params"]["sessionId"], "sess-1");
619 assert_eq!(
620 notif1["params"]["update"]["sessionUpdate"],
621 "agent_message_chunk"
622 );
623 assert_eq!(notif1["params"]["update"]["content"]["type"], "text");
624 assert_eq!(notif1["params"]["update"]["content"]["text"], "Hello");
625
626 let notif2 = rx.try_recv().expect("chunk 2");
628 assert_eq!(notif2["params"]["update"]["content"]["text"], ", world!");
629
630 let mid1 = notif1["params"]["update"]["messageId"]
632 .as_str()
633 .unwrap()
634 .to_string();
635 let mid2 = notif2["params"]["update"]["messageId"]
636 .as_str()
637 .unwrap()
638 .to_string();
639 assert_eq!(mid1, mid2, "all chunks must share same messageId");
640 assert_eq!(mid1, "315f5bdb76d0");
641
642 let notif3 = rx.try_recv().expect("final notification");
644 assert_eq!(notif3["method"], "session/update");
645 assert_eq!(notif3["params"]["update"]["stopReason"], "end_turn");
646 assert_eq!(
647 notif3["params"]["update"]["messageId"].as_str().unwrap(),
648 "315f5bdb76d0"
649 );
650 assert_eq!(
651 notif3["params"]["update"]["content"]["text"]
652 .as_str()
653 .unwrap(),
654 "Hello, world!"
655 );
656
657 assert!(rx.try_recv().is_err());
658 }
659
660 #[tokio::test]
661 async fn bridge_emits_stop_reason_on_turn_finished_empty() {
662 let (bridge, mut rx) = make_bridge(default_kind_map());
663 bridge
664 .emit(AgentEvent::TurnFinished {
665 reason: "no_more_tool_calls".into(),
666 steps: 3,
667 })
668 .await;
669
670 let notif = rx.try_recv().expect("should receive notification");
671 assert_eq!(notif["method"], "session/update");
672 assert_eq!(notif["params"]["update"]["stopReason"], "end_turn");
673 assert_eq!(
674 notif["params"]["update"]["messageId"].as_str().unwrap(),
675 "e3b0c44298fc"
676 );
677 assert!(
678 notif["params"]["update"]["content"].is_null()
679 || notif["params"]["update"].get("content").is_none()
680 );
681 }
682
683 #[tokio::test]
684 async fn bridge_message_id_is_content_hash_of_full_text() {
685 let (bridge, mut rx) = make_bridge(default_kind_map());
686 bridge
687 .emit(AgentEvent::PartialToken {
688 text: "Hello".into(),
689 step: 0,
690 })
691 .await;
692 bridge
693 .emit(AgentEvent::PartialToken {
694 text: ", world!".into(),
695 step: 0,
696 })
697 .await;
698 bridge
699 .emit(AgentEvent::TurnFinished {
700 reason: "no_more_tool_calls".into(),
701 steps: 2,
702 })
703 .await;
704
705 let _n1 = rx.try_recv().unwrap();
706 let _n2 = rx.try_recv().unwrap();
707 let n3 = rx.try_recv().unwrap();
708
709 let mid = n3["params"]["update"]["messageId"].as_str().unwrap();
710 assert_eq!(mid, "315f5bdb76d0");
711 assert_eq!(
712 n3["params"]["update"]["content"]["text"].as_str().unwrap(),
713 "Hello, world!"
714 );
715 }
716
717 #[tokio::test]
718 async fn bridge_message_id_stable_across_sessions_same_content() {
719 let (bridge1, mut rx1) = make_bridge(default_kind_map());
720 let (bridge2, mut rx2) = make_bridge(default_kind_map());
721
722 for bridge in [&bridge1, &bridge2] {
723 bridge
724 .emit(AgentEvent::PartialToken {
725 text: "Hello".into(),
726 step: 0,
727 })
728 .await;
729 bridge
730 .emit(AgentEvent::TurnFinished {
731 reason: "no_more_tool_calls".into(),
732 steps: 2,
733 })
734 .await;
735 }
736
737 let _c1 = rx1.try_recv().unwrap();
738 let f1 = rx1.try_recv().unwrap();
739 let _c2 = rx2.try_recv().unwrap();
740 let f2 = rx2.try_recv().unwrap();
741
742 assert_eq!(
743 f1["params"]["update"]["messageId"], f2["params"]["update"]["messageId"],
744 "identical content must produce identical messageId across sessions"
745 );
746 }
747
748 #[tokio::test]
749 async fn bridge_assistant_text_fallback() {
750 let (bridge, mut rx) = make_bridge(default_kind_map());
751 bridge
752 .emit(AgentEvent::AssistantText {
753 text: "full response".into(),
754 step: 0,
755 })
756 .await;
757 bridge
758 .emit(AgentEvent::TurnFinished {
759 reason: "no_more_tool_calls".into(),
760 steps: 1,
761 })
762 .await;
763
764 let chunk = rx.try_recv().expect("chunk");
765 assert_eq!(
766 chunk["params"]["update"]["content"]["text"]
767 .as_str()
768 .unwrap(),
769 "full response"
770 );
771 assert_eq!(
772 chunk["params"]["update"]["messageId"].as_str().unwrap(),
773 sha256_first_12("full response")
774 );
775
776 let final_notif = rx.try_recv().expect("final");
777 assert_eq!(final_notif["params"]["update"]["stopReason"], "end_turn");
778 assert_eq!(
779 final_notif["params"]["update"]["content"]["text"]
780 .as_str()
781 .unwrap(),
782 "full response"
783 );
784 }
785
786 #[tokio::test]
789 async fn tool_call_emits_pending_with_kind_and_locations() {
790 let mut km = HashMap::new();
792 km.insert("Read".to_string(), ToolKind::Read);
793 let (bridge, mut rx) = make_bridge(km);
794
795 bridge
796 .emit(AgentEvent::ToolCall {
797 name: "Read".into(),
798 id: "tc-1".into(),
799 arguments: r#"{"path":"/tmp/f.txt"}"#.into(),
800 step: 0,
801 })
802 .await;
803
804 let notif = rx.try_recv().expect("pending tool_call");
806 assert_eq!(notif["method"], "session/update");
807 assert_eq!(notif["params"]["sessionId"], "sess-1");
808 assert_eq!(notif["params"]["update"]["sessionUpdate"], "tool_call");
809 assert_eq!(notif["params"]["update"]["toolCallId"], "tc-1");
810 assert_eq!(notif["params"]["update"]["title"], "Read");
811 assert_eq!(notif["params"]["update"]["kind"], "read");
812 assert_eq!(notif["params"]["update"]["status"], "pending");
813 assert_eq!(
815 notif["params"]["update"]["locations"][0]["path"],
816 "/tmp/f.txt"
817 );
818
819 let notif2 = rx.try_recv().expect("in_progress tool_call_update");
821 assert_eq!(
822 notif2["params"]["update"]["sessionUpdate"],
823 "tool_call_update"
824 );
825 assert_eq!(notif2["params"]["update"]["toolCallId"], "tc-1");
826 assert_eq!(notif2["params"]["update"]["status"], "in_progress");
827 assert!(notif2["params"]["update"].get("locations").is_none());
829 }
830
831 #[tokio::test]
832 async fn tool_call_unknown_tool_uses_other_kind() {
833 let (bridge, mut rx) = make_bridge(default_kind_map());
835
836 bridge
837 .emit(AgentEvent::ToolCall {
838 name: "UnknownTool".into(),
839 id: "tc-x".into(),
840 arguments: "{}".into(),
841 step: 0,
842 })
843 .await;
844
845 let notif = rx.try_recv().expect("pending");
846 assert_eq!(notif["params"]["update"]["kind"], "other");
847 }
848
849 #[tokio::test]
850 async fn tool_call_lifecycle_pending_in_progress_completed() {
851 let mut km = HashMap::new();
853 km.insert("Read".to_string(), ToolKind::Read);
854 let (bridge, mut rx) = make_bridge(km);
855
856 bridge
857 .emit(AgentEvent::ToolCall {
858 name: "Read".into(),
859 id: "tc-1".into(),
860 arguments: r#"{"path":"/tmp/f.txt"}"#.into(),
861 step: 0,
862 })
863 .await;
864
865 bridge
866 .emit(AgentEvent::ToolResult {
867 id: "tc-1".into(),
868 name: "Read".into(),
869 output: "file contents".into(),
870 step: 0,
871 is_error: false,
872 })
873 .await;
874
875 let n1 = rx.try_recv().expect("pending");
877 assert_eq!(n1["params"]["update"]["status"], "pending");
878 assert_eq!(n1["params"]["update"]["sessionUpdate"], "tool_call");
879
880 let n2 = rx.try_recv().expect("in_progress");
882 assert_eq!(n2["params"]["update"]["status"], "in_progress");
883 assert_eq!(n2["params"]["update"]["sessionUpdate"], "tool_call_update");
884
885 let n3 = rx.try_recv().expect("completed");
887 assert_eq!(n3["params"]["update"]["status"], "completed");
888 assert_eq!(n3["params"]["update"]["sessionUpdate"], "tool_call_update");
889 assert_eq!(n3["params"]["update"]["toolCallId"], "tc-1");
890 assert_eq!(n3["params"]["update"]["content"]["type"], "content");
891 assert_eq!(
892 n3["params"]["update"]["content"]["content"]["text"],
893 "file contents"
894 );
895
896 assert!(n3["params"]["update"].get("locations").is_none());
898 }
899
900 #[tokio::test]
901 async fn tool_call_lifecycle_failed_on_is_error() {
902 let mut km = HashMap::new();
904 km.insert("Bash".to_string(), ToolKind::Execute);
905 let (bridge, mut rx) = make_bridge(km);
906
907 bridge
908 .emit(AgentEvent::ToolCall {
909 name: "Bash".into(),
910 id: "tc-err".into(),
911 arguments: r#"{"command":"bad"}"#.into(),
912 step: 0,
913 })
914 .await;
915
916 bridge
917 .emit(AgentEvent::ToolResult {
918 id: "tc-err".into(),
919 name: "Bash".into(),
920 output: "command not found".into(),
921 step: 0,
922 is_error: true,
923 })
924 .await;
925
926 let _n1 = rx.try_recv().expect("pending");
927 let _n2 = rx.try_recv().expect("in_progress");
928
929 let n3 = rx.try_recv().expect("failed");
930 assert_eq!(n3["params"]["update"]["status"], "failed");
931 assert_eq!(n3["params"]["update"]["sessionUpdate"], "tool_call_update");
932 assert_eq!(n3["params"]["update"]["toolCallId"], "tc-err");
933 assert_eq!(
934 n3["params"]["update"]["content"]["content"]["text"],
935 "command not found"
936 );
937 }
938
939 #[tokio::test]
940 async fn concurrent_tool_calls_independent_lifecycle() {
941 let mut km = HashMap::new();
943 km.insert("Read".to_string(), ToolKind::Read);
944 km.insert("Bash".to_string(), ToolKind::Execute);
945 let (bridge, mut rx) = make_bridge(km);
946
947 bridge
949 .emit(AgentEvent::ToolCall {
950 name: "Read".into(),
951 id: "tc-1".into(),
952 arguments: r#"{"path":"/tmp/a.txt"}"#.into(),
953 step: 0,
954 })
955 .await;
956 bridge
957 .emit(AgentEvent::ToolCall {
958 name: "Bash".into(),
959 id: "tc-2".into(),
960 arguments: r#"{"command":"echo hi"}"#.into(),
961 step: 0,
962 })
963 .await;
964
965 bridge
967 .emit(AgentEvent::ToolResult {
968 id: "tc-2".into(),
969 name: "Bash".into(),
970 output: "hi".into(),
971 step: 0,
972 is_error: false,
973 })
974 .await;
975 bridge
976 .emit(AgentEvent::ToolResult {
977 id: "tc-1".into(),
978 name: "Read".into(),
979 output: "content".into(),
980 step: 0,
981 is_error: true,
982 })
983 .await;
984
985 let n1 = rx.try_recv().expect("tc-1 pending");
988 assert_eq!(n1["params"]["update"]["toolCallId"], "tc-1");
989 assert_eq!(n1["params"]["update"]["status"], "pending");
990
991 let n2 = rx.try_recv().expect("tc-1 in_progress");
992 assert_eq!(n2["params"]["update"]["toolCallId"], "tc-1");
993 assert_eq!(n2["params"]["update"]["status"], "in_progress");
994
995 let n3 = rx.try_recv().expect("tc-2 pending");
996 assert_eq!(n3["params"]["update"]["toolCallId"], "tc-2");
997 assert_eq!(n3["params"]["update"]["status"], "pending");
998
999 let n4 = rx.try_recv().expect("tc-2 in_progress");
1000 assert_eq!(n4["params"]["update"]["toolCallId"], "tc-2");
1001 assert_eq!(n4["params"]["update"]["status"], "in_progress");
1002
1003 let n5 = rx.try_recv().expect("tc-2 completed");
1004 assert_eq!(n5["params"]["update"]["toolCallId"], "tc-2");
1005 assert_eq!(n5["params"]["update"]["status"], "completed");
1006
1007 let n6 = rx.try_recv().expect("tc-1 failed");
1008 assert_eq!(n6["params"]["update"]["toolCallId"], "tc-1");
1009 assert_eq!(n6["params"]["update"]["status"], "failed");
1010 }
1011
1012 #[tokio::test]
1013 async fn non_tool_events_are_silenced() {
1014 let (bridge, mut rx) = make_bridge(default_kind_map());
1016
1017 bridge
1018 .emit(AgentEvent::Latency {
1019 step: 0,
1020 llm_ms: 100,
1021 })
1022 .await;
1023 bridge
1024 .emit(AgentEvent::Usage {
1025 input_tokens: 10,
1026 output_tokens: 5,
1027 cache_hit_tokens: 0,
1028 cache_miss_tokens: 0,
1029 step: 0,
1030 })
1031 .await;
1032
1033 assert!(rx.try_recv().is_err());
1034 }
1035
1036 #[tokio::test]
1037 async fn tool_call_without_locations() {
1038 let mut km = HashMap::new();
1040 km.insert("Bash".to_string(), ToolKind::Execute);
1041 let (bridge, mut rx) = make_bridge(km);
1042
1043 bridge
1044 .emit(AgentEvent::ToolCall {
1045 name: "Bash".into(),
1046 id: "tc-noloc".into(),
1047 arguments: r#"{"command":"ls"}"#.into(),
1048 step: 0,
1049 })
1050 .await;
1051
1052 let n1 = rx.try_recv().expect("pending");
1053 assert_eq!(n1["params"]["update"]["toolCallId"], "tc-noloc");
1054 assert!(n1["params"]["update"]
1055 .get("locations")
1056 .map_or(true, |l| l.is_null()
1057 || l.as_array().map_or(true, |a| a.is_empty())));
1058 }
1059
1060 #[tokio::test]
1061 async fn tool_result_empty_output_no_content_block() {
1062 let (bridge, mut rx) = make_bridge(default_kind_map());
1064
1065 bridge
1066 .emit(AgentEvent::ToolCall {
1067 name: "Read".into(),
1068 id: "tc-empty".into(),
1069 arguments: "{}".into(),
1070 step: 0,
1071 })
1072 .await;
1073 bridge
1074 .emit(AgentEvent::ToolResult {
1075 id: "tc-empty".into(),
1076 name: "Read".into(),
1077 output: "".into(),
1078 step: 0,
1079 is_error: false,
1080 })
1081 .await;
1082
1083 let _n1 = rx.try_recv().expect("pending");
1084 let _n2 = rx.try_recv().expect("in_progress");
1085 let n3 = rx.try_recv().expect("completed");
1086 assert_eq!(n3["params"]["update"]["status"], "completed");
1087 assert!(
1088 n3["params"]["update"].get("content").is_none()
1089 || n3["params"]["update"]["content"].is_null()
1090 );
1091 }
1092
1093 #[tokio::test]
1094 async fn pending_notification_arrives_before_turn_finished() {
1095 let mut km = HashMap::new();
1097 km.insert("Read".to_string(), ToolKind::Read);
1098 let (bridge, mut rx) = make_bridge(km);
1099
1100 bridge
1101 .emit(AgentEvent::ToolCall {
1102 name: "Read".into(),
1103 id: "tc-before".into(),
1104 arguments: r#"{"path":"/tmp/f.txt"}"#.into(),
1105 step: 0,
1106 })
1107 .await;
1108 bridge
1109 .emit(AgentEvent::ToolResult {
1110 id: "tc-before".into(),
1111 name: "Read".into(),
1112 output: "data".into(),
1113 step: 0,
1114 is_error: false,
1115 })
1116 .await;
1117 bridge
1118 .emit(AgentEvent::TurnFinished {
1119 reason: "no_more_tool_calls".into(),
1120 steps: 1,
1121 })
1122 .await;
1123
1124 let first = rx.try_recv().expect("first");
1126 assert_eq!(first["params"]["update"]["sessionUpdate"], "tool_call");
1127 assert_eq!(first["params"]["update"]["status"], "pending");
1128
1129 let _in_prog = rx.try_recv().expect("in_progress");
1131 let _completed = rx.try_recv().expect("completed");
1132
1133 let final_notif = rx.try_recv().expect("turn finished");
1138 let update = &final_notif["params"]["update"];
1139 let session_update = update["sessionUpdate"].as_str().unwrap_or("");
1140 assert!(
1141 session_update == "agent_message_chunk" || session_update == "end_turn",
1142 "unexpected final sessionUpdate: {session_update}"
1143 );
1144 assert_eq!(update["stopReason"], "end_turn");
1145
1146 assert!(rx.try_recv().is_err());
1147 }
1148
1149 #[tokio::test]
1152 async fn synthesize_cancelled_tool_results_emits_failed_notifications() {
1153 let mut km = HashMap::new();
1154 km.insert("Read".to_string(), ToolKind::Read);
1155 km.insert("Bash".to_string(), ToolKind::Execute);
1156 let (bridge, mut rx) = make_bridge(km);
1157
1158 bridge
1160 .emit(AgentEvent::ToolCall {
1161 name: "Read".into(),
1162 id: "tc-1".into(),
1163 arguments: r#"{"path":"/tmp/f.txt"}"#.into(),
1164 step: 0,
1165 })
1166 .await;
1167 bridge
1168 .emit(AgentEvent::ToolCall {
1169 name: "Bash".into(),
1170 id: "tc-2".into(),
1171 arguments: r#"{"command":"sleep 10"}"#.into(),
1172 step: 0,
1173 })
1174 .await;
1175
1176 for _ in 0..4 {
1179 let _ = rx.try_recv().expect("pending+in_progress notifications");
1180 }
1181
1182 bridge.synthesize_cancelled_tool_results().await;
1184
1185 let mut cancelled_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
1188 for _ in 0..2 {
1189 let notif = rx.try_recv().expect("failed notification");
1190 assert_eq!(notif["params"]["update"]["status"], "failed");
1191 assert_eq!(
1192 notif["params"]["update"]["sessionUpdate"],
1193 "tool_call_update"
1194 );
1195 let id = notif["params"]["update"]["toolCallId"]
1196 .as_str()
1197 .unwrap()
1198 .to_string();
1199 cancelled_ids.insert(id);
1200 assert!(notif["params"]["update"]["content"]["content"]["text"]
1201 .as_str()
1202 .unwrap()
1203 .contains("Cancelled"));
1204 }
1205
1206 assert_eq!(
1207 cancelled_ids,
1208 ["tc-1", "tc-2"].iter().map(|s| s.to_string()).collect()
1209 );
1210 assert!(
1211 rx.try_recv().is_err(),
1212 "no more notifications after synthetic cancel"
1213 );
1214 }
1215
1216 #[tokio::test]
1217 async fn synthesize_cancelled_tool_results_no_pending_calls_is_noop() {
1218 let (bridge, mut rx) = make_bridge(default_kind_map());
1219
1220 bridge.synthesize_cancelled_tool_results().await;
1222
1223 assert!(
1224 rx.try_recv().is_err(),
1225 "no pending calls should produce no notifications"
1226 );
1227 }
1228}