1use crate::backend::{
95 AgentBackend, AgentEvent, AgentSession, PromptMode, SessionExit, SessionSpec,
96};
97#[cfg(unix)]
98use crate::backend_claude::kill_group;
99#[cfg(windows)]
100use crate::backend_claude::win_job;
101use crate::error::{EngineError, Result};
102use crate::stream_bounds::{drain_to_tail, BoundedLines, STDERR_TAIL_CAP};
103use crate::types::TokenUsage;
104use serde_json::{json, Value};
105use std::collections::{HashMap, VecDeque};
106use std::path::PathBuf;
107use std::process::Stdio;
108use std::sync::{Arc, Mutex};
109use tokio::process::{Child, ChildStdin, ChildStdout};
110use tokio::task::JoinHandle;
111
112const SUMMARY_MAX_CHARS: usize = 200;
114const STDERR_TAIL_CHARS: usize = 500;
116
117const ACP_PROTOCOL_VERSION: u64 = 1;
120
121const HANDSHAKE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
128
129mod method {
131 pub(crate) const INITIALIZE: &str = "initialize";
132 pub(crate) const SESSION_NEW: &str = "session/new";
133 pub(crate) const SESSION_PROMPT: &str = "session/prompt";
134 pub(crate) const SESSION_CANCEL: &str = "session/cancel";
135 pub(crate) const SESSION_UPDATE: &str = "session/update";
136 pub(crate) const REQUEST_PERMISSION: &str = "session/request_permission";
137}
138
139const TOOL_NAME_KINDS: &[(&str, &[&str])] = &[
145 ("Bash", &["execute"]),
146 ("Read", &["read"]),
147 ("Write", &["edit"]),
148 ("Edit", &["edit"]),
149 ("NotebookEdit", &["edit"]),
150 ("Glob", &["search"]),
151 ("Grep", &["search"]),
152 ("WebSearch", &["search", "fetch"]),
153 ("WebFetch", &["fetch"]),
154];
155
156const MUTATING_KINDS: &[&str] = &["edit", "delete", "move"];
160
161#[derive(Debug)]
169enum Frame {
170 Response { id: u64, outcome: RpcOutcome },
172 Request {
176 id: Value,
177 method: String,
178 params: Value,
179 raw: Value,
180 },
181 Notification {
183 method: String,
184 params: Value,
185 raw: Value,
186 },
187 Unrecognized(Value),
189}
190
191#[derive(Debug)]
195enum RpcOutcome {
196 Result(Value),
197 Error(Value),
198}
199
200fn classify_line(line: &str) -> Frame {
204 let value = match serde_json::from_str::<Value>(line) {
205 Ok(value) => value,
206 Err(_) => return Frame::Unrecognized(json!({ "unparsed": line })),
207 };
208 classify_value(value)
209}
210
211fn classify_value(value: Value) -> Frame {
212 let obj = match value.as_object() {
213 Some(obj) => obj,
214 None => return Frame::Unrecognized(value),
215 };
216 let has_id = obj.contains_key("id");
217 let method = obj.get("method").and_then(Value::as_str);
218 match (method, has_id) {
219 (Some(method), true) => Frame::Request {
221 id: obj.get("id").cloned().unwrap_or(Value::Null),
222 method: method.to_string(),
223 params: obj.get("params").cloned().unwrap_or(Value::Null),
224 raw: value,
225 },
226 (Some(method), false) => Frame::Notification {
228 method: method.to_string(),
229 params: obj.get("params").cloned().unwrap_or(Value::Null),
230 raw: value,
231 },
232 (None, true) => {
235 let id = obj.get("id").and_then(Value::as_u64);
236 match (id, obj.get("result"), obj.get("error")) {
237 (Some(id), Some(result), _) => Frame::Response {
238 id,
239 outcome: RpcOutcome::Result(result.clone()),
240 },
241 (Some(id), None, Some(error)) => Frame::Response {
242 id,
243 outcome: RpcOutcome::Error(error.clone()),
244 },
245 _ => Frame::Unrecognized(value),
246 }
247 }
248 (None, false) => Frame::Unrecognized(value),
249 }
250}
251
252#[derive(Debug, Clone, PartialEq, Eq)]
258enum PermissionDecision {
259 Allow,
260 Deny(String),
262}
263
264#[derive(Debug, Clone, Default)]
269struct ToolCallInfo {
270 kind: String,
271 title: String,
272 subject: String,
275}
276
277fn tool_call_subject(kind: &str, title: &str, call: &Value) -> String {
281 let raw_input = call.get("rawInput").cloned().unwrap_or(Value::Null);
282 let str_at = |value: &Value, keys: &[&str]| -> Option<String> {
283 keys.iter()
284 .find_map(|k| value.get(*k).and_then(Value::as_str).map(str::to_string))
285 };
286 if kind == "execute" {
287 if let Some(command) = str_at(&raw_input, &["command", "cmd"]) {
288 return command;
289 }
290 }
291 if let Some(path) = call
292 .get("locations")
293 .and_then(Value::as_array)
294 .and_then(|locs| locs.first())
295 .and_then(|loc| loc.get("path"))
296 .and_then(Value::as_str)
297 {
298 return path.to_string();
299 }
300 if let Some(path) = str_at(&raw_input, &["path", "filePath", "file_path"]) {
301 return path;
302 }
303 title.to_string()
304}
305
306fn wildcard_match(pattern: &str, text: &str) -> bool {
310 let parts = pattern.split('*');
311 let anchored_start = !pattern.starts_with('*');
312 let anchored_end = !pattern.ends_with('*');
313 let mut rest = text;
314 let mut first = true;
315 for part in parts {
316 if part.is_empty() {
317 first = false;
318 continue;
319 }
320 match rest.find(part) {
321 Some(idx) if !first || !anchored_start || idx == 0 => {
322 rest = &rest[idx + part.len()..];
323 }
324 _ => return false,
325 }
326 first = false;
327 }
328 !anchored_end || rest.is_empty()
331}
332
333fn split_pattern(pattern: &str) -> (&str, &str) {
336 match pattern.split_once('(') {
337 Some((name, rest)) => (name.trim(), rest.strip_suffix(')').unwrap_or(rest)),
338 None => (pattern.trim(), "*"),
339 }
340}
341
342fn pattern_covers_kind(pattern: &str, kind: &str) -> bool {
347 let (name, _) = split_pattern(pattern);
348 TOOL_NAME_KINDS
349 .iter()
350 .find(|(n, _)| n.eq_ignore_ascii_case(name))
351 .is_some_and(|(_, kinds)| kinds.contains(&kind))
352}
353
354fn pattern_matches(pattern: &str, kind: &str, subject: &str) -> bool {
357 let (_, glob) = split_pattern(pattern);
358 if pattern_covers_kind(pattern, kind) {
359 wildcard_match(glob, subject)
360 } else {
361 false
362 }
363}
364
365fn decide_permission(spec: &SessionSpec, call: &ToolCallInfo) -> PermissionDecision {
377 if call.subject.trim().is_empty() {
378 if let Some(pattern) = spec
379 .disallowed_tools
380 .iter()
381 .find(|pattern| pattern_covers_kind(pattern, &call.kind))
382 {
383 return PermissionDecision::Deny(format!(
384 "tool call carries no subject (no rawInput command or path, no locations[0].path, \
385 no title), so deny pattern {pattern:?} for ACP kind {:?} cannot be evaluated",
386 call.kind
387 ));
388 }
389 }
390 for pattern in &spec.disallowed_tools {
391 if pattern_matches(pattern, &call.kind, &call.subject) {
392 return PermissionDecision::Deny(format!(
393 "matches SessionSpec.disallowed_tools pattern {pattern:?}"
394 ));
395 }
396 }
397 if !spec.writable {
398 let kind = call.kind.trim();
399 if kind.is_empty() || kind == "other" {
400 return PermissionDecision::Deny(format!(
401 "read-only session (writable: false): tool call carries no usable ACP kind \
402 ({kind:?}), so whether it mutates the filesystem cannot be decided"
403 ));
404 }
405 if MUTATING_KINDS.contains(&kind) {
406 return PermissionDecision::Deny(format!(
407 "read-only session (writable: false): ACP kind {:?} mutates the filesystem",
408 call.kind
409 ));
410 }
411 }
412 PermissionDecision::Allow
413}
414
415fn permission_response(decision: &PermissionDecision, options: &[Value]) -> Value {
421 let option_kind = |opt: &Value| -> String {
422 opt.get("kind")
423 .and_then(Value::as_str)
424 .unwrap_or("")
425 .to_string()
426 };
427 let option_id = |opt: &Value| opt.get("optionId").cloned().unwrap_or(Value::Null);
428 let pick = |kinds: &[&str]| -> Option<Value> {
429 options
430 .iter()
431 .find(|opt| kinds.contains(&option_kind(opt).as_str()))
432 .map(option_id)
433 };
434 let selected = match decision {
435 PermissionDecision::Allow => pick(&["allow_once"])
436 .or_else(|| pick(&["allow_always"]))
437 .or_else(|| options.first().map(option_id)),
438 PermissionDecision::Deny(_) => pick(&["reject_once"]).or_else(|| pick(&["reject_always"])),
439 };
440 match selected {
441 Some(option_id) => json!({ "outcome": { "outcome": "selected", "optionId": option_id } }),
442 None => match decision {
443 PermissionDecision::Allow => {
444 json!({ "outcome": { "outcome": "cancelled" } })
446 }
447 PermissionDecision::Deny(_) => json!({ "outcome": { "outcome": "cancelled" } }),
448 },
449 }
450}
451
452fn truncate_chars(text: &str, max: usize) -> String {
458 if text.chars().count() <= max {
459 text.to_string()
460 } else {
461 text.chars().take(max).collect()
462 }
463}
464
465fn last_chars(text: &str, max: usize) -> String {
467 let chars: Vec<char> = text.chars().collect();
468 let start = chars.len().saturating_sub(max);
469 chars[start..].iter().collect()
470}
471
472fn tool_result_summary(update: &Value, tracked: &ToolCallInfo, status: &str) -> String {
475 if let Some(content) = update.get("content").and_then(Value::as_array) {
476 for item in content {
477 if item.get("type").and_then(Value::as_str) == Some("content") {
478 if let Some(text) = item
479 .get("content")
480 .and_then(|c| c.get("text"))
481 .and_then(Value::as_str)
482 {
483 return truncate_chars(text, SUMMARY_MAX_CHARS);
484 }
485 }
486 }
487 }
488 if !tracked.title.is_empty() {
489 return truncate_chars(&tracked.title, SUMMARY_MAX_CHARS);
490 }
491 status.to_string()
492}
493
494#[derive(Debug, Clone)]
505pub struct AcpBackend {
506 program: PathBuf,
507 args: Vec<String>,
508}
509
510impl AcpBackend {
511 pub fn new(program: impl Into<PathBuf>, args: Vec<String>) -> Self {
514 AcpBackend {
515 program: program.into(),
516 args,
517 }
518 }
519
520 pub fn program(&self) -> &std::path::Path {
522 &self.program
523 }
524}
525
526#[async_trait::async_trait]
527impl AgentBackend for AcpBackend {
528 async fn start(&self, spec: SessionSpec) -> Result<Box<dyn AgentSession>> {
529 if spec.resume.is_some() {
530 return Err(EngineError::Backend(
531 "acp backend: resume is unsupported (session/load is an optional v1 \
532 capability this backend does not negotiate)"
533 .to_string(),
534 ));
535 }
536 let model = spec.model.clone();
537
538 let mut command = tokio::process::Command::new(&self.program);
539 command
540 .args(&self.args)
541 .current_dir(&spec.cwd)
542 .env_clear()
547 .envs(crate::agent_env::agent_session_env(
548 &spec.env,
549 &spec.session_id,
550 None,
551 ))
552 .stdin(Stdio::piped())
554 .stdout(Stdio::piped())
555 .stderr(Stdio::piped())
556 .kill_on_drop(true);
557 #[cfg(unix)]
560 command.process_group(0);
561
562 let mut child = command.spawn().map_err(|e| {
563 EngineError::Backend(format!(
564 "failed to spawn acp agent {}: {e}",
565 self.program.display()
566 ))
567 })?;
568
569 #[cfg(windows)]
571 let job = match child.raw_handle() {
572 Some(handle) => match win_job::JobHandle::create_and_assign(handle) {
573 Ok(job) => Some(job),
574 Err(e) => {
575 tracing::warn!(error = %e, "failed to create Job Object for acp child; \
576 tree-kill on abort will be unavailable");
577 None
578 }
579 },
580 None => None,
581 };
582
583 let stdin = child
584 .stdin
585 .take()
586 .ok_or_else(|| EngineError::Backend("acp child has no stdin pipe".to_string()))?;
587 let stdout = child
588 .stdout
589 .take()
590 .ok_or_else(|| EngineError::Backend("acp child has no stdout pipe".to_string()))?;
591 let stderr = child
592 .stderr
593 .take()
594 .ok_or_else(|| EngineError::Backend("acp child has no stderr pipe".to_string()))?;
595
596 let stderr_buf = Arc::new(Mutex::new(String::new()));
601 let stderr_task = {
602 let buf = Arc::clone(&stderr_buf);
603 tokio::spawn(async move {
604 let tail = drain_to_tail(stderr, STDERR_TAIL_CAP).await;
605 *buf.lock().expect("stderr buffer lock") = tail;
606 })
607 };
608
609 let mut session = AcpSession {
610 session_id: spec.session_id.clone(),
611 acp_session_id: None,
612 model,
613 spec,
614 child,
615 #[cfg(windows)]
616 job,
617 stdin,
618 lines: BoundedLines::new(stdout),
619 stderr_buf,
620 stderr_task: Some(stderr_task),
621 queue: VecDeque::new(),
622 next_request_id: 1,
623 prompt_request_id: None,
624 tool_calls: HashMap::new(),
625 message_text: String::new(),
626 message_id: None,
627 last_usage: None,
628 saw_result: false,
629 saw_success_result: false,
630 exit: None,
631 };
632
633 if let Err(e) = session.handshake().await {
637 session.kill_child().await;
638 return Err(e);
639 }
640 Ok(Box::new(session))
641 }
642}
643
644pub struct AcpSession {
657 session_id: String,
658 acp_session_id: Option<String>,
660 model: String,
661 spec: SessionSpec,
663 child: Child,
664 #[cfg(windows)]
665 job: Option<win_job::JobHandle>,
666 stdin: ChildStdin,
667 lines: BoundedLines<ChildStdout>,
668 stderr_buf: Arc<Mutex<String>>,
669 stderr_task: Option<JoinHandle<()>>,
670 queue: VecDeque<AgentEvent>,
672 next_request_id: u64,
673 prompt_request_id: Option<u64>,
676 tool_calls: HashMap<String, ToolCallInfo>,
680 message_text: String,
685 message_id: Option<String>,
686 last_usage: Option<Value>,
689 saw_result: bool,
690 saw_success_result: bool,
691 exit: Option<SessionExit>,
692}
693
694#[cfg(unix)]
695impl Drop for AcpSession {
696 fn drop(&mut self) {
697 crate::backend_claude::kill_unreaped_group(&self.child);
698 }
699}
700
701impl AcpSession {
702 async fn write_message(&mut self, message: Value) -> Result<()> {
710 use tokio::io::AsyncWriteExt;
711 let mut line = serde_json::to_string(&message)
712 .map_err(|e| EngineError::Backend(format!("failed to encode acp message: {e}")))?;
713 line.push('\n');
714 self.stdin.write_all(line.as_bytes()).await.map_err(|e| {
715 EngineError::Backend(format!("failed to write to acp agent stdin: {e}"))
716 })?;
717 self.stdin
718 .flush()
719 .await
720 .map_err(|e| EngineError::Backend(format!("failed to flush acp agent stdin: {e}")))?;
721 Ok(())
722 }
723
724 async fn send_request(&mut self, method: &str, params: Value) -> Result<u64> {
728 let id = self.next_request_id;
729 self.next_request_id += 1;
730 self.write_message(json!({
731 "jsonrpc": "2.0",
732 "id": id,
733 "method": method,
734 "params": params,
735 }))
736 .await?;
737 Ok(id)
738 }
739
740 async fn handshake(&mut self) -> Result<()> {
743 let init_id = self
744 .send_request(
745 method::INITIALIZE,
746 json!({
747 "protocolVersion": ACP_PROTOCOL_VERSION,
748 "clientCapabilities": {
749 "fs": { "readTextFile": false, "writeTextFile": false },
753 "terminal": false,
754 },
755 "clientInfo": {
756 "name": "kranz",
757 "title": "kranz mission engine",
758 "version": env!("CARGO_PKG_VERSION"),
759 },
760 }),
761 )
762 .await?;
763 let init_result = self.pump_until_response(init_id, HANDSHAKE_TIMEOUT).await?;
764 let peer_version = init_result
765 .get("protocolVersion")
766 .and_then(Value::as_u64)
767 .unwrap_or(0);
768 if peer_version != ACP_PROTOCOL_VERSION {
769 return Err(EngineError::Backend(format!(
770 "acp agent negotiated protocol version {peer_version}, but this backend speaks \
771 only stable version {ACP_PROTOCOL_VERSION} (schema v1)"
772 )));
773 }
774
775 let new_id = self
776 .send_request(
777 method::SESSION_NEW,
778 json!({
779 "cwd": self.spec.cwd.display().to_string(),
780 "mcpServers": [],
781 }),
782 )
783 .await?;
784 let new_result = self.pump_until_response(new_id, HANDSHAKE_TIMEOUT).await?;
785 let acp_session_id = new_result
786 .get("sessionId")
787 .and_then(Value::as_str)
788 .ok_or_else(|| {
789 EngineError::Backend("acp session/new response carried no sessionId".to_string())
790 })?
791 .to_string();
792 self.acp_session_id = Some(acp_session_id.clone());
793 self.session_id = acp_session_id.clone();
794 self.queue.push_back(AgentEvent::Init {
798 session_id: acp_session_id,
799 model: self.model.clone(),
800 raw: json!({
801 "initialize": init_result,
802 "sessionNew": new_result,
803 "synthesizedBy": "backend_acp",
804 }),
805 });
806
807 let prompt_text = match &self.spec.prompt {
808 PromptMode::SingleShot(text) | PromptMode::Streaming(text) => text.clone(),
809 };
810 self.send_prompt(&prompt_text).await
811 }
812
813 async fn send_prompt(&mut self, text: &str) -> Result<()> {
816 let acp_session_id = self
817 .acp_session_id
818 .clone()
819 .ok_or_else(|| EngineError::Backend("acp session not established yet".to_string()))?;
820 let id = self
821 .send_request(
822 method::SESSION_PROMPT,
823 json!({
824 "sessionId": acp_session_id,
825 "prompt": [ { "type": "text", "text": text } ],
826 }),
827 )
828 .await?;
829 self.prompt_request_id = Some(id);
830 self.message_text.clear();
833 self.message_id = None;
834 Ok(())
835 }
836
837 async fn pump_until_response(
842 &mut self,
843 id: u64,
844 timeout: std::time::Duration,
845 ) -> Result<Value> {
846 let pump = async {
847 loop {
848 let frame = match self.read_frame().await? {
849 Some(frame) => frame,
850 None => {
851 return Err(EngineError::Backend(format!(
852 "acp agent closed stdout before answering request id {id}; \
853 stderr tail: {}",
854 self.stderr_tail()
855 )))
856 }
857 };
858 match frame {
859 Frame::Response {
860 id: response_id,
861 outcome,
862 } if response_id == id => {
863 return match outcome {
864 RpcOutcome::Result(result) => Ok(result),
865 RpcOutcome::Error(error) => Err(EngineError::Backend(format!(
866 "acp request id {id} failed: {error}"
867 ))),
868 };
869 }
870 other => self.handle_frame(other).await?,
871 }
872 }
873 };
874 match tokio::time::timeout(timeout, pump).await {
875 Ok(result) => result,
876 Err(_) => Err(EngineError::Backend(format!(
877 "acp agent did not answer request id {id} within {}s (handshake timeout)",
878 timeout.as_secs()
879 ))),
880 }
881 }
882
883 async fn read_frame(&mut self) -> Result<Option<Frame>> {
887 loop {
888 match self.lines.next_line().await {
889 Ok(Some(line)) if line.trim().is_empty() => continue,
890 Ok(Some(line)) => return Ok(Some(classify_line(&line))),
891 Ok(None) => return Ok(None),
892 Err(e) => {
893 return Err(EngineError::Backend(format!(
894 "error reading acp agent stdout: {e}; stderr tail: {}",
895 self.stderr_tail()
896 )))
897 }
898 }
899 }
900 }
901
902 async fn handle_frame(&mut self, frame: Frame) -> Result<()> {
905 match frame {
906 Frame::Notification {
907 method,
908 params,
909 raw,
910 } => {
911 if method == method::SESSION_UPDATE {
912 self.handle_session_update(¶ms, raw);
913 } else {
914 self.queue.push_back(AgentEvent::Other { raw });
915 }
916 }
917 Frame::Request {
918 id,
919 method,
920 params,
921 raw,
922 } => {
923 if method == method::REQUEST_PERMISSION {
924 self.handle_permission_request(id, ¶ms, raw).await?;
925 } else {
926 self.write_message(json!({
930 "jsonrpc": "2.0",
931 "id": id,
932 "error": {
933 "code": -32601,
934 "message": format!("kranz acp backend does not support {method:?}"),
935 },
936 }))
937 .await?;
938 self.queue.push_back(AgentEvent::Other { raw });
939 }
940 }
941 Frame::Response { id, outcome } => {
942 if Some(id) == self.prompt_request_id {
943 self.prompt_request_id = None;
944 self.synthesize_result(outcome, id);
945 } else {
946 self.queue.push_back(AgentEvent::Other {
949 raw: match outcome {
950 RpcOutcome::Result(result) => {
951 json!({ "unmatchedResponse": { "id": id, "result": result } })
952 }
953 RpcOutcome::Error(error) => {
954 json!({ "unmatchedResponse": { "id": id, "error": error } })
955 }
956 },
957 });
958 }
959 }
960 Frame::Unrecognized(raw) => {
961 self.queue.push_back(AgentEvent::Other { raw });
962 }
963 }
964 Ok(())
965 }
966
967 fn handle_session_update(&mut self, params: &Value, raw: Value) {
969 let update = params.get("update").cloned().unwrap_or(Value::Null);
970 match update.get("sessionUpdate").and_then(Value::as_str) {
971 Some("agent_message_chunk") => {
972 let text = update
973 .get("content")
974 .and_then(|c| c.get("text"))
975 .and_then(Value::as_str)
976 .unwrap_or("");
977 if text.is_empty() {
978 self.queue.push_back(AgentEvent::Other { raw });
979 return;
980 }
981 let chunk_id = update
984 .get("messageId")
985 .and_then(Value::as_str)
986 .map(str::to_string);
987 if chunk_id.is_some() && chunk_id != self.message_id {
988 self.message_text.clear();
989 self.message_id = chunk_id;
990 }
991 self.message_text.push_str(text);
992 self.queue.push_back(AgentEvent::Text {
993 text: text.to_string(),
994 raw,
995 });
996 }
997 Some("tool_call") => {
998 let id = update
999 .get("toolCallId")
1000 .and_then(Value::as_str)
1001 .unwrap_or_default()
1002 .to_string();
1003 let kind = update
1004 .get("kind")
1005 .and_then(Value::as_str)
1006 .unwrap_or("other")
1007 .to_string();
1008 let title = update
1009 .get("title")
1010 .and_then(Value::as_str)
1011 .unwrap_or_default()
1012 .to_string();
1013 let subject = tool_call_subject(&kind, &title, &update);
1014 self.tool_calls.insert(
1015 id,
1016 ToolCallInfo {
1017 kind: kind.clone(),
1018 title: title.clone(),
1019 subject,
1020 },
1021 );
1022 self.queue.push_back(AgentEvent::ToolUse {
1023 tool: kind,
1024 summary: truncate_chars(&title, SUMMARY_MAX_CHARS),
1025 raw,
1026 });
1027 }
1028 Some("tool_call_update") => {
1029 let id = update
1030 .get("toolCallId")
1031 .and_then(Value::as_str)
1032 .unwrap_or_default()
1033 .to_string();
1034 let status = update
1035 .get("status")
1036 .and_then(Value::as_str)
1037 .unwrap_or("")
1038 .to_string();
1039 {
1040 let tracked = self.tool_calls.entry(id).or_default();
1041 if let Some(kind) = update.get("kind").and_then(Value::as_str) {
1042 tracked.kind = kind.to_string();
1043 }
1044 if let Some(title) = update.get("title").and_then(Value::as_str) {
1045 tracked.title = title.to_string();
1046 }
1047 }
1048 match status.as_str() {
1049 "completed" | "failed" => {
1055 let tracked = self
1056 .tool_calls
1057 .get(
1058 update
1059 .get("toolCallId")
1060 .and_then(Value::as_str)
1061 .unwrap_or_default(),
1062 )
1063 .cloned()
1064 .unwrap_or_default();
1065 self.queue.push_back(AgentEvent::ToolResult {
1066 tool: Some(tracked.kind.clone()),
1067 denied: false,
1068 summary: tool_result_summary(&update, &tracked, &status),
1069 raw,
1070 });
1071 }
1072 _ => self.queue.push_back(AgentEvent::Other { raw }),
1073 }
1074 }
1075 Some("usage_update") => {
1076 self.last_usage = Some(update.clone());
1081 self.queue.push_back(AgentEvent::Other { raw });
1082 }
1083 _ => self.queue.push_back(AgentEvent::Other { raw }),
1084 }
1085 }
1086
1087 async fn handle_permission_request(
1091 &mut self,
1092 id: Value,
1093 params: &Value,
1094 raw: Value,
1095 ) -> Result<()> {
1096 let call_update = params.get("toolCall").cloned().unwrap_or(Value::Null);
1097 let call_id = call_update
1098 .get("toolCallId")
1099 .and_then(Value::as_str)
1100 .unwrap_or_default()
1101 .to_string();
1102 let mut info = self.tool_calls.get(&call_id).cloned().unwrap_or_default();
1105 if let Some(kind) = call_update.get("kind").and_then(Value::as_str) {
1106 info.kind = kind.to_string();
1107 }
1108 if info.kind.is_empty() {
1109 info.kind = "other".to_string();
1110 }
1111 if let Some(title) = call_update.get("title").and_then(Value::as_str) {
1112 info.title = title.to_string();
1113 }
1114 if info.subject.is_empty() {
1115 info.subject = tool_call_subject(&info.kind, &info.title, &call_update);
1116 }
1117 self.tool_calls.insert(call_id.clone(), info.clone());
1118
1119 let decision = decide_permission(&self.spec, &info);
1120 let options = params
1121 .get("options")
1122 .and_then(Value::as_array)
1123 .cloned()
1124 .unwrap_or_default();
1125 let result = permission_response(&decision, &options);
1126 self.write_message(json!({
1127 "jsonrpc": "2.0",
1128 "id": id,
1129 "result": result,
1130 }))
1131 .await?;
1132
1133 if let PermissionDecision::Deny(reason) = &decision {
1134 tracing::info!(
1135 session_id = %self.session_id,
1136 tool_call_id = %call_id,
1137 kind = %info.kind,
1138 decision = "deny",
1139 reason = %reason,
1140 "acp permission request refused at the kranz seam"
1141 );
1142 self.queue.push_back(AgentEvent::ToolResult {
1143 tool: Some(info.kind.clone()),
1144 denied: true,
1145 summary: truncate_chars(
1146 &format!("refused by kranz permission seam: {reason}"),
1147 SUMMARY_MAX_CHARS,
1148 ),
1149 raw,
1150 });
1151 } else {
1152 self.queue.push_back(AgentEvent::Other { raw });
1156 }
1157 Ok(())
1158 }
1159
1160 fn synthesize_result(&mut self, outcome: RpcOutcome, request_id: u64) {
1176 let last_cost_usd = self
1177 .last_usage
1178 .as_ref()
1179 .and_then(|u| u.get("cost"))
1180 .filter(|cost| {
1181 cost.get("currency").and_then(Value::as_str) == Some("USD")
1182 && cost.get("amount").and_then(Value::as_f64).is_some()
1183 })
1184 .and_then(|cost| cost.get("amount").and_then(Value::as_f64));
1185 let (text, is_error, raw) = match outcome {
1186 RpcOutcome::Result(result) => {
1187 let stop_reason = result.get("stopReason").and_then(Value::as_str);
1188 (
1189 std::mem::take(&mut self.message_text),
1190 stop_reason != Some("end_turn"),
1191 json!({
1192 "promptResponse": result,
1193 "usageUpdate": self.last_usage,
1194 "synthesizedBy": "backend_acp",
1195 "stopReason": stop_reason,
1199 }),
1200 )
1201 }
1202 RpcOutcome::Error(error) => (
1203 format!("acp session/prompt failed: {error}"),
1204 true,
1205 json!({
1206 "promptError": error,
1207 "requestId": request_id,
1208 "synthesizedBy": "backend_acp",
1209 }),
1210 ),
1211 };
1212 let event = AgentEvent::Result {
1213 text,
1214 is_error,
1215 usage: TokenUsage::default(),
1216 cost_usd: last_cost_usd,
1217 num_turns: Some(1),
1218 raw,
1219 };
1220 self.observe(&event);
1221 self.queue.push_back(event);
1222 }
1223
1224 fn observe(&mut self, event: &AgentEvent) {
1225 if let AgentEvent::Result { is_error, .. } = event {
1226 self.saw_result = true;
1227 if !is_error {
1228 self.saw_success_result = true;
1229 }
1230 }
1231 }
1232
1233 async fn kill_child(&mut self) {
1238 #[cfg(unix)]
1239 {
1240 let pgid = self
1241 .child
1242 .id()
1243 .and_then(|pid| i32::try_from(pid).ok())
1244 .filter(|pid| *pid > 0);
1245 let group_killed = matches!(pgid, Some(pgid) if kill_group(pgid));
1246 if !group_killed {
1247 let _ = self.child.start_kill();
1248 }
1249 let _ = self.child.wait().await;
1250 if group_killed {
1251 if let Some(pgid) = pgid {
1252 let _ = kill_group(pgid);
1253 }
1254 }
1255 }
1256 #[cfg(windows)]
1257 {
1258 match &self.job {
1259 Some(job) => job.kill(),
1260 None => {
1261 let _ = self.child.start_kill();
1262 }
1263 }
1264 let _ = self.child.wait().await;
1265 }
1266 #[cfg(all(not(unix), not(windows)))]
1267 {
1268 let _ = self.child.start_kill();
1269 let _ = self.child.wait().await;
1270 }
1271 if let Some(task) = self.stderr_task.take() {
1272 let _ = task.await;
1273 }
1274 }
1275
1276 async fn finish_at_eof(&mut self) {
1277 let status = self.child.wait().await;
1278 if let Some(task) = self.stderr_task.take() {
1279 let _ = task.await;
1280 }
1281 let exit = match status {
1282 Ok(status) if status.success() && self.saw_result => SessionExit::Completed,
1283 Ok(status) => SessionExit::Failed(format!(
1284 "acp agent exited with {status}{}; stderr tail: {}",
1285 if self.saw_result {
1286 ""
1287 } else {
1288 " without answering session/prompt"
1289 },
1290 self.stderr_tail(),
1291 )),
1292 Err(e) => SessionExit::Failed(format!(
1293 "failed to reap acp agent process: {e}; stderr tail: {}",
1294 self.stderr_tail(),
1295 )),
1296 };
1297 self.exit = Some(exit);
1298 }
1299
1300 fn stderr_tail(&self) -> String {
1301 let captured = self
1302 .stderr_buf
1303 .lock()
1304 .map(|guard| guard.clone())
1305 .unwrap_or_default();
1306 last_chars(captured.trim_end(), STDERR_TAIL_CHARS)
1307 }
1308}
1309
1310#[async_trait::async_trait]
1311impl AgentSession for AcpSession {
1312 fn session_id(&self) -> String {
1313 self.session_id.clone()
1314 }
1315
1316 async fn next_event(&mut self) -> Result<Option<AgentEvent>> {
1317 loop {
1318 if let Some(event) = self.queue.pop_front() {
1319 return Ok(Some(event));
1320 }
1321 if self.exit.is_some() {
1322 return Ok(None);
1323 }
1324 let frame = match self.read_frame().await {
1325 Ok(Some(frame)) => frame,
1326 Ok(None) => {
1327 self.finish_at_eof().await;
1328 return Ok(None);
1329 }
1330 Err(e) => {
1331 self.kill_child().await;
1332 self.exit = Some(SessionExit::Failed(e.to_string()));
1333 return Ok(None);
1334 }
1335 };
1336 if let Err(e) = self.handle_frame(frame).await {
1337 self.kill_child().await;
1340 self.exit = Some(SessionExit::Failed(e.to_string()));
1341 return Ok(None);
1342 }
1343 }
1344 }
1345
1346 async fn send_user_message(&mut self, text: &str) -> Result<()> {
1347 if self.exit.is_some() {
1348 return Err(EngineError::Backend(
1349 "acp session is closed; cannot send further messages".to_string(),
1350 ));
1351 }
1352 if self.acp_session_id.is_none() {
1353 return Err(EngineError::Backend(
1354 "acp session not established yet; cannot send a message".to_string(),
1355 ));
1356 }
1357 self.send_prompt(text).await
1358 }
1359
1360 async fn abort(&mut self) -> Result<()> {
1361 if let Some(acp_session_id) = self.acp_session_id.clone() {
1366 let _ = self
1367 .write_message(json!({
1368 "jsonrpc": "2.0",
1369 "method": method::SESSION_CANCEL,
1370 "params": { "sessionId": acp_session_id },
1371 }))
1372 .await;
1373 }
1374 let already_exited = matches!(self.child.try_wait(), Ok(Some(_)));
1375 self.kill_child().await;
1376 if self.saw_success_result && already_exited {
1377 self.exit = Some(SessionExit::Completed);
1378 } else {
1379 self.exit = Some(SessionExit::Aborted);
1380 }
1381 Ok(())
1382 }
1383
1384 fn exit_status(&self) -> Option<SessionExit> {
1385 self.exit.clone()
1386 }
1387}
1388
1389#[cfg(test)]
1392mod tests {
1393 use super::*;
1394
1395 #[test]
1396 fn backend_acp_classify_distinguishes_response_request_notification() {
1397 let response =
1398 classify_line(r#"{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}"#);
1399 assert!(matches!(
1400 response,
1401 Frame::Response {
1402 id: 3,
1403 outcome: RpcOutcome::Result(_)
1404 }
1405 ));
1406 let error =
1407 classify_line(r#"{"jsonrpc":"2.0","id":4,"error":{"code":-32603,"message":"boom"}}"#);
1408 assert!(matches!(
1409 error,
1410 Frame::Response {
1411 id: 4,
1412 outcome: RpcOutcome::Error(_)
1413 }
1414 ));
1415 let request = classify_line(
1416 r#"{"jsonrpc":"2.0","id":100,"method":"session/request_permission","params":{}}"#,
1417 );
1418 assert!(matches!(
1419 request,
1420 Frame::Request { ref method, .. } if method == "session/request_permission"
1421 ));
1422 let notification = classify_line(
1423 r#"{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"s","update":{"sessionUpdate":"plan"}}}"#,
1424 );
1425 assert!(matches!(
1426 notification,
1427 Frame::Notification { ref method, .. } if method == "session/update"
1428 ));
1429 let torn = classify_line(r#"{"jsonrpc":"2.0","method":"session/upda"#);
1431 assert!(matches!(torn, Frame::Unrecognized(_)));
1432 }
1433
1434 #[test]
1435 fn backend_acp_wildcard_match_anchors_like_a_shell_glob() {
1436 assert!(wildcard_match("git push*", "git push origin main"));
1437 assert!(wildcard_match("git push*", "git push"));
1438 assert!(!wildcard_match("git push*", "git pull"));
1439 assert!(wildcard_match("*", "anything"));
1440 assert!(wildcard_match(
1441 "cargo * --workspace",
1442 "cargo test --workspace"
1443 ));
1444 assert!(!wildcard_match(
1445 "cargo * --workspace",
1446 "cargo test --package x"
1447 ));
1448 assert!(wildcard_match("*/etc/passwd", "/etc/passwd"));
1449 assert!(!wildcard_match("*/etc/passwd", "/etc/passwd.bak"));
1450 }
1451
1452 #[test]
1453 fn backend_acp_pattern_matches_maps_claude_names_to_acp_kinds() {
1454 assert!(pattern_matches(
1455 "Bash(git push*)",
1456 "execute",
1457 "git push origin main"
1458 ));
1459 assert!(!pattern_matches("Bash(git push*)", "execute", "git pull"));
1460 assert!(!pattern_matches(
1461 "Bash(git push*)",
1462 "edit",
1463 "git push origin main"
1464 ));
1465 assert!(pattern_matches("Write", "edit", "/repo/src/main.rs"));
1466 assert!(!pattern_matches("Write", "read", "/repo/src/main.rs"));
1467 assert!(pattern_matches("Edit(/etc/*)", "edit", "/etc/hosts"));
1468 assert!(pattern_matches("Read", "read", "/anywhere"));
1469 assert!(!pattern_matches("NotAClaudeTool(*)", "execute", "x"));
1471 }
1472
1473 fn spec_with(writable: bool, disallowed: &[&str]) -> SessionSpec {
1474 SessionSpec {
1475 cwd: PathBuf::from("."),
1476 prompt: PromptMode::SingleShot("do the thing".to_string()),
1477 append_system_prompt: None,
1478 model: "acp-model".to_string(),
1479 effort: "high".to_string(),
1480 session_id: "sess-1".to_string(),
1481 resume: None,
1482 permission_mode: None,
1483 allowed_tools: vec![],
1484 disallowed_tools: disallowed.iter().map(|s| s.to_string()).collect(),
1485 tools: vec![],
1486 writable,
1487 settings_json: None,
1488 json_schema: None,
1489 max_budget_usd: None,
1490 max_turns: None,
1491 env: Default::default(),
1492 sandbox: None,
1493 hook_status: None,
1494 }
1495 }
1496
1497 #[test]
1498 fn backend_acp_permission_denies_disallowed_and_mutating_kinds() {
1499 let spec = spec_with(true, &["Bash(git push*)"]);
1500 let push = ToolCallInfo {
1501 kind: "execute".to_string(),
1502 title: "git push origin main".to_string(),
1503 subject: "git push origin main".to_string(),
1504 };
1505 assert!(matches!(
1506 decide_permission(&spec, &push),
1507 PermissionDecision::Deny(reason) if reason.contains("Bash(git push*)")
1508 ));
1509 let test = ToolCallInfo {
1510 kind: "execute".to_string(),
1511 title: "cargo test".to_string(),
1512 subject: "cargo test".to_string(),
1513 };
1514 assert_eq!(decide_permission(&spec, &test), PermissionDecision::Allow);
1515
1516 let ro = spec_with(false, &[]);
1518 let edit = ToolCallInfo {
1519 kind: "edit".to_string(),
1520 title: "write src/main.rs".to_string(),
1521 subject: "/repo/src/main.rs".to_string(),
1522 };
1523 assert!(matches!(
1524 decide_permission(&ro, &edit),
1525 PermissionDecision::Deny(reason) if reason.contains("writable: false")
1526 ));
1527 assert_eq!(decide_permission(&ro, &test), PermissionDecision::Allow);
1528 let read = ToolCallInfo {
1529 kind: "read".to_string(),
1530 title: "read src/main.rs".to_string(),
1531 subject: "/repo/src/main.rs".to_string(),
1532 };
1533 assert_eq!(decide_permission(&ro, &read), PermissionDecision::Allow);
1534 }
1535
1536 #[test]
1541 fn backend_acp_permission_denies_when_the_subject_is_missing() {
1542 let spec = spec_with(true, &["Bash(git push*)"]);
1543 let no_subject = ToolCallInfo {
1544 kind: "execute".to_string(),
1545 title: String::new(),
1546 subject: String::new(),
1547 };
1548 assert!(
1549 matches!(
1550 decide_permission(&spec, &no_subject),
1551 PermissionDecision::Deny(ref reason)
1552 if reason.contains("no subject") && reason.contains("Bash(git push*)")
1553 ),
1554 "got {:?}",
1555 decide_permission(&spec, &no_subject)
1556 );
1557
1558 let blank_subject = ToolCallInfo {
1560 subject: " ".to_string(),
1561 ..no_subject.clone()
1562 };
1563 assert!(matches!(
1564 decide_permission(&spec, &blank_subject),
1565 PermissionDecision::Deny(_)
1566 ));
1567
1568 let read_no_subject = ToolCallInfo {
1571 kind: "read".to_string(),
1572 ..no_subject.clone()
1573 };
1574 assert_eq!(
1575 decide_permission(&spec_with(true, &["Bash(git push*)"]), &read_no_subject),
1576 PermissionDecision::Allow
1577 );
1578 }
1579
1580 #[test]
1584 fn backend_acp_read_only_denies_an_unclassifiable_kind() {
1585 let ro = spec_with(false, &[]);
1586 for kind in ["", "other"] {
1587 let call = ToolCallInfo {
1588 kind: kind.to_string(),
1589 title: "do something".to_string(),
1590 subject: "/repo/src/main.rs".to_string(),
1591 };
1592 assert!(
1593 matches!(
1594 decide_permission(&ro, &call),
1595 PermissionDecision::Deny(ref reason) if reason.contains("kind")
1596 ),
1597 "kind {kind:?} got {:?}",
1598 decide_permission(&ro, &call)
1599 );
1600 }
1601 let writable = spec_with(true, &[]);
1604 let other = ToolCallInfo {
1605 kind: "other".to_string(),
1606 title: "think".to_string(),
1607 subject: "think".to_string(),
1608 };
1609 assert_eq!(
1610 decide_permission(&writable, &other),
1611 PermissionDecision::Allow
1612 );
1613 }
1614
1615 #[test]
1616 fn backend_acp_permission_response_picks_options_or_cancels() {
1617 let options = vec![
1618 json!({ "optionId": "allow-1", "name": "Allow", "kind": "allow_once" }),
1619 json!({ "optionId": "reject-1", "name": "Reject", "kind": "reject_once" }),
1620 ];
1621 let allow = permission_response(&PermissionDecision::Allow, &options);
1622 assert_eq!(allow["outcome"]["optionId"], json!("allow-1"));
1623 let deny = permission_response(&PermissionDecision::Deny("nope".to_string()), &options);
1624 assert_eq!(deny["outcome"]["optionId"], json!("reject-1"));
1625 let deny_no_reject = permission_response(
1628 &PermissionDecision::Deny("nope".to_string()),
1629 &[options[0].clone()],
1630 );
1631 assert_eq!(deny_no_reject["outcome"]["outcome"], json!("cancelled"));
1632 }
1633}