Skip to main content

zeroclaw_channels/
acp_channel.rs

1//! ACP (Agent Client Protocol) back-channel.
2//!
3//! Bridges ZeroClaw's [`Channel`] abstraction onto an active ACP session so
4//! tools like `ask_user`, `escalate_to_human`, and `reaction` can talk back
5//! to the IDE/CLI client (Toad, Zed, etc.) instead of returning
6//! "no channels available".
7//!
8//! ## What this channel does
9//!
10//! - `send` emits an `agent_message_chunk` `session/update` notification —
11//!   the ACP client renders it inline in the conversation.
12//! - `request_choice` issues a `session/request_permission` JSON-RPC request
13//!   with the question's choices mapped to permission options. Returns the
14//!   selected option's text (or `Err` on cancellation/timeout).
15//! - `listen` is **not implemented**. Free-form ACP "ask the user" has no
16//!   first-class method until the [elicitation RFD][rfd] lands; until then
17//!   `ask_user` callers under ACP must supply structured `choices`.
18//!
19//! [rfd]: https://github.com/zed-industries/agent-client-protocol/blob/main/docs/rfds/elicitation.mdx
20
21use async_trait::async_trait;
22use serde_json::json;
23use std::sync::Arc;
24use std::time::Duration;
25use zeroclaw_api::channel::{
26    Channel, ChannelApprovalRequest, ChannelApprovalResponse, ChannelMessage, SendMessage,
27};
28
29use crate::orchestrator::acp_server::RpcOutbound;
30
31/// Per-session ACP back-channel. One instance is registered into each tool's
32/// channel map at session/new time and torn down on session/stop.
33pub struct AcpChannel {
34    name: String,
35    session_id: String,
36    rpc: Arc<RpcOutbound>,
37    /// How long to wait for a `session/request_permission` response before
38    /// giving up and returning an error. Callers that never respond (crash,
39    /// network drop, user closes IDE) would otherwise park `execute_tool_call`
40    /// forever and hold the session slot against `max_sessions`.
41    approval_timeout: Duration,
42}
43
44impl AcpChannel {
45    /// Build an ACP channel bound to a specific ACP session id and the
46    /// server's outbound JSON-RPC plumbing.
47    ///
48    /// `approval_timeout` caps how long `request_approval` and `request_choice`
49    /// will wait for a client response. Pass `session_timeout_secs` from
50    /// `AcpServerConfig` so the bound is consistent with the session lifetime.
51    pub fn new(
52        name: impl Into<String>,
53        session_id: impl Into<String>,
54        rpc: Arc<RpcOutbound>,
55        approval_timeout: Duration,
56    ) -> Self {
57        Self {
58            name: name.into(),
59            session_id: session_id.into(),
60            rpc,
61            approval_timeout,
62        }
63    }
64}
65
66impl ::zeroclaw_api::attribution::Attributable for AcpChannel {
67    fn role(&self) -> ::zeroclaw_api::attribution::Role {
68        ::zeroclaw_api::attribution::Role::Channel(
69            ::zeroclaw_api::attribution::ChannelKind::AcpChannel,
70        )
71    }
72    fn alias(&self) -> &str {
73        &self.name
74    }
75}
76
77/// Map a tool name to the ACP `kind` field for approval prompts.
78/// `file_edit` / `file_write` are `"edit"` so clients render a diff view;
79/// everything else falls back to `"execute"`.
80fn map_approval_kind(tool_name: &str) -> &'static str {
81    match tool_name {
82        "file_edit" | "file_write" => "edit",
83        _ => "execute",
84    }
85}
86
87/// Build the `rawInput` object for a `session/request_permission` approval.
88///
89/// This carries the raw tool arguments so clients that inspect `rawInput`
90/// directly can read the original field names. Structured diff rendering is
91/// driven by the `content` array (see `build_approval_content`).
92fn build_approval_raw_input(
93    tool_name: &str,
94    raw_arguments: &Option<serde_json::Value>,
95) -> serde_json::Value {
96    if let Some(args) = raw_arguments {
97        match tool_name {
98            "file_edit" => {
99                let path = args.get("path").cloned().unwrap_or(serde_json::Value::Null);
100                let old_text = args
101                    .get("old_string")
102                    .cloned()
103                    .unwrap_or(serde_json::Value::Null);
104                let new_text = args
105                    .get("new_string")
106                    .cloned()
107                    .unwrap_or(serde_json::Value::Null);
108                return json!({ "path": path, "oldText": old_text, "newText": new_text });
109            }
110            "file_write" => {
111                let path = args.get("path").cloned().unwrap_or(serde_json::Value::Null);
112                let new_text = args
113                    .get("content")
114                    .cloned()
115                    .unwrap_or(serde_json::Value::Null);
116                return json!({ "path": path, "newText": new_text });
117            }
118            _ => {}
119        }
120    }
121    json!({ "tool": tool_name })
122}
123
124/// Build the `content` array for a `session/request_permission` approval.
125///
126/// Zed and Toad render tool call content items from the `content` array, not
127/// from `rawInput`. For file-editing tools, emit an ACP `Diff` content item
128/// (`{ "type": "diff", "path": ..., "oldText": ..., "newText": ... }`) so the
129/// client renders a side-by-side diff editor instead of raw JSON field names.
130/// Other tools fall back to a plain-text content block containing the
131/// pre-computed `arguments_summary`.
132fn build_approval_content(
133    tool_name: &str,
134    raw_arguments: &Option<serde_json::Value>,
135    fallback_summary: &str,
136) -> serde_json::Value {
137    if let Some(args) = raw_arguments {
138        match tool_name {
139            "file_edit" => {
140                let path = args.get("path").cloned().unwrap_or(serde_json::Value::Null);
141                let old_text = args
142                    .get("old_string")
143                    .cloned()
144                    .unwrap_or(serde_json::Value::Null);
145                let new_text = args
146                    .get("new_string")
147                    .cloned()
148                    .unwrap_or(serde_json::Value::Null);
149                return json!([{
150                    "type": "diff",
151                    "path": path,
152                    "oldText": old_text,
153                    "newText": new_text,
154                }]);
155            }
156            "file_write" => {
157                let path = args.get("path").cloned().unwrap_or(serde_json::Value::Null);
158                let new_text = args
159                    .get("content")
160                    .cloned()
161                    .unwrap_or(serde_json::Value::Null);
162                return json!([{
163                    "type": "diff",
164                    "path": path,
165                    "newText": new_text,
166                }]);
167            }
168            _ => {}
169        }
170    }
171    json!([{
172        "type": "content",
173        "content": {
174            "type": "text",
175            "text": fallback_summary,
176        }
177    }])
178}
179
180#[async_trait]
181impl Channel for AcpChannel {
182    fn name(&self) -> &str {
183        &self.name
184    }
185
186    async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
187        // Surface the message inline in the ACP client as a normal agent
188        // message chunk. This is intentionally one-way — there's no inbound
189        // counterpart for free-form replies (see `listen`).
190        self.rpc
191            .notify(
192                "session/update",
193                json!({
194                    "sessionId": self.session_id,
195                    "update": {
196                        "sessionUpdate": "agent_message_chunk",
197                        "content": {
198                            "type": "text",
199                            "text": message.content,
200                        }
201                    }
202                }),
203            )
204            .await;
205        Ok(())
206    }
207
208    async fn listen(&self, _tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> anyhow::Result<()> {
209        // ACP has no first-class "next free-form user message in this session"
210        // method. The elicitation RFD is the future fix; until it lands,
211        // `ask_user` under ACP must supply structured `choices`, which routes
212        // through `request_choice` → `session/request_permission` instead.
213        // RFD: https://github.com/zed-industries/agent-client-protocol/blob/main/docs/rfds/elicitation.mdx
214        anyhow::bail!(
215            "AcpChannel.listen is not supported (free-form ask_user awaits ACP elicitation RFD)"
216        )
217    }
218
219    fn supports_free_form_ask(&self) -> bool {
220        false
221    }
222
223    async fn add_reaction(
224        &self,
225        _channel_id: &str,
226        _message_id: &str,
227        _emoji: &str,
228    ) -> anyhow::Result<()> {
229        // ACP renders agent output as message chunks — there's no per-message
230        // reaction primitive in the protocol, so silently no-oping (the trait
231        // default) would falsely report success to the agent. Surface as Err
232        // so the `reaction` tool's caller sees the truth.
233        anyhow::bail!("AcpChannel does not support reactions")
234    }
235
236    async fn remove_reaction(
237        &self,
238        _channel_id: &str,
239        _message_id: &str,
240        _emoji: &str,
241    ) -> anyhow::Result<()> {
242        anyhow::bail!("AcpChannel does not support reactions")
243    }
244
245    async fn request_choice(
246        &self,
247        question: &str,
248        choices: &[String],
249        timeout: Duration,
250    ) -> anyhow::Result<Option<String>> {
251        if choices.is_empty() {
252            // Caller should already gate on this via supports_free_form_ask,
253            // but be defensive — no choices means no permission options to
254            // present, and `session/request_permission` requires at least one.
255            anyhow::bail!("AcpChannel.request_choice requires at least one choice")
256        }
257
258        // Build permission options. Each choice becomes its own option with a
259        // synthetic id; we map the response id back to the choice text.
260        // `kind` mirrors how Toad/Zed render: `allow_once` looks like a
261        // primary action; `reject_once` is the cancel-style fallback.
262        let mut options = Vec::with_capacity(choices.len());
263        for (i, choice) in choices.iter().enumerate() {
264            let kind = if i == choices.len() - 1 && choices.len() > 1 {
265                "reject_once"
266            } else {
267                "allow_once"
268            };
269            options.push(json!({
270                "optionId": format!("choice-{i}"),
271                "name": choice,
272                "kind": kind,
273            }));
274        }
275
276        let params = json!({
277            "sessionId": self.session_id,
278            "options": options,
279            // `toolCall` is required by the ACP schema. We use a synthetic
280            // ask_user tool call so the client surfaces the prompt with a
281            // sensible title.
282            "toolCall": {
283                "toolCallId": format!("ask-user-{}", uuid::Uuid::new_v4()),
284                "title": question,
285                "kind": "other",
286                "status": "pending",
287            }
288        });
289
290        let call = self.rpc.request("session/request_permission", params);
291        let response = match tokio::time::timeout(timeout, call).await {
292            Ok(Ok(value)) => value,
293            Ok(Err(e)) => {
294                anyhow::bail!("ACP request_permission failed: {} ({})", e.message, e.code)
295            }
296            Err(_) => anyhow::bail!("ACP request_permission timed out after {timeout:?}"),
297        };
298
299        // Response shape: { outcome: { outcome: "selected", optionId: "..." } | { outcome: "cancelled" } }
300        let outcome = response.get("outcome");
301        let kind = outcome
302            .and_then(|o| o.get("outcome"))
303            .and_then(|s| s.as_str())
304            .unwrap_or("");
305        match kind {
306            "selected" => {
307                let option_id = outcome
308                    .and_then(|o| o.get("optionId"))
309                    .and_then(|s| s.as_str())
310                    .unwrap_or("");
311                let idx = option_id
312                    .strip_prefix("choice-")
313                    .and_then(|s| s.parse::<usize>().ok());
314                match idx.and_then(|i| choices.get(i)) {
315                    Some(text) => Ok(Some(text.clone())),
316                    None => anyhow::bail!("ACP returned unknown optionId: {option_id}"),
317                }
318            }
319            "cancelled" => Ok(None),
320            other => anyhow::bail!("ACP returned unexpected outcome: {other}"),
321        }
322    }
323
324    async fn request_approval(
325        &self,
326        _recipient: &str,
327        request: &ChannelApprovalRequest,
328    ) -> anyhow::Result<Option<ChannelApprovalResponse>> {
329        let is_edit_tool = matches!(request.tool_name.as_str(), "file_edit" | "file_write");
330        let mut options = vec![
331            json!({
332                "optionId": "allow-once",
333                "name": "Allow once",
334                "kind": "allow_once",
335            }),
336            json!({
337                "optionId": "allow-always",
338                "name": "Always allow",
339                "kind": "allow_always",
340            }),
341        ];
342        if is_edit_tool {
343            options.push(json!({
344                "optionId": "reject-with-edit",
345                "name": "Reject with edit",
346                "kind": "reject_with_edit",
347            }));
348        }
349        options.push(json!({
350            "optionId": "reject-once",
351            "name": "Reject",
352            "kind": "reject_once",
353        }));
354
355        let tool_call_id = format!("approval-{}", uuid::Uuid::new_v4());
356        let title = format!("Approve {}?", request.tool_name);
357        let kind = map_approval_kind(&request.tool_name);
358        let raw_input = build_approval_raw_input(&request.tool_name, &request.raw_arguments);
359        let content = build_approval_content(
360            &request.tool_name,
361            &request.raw_arguments,
362            &request.arguments_summary,
363        );
364
365        // For edit tools, also surface the new_string (or content) directly so that
366        // "reject-with-edit" can present exactly the proposed replacement for editing,
367        // without the surrounding path/old_string fields and with newlines preserved.
368        let mut tool_call = json!({
369            "toolCallId": tool_call_id,
370            "title": title,
371            "kind": kind,
372            "status": "pending",
373            "rawInput": raw_input,
374            "content": content,
375        });
376        if is_edit_tool
377            && let Some(args) = &request.raw_arguments
378            && let Some(new_text) = args.get("new_string").or_else(|| args.get("content"))
379            && let Some(s) = new_text.as_str()
380        {
381            tool_call["proposedEdit"] = json!(s);
382        }
383        let params = json!({
384            "sessionId": self.session_id,
385            "options": options,
386            "toolCall": tool_call,
387        });
388
389        let call = self.rpc.request("session/request_permission", params);
390        let response = match tokio::time::timeout(self.approval_timeout, call).await {
391            Ok(Ok(value)) => value,
392            Ok(Err(e)) => {
393                anyhow::bail!("ACP request_permission failed: {} ({})", e.message, e.code)
394            }
395            Err(_) => anyhow::bail!(
396                "ACP request_permission timed out after {:?}",
397                self.approval_timeout
398            ),
399        };
400
401        let outcome = response.get("outcome");
402        let kind = outcome
403            .and_then(|o| o.get("outcome"))
404            .and_then(|s| s.as_str())
405            .unwrap_or("");
406        match kind {
407            "selected" => {
408                let option_id = outcome
409                    .and_then(|o| o.get("optionId"))
410                    .and_then(|s| s.as_str())
411                    .unwrap_or("");
412                match option_id {
413                    "allow-once" => Ok(Some(ChannelApprovalResponse::Approve)),
414                    "allow-always" => Ok(Some(ChannelApprovalResponse::AlwaysApprove)),
415                    "reject-once" | "reject-always" => Ok(Some(ChannelApprovalResponse::Deny)),
416                    "reject-with-edit" => {
417                        let replacement = outcome
418                            .and_then(|o| o.get("replacementContent"))
419                            .and_then(|s| s.as_str())
420                            .unwrap_or("")
421                            .to_string();
422                        Ok(Some(ChannelApprovalResponse::DenyWithEdit { replacement }))
423                    }
424                    other => anyhow::bail!("ACP returned unknown permission optionId: {other}"),
425                }
426            }
427            "cancelled" => Ok(Some(ChannelApprovalResponse::Deny)),
428            other => anyhow::bail!("ACP returned unexpected permission outcome: {other}"),
429        }
430    }
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436    use tokio::sync::mpsc;
437
438    fn make_rpc() -> (Arc<RpcOutbound>, mpsc::Receiver<String>) {
439        let (tx, rx) = mpsc::channel::<String>(16);
440        (Arc::new(RpcOutbound::new(tx)), rx)
441    }
442
443    #[tokio::test]
444    async fn name_returns_provided_name() {
445        let (rpc, _rx) = make_rpc();
446        let ch = AcpChannel::new("acp", "sess-1", rpc, Duration::from_secs(30));
447        assert_eq!(ch.name(), "acp");
448    }
449
450    #[tokio::test]
451    async fn supports_free_form_ask_is_false() {
452        let (rpc, _rx) = make_rpc();
453        let ch = AcpChannel::new("acp", "sess-1", rpc, Duration::from_secs(30));
454        assert!(!ch.supports_free_form_ask());
455    }
456
457    #[tokio::test]
458    async fn send_emits_agent_message_chunk_notification() {
459        let (rpc, mut rx) = make_rpc();
460        let ch = AcpChannel::new("acp", "sess-1", rpc, Duration::from_secs(30));
461
462        ch.send(&SendMessage::new("hello", "")).await.unwrap();
463
464        let line = rx.recv().await.unwrap();
465        let v: serde_json::Value = serde_json::from_str(&line).unwrap();
466        assert_eq!(v["jsonrpc"], "2.0");
467        assert_eq!(v["method"], "session/update");
468        assert_eq!(v["params"]["sessionId"], "sess-1");
469        assert_eq!(
470            v["params"]["update"]["sessionUpdate"],
471            "agent_message_chunk"
472        );
473        assert_eq!(v["params"]["update"]["content"]["text"], "hello");
474        // Notifications must not have an id.
475        assert!(v.get("id").is_none());
476    }
477
478    #[tokio::test]
479    async fn add_reaction_returns_error() {
480        let (rpc, _rx) = make_rpc();
481        let ch = AcpChannel::new("acp", "sess-1", rpc, Duration::from_secs(30));
482        let res = ch.add_reaction("chan", "msg", "👍").await;
483        assert!(res.is_err());
484    }
485
486    #[tokio::test]
487    async fn remove_reaction_returns_error() {
488        let (rpc, _rx) = make_rpc();
489        let ch = AcpChannel::new("acp", "sess-1", rpc, Duration::from_secs(30));
490        let res = ch.remove_reaction("chan", "msg", "👍").await;
491        assert!(res.is_err());
492    }
493
494    #[tokio::test]
495    async fn listen_returns_error() {
496        let (rpc, _rx) = make_rpc();
497        let ch = AcpChannel::new("acp", "sess-1", rpc, Duration::from_secs(30));
498        let (tx, _) = mpsc::channel(1);
499        let res = ch.listen(tx).await;
500        assert!(res.is_err());
501    }
502
503    #[tokio::test]
504    async fn request_choice_rejects_empty_choices() {
505        let (rpc, _rx) = make_rpc();
506        let ch = AcpChannel::new("acp", "sess-1", rpc, Duration::from_secs(30));
507        let res = ch
508            .request_choice("Pick one", &[], Duration::from_secs(1))
509            .await;
510        assert!(res.is_err());
511    }
512
513    #[tokio::test]
514    async fn request_choice_emits_request_permission_and_resolves_selection() {
515        let (rpc, mut rx) = make_rpc();
516        let rpc_for_resp = Arc::clone(&rpc);
517        let ch = AcpChannel::new("acp", "sess-1", Arc::clone(&rpc), Duration::from_secs(30));
518
519        let choices = vec![
520            "Option A".to_string(),
521            "Option B".to_string(),
522            "Cancel".to_string(),
523        ];
524
525        // Spawn the request; capture the outbound id, then dispatch a
526        // matching "selected" response so the await resolves.
527        let task = zeroclaw_spawn::spawn!(async move {
528            ch.request_choice("Confirm?", &choices, Duration::from_secs(5))
529                .await
530        });
531
532        let line = rx.recv().await.unwrap();
533        let req: serde_json::Value = serde_json::from_str(&line).unwrap();
534        assert_eq!(req["method"], "session/request_permission");
535        assert_eq!(req["params"]["options"].as_array().unwrap().len(), 3);
536        assert_eq!(req["params"]["options"][0]["name"], "Option A");
537        assert_eq!(req["params"]["options"][2]["kind"], "reject_once");
538        let id = req["id"].as_str().unwrap().to_string();
539
540        // Simulate the ACP client picking "Option B" (choice-1).
541        rpc_for_resp.dispatch_response(
542            &id,
543            Some(json!({"outcome": {"outcome": "selected", "optionId": "choice-1"}})),
544            None,
545        );
546
547        let result = task.await.unwrap().unwrap();
548        assert_eq!(result, Some("Option B".to_string()));
549    }
550
551    #[tokio::test]
552    async fn request_choice_handles_cancel_outcome() {
553        let (rpc, mut rx) = make_rpc();
554        let rpc_for_resp = Arc::clone(&rpc);
555        let ch = AcpChannel::new("acp", "sess-1", Arc::clone(&rpc), Duration::from_secs(30));
556
557        let choices = vec!["Yes".to_string(), "No".to_string()];
558
559        let task = zeroclaw_spawn::spawn!(async move {
560            ch.request_choice("Confirm?", &choices, Duration::from_secs(5))
561                .await
562        });
563
564        let line = rx.recv().await.unwrap();
565        let req: serde_json::Value = serde_json::from_str(&line).unwrap();
566        let id = req["id"].as_str().unwrap().to_string();
567
568        rpc_for_resp.dispatch_response(
569            &id,
570            Some(json!({"outcome": {"outcome": "cancelled"}})),
571            None,
572        );
573
574        let result = task.await.unwrap().unwrap();
575        assert_eq!(result, None);
576    }
577
578    #[tokio::test]
579    async fn request_choice_times_out_when_no_response() {
580        let (rpc, _rx) = make_rpc();
581        let ch = AcpChannel::new("acp", "sess-1", rpc, Duration::from_secs(30));
582        let choices = vec!["Yes".to_string(), "No".to_string()];
583        let res = ch
584            .request_choice("Confirm?", &choices, Duration::from_millis(50))
585            .await;
586        assert!(res.is_err());
587        let msg = format!("{}", res.unwrap_err());
588        assert!(msg.contains("timed out"), "unexpected error: {msg}");
589    }
590
591    #[tokio::test]
592    async fn request_approval_emits_request_permission_and_resolves_approve() {
593        let (rpc, mut rx) = make_rpc();
594        let rpc_for_resp = Arc::clone(&rpc);
595        let ch = AcpChannel::new("acp", "sess-1", Arc::clone(&rpc), Duration::from_secs(30));
596        let request = ChannelApprovalRequest {
597            tool_name: "git".to_string(),
598            arguments_summary: "git status --short".to_string(),
599            raw_arguments: None,
600        };
601
602        let task = zeroclaw_spawn::spawn!(async move { ch.request_approval("", &request).await });
603
604        let line = rx.recv().await.unwrap();
605        let req: serde_json::Value = serde_json::from_str(&line).unwrap();
606        assert_eq!(req["method"], "session/request_permission");
607        assert_eq!(req["params"]["sessionId"], "sess-1");
608        assert_eq!(req["params"]["options"].as_array().unwrap().len(), 3);
609        assert_eq!(req["params"]["options"][0]["optionId"], "allow-once");
610        assert_eq!(req["params"]["options"][1]["kind"], "allow_always");
611        assert_eq!(req["params"]["toolCall"]["title"], "Approve git?");
612        assert_eq!(req["params"]["toolCall"]["status"], "pending");
613        assert_eq!(
614            req["params"]["toolCall"]["content"][0]["content"]["text"],
615            "git status --short"
616        );
617        let id = req["id"].as_str().unwrap().to_string();
618
619        rpc_for_resp.dispatch_response(
620            &id,
621            Some(json!({"outcome": {"outcome": "selected", "optionId": "allow-once"}})),
622            None,
623        );
624
625        let result = task.await.unwrap().unwrap();
626        assert_eq!(result, Some(ChannelApprovalResponse::Approve));
627    }
628
629    #[tokio::test]
630    async fn request_approval_maps_always_and_cancel() {
631        let (rpc, mut rx) = make_rpc();
632        let rpc_for_resp = Arc::clone(&rpc);
633        let ch = AcpChannel::new("acp", "sess-1", Arc::clone(&rpc), Duration::from_secs(30));
634        let request = ChannelApprovalRequest {
635            tool_name: "git".to_string(),
636            arguments_summary: "git commit".to_string(),
637            raw_arguments: None,
638        };
639
640        let task = zeroclaw_spawn::spawn!(async move { ch.request_approval("", &request).await });
641        let line = rx.recv().await.unwrap();
642        let req: serde_json::Value = serde_json::from_str(&line).unwrap();
643        let id = req["id"].as_str().unwrap().to_string();
644
645        rpc_for_resp.dispatch_response(
646            &id,
647            Some(json!({"outcome": {"outcome": "selected", "optionId": "allow-always"}})),
648            None,
649        );
650        assert_eq!(
651            task.await.unwrap().unwrap(),
652            Some(ChannelApprovalResponse::AlwaysApprove)
653        );
654
655        let (rpc, mut rx) = make_rpc();
656        let rpc_for_resp = Arc::clone(&rpc);
657        let ch = AcpChannel::new("acp", "sess-1", Arc::clone(&rpc), Duration::from_secs(30));
658        let request = ChannelApprovalRequest {
659            tool_name: "git".to_string(),
660            arguments_summary: "git push".to_string(),
661            raw_arguments: None,
662        };
663        let task = zeroclaw_spawn::spawn!(async move { ch.request_approval("", &request).await });
664        let line = rx.recv().await.unwrap();
665        let req: serde_json::Value = serde_json::from_str(&line).unwrap();
666        let id = req["id"].as_str().unwrap().to_string();
667        rpc_for_resp.dispatch_response(
668            &id,
669            Some(json!({"outcome": {"outcome": "cancelled"}})),
670            None,
671        );
672        assert_eq!(
673            task.await.unwrap().unwrap(),
674            Some(ChannelApprovalResponse::Deny)
675        );
676    }
677
678    #[tokio::test]
679    async fn file_edit_approval_emits_diff_content_item() {
680        let (rpc, mut rx) = make_rpc();
681        let rpc_for_resp = Arc::clone(&rpc);
682        let ch = AcpChannel::new("acp", "sess-1", Arc::clone(&rpc), Duration::from_secs(30));
683        let request = ChannelApprovalRequest {
684            tool_name: "file_edit".to_string(),
685            arguments_summary: "old_string: let x = 1;, new_string: let x = 2;".to_string(),
686            raw_arguments: Some(serde_json::json!({
687                "path": "src/foo.rs",
688                "old_string": "let x = 1;",
689                "new_string": "let x = 2;"
690            })),
691        };
692
693        let task = zeroclaw_spawn::spawn!(async move { ch.request_approval("", &request).await });
694        let line = rx.recv().await.unwrap();
695        let req: serde_json::Value = serde_json::from_str(&line).unwrap();
696
697        // kind must be "edit" for diff rendering
698        assert_eq!(req["params"]["toolCall"]["kind"], "edit");
699
700        // content must carry a Diff item, not a plain text fallback
701        let content = &req["params"]["toolCall"]["content"];
702        assert_eq!(
703            content[0]["type"], "diff",
704            "file_edit approval must emit a diff content item"
705        );
706        assert_eq!(content[0]["path"], "src/foo.rs");
707        assert_eq!(content[0]["oldText"], "let x = 1;");
708        assert_eq!(content[0]["newText"], "let x = 2;");
709
710        let id = req["id"].as_str().unwrap().to_string();
711        rpc_for_resp.dispatch_response(
712            &id,
713            Some(json!({"outcome": {"outcome": "selected", "optionId": "allow-once"}})),
714            None,
715        );
716        assert_eq!(
717            task.await.unwrap().unwrap(),
718            Some(ChannelApprovalResponse::Approve)
719        );
720    }
721
722    #[test]
723    fn build_approval_content_returns_diff_for_file_edit() {
724        let args = serde_json::json!({
725            "path": "README.md",
726            "old_string": "# Old Title",
727            "new_string": "# New Title"
728        });
729        let content = build_approval_content("file_edit", &Some(args), "fallback");
730        let arr = content.as_array().expect("content must be an array");
731        assert_eq!(arr.len(), 1);
732        assert_eq!(arr[0]["type"], "diff");
733        assert_eq!(arr[0]["path"], "README.md");
734        assert_eq!(arr[0]["oldText"], "# Old Title");
735        assert_eq!(arr[0]["newText"], "# New Title");
736    }
737
738    #[test]
739    fn build_approval_content_falls_back_to_text_for_other_tools() {
740        let content = build_approval_content("shell", &None, "ls -la");
741        let arr = content.as_array().expect("content must be an array");
742        assert_eq!(arr[0]["type"], "content");
743        assert_eq!(arr[0]["content"]["type"], "text");
744        assert_eq!(arr[0]["content"]["text"], "ls -la");
745    }
746
747    #[tokio::test]
748    async fn request_approval_maps_reject_with_edit_to_deny_with_edit() {
749        let (rpc, mut rx) = make_rpc();
750        let rpc_for_resp = Arc::clone(&rpc);
751        let ch = AcpChannel::new("acp", "sess-1", Arc::clone(&rpc), Duration::from_secs(30));
752        let request = ChannelApprovalRequest {
753            tool_name: "file_edit".to_string(),
754            arguments_summary: "edit foo.rs".to_string(),
755            raw_arguments: Some(serde_json::json!({
756                "path": "foo.rs",
757                "old_string": "let x = 1;",
758                "new_string": "let x = 2;"
759            })),
760        };
761
762        let task = zeroclaw_spawn::spawn!(async move { ch.request_approval("", &request).await });
763
764        let line = rx.recv().await.unwrap();
765        let req: serde_json::Value = serde_json::from_str(&line).unwrap();
766        let id = req["id"].as_str().unwrap().to_string();
767
768        rpc_for_resp.dispatch_response(
769            &id,
770            Some(serde_json::json!({
771                "outcome": {
772                    "outcome": "selected",
773                    "optionId": "reject-with-edit",
774                    "replacementContent": "let x = 99;"
775                }
776            })),
777            None,
778        );
779
780        let result = task.await.unwrap().unwrap();
781        match result {
782            Some(ChannelApprovalResponse::DenyWithEdit { replacement }) => {
783                assert_eq!(replacement, "let x = 99;");
784            }
785            other => panic!("expected DenyWithEdit, got {other:?}"),
786        }
787    }
788
789    #[tokio::test]
790    async fn file_edit_approval_includes_reject_with_edit_option() {
791        let (rpc, mut rx) = make_rpc();
792        let rpc_for_resp = Arc::clone(&rpc);
793        let ch = AcpChannel::new("acp", "sess-1", Arc::clone(&rpc), Duration::from_secs(30));
794        let request = ChannelApprovalRequest {
795            tool_name: "file_edit".to_string(),
796            arguments_summary: "edit foo.rs".to_string(),
797            raw_arguments: Some(serde_json::json!({
798                "path": "foo.rs",
799                "old_string": "a",
800                "new_string": "b"
801            })),
802        };
803
804        let task = zeroclaw_spawn::spawn!(async move { ch.request_approval("", &request).await });
805
806        let line = rx.recv().await.unwrap();
807        let req: serde_json::Value = serde_json::from_str(&line).unwrap();
808
809        let options = req["params"]["options"].as_array().unwrap();
810        let has_reject_edit = options.iter().any(|o| o["optionId"] == "reject-with-edit");
811        assert!(
812            has_reject_edit,
813            "file_edit approval must offer reject-with-edit"
814        );
815
816        let id = req["id"].as_str().unwrap().to_string();
817        rpc_for_resp.dispatch_response(
818            &id,
819            Some(serde_json::json!({"outcome": {"outcome": "cancelled"}})),
820            None,
821        );
822        task.await.unwrap().unwrap();
823    }
824
825    #[tokio::test]
826    async fn reject_with_edit_missing_replacement_defaults_to_empty() {
827        let (rpc, mut rx) = make_rpc();
828        let rpc_for_resp = Arc::clone(&rpc);
829        let ch = AcpChannel::new("acp", "sess-1", Arc::clone(&rpc), Duration::from_secs(30));
830        let request = ChannelApprovalRequest {
831            tool_name: "file_edit".to_string(),
832            arguments_summary: "edit foo.rs".to_string(),
833            raw_arguments: None,
834        };
835
836        let task = zeroclaw_spawn::spawn!(async move { ch.request_approval("", &request).await });
837
838        let line = rx.recv().await.unwrap();
839        let req: serde_json::Value = serde_json::from_str(&line).unwrap();
840        let id = req["id"].as_str().unwrap().to_string();
841
842        // Response has optionId but no replacementContent.
843        rpc_for_resp.dispatch_response(
844            &id,
845            Some(serde_json::json!({
846                "outcome": {
847                    "outcome": "selected",
848                    "optionId": "reject-with-edit"
849                }
850            })),
851            None,
852        );
853
854        let result = task.await.unwrap().unwrap();
855        // Absent replacementContent defaults to empty string — caller must guard.
856        assert!(
857            matches!(result, Some(ChannelApprovalResponse::DenyWithEdit { replacement }) if replacement.is_empty())
858        );
859    }
860
861    #[tokio::test]
862    async fn file_write_approval_includes_reject_with_edit_option() {
863        let (rpc, mut rx) = make_rpc();
864        let rpc_for_resp = Arc::clone(&rpc);
865        let ch = AcpChannel::new("acp", "sess-1", Arc::clone(&rpc), Duration::from_secs(30));
866        let request = ChannelApprovalRequest {
867            tool_name: "file_write".to_string(),
868            arguments_summary: "write bar.rs".to_string(),
869            raw_arguments: None,
870        };
871
872        let task = zeroclaw_spawn::spawn!(async move { ch.request_approval("", &request).await });
873
874        let line = rx.recv().await.unwrap();
875        let req: serde_json::Value = serde_json::from_str(&line).unwrap();
876
877        let options = req["params"]["options"].as_array().unwrap();
878        let has_reject_edit = options.iter().any(|o| o["optionId"] == "reject-with-edit");
879        assert!(
880            has_reject_edit,
881            "file_write approval must offer reject-with-edit"
882        );
883
884        let id = req["id"].as_str().unwrap().to_string();
885        rpc_for_resp.dispatch_response(
886            &id,
887            Some(serde_json::json!({"outcome": {"outcome": "cancelled"}})),
888            None,
889        );
890        task.await.unwrap().unwrap();
891    }
892}