//! ACP stdio clients for driving `kigi agent stdio` end-to-end: the typed //! [`KigiStdioClient`] and the raw-wire [`RawStdioClient`], which emits //! verbatim JSON-RPC lines for shapes the typed client cannot produce. use std::path::Path; use std::sync::Arc; use std::sync::atomic::{AtomicU32, Ordering}; use std::time::Duration; use agent_client_protocol::{self as acp, Agent as _}; use kigi_acp_lib::LineBufferedRead; use tempfile::TempDir; use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; use crate::env::{kigi_binary, test_env_cmd_tokio}; use crate::headless::stderr_tail; use crate::mock_server::MockInferenceServer; use crate::process::spawn_piped_with_stderr_capture; /// Spawn `kigi agent stdio` with the hermetic test env. `leading_args` go /// before the `agent stdio` subcommand (global flags); `extra_env` is applied /// after the debug-logging kill-list so a test can still set e.g. /// `KIGI_DEBUG_LOG=1` explicitly. fn spawn_agent_process( server: &MockInferenceServer, cwd: &Path, home: &Path, extra_env: &[(&str, &str)], leading_args: &[&str], ) -> (tokio::process::Child, Arc>>) { let binary = kigi_binary(); let mut cmd = tokio::process::Command::new(&binary); cmd.args(leading_args) .args(["agent", "stdio"]) .current_dir(cwd); test_env_cmd_tokio(&mut cmd, &server.url(), home); // Clear inherited debug-logging knobs so a test controls logging only via // `extra_env` / `leading_args`. for k in [ "KIGI_DEBUG_LOG", "KIGI_LOG_FILE", "KIGI_LOG_SAMPLING", "KIGI_HOOKS_LOG", ] { cmd.env_remove(k); } for (k, v) in extra_env { cmd.env(k, v); } spawn_piped_with_stderr_capture(cmd) } #[derive(Default)] struct TextCapture { chunks: std::sync::Mutex>, notification_count: AtomicU32, } /// ACP client that auto-approves every permission request. struct TestAcpClient { capture: Arc, } #[async_trait::async_trait(?Send)] impl acp::Client for TestAcpClient { async fn request_permission( &self, args: acp::RequestPermissionRequest, ) -> acp::Result { let outcome = args .options .iter() .find(|o| o.kind == acp::PermissionOptionKind::AllowOnce) .or(args.options.first()) .map(|o| { acp::RequestPermissionOutcome::Selected(acp::SelectedPermissionOutcome::new( o.option_id.clone(), )) }) .unwrap_or(acp::RequestPermissionOutcome::Cancelled); Ok(acp::RequestPermissionResponse::new(outcome)) } async fn session_notification(&self, args: acp::SessionNotification) -> acp::Result<()> { self.capture .notification_count .fetch_add(1, Ordering::SeqCst); if let acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk { content, .. }) = args.update && let acp::ContentBlock::Text(text_content) = content && !text_content.text.is_empty() { self.capture.chunks.lock().unwrap().push(text_content.text); } Ok(()) } } /// Drives `kigi agent stdio` via the ACP protocol over pipes: spawn → /// initialize → authenticate → session → prompt. The child process is killed /// on drop. pub struct KigiStdioClient { conn: acp::ClientSideConnection, _child: tokio::process::Child, home: Option, capture: Arc, stderr: Arc>>, } impl KigiStdioClient { pub async fn spawn(server: &MockInferenceServer, cwd: &Path) -> Self { let home = TempDir::new().expect("create temp home"); Self::spawn_with_home(server, cwd, home).await } pub async fn spawn_with_home(server: &MockInferenceServer, cwd: &Path, home: TempDir) -> Self { Self::spawn_with_home_and_env(server, cwd, home, &[]).await } /// Like [`spawn_with_home`] but applies extra environment variables to the /// child process, after the standard test env. pub async fn spawn_with_home_and_env( server: &MockInferenceServer, cwd: &Path, home: TempDir, extra_env: &[(&str, &str)], ) -> Self { Self::spawn_with_home_env_and_args(server, cwd, home, extra_env, &[]).await } /// Like [`spawn_with_home_and_env`] but also prepends `leading_args` before /// the `agent stdio` subcommand, so a test can exercise top-level global /// flags such as `--debug` rather than only their env equivalents. pub async fn spawn_with_home_env_and_args( server: &MockInferenceServer, cwd: &Path, home: TempDir, extra_env: &[(&str, &str)], leading_args: &[&str], ) -> Self { let (mut child, stderr) = spawn_agent_process(server, cwd, home.path(), extra_env, leading_args); let outgoing = child.stdin.take().unwrap().compat_write(); let incoming = child.stdout.take().unwrap().compat(); let capture = Arc::new(TextCapture::default()); let client = TestAcpClient { capture: capture.clone(), }; let incoming = LineBufferedRead::spawn_local(incoming); let (conn, handle_io) = acp::ClientSideConnection::new(client, outgoing, incoming, |fut| { tokio::task::spawn_local(fut); }); tokio::task::spawn_local(handle_io); Self { conn, _child: child, home: Some(home), capture, stderr, } } /// Initialize and authenticate (picks `api_key` auth method). pub async fn initialize(&self) -> acp::InitializeResponse { let init_resp = self .conn .initialize( acp::InitializeRequest::new(acp::ProtocolVersion::V1) .client_capabilities( acp::ClientCapabilities::new() .fs(acp::FileSystemCapabilities::new()) .terminal(false), ) .meta( serde_json::json!({ "startupHints": { "nonInteractive": true, "skipGitStatus": true, "skipProjectLayout": true }, "clientType": "test-client", "clientVersion": "0.0.0-test" }) .as_object() .cloned(), ), ) .await .expect("initialize failed"); let api_key_method = init_resp .auth_methods .iter() .find(|m| &*m.id().0 == "xai.api_key") .unwrap_or_else(|| { let ids: Vec<_> = init_resp.auth_methods.iter().map(|m| &m.id().0).collect(); panic!( "expected auth method 'xai.api_key' but got: {ids:?}\n\ If the method ID changed, update this test." ) }); self.conn .authenticate( acp::AuthenticateRequest::new(api_key_method.id().clone()) .meta(serde_json::json!({"headless": true}).as_object().cloned()), ) .await .expect("authenticate failed"); init_resp } pub async fn create_session(&self, cwd: &Path) -> acp::SessionId { let resp = self .conn .new_session(acp::NewSessionRequest::new(cwd.to_path_buf()).mcp_servers(vec![])) .await .expect("session/new failed"); resp.session_id } pub async fn create_session_with_model(&self, cwd: &Path, model_id: &str) -> acp::SessionId { let resp = self .conn .new_session( acp::NewSessionRequest::new(cwd.to_path_buf()) .mcp_servers(vec![]) .meta( serde_json::json!({ "modelId": model_id }) .as_object() .cloned(), ), ) .await .expect("session/new with modelId failed"); resp.session_id } pub async fn set_model( &self, session_id: &acp::SessionId, model_id: &str, ) -> acp::Result { use acp::Agent as _; self.conn .set_session_model(acp::SetSessionModelRequest::new( session_id.clone(), acp::ModelId::new(model_id), )) .await } pub async fn prompt( &self, session_id: &acp::SessionId, text: &str, ) -> acp::Result { self.conn .prompt(acp::PromptRequest::new( session_id.clone(), vec![acp::ContentBlock::Text(acp::TextContent::new( text.to_string(), ))], )) .await } pub fn captured_text(&self) -> String { self.capture.chunks.lock().unwrap().join("") } pub fn notification_count(&self) -> u32 { self.capture.notification_count.load(Ordering::SeqCst) } pub fn stderr(&self) -> String { String::from_utf8_lossy(&self.stderr.lock().unwrap()).into_owned() } pub fn take_home(&mut self) -> TempDir { self.home.take().expect("test home already taken") } pub fn home_path(&self) -> &std::path::Path { self.home.as_ref().expect("test home already taken").path() } pub async fn initialize_with_timeout(&self) -> acp::InitializeResponse { tokio::time::timeout(Duration::from_secs(20), self.initialize()) .await .unwrap_or_else(|_| panic!("initialize timed out\nstderr:\n{}", self.stderr())) } pub async fn create_session_with_timeout(&self, cwd: &Path) -> acp::SessionId { tokio::time::timeout(Duration::from_secs(20), self.create_session(cwd)) .await .unwrap_or_else(|_| panic!("session/new timed out\nstderr:\n{}", self.stderr())) } pub async fn create_session_with_model_timeout( &self, cwd: &Path, model_id: &str, ) -> acp::SessionId { tokio::time::timeout( Duration::from_secs(20), self.create_session_with_model(cwd, model_id), ) .await .unwrap_or_else(|_| { panic!( "session/new with modelId={model_id} timed out\nstderr:\n{}", self.stderr() ) }) } pub async fn set_model_with_timeout( &self, session_id: &acp::SessionId, model_id: &str, ) -> acp::Result { tokio::time::timeout( Duration::from_secs(20), self.set_model(session_id, model_id), ) .await .unwrap_or_else(|_| { panic!( "session/set_model({model_id}) timed out\nstderr:\n{}", self.stderr() ) }) } pub async fn prompt_with_timeout( &self, session_id: &acp::SessionId, text: &str, ) -> acp::Result { tokio::time::timeout(Duration::from_secs(30), self.prompt(session_id, text)) .await .unwrap_or_else(|_| panic!("prompt timed out\nstderr:\n{}", self.stderr())) } pub async fn load_session_with_timeout( &self, session_id: &acp::SessionId, cwd: &Path, ) -> acp::LoadSessionResponse { // 60s: session/load replays history and is slower under Rosetta // (macos-x86_64 lifecycle CI). 20s flaked repeatedly there. tokio::time::timeout( Duration::from_secs(60), self.conn.load_session( acp::LoadSessionRequest::new(session_id.clone(), cwd.to_path_buf()) .mcp_servers(vec![]), ), ) .await .unwrap_or_else(|_| panic!("session/load timed out\nstderr:\n{}", self.stderr())) .expect("session/load failed") } pub async fn ext_method( &self, method: &str, params: serde_json::Value, ) -> acp::Result { let raw = serde_json::value::RawValue::from_string(params.to_string()) .expect("serialize ext params"); self.conn .ext_method(acp::ExtRequest::new(method, std::sync::Arc::from(raw))) .await } } /// Drives `kigi agent stdio` with verbatim newline-delimited JSON-RPC lines. /// /// Exists for wire shapes the typed [`KigiStdioClient`] (`ClientSideConnection`, /// integer ids) can never produce — e.g. Xcode's Swift/Foundation `JSONEncoder` /// output: escaped-slash methods (`"session\/prompt"`) and string UUID request /// ids. The child process is killed on drop. pub struct RawStdioClient { stdin: tokio::process::ChildStdin, stdout: tokio::io::BufReader, stderr: Arc>>, _child: tokio::process::Child, _home: TempDir, } impl RawStdioClient { pub async fn spawn(server: &MockInferenceServer, cwd: &Path) -> Self { let home = TempDir::new().expect("create temp home"); let (mut child, stderr) = spawn_agent_process(server, cwd, home.path(), &[], &[]); let stdin = child.stdin.take().expect("child stdin missing"); let child_stdout = child.stdout.take().expect("child stdout missing"); Self { stdin, stdout: tokio::io::BufReader::new(child_stdout), stderr, _child: child, _home: home, } } pub fn stderr(&self) -> String { String::from_utf8_lossy(&self.stderr.lock().unwrap()).into_owned() } /// Write `line` verbatim — no re-encoding — followed by `\n`, then flush. pub async fn send_line(&mut self, line: &str) { use tokio::io::AsyncWriteExt as _; self.stdin .write_all(line.as_bytes()) .await .expect("write line to agent stdin"); self.stdin.write_all(b"\n").await.expect("write newline"); self.stdin.flush().await.expect("flush agent stdin"); } /// Read stdout lines until the response to `id` arrives (no `method` key + /// exact string-id match). Returning IS the id-echo assertion: an id echoed /// with different bytes or as a different JSON type never matches and /// surfaces in the timeout diagnostics instead. Any agent→client request is /// refused with a JSON-RPC error so a turn can never hang on this /// capability-less client. The timeout panic reports how much non-matching /// traffic was seen — 0 means true silence, the acp-0.6 escaped-method /// symptom. pub async fn response_for_id( &mut self, id: &str, what: &str, timeout: Duration, ) -> serde_json::Value { use tokio::io::AsyncBufReadExt as _; let deadline = tokio::time::Instant::now() + timeout; let mut line = String::new(); let mut skipped = 0_usize; let mut skipped_tail: Vec = Vec::new(); loop { line.clear(); let next_line = self.stdout.read_line(&mut line); let Ok(io_result) = tokio::time::timeout_at(deadline, next_line).await else { panic!( "{what}: no matching response within {timeout:?} ({skipped} other messages \ seen; last: {skipped_tail:?})\nstderr:\n{}", stderr_tail(&self.stderr(), 1200) ); }; let read = io_result.unwrap_or_else(|e| panic!("{what}: agent stdout read failed: {e}")); if read == 0 { panic!( "{what}: agent closed stdout before responding ({skipped} other messages \ seen)\nstderr:\n{}", stderr_tail(&self.stderr(), 1200) ); } let Ok(msg) = serde_json::from_str::(line.trim_end()) else { push_skipped_tail(&mut skipped, &mut skipped_tail, &line); continue; }; let is_response = msg.get("method").is_none(); if is_response && msg.get("id").and_then(|v| v.as_str()) == Some(id) { return msg; } push_skipped_tail(&mut skipped, &mut skipped_tail, &line); if !is_response && let Some(req_id) = msg.get("id") { let refusal = serde_json::json!({ "jsonrpc": "2.0", "id": req_id, "error": { "code": -32601, "message": "unsupported by raw test client" }, }); self.send_line(&refusal.to_string()).await; } } } } /// Record a non-matching line for [`RawStdioClient::response_for_id`]'s timeout /// diagnostics. fn push_skipped_tail(skipped: &mut usize, tail: &mut Vec, line: &str) { *skipped += 1; if tail.len() == 3 { tail.remove(0); } tail.push(line.trim_end().chars().take(200).collect()); }