1use 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
31pub struct AcpChannel {
34 name: String,
35 session_id: String,
36 rpc: Arc<RpcOutbound>,
37 approval_timeout: Duration,
42}
43
44impl AcpChannel {
45 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
77fn map_approval_kind(tool_name: &str) -> &'static str {
81 match tool_name {
82 "file_edit" | "file_write" => "edit",
83 _ => "execute",
84 }
85}
86
87fn 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
124fn 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 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 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 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 anyhow::bail!("AcpChannel.request_choice requires at least one choice")
256 }
257
258 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": {
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 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 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 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 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 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 assert_eq!(req["params"]["toolCall"]["kind"], "edit");
699
700 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 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 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}