//! The headless ACP/L4 client, deterministically: drive `run_acp_prompt` against //! a mock ACP agent (a tiny Python script that speaks the same newline-delimited //! JSON-RPC handshake a real vendor CLI would) — proving the client does //! initialize → session/new → session/prompt, assembles streamed assistant text, //! and auto-answers a permission request, with no real vendor binary or login. use joule_coded::acp::run_acp_prompt; use std::io::Write; /// A mock ACP agent: replies to initialize + session/new, then on session/prompt /// asks one permission, streams two text chunks, and ends the turn. const MOCK_AGENT: &str = r#" import sys, json def send(obj): sys.stdout.write(json.dumps(obj) + "\n"); sys.stdout.flush() for line in sys.stdin: line = line.strip() if not line: continue msg = json.loads(line) method, mid = msg.get("method"), msg.get("id") if method == "initialize": send({"jsonrpc":"2.0","id":mid,"result":{"protocolVersion":1,"agentCapabilities":{}}}) elif method == "session/new": send({"jsonrpc":"2.0","id":mid,"result":{"sessionId":"sess-1"}}) elif method == "session/prompt": # ask permission (agent->client request id 100) send({"jsonrpc":"2.0","id":100,"method":"session/request_permission", "params":{"sessionId":"sess-1","options":[ {"optionId":"allow","name":"Allow","kind":"allow_once"}, {"optionId":"reject","name":"Reject","kind":"reject_once"}]}}) # read the client's permission answer ans = json.loads(sys.stdin.readline().strip()) # stream two assistant chunks for chunk in ["Hello ", "from ACP"]: send({"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"sess-1", "update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":chunk}}}}) send({"jsonrpc":"2.0","id":mid,"result":{"stopReason":"end_turn"}}) break "#; #[test] // Availability probe: blocking on `python3 --version` is fine in a test. #[allow(clippy::disallowed_methods)] fn headless_acp_prompt_handshakes_and_assembles_streamed_text() { if std::process::Command::new("python3") .arg("--version") .output() .is_err() { eprintln!("skipping: python3 not available"); return; } let dir = std::env::temp_dir().join(format!("jc-acp-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let script = dir.join("mock_agent.py"); std::fs::File::create(&script) .unwrap() .write_all(MOCK_AGENT.as_bytes()) .unwrap(); let outcome = run_acp_prompt( "python3", &[script.to_string_lossy().to_string()], &dir, "say hello", true, // allow tools — approve the permission request ) .expect("ACP prompt should complete against the mock agent"); assert_eq!(outcome.text, "Hello from ACP", "assembled streamed chunks"); assert_eq!(outcome.stop_reason, "end_turn"); assert_eq!(outcome.permissions_granted, 1, "auto-approved one permission"); // With tools refused, the same run still completes (the agent ends the turn). let refused = run_acp_prompt( "python3", &[script.to_string_lossy().to_string()], &dir, "say hello", false, ) .expect("completes even when permission is refused"); assert_eq!(refused.permissions_granted, 0, "no permission granted when refused"); std::fs::remove_dir_all(&dir).ok(); }