Skip to main content

recursive/acp/
bridge.rs

1//! ACP EventSink bridge: translates [`AgentEvent`]s into `session/update`
2//! notifications pushed through an mpsc channel.
3//!
4//! The server creates one `AcpBridge` per session turn, injects it as the
5//! [`EventSink`] for that session's [`AgentRuntime`], and drains the
6//! receiver after each `session/prompt` call to write notifications to
7//! stdout.
8//!
9//! # MessageId strategy (S2-C6, S2-C8)
10//!
11//! The bridge buffers [`AgentEvent::PartialToken`] deltas internally.
12//! When [`AgentEvent::TurnFinished`] arrives, it computes a content-hash
13//! `messageId` (SHA-256 first 12 hex chars of the *full* accumulated text)
14//! and emits every buffered chunk with the *same* `messageId`.  The final
15//! notification carries `stopReason`, `messageId`, and the completed
16//! message content.  This satisfies:
17//!
18//! - **S2-C6**: all chunks share one `messageId`.
19//! - **S2-C8**: `messageId` is a stable content hash.
20//! - **S2-C7**: the final notification includes the completed message.
21//!
22//! # Tool call notifications (Sprint 3)
23//!
24//! Tool calls follow the lifecycle:
25//!   1. `tool_call` with status `pending` (inc. locations)
26//!   2. `tool_call_update` with status `in_progress` (synthesised by bridge)
27//!   3. `tool_call_update` with status `completed` or `failed` (on ToolResult)
28//!
29//! Per-call state tracking supports concurrent tool calls arriving and
30//! completing out-of-order (S3-C7).
31
32use 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
41// ---------------------------------------------------------------------------
42// SHA-256 helper
43// ---------------------------------------------------------------------------
44
45/// Compute the SHA-256 hash of `text` and return the first 12 hex characters.
46/// This is stable: identical input always produces the same output.
47pub 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    // First 6 bytes → 12 hex chars
52    hex_encode(&result[..6])
53}
54
55/// Encode a byte slice as lowercase hex.
56fn hex_encode(bytes: &[u8]) -> String {
57    bytes.iter().map(|b| format!("{b:02x}")).collect()
58}
59
60// ---------------------------------------------------------------------------
61// ToolCallState — per-call tracking
62// ---------------------------------------------------------------------------
63
64/// Tracks the lifecycle state of one in-flight tool call so the bridge can
65/// emit the correct sequence of notifications and handle out-of-order results.
66#[derive(Debug)]
67#[allow(dead_code)]
68struct ToolCallState {
69    /// The tool name (used as `title` in notifications).
70    name: String,
71    /// The ACP tool kind for this call.
72    kind: ToolKind,
73}
74
75// ---------------------------------------------------------------------------
76// extract_locations (S3-C5)
77// ---------------------------------------------------------------------------
78
79/// Extract `ToolCallLocation` entries from tool arguments.
80///
81/// - Read    → `path`
82/// - Edit    → `file_path` + optional `start_line`
83/// - Write   → `file_path`
84/// - Glob    → `pattern`
85/// - WebFetch → `url`
86/// - Bash    → `cwd` when present
87///
88/// Malformed or missing arguments produce an empty `Vec` (never panics).
89pub 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
141// ---------------------------------------------------------------------------
142// AcpBridge
143// ---------------------------------------------------------------------------
144
145/// An [`EventSink`] that translates agent events into ACP `session/update`
146/// JSON-RPC notification [`Value`]s and pushes them into an mpsc channel.
147///
148/// The receiver half is drained by the server's dispatch after each
149/// `session/prompt` completes.
150///
151/// **Buffering contract:** text deltas are buffered internally; all
152/// notifications (chunks + final) are emitted only when
153/// [`AgentEvent::TurnFinished`] arrives, so every chunk can carry the
154/// stable, content-derived `messageId`.
155pub struct AcpBridge {
156    session_id: String,
157    tx: mpsc::UnboundedSender<Value>,
158    /// Buffered [(delta_text, step)] tuples from `PartialToken` events.
159    partials: tokio::sync::Mutex<Vec<(String, usize)>>,
160    /// Tool name → ToolKind mapping (S3-C4). Used to populate the `kind`
161    /// field in `tool_call` notifications.
162    kind_map: HashMap<String, ToolKind>,
163    /// Per-call state for in-flight tool calls (S3-C7). Keyed by toolCallId.
164    pending_calls: tokio::sync::Mutex<HashMap<String, ToolCallState>>,
165    /// Turn counter used for `turnId` in `end_turn` notifications.
166    turn: u64,
167    /// Cancellation flag: when set, synthetic ToolResults are emitted for
168    /// in-flight calls instead of waiting for natural completion.
169    cancelled: tokio::sync::Mutex<bool>,
170    /// Sprint-3 gate: when false, tool_call / tool_call_update notifications
171    /// are suppressed (Sprint 2 contract: only agent_message_chunk + end_turn).
172    tool_call_notifications_enabled: bool,
173}
174
175impl AcpBridge {
176    /// Create a new bridge and return it together with the receiver half.
177    ///
178    /// `kind_map` maps tool registry names to their ACP `ToolKind` values,
179    /// populated from `ToolRegistry::build_kind_map()` at bridge construction
180    /// time (S3-C4).
181    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    /// Emit synthetic `tool_call_update` notifications for every in-flight
202    /// tool call, marking each as `failed` with a cancellation message.
203    ///
204    /// Called by `handle_session_cancel` after firing the session's
205    /// `CancellationToken`, to ensure Invariant #8 is preserved: every
206    /// `Role::Assistant` message with non-empty `tool_calls` has a
207    /// corresponding `tool_result`.
208    ///
209    /// This method:
210    /// 1. Locks `pending_calls` and drains all entries.
211    /// 2. For each pending call, sends a `tool_call_update` notification
212    ///    with status `"failed"` and a content block explaining cancellation.
213    /// 3. Sets the `cancelled` flag so subsequent `ToolResult` events for
214    ///    already-cancelled calls are suppressed.
215    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                // Buffer; don't emit yet — we need the full text for messageId.
256                let mut partials = self.partials.lock().await;
257                partials.push((text, step));
258            }
259            AgentEvent::AssistantText { text, step } => {
260                // Non-streaming fallback: treat the full text as a single chunk.
261                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                // 1. Drain buffered deltas and compute the full text.
268                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                // 2. Emit one agent_message_chunk per buffered delta,
277                //    all sharing the same content-hash messageId.
278                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                // 3. Emit the terminal end_turn notification with stopReason,
298                //    messageId, completed content, and turnId.
299                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            // ── Sprint 3: Tool call notifications (S3-C1, S3-C2, S3-C6, S3-C7, S3-C8) ──
338            AgentEvent::ToolCall {
339                name,
340                id,
341                arguments,
342                step: _step,
343            } => {
344                // Sprint 2 contract: suppress tool_call notifications.
345                if !self.tool_call_notifications_enabled {
346                    return;
347                }
348                // Parse arguments to extract locations (S3-C5).
349                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                // Register per-call state (S3-C7).
354                {
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                // 1. Emit pending tool_call notification with locations (S3-C1, S3-C6).
366                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                // 2. Synthesise in_progress tool_call_update (S3-C2).
386                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                // Sprint 2 contract: suppress tool_call_update notifications.
409                if !self.tool_call_notifications_enabled {
410                    return;
411                }
412                // Look up (and remove) per-call state so concurrent calls don't
413                // interfere (S3-C7).
414                let _state = {
415                    let mut pending = self.pending_calls.lock().await;
416                    pending.remove(&id)
417                };
418
419                // 3. Emit completed or failed tool_call_update (S3-C2).
420                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                // Include content if there is output.
435                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            // Silence other events: Latency, Usage, PlanMode*, etc.
448            _ => {}
449        }
450    }
451}
452
453// ---------------------------------------------------------------------------
454// Tests
455// ---------------------------------------------------------------------------
456
457#[cfg(test)]
458mod tests {
459    use super::*;
460
461    // ── SHA-256 ──────────────────────────────────────────────────────────
462
463    #[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    // ── extract_locations (S3-C5) ────────────────────────────────────────
490
491    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    // ── Bridge: text buffering (Sprint 2 regression) ─────────────────────
587
588    #[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        // No notifications yet
606        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        // First chunk: "Hello"
616        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        // Second chunk: ", world!"
627        let notif2 = rx.try_recv().expect("chunk 2");
628        assert_eq!(notif2["params"]["update"]["content"]["text"], ", world!");
629
630        // Both chunks share the same messageId (S2-C6)
631        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        // Final notification: stopReason + messageId + completed content
643        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    // ── Sprint 3: Tool call notifications ────────────────────────────────
787
788    #[tokio::test]
789    async fn tool_call_emits_pending_with_kind_and_locations() {
790        // S3-C1: ToolCall → pending tool_call notification with kind + locations.
791        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        // First: pending tool_call (S3-C1)
805        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        // S3-C6: locations present in pending notification
814        assert_eq!(
815            notif["params"]["update"]["locations"][0]["path"],
816            "/tmp/f.txt"
817        );
818
819        // Second: in_progress (synthesised)
820        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        // S3-C6: in_progress has NO locations
828        assert!(notif2["params"]["update"].get("locations").is_none());
829    }
830
831    #[tokio::test]
832    async fn tool_call_unknown_tool_uses_other_kind() {
833        // S3-C4: bridge with empty kind_map uses "other"
834        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        // S3-C2: full lifecycle pending → in_progress → completed
852        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        // 1. pending
876        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        // 2. in_progress
881        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        // 3. completed with content
886        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        // S3-C6: completed has NO locations
897        assert!(n3["params"]["update"].get("locations").is_none());
898    }
899
900    #[tokio::test]
901    async fn tool_call_lifecycle_failed_on_is_error() {
902        // S3-C2: is_error=true → status "failed"
903        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        // S3-C7: Two parallel tool calls with out-of-order results.
942        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        // Emit two ToolCalls back-to-back
948        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        // Results arrive out of order (tc-2 first, tc-1 second)
966        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        // Expected order: tc-1 pending, tc-1 in_progress, tc-2 pending, tc-2 in_progress,
986        //                 tc-2 completed, tc-1 failed
987        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        // S3-C8: Latency, Usage events produce no notifications.
1015        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        // ToolCall with no path-like args → no locations in pending
1039        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        // ToolResult with empty output → no content field in notification.
1063        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        // S3-C1: tool_call pending notification arrives before TurnFinished.
1096        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        // First notification must be the pending tool_call
1125        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        // Drain remaining tool notifications
1130        let _in_prog = rx.try_recv().expect("in_progress");
1131        let _completed = rx.try_recv().expect("completed");
1132
1133        // Final notification: either an agent_message_chunk (with stopReason=end_turn)
1134        // or a SessionUpdate::End_turn notification (carries stopReason at top level).
1135        // The bridge's exact sequence depends on whether TurnFinished synthesises a
1136        // chunk; accept either form.
1137        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    // ── S1-C4: Synthetic ToolResults on cancel ───────────────────────────
1150
1151    #[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        // Add two in-flight tool calls
1159        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        // Drain the pending+in_progress notifications (4 total: tc-1 pending+in_progress, tc-2 pending+in_progress).
1177        // pending_calls is a HashMap so iteration order is not guaranteed; drain without order assumptions.
1178        for _ in 0..4 {
1179            let _ = rx.try_recv().expect("pending+in_progress notifications");
1180        }
1181
1182        // Call the synthetic cancel method
1183        bridge.synthesize_cancelled_tool_results().await;
1184
1185        // Should get exactly two failed notifications (one per cancelled call). Order
1186        // is non-deterministic (HashMap iteration), so collect all and assert the set.
1187        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        // No pending calls — synthetic cancel should produce no notifications
1221        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}