//! Integration tests for leader mode with stdio clients. //! //! These tests verify that the leader server correctly handles stdio clients, //! including message routing, multiple client connections, and proper cleanup. //! //! Currently Unix-only: the tests use `tokio::net::UnixStream` directly //! (rather than the leader's `LeaderStream` transport abstraction) so they //! exercise the on-disk socket path. Equivalent Windows coverage would //! need to go through `LeaderStream`/`LeaderListener` and is tracked as a //! follow-up. #![cfg(unix)] use std::time::Duration; use kigi_shell::cpu_profile::ControlErrorCode; use kigi_shell::leader::{ ClientCapabilities, ClientMode, ControlCommand, ControlPayload, LeaderClient, LeaderServerControlState, LeaderServerMetadata, protocol::{ClientMessage, ServerMessage, read_message, write_message}, spawn_leader_server, }; use tempfile::TempDir; use tokio::net::UnixStream; /// Pipe character used for ID namespacing (must match server.rs) const ID_NAMESPACE_SEP: char = '|'; /// Wait for socket file to exist and be connectable. async fn wait_for_socket(sock_path: &std::path::Path) { let deadline = tokio::time::Instant::now() + Duration::from_secs(5); while tokio::time::Instant::now() < deadline { if sock_path.exists() { // Try to connect to verify it's actually listening if UnixStream::connect(sock_path).await.is_ok() { return; } } tokio::time::sleep(Duration::from_millis(10)).await; } panic!("Timeout waiting for socket to become available"); } /// Helper to set up a test server and return its socket path and handle. /// Waits for the socket to be connectable rather than using a fixed sleep. async fn setup_test_server( temp: &TempDir, ) -> ( std::path::PathBuf, tokio_util::sync::CancellationToken, tokio::sync::mpsc::UnboundedReceiver, tokio::sync::mpsc::UnboundedSender, ) { let sock_path = temp.path().join("leader.sock"); let handle = spawn_leader_server(sock_path.clone()).await.unwrap(); // Wait for socket to be connectable instead of fixed sleep wait_for_socket(&sock_path).await; (sock_path, handle.cancel, handle.acp_rx, handle.response_tx) } async fn setup_control_test_server( temp: &TempDir, ) -> (std::path::PathBuf, kigi_shell::leader::ServerHandle) { let sock_path = temp.path().join("leader-control.sock"); let handle = spawn_leader_server(sock_path.clone()).await.unwrap(); wait_for_socket(&sock_path).await; (sock_path, handle) } /// Parse a namespaced ID to extract client ID and original ID JSON. /// Format: "client_idoriginal_id_json" fn parse_namespaced_id(namespaced_id: &str) -> Option<(u64, String)> { let (client_part, original_json) = namespaced_id.split_once(ID_NAMESPACE_SEP)?; let client_id: u64 = client_part.parse().ok()?; Some((client_id, original_json.to_string())) } /// Check if a namespaced ID ends with the given original ID value. fn namespaced_id_has_original(namespaced_id: &str, expected_original: &str) -> bool { if let Some((_, original_json)) = parse_namespaced_id(namespaced_id) { original_json == expected_original } else { false } } /// Test that a single stdio client can connect to the leader server. #[tokio::test] async fn test_single_stdio_client_connects() { let temp = TempDir::new().unwrap(); let (sock_path, cancel, _acp_rx, _response_tx) = setup_test_server(&temp).await; // Connect as stdio client let client = LeaderClient::connect( sock_path, "test-stdio", ClientMode::Stdio, ClientCapabilities::default(), ) .await .unwrap(); // Should be connected client.cancel(); cancel.cancel(); } /// Test that a stdio client can send ACP messages and the server receives them. #[tokio::test] async fn test_stdio_client_sends_acp_message() { let temp = TempDir::new().unwrap(); let (sock_path, cancel, mut acp_rx, _response_tx) = setup_test_server(&temp).await; let client = LeaderClient::connect( sock_path, "test-stdio", ClientMode::Stdio, ClientCapabilities::default(), ) .await .unwrap(); // Send an ACP message (JSON-RPC format) let test_message = r#"{"jsonrpc":"2.0","method":"initialize","id":1}"#; client.send(test_message.to_string()).unwrap(); // Server should receive the message (with namespaced ID) let received = tokio::time::timeout(Duration::from_secs(2), acp_rx.recv()) .await .expect("timeout waiting for message") .expect("channel closed"); // Parse and verify the message was received (ID will be namespaced) let json: serde_json::Value = serde_json::from_str(&received).unwrap(); assert_eq!(json["method"], "initialize"); // ID should be namespaced with format "client_id1" where 1 is the original ID assert!( namespaced_id_has_original(json["id"].as_str().unwrap(), "1"), "ID should contain original ID 1" ); client.cancel(); cancel.cancel(); } /// Test that a stdio client can receive responses from the server. #[tokio::test] async fn test_stdio_client_receives_response() { let temp = TempDir::new().unwrap(); let (sock_path, cancel, mut acp_rx, response_tx) = setup_test_server(&temp).await; let mut client = LeaderClient::connect( sock_path, "test-stdio", ClientMode::Stdio, ClientCapabilities::default(), ) .await .unwrap(); // Send request let test_message = r#"{"jsonrpc":"2.0","method":"test","id":42}"#; client.send(test_message.to_string()).unwrap(); // Get the namespaced ID from the server's view let received = acp_rx.recv().await.unwrap(); let json: serde_json::Value = serde_json::from_str(&received).unwrap(); let namespaced_id = json["id"].as_str().unwrap().to_string(); // Send response with namespaced ID (server will restore original ID) let response = format!( r#"{{"jsonrpc":"2.0","result":{{"data":"test"}},"id":"{}"}}"#, namespaced_id ); response_tx.send(response).unwrap(); // Client should receive response with original ID restored let client_response = tokio::time::timeout(Duration::from_secs(2), client.recv()) .await .expect("timeout waiting for response") .expect("channel closed"); let response_json: serde_json::Value = serde_json::from_str(&client_response).unwrap(); assert_eq!(response_json["id"], 42); assert_eq!(response_json["result"]["data"], "test"); client.cancel(); cancel.cancel(); } /// Test that multiple stdio clients can connect to the same leader. #[tokio::test] async fn test_multiple_stdio_clients() { let temp = TempDir::new().unwrap(); let (sock_path, cancel, mut acp_rx, response_tx) = setup_test_server(&temp).await; // Connect two stdio clients let mut client1 = LeaderClient::connect( sock_path.clone(), "client-1", ClientMode::Stdio, ClientCapabilities::default(), ) .await .unwrap(); let mut client2 = LeaderClient::connect( sock_path, "client-2", ClientMode::Stdio, ClientCapabilities::default(), ) .await .unwrap(); // Client 1 sends a message client1 .send(r#"{"jsonrpc":"2.0","method":"from_client_1","id":1}"#.to_string()) .unwrap(); // Client 2 sends a message client2 .send(r#"{"jsonrpc":"2.0","method":"from_client_2","id":2}"#.to_string()) .unwrap(); // Server receives both messages let msg1 = acp_rx.recv().await.unwrap(); let msg2 = acp_rx.recv().await.unwrap(); let json1: serde_json::Value = serde_json::from_str(&msg1).unwrap(); let json2: serde_json::Value = serde_json::from_str(&msg2).unwrap(); // Messages should have different namespaced IDs (different client prefixes) let id1 = json1["id"].as_str().unwrap(); let id2 = json2["id"].as_str().unwrap(); // Extract client ID prefixes using the Unit Separator let (client_id_1, _) = parse_namespaced_id(id1).expect("Should parse namespaced ID"); let (client_id_2, _) = parse_namespaced_id(id2).expect("Should parse namespaced ID"); assert_ne!( client_id_1, client_id_2, "Clients should have different IDs" ); // Send response to client 1 using its namespaced ID let response1 = format!( r#"{{"jsonrpc":"2.0","result":"response_1","id":"{}"}}"#, id1 ); response_tx.send(response1).unwrap(); // Send response to client 2 using its namespaced ID let response2 = format!( r#"{{"jsonrpc":"2.0","result":"response_2","id":"{}"}}"#, id2 ); response_tx.send(response2).unwrap(); // Each client should receive its own response let recv1 = tokio::time::timeout(Duration::from_secs(2), client1.recv()) .await .expect("timeout") .expect("closed"); let recv2 = tokio::time::timeout(Duration::from_secs(2), client2.recv()) .await .expect("timeout") .expect("closed"); let recv_json1: serde_json::Value = serde_json::from_str(&recv1).unwrap(); let recv_json2: serde_json::Value = serde_json::from_str(&recv2).unwrap(); // IDs should be restored to originals assert_eq!(recv_json1["id"], 1); assert_eq!(recv_json2["id"], 2); client1.cancel(); client2.cancel(); cancel.cancel(); } /// Test that multiple clients sending the same message IDs are correctly disambiguated. /// This verifies that ID namespacing prevents collisions when different clients /// use the same request IDs. #[tokio::test] async fn test_multiple_clients_same_message_ids() { let temp = TempDir::new().unwrap(); let (sock_path, cancel, mut acp_rx, response_tx) = setup_test_server(&temp).await; // Connect three stdio clients let mut client1 = LeaderClient::connect( sock_path.clone(), "client-1", ClientMode::Stdio, ClientCapabilities::default(), ) .await .unwrap(); let mut client2 = LeaderClient::connect( sock_path.clone(), "client-2", ClientMode::Stdio, ClientCapabilities::default(), ) .await .unwrap(); let mut client3 = LeaderClient::connect( sock_path, "client-3", ClientMode::Stdio, ClientCapabilities::default(), ) .await .unwrap(); // All three clients send messages with the SAME ID (id: 1) client1 .send(r#"{"jsonrpc":"2.0","method":"method_1","id":1}"#.to_string()) .unwrap(); client2 .send(r#"{"jsonrpc":"2.0","method":"method_2","id":1}"#.to_string()) .unwrap(); client3 .send(r#"{"jsonrpc":"2.0","method":"method_3","id":1}"#.to_string()) .unwrap(); // Collect all three messages from the server let msg1 = acp_rx.recv().await.unwrap(); let msg2 = acp_rx.recv().await.unwrap(); let msg3 = acp_rx.recv().await.unwrap(); let json1: serde_json::Value = serde_json::from_str(&msg1).unwrap(); let json2: serde_json::Value = serde_json::from_str(&msg2).unwrap(); let json3: serde_json::Value = serde_json::from_str(&msg3).unwrap(); // All messages should have namespaced IDs with original ID "1" let id1 = json1["id"].as_str().unwrap(); let id2 = json2["id"].as_str().unwrap(); let id3 = json3["id"].as_str().unwrap(); assert!( namespaced_id_has_original(id1, "1"), "ID should contain original ID 1" ); assert!( namespaced_id_has_original(id2, "1"), "ID should contain original ID 1" ); assert!( namespaced_id_has_original(id3, "1"), "ID should contain original ID 1" ); // But the full namespaced IDs should all be different (different client prefixes) assert_ne!(id1, id2, "Namespaced IDs should be unique"); assert_ne!(id2, id3, "Namespaced IDs should be unique"); assert_ne!(id1, id3, "Namespaced IDs should be unique"); // Build a map of method -> namespaced_id for targeted responses let mut method_to_id: std::collections::HashMap = std::collections::HashMap::new(); method_to_id.insert( json1["method"].as_str().unwrap().to_string(), id1.to_string(), ); method_to_id.insert( json2["method"].as_str().unwrap().to_string(), id2.to_string(), ); method_to_id.insert( json3["method"].as_str().unwrap().to_string(), id3.to_string(), ); // Send responses back using the namespaced IDs - each with a unique result let resp1 = format!( r#"{{"jsonrpc":"2.0","result":"result_for_client_1","id":"{}"}}"#, method_to_id.get("method_1").unwrap() ); let resp2 = format!( r#"{{"jsonrpc":"2.0","result":"result_for_client_2","id":"{}"}}"#, method_to_id.get("method_2").unwrap() ); let resp3 = format!( r#"{{"jsonrpc":"2.0","result":"result_for_client_3","id":"{}"}}"#, method_to_id.get("method_3").unwrap() ); response_tx.send(resp1).unwrap(); response_tx.send(resp2).unwrap(); response_tx.send(resp3).unwrap(); // Each client should receive its own response with the original ID restored let recv1 = tokio::time::timeout(Duration::from_secs(2), client1.recv()) .await .expect("timeout") .expect("closed"); let recv2 = tokio::time::timeout(Duration::from_secs(2), client2.recv()) .await .expect("timeout") .expect("closed"); let recv3 = tokio::time::timeout(Duration::from_secs(2), client3.recv()) .await .expect("timeout") .expect("closed"); let recv_json1: serde_json::Value = serde_json::from_str(&recv1).unwrap(); let recv_json2: serde_json::Value = serde_json::from_str(&recv2).unwrap(); let recv_json3: serde_json::Value = serde_json::from_str(&recv3).unwrap(); // All IDs should be restored to the original value (1) assert_eq!(recv_json1["id"], 1, "Client 1's ID should be restored to 1"); assert_eq!(recv_json2["id"], 1, "Client 2's ID should be restored to 1"); assert_eq!(recv_json3["id"], 1, "Client 3's ID should be restored to 1"); // Each client should have received its unique result assert_eq!(recv_json1["result"], "result_for_client_1"); assert_eq!(recv_json2["result"], "result_for_client_2"); assert_eq!(recv_json3["result"], "result_for_client_3"); client1.cancel(); client2.cancel(); client3.cancel(); cancel.cancel(); } /// Test that the server handles client disconnect properly. #[tokio::test] async fn test_stdio_client_disconnect() { let temp = TempDir::new().unwrap(); let (sock_path, cancel, _acp_rx, _response_tx) = setup_test_server(&temp).await; // Connect and then disconnect let stream = UnixStream::connect(&sock_path).await.unwrap(); let (mut reader, mut writer) = tokio::io::split(stream); // Register write_message( &mut writer, &ClientMessage::Register { client_type: "test".into(), mode: ClientMode::Stdio, capabilities: ClientCapabilities::default(), }, ) .await .unwrap(); let response: ServerMessage = read_message(&mut reader).await.unwrap(); assert!(matches!(response, ServerMessage::Registered { .. })); // Send disconnect message write_message(&mut writer, &ClientMessage::Disconnect) .await .unwrap(); // Give server time to process disconnect tokio::time::sleep(Duration::from_millis(100)).await; cancel.cancel(); } /// Test ping-pong heartbeat with stdio client. #[tokio::test] async fn test_stdio_client_ping_pong() { let temp = TempDir::new().unwrap(); let (sock_path, cancel, _acp_rx, _response_tx) = setup_test_server(&temp).await; let stream = UnixStream::connect(&sock_path).await.unwrap(); let (mut reader, mut writer) = tokio::io::split(stream); // Register first write_message( &mut writer, &ClientMessage::Register { client_type: "test".into(), mode: ClientMode::Stdio, capabilities: ClientCapabilities::default(), }, ) .await .unwrap(); let _: ServerMessage = read_message(&mut reader).await.unwrap(); // Send ping write_message(&mut writer, &ClientMessage::Ping) .await .unwrap(); // Should receive pong let response: ServerMessage = read_message(&mut reader).await.unwrap(); assert!(matches!(response, ServerMessage::Pong)); cancel.cancel(); } /// Test that server shuts down when all clients disconnect (after having clients). #[tokio::test] async fn test_server_exits_when_all_clients_disconnect() { let temp = TempDir::new().unwrap(); let sock_path = temp.path().join("leader.sock"); let handle = spawn_leader_server(sock_path.clone()).await.unwrap(); // Wait for socket to be connectable wait_for_socket(&sock_path).await; // Connect a client let stream = UnixStream::connect(&sock_path).await.unwrap(); let (mut reader, mut writer) = tokio::io::split(stream); write_message( &mut writer, &ClientMessage::Register { client_type: "test".into(), mode: ClientMode::Stdio, capabilities: ClientCapabilities::default(), }, ) .await .unwrap(); let _: ServerMessage = read_message(&mut reader).await.unwrap(); // Disconnect write_message(&mut writer, &ClientMessage::Disconnect) .await .unwrap(); // Server should shut down on its own (all clients disconnected) // We can verify by checking the cancel token is cancelled or socket is removed tokio::time::sleep(Duration::from_millis(200)).await; // Socket should be cleaned up // Note: The server exits when all clients disconnect, so socket may be removed // We just verify the test completes without hanging handle.cancel.cancel(); } #[tokio::test] async fn test_runtime_profile_start_status_stop_across_clients() { let temp = TempDir::new().unwrap(); let (sock_path, handle) = setup_control_test_server(&temp).await; let output_path = temp.path().join("integration-runtime-profile.folded"); let client_a = LeaderClient::connect( sock_path.clone(), "client-a", ClientMode::Stdio, ClientCapabilities::default(), ) .await .unwrap(); let client_b = LeaderClient::connect( sock_path, "client-b", ClientMode::Stdio, ClientCapabilities::default(), ) .await .unwrap(); let runtime_cpu_profile = client_a .registration() .leader_capabilities .as_ref() .is_some_and(|capabilities| capabilities.runtime_cpu_profile); if runtime_cpu_profile { // In sandboxed CI (Bazel), pprof may report as supported at compile time // but fail at runtime because signal-based sampling is blocked. let start_result = client_a .send_control(ControlCommand::StartCpuProfile { output: Some(output_path.display().to_string()), frequency_hz: Some(200), }) .await .unwrap(); let Ok(started) = start_result else { client_a.cancel(); client_b.cancel(); handle.cancel.cancel(); return; // pprof can't start in sandbox — skip }; assert!(matches!( started, ControlPayload::CpuProfileStarted { svg_path, .. } if svg_path == output_path )); let status = client_b .send_control(ControlCommand::CpuProfileStatus) .await .unwrap() .unwrap(); assert!( runtime_cpu_profile, "registration should stay consistent with status behavior" ); assert!(matches!( status, ControlPayload::CpuProfileStatus { active: true, stopping: false, svg_path: Some(path), frequency_hz: Some(200), .. } if path == output_path )); let stopped = client_b .send_control(ControlCommand::StopCpuProfile) .await .unwrap() .unwrap(); assert!(matches!( stopped, ControlPayload::CpuProfileStopped { svg_path, .. } if svg_path == output_path )); assert!(output_path.exists()); } else { let error = client_a .send_control(ControlCommand::StartCpuProfile { output: Some(output_path.display().to_string()), frequency_hz: Some(200), }) .await .unwrap() .unwrap_err(); assert_eq!(error.code, ControlErrorCode::RuntimeProfilingUnsupported); } client_a.cancel(); client_b.cancel(); handle.cancel.cancel(); } #[tokio::test] async fn test_runtime_profile_finalizes_on_graceful_shutdown() { let temp = TempDir::new().unwrap(); let (sock_path, handle) = setup_control_test_server(&temp).await; let output_path = temp.path().join("shutdown-finalized-profile.folded"); let client = LeaderClient::connect( sock_path, "client", ClientMode::Stdio, ClientCapabilities::default(), ) .await .unwrap(); let runtime_cpu_profile = client .registration() .leader_capabilities .as_ref() .is_some_and(|capabilities| capabilities.runtime_cpu_profile); if runtime_cpu_profile { let start_result = client .send_control(ControlCommand::StartCpuProfile { output: Some(output_path.display().to_string()), frequency_hz: Some(200), }) .await .unwrap(); if start_result.is_err() { // pprof can't start in sandbox — skip client.cancel(); handle.cancel.cancel(); return; } handle.cancel.cancel(); tokio::time::sleep(Duration::from_millis(200)).await; assert!(output_path.exists()); } else { let error = client .send_control(ControlCommand::StartCpuProfile { output: Some(output_path.display().to_string()), frequency_hz: Some(200), }) .await .unwrap() .unwrap_err(); assert_eq!(error.code, ControlErrorCode::RuntimeProfilingUnsupported); handle.cancel.cancel(); } client.cancel(); } #[tokio::test] async fn test_runtime_profile_creates_missing_parent_directory_end_to_end() { let temp = TempDir::new().unwrap(); let (sock_path, handle) = setup_control_test_server(&temp).await; let nested_output = temp .path() .join("nested") .join("profiles") .join("profile.folded"); let client = LeaderClient::connect( sock_path, "client", ClientMode::Stdio, ClientCapabilities::default(), ) .await .unwrap(); let runtime_cpu_profile = client .registration() .leader_capabilities .as_ref() .is_some_and(|capabilities| capabilities.runtime_cpu_profile); if runtime_cpu_profile { let start_result = client .send_control(ControlCommand::StartCpuProfile { output: Some(nested_output.display().to_string()), frequency_hz: Some(200), }) .await .unwrap(); let Ok(started) = start_result else { client.cancel(); handle.cancel.cancel(); return; // pprof can't start in sandbox — skip }; assert!(matches!( started, ControlPayload::CpuProfileStarted { svg_path, .. } if svg_path == nested_output )); let stopped = client .send_control(ControlCommand::StopCpuProfile) .await .unwrap() .unwrap(); assert!(matches!( stopped, ControlPayload::CpuProfileStopped { svg_path, .. } if svg_path == nested_output )); assert!(nested_output.exists()); } else { let error = client .send_control(ControlCommand::StartCpuProfile { output: Some(nested_output.display().to_string()), frequency_hz: Some(200), }) .await .unwrap() .unwrap_err(); assert_eq!(error.code, ControlErrorCode::RuntimeProfilingUnsupported); } client.cancel(); handle.cancel.cancel(); } #[tokio::test] async fn test_runtime_profile_rejects_output_collision_end_to_end() { let temp = TempDir::new().unwrap(); let (sock_path, handle) = setup_control_test_server(&temp).await; let output_path = temp.path().join("existing-profile.folded"); std::fs::write(&output_path, "already exists").unwrap(); let client = LeaderClient::connect( sock_path, "client", ClientMode::Stdio, ClientCapabilities::default(), ) .await .unwrap(); let error = client .send_control(ControlCommand::StartCpuProfile { output: Some(output_path.display().to_string()), frequency_hz: Some(200), }) .await .unwrap() .unwrap_err(); if client .registration() .leader_capabilities .as_ref() .is_some_and(|capabilities| capabilities.runtime_cpu_profile) { assert_eq!(error.code, ControlErrorCode::OutputPathCollision); } else { assert_eq!(error.code, ControlErrorCode::RuntimeProfilingUnsupported); } client.cancel(); handle.cancel.cancel(); } /// Test ACP message with session-based routing. #[tokio::test] async fn test_session_based_routing() { let temp = TempDir::new().unwrap(); let (sock_path, cancel, mut acp_rx, response_tx) = setup_test_server(&temp).await; let mut client = LeaderClient::connect( sock_path, "test-stdio", ClientMode::Stdio, ClientCapabilities::default(), ) .await .unwrap(); // Send a message with sessionId in params let test_message = r#"{"jsonrpc":"2.0","method":"session/start","id":1,"params":{"sessionId":"session-123"}}"#; client.send(test_message.to_string()).unwrap(); // Server receives the message let received = acp_rx.recv().await.unwrap(); let json: serde_json::Value = serde_json::from_str(&received).unwrap(); // Verify session_id is in params assert_eq!(json["params"]["sessionId"], "session-123"); // Send a response with session_id for routing (without using request ID) // This tests the session-based fallback routing let response = r#"{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-123","data":"update"}}"#; response_tx.send(response.to_string()).unwrap(); // Client should receive the session update let client_response = tokio::time::timeout(Duration::from_secs(2), client.recv()) .await .expect("timeout waiting for session update") .expect("channel closed"); let response_json: serde_json::Value = serde_json::from_str(&client_response).unwrap(); assert_eq!(response_json["params"]["sessionId"], "session-123"); client.cancel(); cancel.cancel(); } /// Test that a stdio client can receive tool call results (e.g., read_file output) routed from the server. /// This verifies routing of tool responses (common in ACP tool execution flow) in leader mode. #[tokio::test] async fn test_stdio_client_receives_tool_result() { let temp = TempDir::new().unwrap(); let (sock_path, cancel, mut acp_rx, response_tx) = setup_test_server(&temp).await; let mut client = LeaderClient::connect( sock_path, "test-stdio", ClientMode::Stdio, ClientCapabilities::default(), ) .await .unwrap(); // Send a simulated tool call request (e.g., read_file invocation) let test_tool_call = r#"{"jsonrpc":"2.0","method":"tool/call","id":100,"params":{"name":"read_file","arguments":{"target_file":"/path/to/test.txt"}}}"#; client.send(test_tool_call.to_string()).unwrap(); // Server should receive the tool call (with namespaced ID) let received = acp_rx.recv().await.unwrap(); let json: serde_json::Value = serde_json::from_str(&received).unwrap(); assert_eq!(json["method"], "tool/call"); assert_eq!(json["params"]["name"], "read_file"); let namespaced_id = json["id"].as_str().unwrap().to_string(); // Simulate tool result response from agent (e.g., read_file success) let tool_result = format!( r#"{{"jsonrpc":"2.0","result":{{"content":"test file content"}},"id":"{}"}}"#, namespaced_id ); response_tx.send(tool_result).unwrap(); // Client should receive the tool result with original ID restored let client_response = tokio::time::timeout(Duration::from_secs(2), client.recv()) .await .expect("timeout waiting for tool result") .expect("channel closed"); let result_json: serde_json::Value = serde_json::from_str(&client_response).unwrap(); assert_eq!(result_json["id"], 100); assert_eq!(result_json["result"]["content"], "test file content"); client.cancel(); cancel.cancel(); } /// Test that a session/new request without modelId is forwarded unchanged when /// the client has no default_model set. This is the scenario that caused the /// "unknown model id" error in leader mode (GitHub issue). #[tokio::test] async fn test_session_new_without_model_id_no_default() { let temp = TempDir::new().unwrap(); let (sock_path, cancel, mut acp_rx, _response_tx) = setup_test_server(&temp).await; // Connect with yolo_mode but no default_model (typical VS Code extension setup) let client = LeaderClient::connect( sock_path, "vscode-ext", ClientMode::Stdio, ClientCapabilities { yolo_mode: false, default_model: None, ..Default::default() }, ) .await .unwrap(); // Send session/new without modelId (exactly like the VS Code extension does) let session_new = r#"{"jsonrpc":"2.0","id":31,"method":"session/new","params":{"cwd":"/tmp","mcpServers":[],"_meta":{"yoloMode":true}}}"#; client.send(session_new.to_string()).unwrap(); // Server should forward it without injecting modelId let received = acp_rx.recv().await.unwrap(); let json: serde_json::Value = serde_json::from_str(&received).unwrap(); assert_eq!(json["method"], "session/new"); // _meta should have yoloMode but NOT modelId let meta = &json["params"]["_meta"]; assert_eq!(meta["yoloMode"], true); assert!( meta.get("modelId").is_none(), "modelId should not be injected when client has no default_model" ); client.cancel(); cancel.cancel(); } /// Test that when a client registers with yolo_mode, yoloMode is injected /// into session/new _meta, but modelId is NOT injected when default_model is None. #[tokio::test] async fn test_session_new_yolo_mode_no_model() { let temp = TempDir::new().unwrap(); let (sock_path, cancel, mut acp_rx, _response_tx) = setup_test_server(&temp).await; // Client with yolo_mode=true but no default model let client = LeaderClient::connect( sock_path, "vscode-ext", ClientMode::Stdio, ClientCapabilities { yolo_mode: true, default_model: None, ..Default::default() }, ) .await .unwrap(); // Send session/new without modelId or yoloMode in _meta let session_new = r#"{"jsonrpc":"2.0","id":1,"method":"session/new","params":{"cwd":"/tmp","mcpServers":[]}}"#; client.send(session_new.to_string()).unwrap(); let received = acp_rx.recv().await.unwrap(); let json: serde_json::Value = serde_json::from_str(&received).unwrap(); // yoloMode should be injected from capabilities let meta = &json["params"]["_meta"]; assert_eq!(meta["yoloMode"], true); // modelId should NOT be present assert!( meta.get("modelId").is_none(), "modelId should not be injected when default_model is None" ); client.cancel(); cancel.cancel(); } /// Test that an empty default_model is NOT injected into session/new requests. #[tokio::test] async fn test_session_new_empty_default_model_not_injected() { let temp = TempDir::new().unwrap(); let (sock_path, cancel, mut acp_rx, _response_tx) = setup_test_server(&temp).await; // Client with empty string default_model (edge case from config) let client = LeaderClient::connect( sock_path, "vscode-ext", ClientMode::Stdio, ClientCapabilities { yolo_mode: true, default_model: Some("".to_string()), ..Default::default() }, ) .await .unwrap(); let session_new = r#"{"jsonrpc":"2.0","id":1,"method":"session/new","params":{"cwd":"/tmp","mcpServers":[]}}"#; client.send(session_new.to_string()).unwrap(); let received = acp_rx.recv().await.unwrap(); let json: serde_json::Value = serde_json::from_str(&received).unwrap(); // yoloMode should be injected let meta = &json["params"]["_meta"]; assert_eq!(meta["yoloMode"], true); // Empty modelId should NOT be injected assert!( meta.get("modelId").is_none(), "empty default_model should not be injected as modelId" ); client.cancel(); cancel.cancel(); } /// Test that a valid default_model IS injected into session/new requests. #[tokio::test] async fn test_session_new_valid_default_model_injected() { let temp = TempDir::new().unwrap(); let (sock_path, cancel, mut acp_rx, _response_tx) = setup_test_server(&temp).await; let client = LeaderClient::connect( sock_path, "vscode-ext", ClientMode::Stdio, ClientCapabilities { yolo_mode: false, default_model: Some("kigi-3-fast".to_string()), ..Default::default() }, ) .await .unwrap(); let session_new = r#"{"jsonrpc":"2.0","id":1,"method":"session/new","params":{"cwd":"/tmp","mcpServers":[]}}"#; client.send(session_new.to_string()).unwrap(); let received = acp_rx.recv().await.unwrap(); let json: serde_json::Value = serde_json::from_str(&received).unwrap(); // modelId should be injected from default_model let meta = &json["params"]["_meta"]; assert_eq!(meta["modelId"], "kigi-3-fast"); client.cancel(); cancel.cancel(); } // ── Session ownership & notification routing ────────────────────────── /// Test that the leader tracks session ownership from session/new responses /// and routes subsequent notifications (which have no request ID) to the /// correct client based on sessionId. #[tokio::test] async fn test_session_ownership_from_response_routes_notifications() { let temp = TempDir::new().unwrap(); let (sock_path, cancel, mut acp_rx, response_tx) = setup_test_server(&temp).await; let mut client = LeaderClient::connect( sock_path, "vscode-ext", ClientMode::Stdio, ClientCapabilities::default(), ) .await .unwrap(); // Client sends session/new let session_new = r#"{"jsonrpc":"2.0","id":1,"method":"session/new","params":{"cwd":"/tmp","mcpServers":[]}}"#; client.send(session_new.to_string()).unwrap(); // Server receives the request (with namespaced ID) let received = acp_rx.recv().await.unwrap(); let json: serde_json::Value = serde_json::from_str(&received).unwrap(); let namespaced_id = json["id"].as_str().unwrap().to_string(); // Simulate agent response with sessionId in result let response = format!( r#"{{"jsonrpc":"2.0","result":{{"sessionId":"sess-abc-123"}},"id":"{}"}}"#, namespaced_id ); response_tx.send(response).unwrap(); // Client receives the session/new response let client_response = tokio::time::timeout(Duration::from_secs(2), client.recv()) .await .expect("timeout") .expect("closed"); let resp_json: serde_json::Value = serde_json::from_str(&client_response).unwrap(); assert_eq!(resp_json["result"]["sessionId"], "sess-abc-123"); assert_eq!(resp_json["id"], 1); // original ID restored // Now send a notification for this session (no id field, only sessionId in params) // This tests session-based routing — the leader must know which client owns sess-abc-123 let notification = r#"{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"sess-abc-123","status":"ready"}}"#; response_tx.send(notification.to_string()).unwrap(); // Client should receive the notification routed via session ownership let notif_response = tokio::time::timeout(Duration::from_secs(2), client.recv()) .await .expect("timeout waiting for notification") .expect("closed"); let notif_json: serde_json::Value = serde_json::from_str(¬if_response).unwrap(); assert_eq!(notif_json["params"]["sessionId"], "sess-abc-123"); assert_eq!(notif_json["params"]["status"], "ready"); client.cancel(); cancel.cancel(); } // ── Multi-client session isolation ──────────────────────────────────── /// Test that two clients with different sessions receive only their own /// notifications, not each other's. This is critical for VS Code extension /// isolation when multiple instances are connected. #[tokio::test] async fn test_two_clients_session_isolation() { let temp = TempDir::new().unwrap(); let (sock_path, cancel, mut acp_rx, response_tx) = setup_test_server(&temp).await; // Connect two clients (simulating two VS Code windows) let mut client1 = LeaderClient::connect( sock_path.clone(), "vscode-1", ClientMode::Stdio, ClientCapabilities::default(), ) .await .unwrap(); let mut client2 = LeaderClient::connect( sock_path, "vscode-2", ClientMode::Stdio, ClientCapabilities::default(), ) .await .unwrap(); // Client 1 creates session A client1 .send(r#"{"jsonrpc":"2.0","id":1,"method":"session/new","params":{"cwd":"/project-a","mcpServers":[]}}"#.to_string()) .unwrap(); let msg1 = acp_rx.recv().await.unwrap(); let json1: serde_json::Value = serde_json::from_str(&msg1).unwrap(); let id1 = json1["id"].as_str().unwrap().to_string(); // Client 2 creates session B client2 .send(r#"{"jsonrpc":"2.0","id":1,"method":"session/new","params":{"cwd":"/project-b","mcpServers":[]}}"#.to_string()) .unwrap(); let msg2 = acp_rx.recv().await.unwrap(); let json2: serde_json::Value = serde_json::from_str(&msg2).unwrap(); let id2 = json2["id"].as_str().unwrap().to_string(); // IDs should be different (different client prefixes, same original ID) assert_ne!(id1, id2); // Respond with different session IDs response_tx .send(format!( r#"{{"jsonrpc":"2.0","result":{{"sessionId":"sess-AAA"}},"id":"{}"}}"#, id1 )) .unwrap(); response_tx .send(format!( r#"{{"jsonrpc":"2.0","result":{{"sessionId":"sess-BBB"}},"id":"{}"}}"#, id2 )) .unwrap(); // Each client should receive their own response let resp1 = tokio::time::timeout(Duration::from_secs(2), client1.recv()) .await .expect("timeout") .expect("closed"); let resp2 = tokio::time::timeout(Duration::from_secs(2), client2.recv()) .await .expect("timeout") .expect("closed"); let r1: serde_json::Value = serde_json::from_str(&resp1).unwrap(); let r2: serde_json::Value = serde_json::from_str(&resp2).unwrap(); assert_eq!(r1["result"]["sessionId"], "sess-AAA"); assert_eq!(r2["result"]["sessionId"], "sess-BBB"); // Now send a notification for session A — only client 1 should get it let notif_a = r#"{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"sess-AAA","data":"for-client-1"}}"#; response_tx.send(notif_a.to_string()).unwrap(); let recv1 = tokio::time::timeout(Duration::from_secs(2), client1.recv()) .await .expect("timeout") .expect("closed"); let n1: serde_json::Value = serde_json::from_str(&recv1).unwrap(); assert_eq!(n1["params"]["sessionId"], "sess-AAA"); assert_eq!(n1["params"]["data"], "for-client-1"); // Send a notification for session B — only client 2 should get it let notif_b = r#"{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"sess-BBB","data":"for-client-2"}}"#; response_tx.send(notif_b.to_string()).unwrap(); let recv2 = tokio::time::timeout(Duration::from_secs(2), client2.recv()) .await .expect("timeout") .expect("closed"); let n2: serde_json::Value = serde_json::from_str(&recv2).unwrap(); assert_eq!(n2["params"]["sessionId"], "sess-BBB"); assert_eq!(n2["params"]["data"], "for-client-2"); client1.cancel(); client2.cancel(); cancel.cancel(); } // ── Multi-client model switch fan-out ───────────────────────────────── /// Multi-client model switch: when one TUI client switches models on a /// session shared with another TUI client, the leader must fan the /// `kigi/session_notification` (carrying the `ModelChanged` update) out /// to **every** subscriber of that session — not just the invoker — so /// the follower client mirrors the new model in its UI. /// /// This is the leader-server half of the multi-client model sync fix. /// The other half is: /// - agent emits the notification from `model_switch::apply` after the /// actor confirms the swap (covered by `extensions::notification` /// wire-format tests in `kigi-shell`); /// - pager applies the notification silently on followers and skips it /// on the invoker (covered by `model_changed_*` tests in /// `kigi-tui`'s `acp_handler` tests). /// /// The setModel response itself still routes back to the invoker only — /// this test asserts BOTH (broadcast to all + response to one) so a /// future regression that, say, accidentally suppresses the notification /// when a response is also produced is caught. #[tokio::test] async fn test_set_model_broadcasts_to_session_subscribers() { let temp = TempDir::new().unwrap(); let (sock_path, cancel, mut acp_rx, response_tx) = setup_test_server(&temp).await; // Two TUIs connected to the same leader, sharing one session. let mut invoker = LeaderClient::connect( sock_path.clone(), "kigi-tui-A", ClientMode::Stdio, ClientCapabilities::default(), ) .await .unwrap(); let mut follower = LeaderClient::connect( sock_path, "kigi-tui-B", ClientMode::Stdio, ClientCapabilities::default(), ) .await .unwrap(); // Subscribe both clients to the SAME session id. The leader registers // a client as a subscriber the first time it sees a sessionId-carrying // ACP message from that client — any session-scoped method works, so // we use a cheap synthetic one with distinct ids per client. let shared_sid = "sess-shared-multi-client"; invoker .send(format!( r#"{{"jsonrpc":"2.0","id":1,"method":"session/_subscribe_test","params":{{"sessionId":"{}"}}}}"#, shared_sid )) .unwrap(); follower .send(format!( r#"{{"jsonrpc":"2.0","id":2,"method":"session/_subscribe_test","params":{{"sessionId":"{}"}}}}"#, shared_sid )) .unwrap(); // Drain the two synthetic subscribe requests off the server's outgoing // channel; the leader has already registered both clients as // subscribers of `shared_sid` by the time it puts them on `acp_rx`. let _ = tokio::time::timeout(Duration::from_secs(2), acp_rx.recv()) .await .expect("timeout draining subscribe 1") .expect("closed"); let _ = tokio::time::timeout(Duration::from_secs(2), acp_rx.recv()) .await .expect("timeout draining subscribe 2") .expect("closed"); // Invoker sends `session/setModel` for the shared session. invoker .send(format!( r#"{{"jsonrpc":"2.0","id":42,"method":"session/setModel","params":{{"sessionId":"{}","modelId":"kigi-4"}}}}"#, shared_sid )) .unwrap(); let received = acp_rx.recv().await.unwrap(); let json: serde_json::Value = serde_json::from_str(&received).unwrap(); let setmodel_ns_id = json["id"].as_str().unwrap().to_string(); assert_eq!(json["method"], "session/setModel"); assert_eq!(json["params"]["modelId"], "kigi-4"); // Simulate the agent's two outputs for a successful switch: // // 1. A session-scoped `ModelChanged` broadcast — what `model_switch::apply` // now emits via the gateway after the actor confirms the swap. // 2. The `SetSessionModelResponse` — routed by the leader to the // invoker only via namespaced-id matching. // // Order matters for the assertions below: the broadcast is fired // BEFORE the response in `model_switch::apply`, so it must arrive at // each subscriber's recv() first. let broadcast = format!( r#"{{"jsonrpc":"2.0","method":"kigi/session_notification","params":{{"sessionId":"{}","update":{{"sessionUpdate":"model_changed","model_id":"kigi-4","reasoning_effort":"high"}}}}}}"#, shared_sid ); response_tx.send(broadcast.clone()).unwrap(); let response = format!( r#"{{"jsonrpc":"2.0","result":{{"meta":{{"model":"kigi-4"}}}},"id":"{}"}}"#, setmodel_ns_id ); response_tx.send(response).unwrap(); // --- Invoker: must receive BOTH the broadcast AND the targeted // response, in that order. The broadcast is the multi-client sync // signal; the response is what the invoker's `SwitchModelComplete` // dispatch handler keys on for the user-facing "Switched to X" // message. The pager's broadcast handler ignores it (it gates on // `model_switch_pending == true`), so the invoker doesn't // double-apply state — but the leader is still required to fan it // out, because the same JSON-RPC connection is what the response // travels on; suppressing it leader-side would also suppress it // for the follower below. let invoker_msg1 = tokio::time::timeout(Duration::from_secs(2), invoker.recv()) .await .expect("timeout waiting for broadcast on invoker") .expect("invoker channel closed"); let inv1: serde_json::Value = serde_json::from_str(&invoker_msg1).unwrap(); assert_eq!(inv1["method"], "kigi/session_notification"); assert_eq!(inv1["params"]["sessionId"], shared_sid); assert_eq!(inv1["params"]["update"]["sessionUpdate"], "model_changed"); assert_eq!(inv1["params"]["update"]["model_id"], "kigi-4"); assert_eq!(inv1["params"]["update"]["reasoning_effort"], "high"); let invoker_msg2 = tokio::time::timeout(Duration::from_secs(2), invoker.recv()) .await .expect("timeout waiting for setModel response on invoker") .expect("invoker channel closed"); let inv2: serde_json::Value = serde_json::from_str(&invoker_msg2).unwrap(); assert_eq!( inv2["id"], 42, "response id must be restored to the invoker's original" ); assert_eq!(inv2["result"]["meta"]["model"], "kigi-4"); // --- Follower: must receive the broadcast (this is the fix — before // this notification existed, the follower's status bar / `/model` // dropdown / prompt header stayed stuck on the pre-switch model). // It must NOT receive the targeted response (that one is routed // by request id to the invoker only). let follower_msg = tokio::time::timeout(Duration::from_secs(2), follower.recv()) .await .expect( "timeout waiting for broadcast on follower — \ model switch did not propagate across leader clients", ) .expect("follower channel closed"); let f: serde_json::Value = serde_json::from_str(&follower_msg).unwrap(); assert_eq!(f["method"], "kigi/session_notification"); assert_eq!(f["params"]["sessionId"], shared_sid); assert_eq!(f["params"]["update"]["sessionUpdate"], "model_changed"); assert_eq!(f["params"]["update"]["model_id"], "kigi-4"); assert_eq!(f["params"]["update"]["reasoning_effort"], "high"); // Follower must NOT see the namespaced setModel response — the // leader routes responses by request-id prefix, and only the invoker's // ClientId prefixes that id. let unexpected = tokio::time::timeout(Duration::from_millis(200), follower.recv()).await; assert!( unexpected.is_err(), "follower received an extra message after the broadcast — \ leader is leaking the invoker's targeted response: {:?}", unexpected ); invoker.cancel(); follower.cancel(); cancel.cancel(); } // ── Capability injection scope ──────────────────────────────────────── /// Test that capability injection ONLY applies to session/new, NOT to other /// methods like session/prompt or session/load. The leader must not mutate /// arbitrary requests. #[tokio::test] async fn test_capabilities_not_injected_into_non_session_new() { let temp = TempDir::new().unwrap(); let (sock_path, cancel, mut acp_rx, _response_tx) = setup_test_server(&temp).await; let client = LeaderClient::connect( sock_path, "vscode-ext", ClientMode::Stdio, ClientCapabilities { yolo_mode: true, default_model: Some("kigi-3-fast".to_string()), ..Default::default() }, ) .await .unwrap(); // Send session/prompt — capabilities should NOT be injected let prompt = r#"{"jsonrpc":"2.0","id":10,"method":"session/prompt","params":{"sessionId":"sess-123","prompt":{"content":"hello"}}}"#; client.send(prompt.to_string()).unwrap(); let received = acp_rx.recv().await.unwrap(); let json: serde_json::Value = serde_json::from_str(&received).unwrap(); // The params should NOT have _meta.yoloMode or _meta.modelId injected assert!( json["params"].get("_meta").is_none(), "session/prompt should not have _meta injected" ); // Original params should be preserved assert_eq!(json["params"]["sessionId"], "sess-123"); // Send session/load — should get clientIdentifier but NOT yoloMode/modelId let load = r#"{"jsonrpc":"2.0","id":11,"method":"session/load","params":{"sessionId":"sess-456","cwd":"/tmp","mcpServers":[]}}"#; client.send(load.to_string()).unwrap(); let received = acp_rx.recv().await.unwrap(); let json: serde_json::Value = serde_json::from_str(&received).unwrap(); // session/load should NOT get yoloMode or modelId injected assert!( json["params"]["_meta"].get("yoloMode").is_none(), "session/load should not have yoloMode injected" ); assert!( json["params"]["_meta"].get("modelId").is_none(), "session/load should not have modelId injected" ); client.cancel(); cancel.cancel(); } /// Test that capability injection does NOT overwrite yoloMode if the /// request already has _meta.yoloMode set to false. This lets a client /// disable YOLO for a single session even when its default capability is on. #[tokio::test] async fn test_yolo_mode_injection_preserves_explicit_false() { let temp = TempDir::new().unwrap(); let (sock_path, cancel, mut acp_rx, _response_tx) = setup_test_server(&temp).await; // Client registered with yolo_mode=true let client = LeaderClient::connect( sock_path, "vscode-ext", ClientMode::Stdio, ClientCapabilities { yolo_mode: true, default_model: None, ..Default::default() }, ) .await .unwrap(); // Request explicitly sets yoloMode to false let session_new = r#"{"jsonrpc":"2.0","id":1,"method":"session/new","params":{"cwd":"/tmp","mcpServers":[],"_meta":{"yoloMode":false}}}"#; client.send(session_new.to_string()).unwrap(); let received = acp_rx.recv().await.unwrap(); let json: serde_json::Value = serde_json::from_str(&received).unwrap(); // Per-session yoloMode=false must win over client default yolo_mode=true assert_eq!(json["params"]["_meta"]["yoloMode"], false); client.cancel(); cancel.cancel(); } // ── Notification (no ID) pass-through ───────────────────────────────── /// Test that JSON-RPC notifications (no "id" field) sent by a client are /// forwarded without ID rewriting. Notifications include cancel, yolo_mode_changed, etc. #[tokio::test] async fn test_client_notification_forwarded_without_id_rewrite() { let temp = TempDir::new().unwrap(); let (sock_path, cancel, mut acp_rx, _response_tx) = setup_test_server(&temp).await; let client = LeaderClient::connect( sock_path, "vscode-ext", ClientMode::Stdio, ClientCapabilities::default(), ) .await .unwrap(); // Send a cancel notification (no "id" field) let cancel_notif = r#"{"jsonrpc":"2.0","method":"session/cancel","params":{"sessionId":"sess-123","reason":"user"}}"#; client.send(cancel_notif.to_string()).unwrap(); let received = acp_rx.recv().await.unwrap(); let json: serde_json::Value = serde_json::from_str(&received).unwrap(); // Should not have an "id" field added assert!( json.get("id").is_none(), "notifications should not get an id" ); assert_eq!(json["method"], "session/cancel"); assert_eq!(json["params"]["sessionId"], "sess-123"); client.cancel(); cancel.cancel(); } /// `session/cancel` carrying `_meta.cancelPromptId` (the cancelling /// client's awaited prompt id) must reach the agent **unmodified** with /// multiple clients attached — the meta is how the session actor scopes the /// cancel to the canceller's own queued prompt while preserving the other /// client's queued work (the actor-side semantics are covered by /// `cancel_running_task_resolves_cancellers_queued_prompt`). Also pins that a /// second client's interleaved cancel for a different session passes through /// independently (no cross-client meta bleed or reordering). #[tokio::test] async fn test_cancel_prompt_id_meta_passes_through_with_two_clients() { let temp = TempDir::new().unwrap(); let (sock_path, cancel, mut acp_rx, _response_tx) = setup_test_server(&temp).await; let client_a = LeaderClient::connect( sock_path.clone(), "kigi-pager", ClientMode::Stdio, ClientCapabilities::default(), ) .await .unwrap(); let client_b = LeaderClient::connect( sock_path, "kigi-pager", ClientMode::Stdio, ClientCapabilities::default(), ) .await .unwrap(); // A cancels its queued prompt on the shared session; B cancels a turn on // another session at the same time. let cancel_a = r#"{"jsonrpc":"2.0","method":"session/cancel","params":{"sessionId":"sess-shared","_meta":{"cancelSubagents":true,"cancelPromptId":"a-queued-pid"}}}"#; let cancel_b = r#"{"jsonrpc":"2.0","method":"session/cancel","params":{"sessionId":"sess-other","_meta":{"cancelSubagents":false}}}"#; client_a.send(cancel_a.to_string()).unwrap(); client_b.send(cancel_b.to_string()).unwrap(); let mut saw_a = false; let mut saw_b = false; for _ in 0..2 { let received = tokio::time::timeout(Duration::from_secs(5), acp_rx.recv()) .await .expect("timed out waiting for forwarded cancel") .unwrap(); let json: serde_json::Value = serde_json::from_str(&received).unwrap(); assert_eq!(json["method"], "session/cancel"); assert!(json.get("id").is_none(), "notifications must stay id-less"); match json["params"]["sessionId"].as_str() { Some("sess-shared") => { assert_eq!( json["params"]["_meta"]["cancelPromptId"], "a-queued-pid", "cancelPromptId must pass through the leader unmodified" ); assert_eq!(json["params"]["_meta"]["cancelSubagents"], true); saw_a = true; } Some("sess-other") => { assert!( json["params"]["_meta"].get("cancelPromptId").is_none(), "no cancelPromptId bleed across clients" ); assert_eq!(json["params"]["_meta"]["cancelSubagents"], false); saw_b = true; } other => panic!("unexpected sessionId: {other:?}"), } } assert!(saw_a && saw_b, "both cancels must be forwarded"); client_a.cancel(); client_b.cancel(); cancel.cancel(); } // ── Extension method routing ────────────────────────────────────────── /// Test that extension methods (prefixed with _) are correctly forwarded /// through the leader with proper ID namespacing and response routing. #[tokio::test] async fn test_extension_method_roundtrip() { let temp = TempDir::new().unwrap(); let (sock_path, cancel, mut acp_rx, response_tx) = setup_test_server(&temp).await; let mut client = LeaderClient::connect( sock_path, "vscode-ext", ClientMode::Stdio, ClientCapabilities::default(), ) .await .unwrap(); // Send an extension method call (e.g., fuzzy search open) let ext_call = r#"{"jsonrpc":"2.0","id":50,"method":"_kigi/search/fuzzy/open","params":{"sessionId":"sess-123","hidden":false}}"#; client.send(ext_call.to_string()).unwrap(); let received = acp_rx.recv().await.unwrap(); let json: serde_json::Value = serde_json::from_str(&received).unwrap(); // Method should be preserved, ID should be namespaced assert_eq!(json["method"], "_kigi/search/fuzzy/open"); let namespaced_id = json["id"].as_str().unwrap(); assert!(namespaced_id.contains(ID_NAMESPACE_SEP)); assert!(namespaced_id.ends_with("|50")); // Simulate response let response = format!( r#"{{"jsonrpc":"2.0","result":{{"searchId":"search-xyz"}},"id":"{}"}}"#, namespaced_id ); response_tx.send(response).unwrap(); let client_response = tokio::time::timeout(Duration::from_secs(2), client.recv()) .await .expect("timeout") .expect("closed"); let resp_json: serde_json::Value = serde_json::from_str(&client_response).unwrap(); // Original ID should be restored, result preserved assert_eq!(resp_json["id"], 50); assert_eq!(resp_json["result"]["searchId"], "search-xyz"); client.cancel(); cancel.cancel(); } // ── Error response routing ──────────────────────────────────────────── /// Test that JSON-RPC error responses from the agent are correctly routed /// back to the requesting client with the original ID restored. #[tokio::test] async fn test_error_response_routing() { let temp = TempDir::new().unwrap(); let (sock_path, cancel, mut acp_rx, response_tx) = setup_test_server(&temp).await; let mut client = LeaderClient::connect( sock_path, "vscode-ext", ClientMode::Stdio, ClientCapabilities::default(), ) .await .unwrap(); // Send a request client .send( r#"{"jsonrpc":"2.0","id":99,"method":"session/new","params":{"cwd":"/tmp","mcpServers":[]}}"# .to_string(), ) .unwrap(); let received = acp_rx.recv().await.unwrap(); let json: serde_json::Value = serde_json::from_str(&received).unwrap(); let namespaced_id = json["id"].as_str().unwrap().to_string(); // Simulate an error response (e.g., auth_required) let error_response = format!( r#"{{"jsonrpc":"2.0","error":{{"code":-32001,"message":"auth_required","data":"No credentials"}},"id":"{}"}}"#, namespaced_id ); response_tx.send(error_response).unwrap(); let client_response = tokio::time::timeout(Duration::from_secs(2), client.recv()) .await .expect("timeout") .expect("closed"); let resp_json: serde_json::Value = serde_json::from_str(&client_response).unwrap(); // Original ID restored, error preserved assert_eq!(resp_json["id"], 99); assert_eq!(resp_json["error"]["code"], -32001); assert_eq!(resp_json["error"]["message"], "auth_required"); assert_eq!(resp_json["error"]["data"], "No credentials"); client.cancel(); cancel.cancel(); } // ── Session cleanup on disconnect ───────────────────────────────────── /// Test that when a client disconnects, notifications for its sessions are /// still delivered to the next active client via fallback routing. /// /// Session ownership entries are intentionally *not* removed on disconnect so /// the server can distinguish IPC-originated sessions (present in /// `session_owners`) from relay-originated ones (absent). The session-based /// routing path naturally falls through (the dead client is gone from /// `clients`), and the fallback picks up the notification for the new client. #[tokio::test] async fn test_session_ownership_cleanup_on_disconnect() { use kigi_shell::leader::run_leader_server; let temp = TempDir::new().unwrap(); let sock_path = temp.path().join("leader.sock"); // Manually create server with no_exit_on_disconnect=true so the server // survives the first client disconnecting. let (acp_tx, mut acp_rx) = tokio::sync::mpsc::unbounded_channel(); let (response_tx, response_rx) = tokio::sync::mpsc::unbounded_channel(); let cancel = tokio_util::sync::CancellationToken::new(); let cancel_clone = cancel.clone(); let sock_clone = sock_path.clone(); tokio::spawn(async move { let client_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); let agent_busy = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); let (_ready_tx, ready_rx) = tokio::sync::watch::channel(true); let (shutdown_tx, _shutdown_rx) = tokio::sync::watch::channel(kigi_shell::leader::ShutdownReason::Manual); let control_state = LeaderServerControlState::new(LeaderServerMetadata { pid: std::process::id(), socket_path: sock_clone.clone(), lock_path: sock_clone.with_extension("lock"), socket_suffix: String::new(), leader_binary_version: env!("CARGO_PKG_VERSION").to_string(), }); let _ = run_leader_server( sock_clone, acp_tx, response_rx, cancel_clone, true, client_count, agent_busy, kigi_shell::agent::activity::AgentActivity::default(), ready_rx, shutdown_tx, None, control_state, ) .await; }); wait_for_socket(&sock_path).await; // Connect client, create session, then disconnect { let mut client = LeaderClient::connect( sock_path.clone(), "vscode-temp", ClientMode::Stdio, ClientCapabilities::default(), ) .await .unwrap(); // Create session client .send(r#"{"jsonrpc":"2.0","id":1,"method":"session/new","params":{"cwd":"/tmp","mcpServers":[]}}"#.to_string()) .unwrap(); let received = acp_rx.recv().await.unwrap(); let json: serde_json::Value = serde_json::from_str(&received).unwrap(); let namespaced_id = json["id"].as_str().unwrap().to_string(); // Respond with session ID response_tx .send(format!( r#"{{"jsonrpc":"2.0","result":{{"sessionId":"sess-temp"}},"id":"{}"}}"#, namespaced_id )) .unwrap(); let _ = tokio::time::timeout(Duration::from_secs(2), client.recv()).await; // Client disconnects (dropped) client.cancel(); } // Give server time to process disconnect tokio::time::sleep(Duration::from_millis(200)).await; // The server sends an eviction notification for "sess-temp" when client1 // disconnects. Drain it before client2's initialize to keep the channel // in sync. Also verifies the eviction was actually sent. let eviction = acp_rx.recv().await.unwrap(); let eviction_json: serde_json::Value = serde_json::from_str(&eviction).unwrap(); assert_eq!(eviction_json["method"], "kigi/internal/evict_sessions"); // Connect a NEW client — server should still be running let mut client2 = LeaderClient::connect( sock_path, "vscode-new", ClientMode::Stdio, ClientCapabilities::default(), ) .await .unwrap(); // Make client2 active (so it becomes fallback) client2 .send(r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"#.to_string()) .unwrap(); let _ = acp_rx.recv().await.unwrap(); // Send a notification for the old session — it should be DROPPED, not // forwarded to client2. The dead client's session entry is still in // session_owners (for relay detection), and tier-2 routing sees the // owner is dead → drops the notification to prevent cross-session leaks. // The reconnecting client will replay via session/load instead. let old_notif = r#"{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"sess-temp","data":"orphan"}}"#; response_tx.send(old_notif.to_string()).unwrap(); // client2 should NOT receive the dead-session notification. // Send a second notification without a sessionId — this one SHOULD // arrive via fallback routing, proving client2 is alive and connected. let probe = r#"{"jsonrpc":"2.0","method":"kigi/probe","params":{"ping":true}}"#; response_tx.send(probe.to_string()).unwrap(); let recv = tokio::time::timeout(Duration::from_secs(2), client2.recv()) .await .expect("timeout") .expect("closed"); let json: serde_json::Value = serde_json::from_str(&recv).unwrap(); // The first message client2 sees is the probe, not the orphan notification assert_eq!(json["params"]["ping"], true); client2.cancel(); cancel.cancel(); } // ============================================================================= // Code-nav capability injection integration tests // // These tests exercise the full leader→agent injection pipeline for the // `code_nav_enabled` capability, verifying that per-client isolation is // correct from the leader boundary all the way to the forwarded ACP payload. // ============================================================================= /// Verify that the leader injects `codeNavEnabled: true` into session/new for /// a web client that registered with `code_nav_enabled: true`. #[tokio::test] async fn test_code_nav_capable_client_gets_true_injected_into_session_new() { let temp = TempDir::new().unwrap(); let (sock_path, cancel, mut acp_rx, _response_tx) = setup_test_server(&temp).await; // Web client that advertised code-nav capability during registration. let web_client = LeaderClient::connect( sock_path, "kigi-web", ClientMode::Stdio, ClientCapabilities { code_nav_enabled: true, ..Default::default() }, ) .await .unwrap(); let session_new = r#"{"jsonrpc":"2.0","id":1,"method":"session/new","params":{"cwd":"/repo","mcpServers":[]}}"#; web_client.send(session_new.to_string()).unwrap(); let forwarded = acp_rx.recv().await.unwrap(); let json: serde_json::Value = serde_json::from_str(&forwarded).unwrap(); assert_eq!(json["method"], "session/new"); assert_eq!( json["params"]["_meta"]["codeNavEnabled"], serde_json::json!(true), "leader must inject codeNavEnabled=true for web client with code-nav capability" ); web_client.cancel(); cancel.cancel(); } /// Verify that the leader injects `codeNavEnabled: false` into session/new for /// a TUI client that did NOT register `code_nav_enabled`. #[tokio::test] async fn test_non_code_nav_client_gets_false_injected_into_session_new() { let temp = TempDir::new().unwrap(); let (sock_path, cancel, mut acp_rx, _response_tx) = setup_test_server(&temp).await; // TUI client with no code-nav capability. let tui_client = LeaderClient::connect( sock_path, "kigi-tui", ClientMode::Stdio, ClientCapabilities { code_nav_enabled: false, ..Default::default() }, ) .await .unwrap(); let session_new = r#"{"jsonrpc":"2.0","id":1,"method":"session/new","params":{"cwd":"/repo","mcpServers":[]}}"#; tui_client.send(session_new.to_string()).unwrap(); let forwarded = acp_rx.recv().await.unwrap(); let json: serde_json::Value = serde_json::from_str(&forwarded).unwrap(); assert_eq!(json["method"], "session/new"); assert_eq!( json["params"]["_meta"]["codeNavEnabled"], serde_json::json!(false), "leader must inject codeNavEnabled=false for client without code-nav capability" ); tui_client.cancel(); cancel.cancel(); } /// Verify leader-mode per-client isolation end to end: /// two clients with different code-nav capabilities receive independent /// `codeNavEnabled` values in their session/new requests, proving that /// one client's capability does not contaminate the other. #[tokio::test] async fn test_leader_code_nav_client_isolation() { let temp = TempDir::new().unwrap(); let (sock_path, cancel, mut acp_rx, _response_tx) = setup_test_server(&temp).await; // Web client with code-nav capability. let web_client = LeaderClient::connect( sock_path.clone(), "kigi-web", ClientMode::Stdio, ClientCapabilities { code_nav_enabled: true, ..Default::default() }, ) .await .unwrap(); // TUI client without code-nav capability. let tui_client = LeaderClient::connect( sock_path, "kigi-tui", ClientMode::Stdio, ClientCapabilities { code_nav_enabled: false, ..Default::default() }, ) .await .unwrap(); let session_new = r#"{"jsonrpc":"2.0","id":1,"method":"session/new","params":{"cwd":"/repo","mcpServers":[]}}"#; // Web client sends session/new first. web_client.send(session_new.to_string()).unwrap(); let web_fwd = acp_rx.recv().await.unwrap(); let web_json: serde_json::Value = serde_json::from_str(&web_fwd).unwrap(); // TUI client sends session/new second. tui_client.send(session_new.to_string()).unwrap(); let tui_fwd = acp_rx.recv().await.unwrap(); let tui_json: serde_json::Value = serde_json::from_str(&tui_fwd).unwrap(); // Each client's request must carry its own capability — no cross-contamination. assert_eq!( web_json["params"]["_meta"]["codeNavEnabled"], serde_json::json!(true), "web client must get codeNavEnabled=true" ); assert_eq!( tui_json["params"]["_meta"]["codeNavEnabled"], serde_json::json!(false), "tui client must get codeNavEnabled=false, not contaminated by web client" ); web_client.cancel(); tui_client.cancel(); cancel.cancel(); } /// Verify that `codeNavEnabled` is also injected into session/load so that /// reconnecting sessions receive the correct per-client capability. #[tokio::test] async fn test_code_nav_capability_injected_into_session_load() { let temp = TempDir::new().unwrap(); let (sock_path, cancel, mut acp_rx, _response_tx) = setup_test_server(&temp).await; let web_client = LeaderClient::connect( sock_path, "kigi-web", ClientMode::Stdio, ClientCapabilities { code_nav_enabled: true, ..Default::default() }, ) .await .unwrap(); let session_load = r#"{"jsonrpc":"2.0","id":2,"method":"session/load","params":{"sessionId":"sess-abc","cwd":"/repo","mcpServers":[]}}"#; web_client.send(session_load.to_string()).unwrap(); let forwarded = acp_rx.recv().await.unwrap(); let json: serde_json::Value = serde_json::from_str(&forwarded).unwrap(); assert_eq!(json["method"], "session/load"); assert_eq!( json["params"]["_meta"]["codeNavEnabled"], serde_json::json!(true), "leader must inject codeNavEnabled into session/load for reconnect isolation" ); web_client.cancel(); cancel.cancel(); } /// Verify that an `kigi/code/status` extension request is forwarded to the /// agent with the correct method, sessionId, and cwd in the params. /// /// This tests the routing boundary between leader and agent for the /// code-nav extension surface without requiring a live agent. #[tokio::test] async fn test_code_status_ext_request_forwarded_to_agent() { let temp = TempDir::new().unwrap(); let (sock_path, cancel, mut acp_rx, _response_tx) = setup_test_server(&temp).await; let web_client = LeaderClient::connect( sock_path, "kigi-web", ClientMode::Stdio, ClientCapabilities { code_nav_enabled: true, ..Default::default() }, ) .await .unwrap(); // Send kigi/code/status with a sessionId — the leader must forward it to the agent. let status_req = r#"{"jsonrpc":"2.0","id":42,"method":"extensions/ext","params":{"method":"kigi/code/status","params":{"sessionId":"sess-web-1","cwd":"/repo"}}}"#; web_client.send(status_req.to_string()).unwrap(); let forwarded = acp_rx.recv().await.unwrap(); let json: serde_json::Value = serde_json::from_str(&forwarded).unwrap(); assert_eq!(json["method"], "extensions/ext"); assert_eq!(json["params"]["method"], "kigi/code/status"); assert_eq!(json["params"]["params"]["sessionId"], "sess-web-1"); assert_eq!(json["params"]["params"]["cwd"], "/repo"); web_client.cancel(); cancel.cancel(); } // ── Startup readiness gate ──────────────────────────────────────────── /// Raw-protocol test for the server-side readiness handshake. /// /// Verifies that when the leader is not yet ready: /// - A connecting client receives `Registered { ready: false }` in the wire protocol. /// - The server then sends `LeaderReady` once `ready_tx` fires. /// - Post-readiness ACP traffic is forwarded to the agent normally. /// /// This test uses the raw IPC wire protocol (`write_message` / `read_message`) /// rather than `LeaderClient::connect`, because the high-level client now correctly /// blocks in `connect()` until `LeaderReady` is received — which prevents it from /// being used to test the intermediate `Registered { ready: false }` state. #[tokio::test] async fn test_raw_registration_handshake_not_ready_then_ready() { use kigi_shell::leader::run_leader_server; use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicUsize}; use tokio::sync::{mpsc, watch}; use tokio_util::sync::CancellationToken; let temp = TempDir::new().unwrap(); let sock_path = temp.path().join("leader.sock"); let (acp_tx, mut acp_rx) = mpsc::unbounded_channel::(); let (response_tx, response_rx) = mpsc::unbounded_channel::(); let cancel = CancellationToken::new(); let (ready_tx, ready_rx) = watch::channel(false); // NOT ready yet let sock_clone = sock_path.clone(); let cancel_clone = cancel.clone(); tokio::spawn(async move { let control_state = LeaderServerControlState::new(LeaderServerMetadata { pid: std::process::id(), socket_path: sock_clone.clone(), lock_path: sock_clone.with_extension("lock"), socket_suffix: String::new(), leader_binary_version: env!("CARGO_PKG_VERSION").to_string(), }); let _ = run_leader_server( sock_clone, acp_tx, response_rx, cancel_clone, true, Arc::new(AtomicUsize::new(0)), Arc::new(AtomicBool::new(false)), kigi_shell::agent::activity::AgentActivity::default(), ready_rx, watch::channel(kigi_shell::leader::ShutdownReason::Manual).0, None, control_state, ) .await; }); wait_for_socket(&sock_path).await; // Connect via raw socket to observe the wire-level handshake. let stream = UnixStream::connect(&sock_path).await.unwrap(); let (mut reader, mut writer) = tokio::io::split(stream); // Register manually. write_message( &mut writer, &ClientMessage::Register { client_type: "raw-test".into(), mode: ClientMode::Stdio, capabilities: ClientCapabilities::default(), }, ) .await .unwrap(); // ── Server must respond Registered { ready: false } ─────────────────────── let reg_msg: ServerMessage = tokio::time::timeout(Duration::from_secs(2), read_message(&mut reader)) .await .expect("timeout waiting for Registered") .expect("read error"); match reg_msg { ServerMessage::Registered { client_id: _, ready, .. } => { assert!( !ready, "leader is not ready yet; Registered.ready must be false" ); } other => panic!("Expected Registered, got {other:?}"), } // ── Signal readiness (simulates auth + prefetch completing) ─────────────── // The server's per-client session is now blocked in its readiness wait loop. // Signalling here causes it to send LeaderReady to this client. ready_tx.send(true).unwrap(); // ── Server must now send LeaderReady ────────────────────────────────────── let ready_msg: ServerMessage = tokio::time::timeout(Duration::from_secs(2), read_message(&mut reader)) .await .expect("timeout waiting for LeaderReady") .expect("read error"); assert!( matches!(ready_msg, ServerMessage::LeaderReady), "Expected LeaderReady, got {ready_msg:?}" ); // ── Post-ready: ACP flows normally ──────────────────────────────────────── write_message( &mut writer, &ClientMessage::Acp { payload: r#"{"jsonrpc":"2.0","id":1,"method":"session/new","params":{"cwd":"/tmp","mcpServers":[]}}"#.into(), }, ) .await .unwrap(); let forwarded = tokio::time::timeout(Duration::from_secs(2), acp_rx.recv()) .await .expect("timeout: post-ready ACP must reach agent") .expect("channel closed"); let fwd: serde_json::Value = serde_json::from_str(&forwarded).unwrap(); assert_eq!(fwd["method"], "session/new"); let namespaced_id = fwd["id"].as_str().unwrap().to_string(); // Round-trip the response. response_tx .send(format!( r#"{{"jsonrpc":"2.0","result":{{"sessionId":"sess-ok"}},"id":"{namespaced_id}"}}"# )) .unwrap(); let raw_resp: ServerMessage = tokio::time::timeout(Duration::from_secs(2), read_message(&mut reader)) .await .expect("timeout waiting for ACP response") .expect("read error"); match raw_resp { ServerMessage::Acp { payload } => { let resp: serde_json::Value = serde_json::from_str(&payload).unwrap(); assert_eq!(resp["id"], 1, "ID must be restored to original"); assert_eq!(resp["result"]["sessionId"], "sess-ok"); } other => panic!("Expected Acp, got {other:?}"), } drop(response_tx); cancel.cancel(); } /// End-to-end readiness contract test: `LeaderClient::connect` blocks until the /// leader signals `LeaderReady`, then returns a fully-ready connection. /// /// This is the key regression test: first-party clients /// (TUI, headless) that call `connect_or_spawn` must not see `leader_starting` /// errors on their initial `initialize` request even when auth/prefetch are slow. /// /// The contract: `LeaderClient::connect` (and therefore `connect_or_spawn`) only /// returns AFTER the leader has signalled readiness, so callers can safely send /// `initialize` immediately. #[tokio::test] async fn test_connect_waits_for_leader_ready() { use kigi_shell::leader::run_leader_server; use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicUsize}; use tokio::sync::{mpsc, watch}; use tokio_util::sync::CancellationToken; let temp = TempDir::new().unwrap(); let sock_path = temp.path().join("leader.sock"); let (acp_tx, mut acp_rx) = mpsc::unbounded_channel::(); let (response_tx, response_rx) = mpsc::unbounded_channel::(); let cancel = CancellationToken::new(); let (ready_tx, ready_rx) = watch::channel(false); // NOT ready yet let sock_clone = sock_path.clone(); let cancel_clone = cancel.clone(); tokio::spawn(async move { let control_state = LeaderServerControlState::new(LeaderServerMetadata { pid: std::process::id(), socket_path: sock_clone.clone(), lock_path: sock_clone.with_extension("lock"), socket_suffix: String::new(), leader_binary_version: env!("CARGO_PKG_VERSION").to_string(), }); let _ = run_leader_server( sock_clone, acp_tx, response_rx, cancel_clone, true, Arc::new(AtomicUsize::new(0)), Arc::new(AtomicBool::new(false)), kigi_shell::agent::activity::AgentActivity::default(), ready_rx, watch::channel(kigi_shell::leader::ShutdownReason::Manual).0, None, control_state, ) .await; }); wait_for_socket(&sock_path).await; // Spawn a task to signal readiness after a short delay (simulating slow auth/prefetch). let ready_delay_ms = 150u64; tokio::spawn(async move { tokio::time::sleep(Duration::from_millis(ready_delay_ms)).await; ready_tx.send(true).unwrap(); }); // LeaderClient::connect should block until LeaderReady arrives, then return. // If it returned immediately (before readiness), the subsequent `initialize` // would hit `leader_starting` errors — the bug this test guards against. let connect_start = tokio::time::Instant::now(); let mut client = LeaderClient::connect( sock_path, "kigi-tui", ClientMode::Stdio, ClientCapabilities::default(), ) .await .expect("connect must succeed after leader becomes ready"); let elapsed = connect_start.elapsed(); // Connect must have waited at least as long as the readiness delay. assert!( elapsed >= Duration::from_millis(ready_delay_ms.saturating_sub(30)), "connect returned too early ({elapsed:?}); should have waited for LeaderReady" ); // Now that we're connected, send `initialize` — must reach the agent (not get // a leader_starting error), because the leader is fully ready. client .send(r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"#.to_string()) .unwrap(); let forwarded = tokio::time::timeout(Duration::from_secs(2), acp_rx.recv()) .await .expect("timeout: initialize must be forwarded to agent after readiness") .expect("channel closed"); let fwd_json: serde_json::Value = serde_json::from_str(&forwarded).unwrap(); assert_eq!( fwd_json["method"], "initialize", "initialize must reach the agent, not be rejected with leader_starting" ); // Verify full round-trip. let namespaced_id = fwd_json["id"].as_str().unwrap().to_string(); response_tx .send(format!( r#"{{"jsonrpc":"2.0","result":{{"serverInfo":{{}}}},"id":"{namespaced_id}"}}"# )) .unwrap(); let client_resp = tokio::time::timeout(Duration::from_secs(2), client.recv()) .await .expect("timeout waiting for initialize response") .expect("channel closed"); let resp_json: serde_json::Value = serde_json::from_str(&client_resp).unwrap(); assert_eq!(resp_json["id"], 1, "ID must be restored to original"); drop(response_tx); client.cancel(); cancel.cancel(); } // ── Version mismatch notification ──────────────────────────────────── /// Integration test: a connected client receives `kigi/leader/version_mismatch` /// when its `client_version` differs from the leader's version. /// /// Uses `leader_version_override` so the test bypasses the `"unknown"` constant /// that appears in dev builds where `VERSION_WITH_COMMIT` is not set. #[tokio::test] async fn test_version_mismatch_notification_sent_to_client() { use kigi_shell::leader::{ClientCapabilities, ClientMode, LeaderClient, run_leader_server}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicUsize}; use tokio::sync::{mpsc, watch}; use tokio_util::sync::CancellationToken; let temp = TempDir::new().unwrap(); let sock_path = temp.path().join("leader.sock"); let (acp_tx, _acp_rx) = mpsc::unbounded_channel::(); let (_response_tx, response_rx) = mpsc::unbounded_channel::(); let cancel = CancellationToken::new(); let sock_clone = sock_path.clone(); let cancel_clone = cancel.clone(); tokio::spawn(async move { let control_state = LeaderServerControlState::new(LeaderServerMetadata { pid: std::process::id(), socket_path: sock_clone.clone(), lock_path: sock_clone.with_extension("lock"), socket_suffix: String::new(), leader_binary_version: env!("CARGO_PKG_VERSION").to_string(), }); let _ = run_leader_server( sock_clone, acp_tx, response_rx, cancel_clone, true, Arc::new(AtomicUsize::new(0)), Arc::new(AtomicBool::new(false)), kigi_shell::agent::activity::AgentActivity::default(), watch::channel(true).1, watch::channel(kigi_shell::leader::ShutdownReason::Manual).0, Some("test-leader-0.1.150"), // override so detection is enabled in test builds control_state, ) .await; }); wait_for_socket(&sock_path).await; // Connect with a version that differs from the leader override. let mut client = LeaderClient::connect( sock_path, "test-client", ClientMode::Stdio, ClientCapabilities { client_version: Some("test-client-0.1.157".to_string()), ..Default::default() }, ) .await .unwrap(); // The client should receive the version mismatch notification. let msg = tokio::time::timeout(Duration::from_secs(2), client.recv()) .await .expect("timeout waiting for version mismatch notification") .expect("channel closed"); let json: serde_json::Value = serde_json::from_str(&msg).unwrap(); assert_eq!(json["method"], "kigi/leader/version_mismatch"); assert_eq!(json["params"]["clientVersion"], "test-client-0.1.157"); assert_eq!(json["params"]["leaderVersion"], "test-leader-0.1.150"); client.cancel(); cancel.cancel(); } /// Confirm no mismatch notification is sent when versions match. #[tokio::test] async fn test_no_version_mismatch_notification_when_versions_match() { use kigi_shell::leader::{ClientCapabilities, ClientMode, LeaderClient, run_leader_server}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicUsize}; use tokio::sync::{mpsc, watch}; use tokio_util::sync::CancellationToken; let temp = TempDir::new().unwrap(); let sock_path = temp.path().join("leader.sock"); let (acp_tx, _acp_rx) = mpsc::unbounded_channel::(); let (_response_tx, response_rx) = mpsc::unbounded_channel::(); let cancel = CancellationToken::new(); let sock_clone = sock_path.clone(); let cancel_clone = cancel.clone(); tokio::spawn(async move { let control_state = LeaderServerControlState::new(LeaderServerMetadata { pid: std::process::id(), socket_path: sock_clone.clone(), lock_path: sock_clone.with_extension("lock"), socket_suffix: String::new(), leader_binary_version: env!("CARGO_PKG_VERSION").to_string(), }); let _ = run_leader_server( sock_clone, acp_tx, response_rx, cancel_clone, true, Arc::new(AtomicUsize::new(0)), Arc::new(AtomicBool::new(false)), kigi_shell::agent::activity::AgentActivity::default(), watch::channel(true).1, watch::channel(kigi_shell::leader::ShutdownReason::Manual).0, Some("same-version-0.1.150"), control_state, ) .await; }); wait_for_socket(&sock_path).await; // Connect with the same version as the leader. let mut client = LeaderClient::connect( sock_path, "test-client", ClientMode::Stdio, ClientCapabilities { client_version: Some("same-version-0.1.150".to_string()), ..Default::default() }, ) .await .unwrap(); // No mismatch notification should arrive within a short window. let received = tokio::time::timeout(Duration::from_millis(200), client.recv()).await; assert!( received.is_err(), "no version mismatch notification expected when versions match" ); client.cancel(); cancel.cancel(); } // ── Shutdown reason end-to-end ──────────────────────────────────────── /// End-to-end test: a connected `LeaderClient` receives `ShuttingDown { reason: AutoUpdate }` /// and `LeaderClient::shutting_down_reason()` updates to `Some(AutoUpdate)`. /// /// This covers the full propagation path: /// shutdown_tx.send(AutoUpdate) → cancel → server cancel branch reads reason → /// broadcast_shutdown(AutoUpdate) → client read loop receives ShuttingDown → /// shutting_down_tx.send(Some(AutoUpdate)) → shutting_down_rx observes Some(AutoUpdate) #[tokio::test] async fn test_auto_update_shutdown_reason_reaches_client() { use kigi_shell::leader::{ClientCapabilities, ClientMode, LeaderClient, ShutdownReason}; let temp = TempDir::new().unwrap(); let sock_path = temp.path().join("leader.sock"); let handle = spawn_leader_server(sock_path.clone()).await.unwrap(); wait_for_socket(&sock_path).await; let client = LeaderClient::connect( sock_path, "test-client", ClientMode::Stdio, ClientCapabilities::default(), ) .await .unwrap(); let mut shutdown_reason_rx = client.shutting_down_reason(); let mut disconnect_rx = client.disconnect_reason(); // Initial state: no ShuttingDown seen yet. assert_eq!( *shutdown_reason_rx.borrow(), None, "shutdown reason must be None before any ShuttingDown message" ); // Simulate what run_auto_update_checker does: // write AutoUpdate BEFORE cancelling so the server reads the right reason. handle.shutdown_tx.send(ShutdownReason::AutoUpdate).unwrap(); handle.cancel.cancel(); // Wait for the ShuttingDown reason to propagate to the client. tokio::time::timeout(Duration::from_secs(2), shutdown_reason_rx.changed()) .await .expect("timeout waiting for shutting_down_reason to change") .expect("watch sender dropped"); assert_eq!( *shutdown_reason_rx.borrow(), Some(ShutdownReason::AutoUpdate), "connected client must observe AutoUpdate as the shutdown reason" ); // Also verify the disconnect reason eventually becomes LeaderShutdown. tokio::time::timeout( Duration::from_secs(2), disconnect_rx.wait_for(|r| *r != kigi_shell::leader::DisconnectReason::Connected), ) .await .expect("timeout waiting for disconnect reason to change") .expect("watch sender dropped"); assert_eq!( *disconnect_rx.borrow(), kigi_shell::leader::DisconnectReason::LeaderShutdown ); } // ── Disruptive relaunch-for-update ──────────────────────────────────── /// A `RelaunchForUpdate` with a strictly-newer target is accepted with a /// `Relaunching` ack, and the leader then broadcasts `ShuttingDown { AutoUpdate }` /// (idle → drains immediately) so connected clients reconnect onto the new binary. #[tokio::test] async fn test_relaunch_for_update_accepts_and_shuts_down() { use kigi_shell::leader::{ ClientCapabilities, ClientMode, ControlCommand, ControlPayload, LeaderClient, ShutdownReason, }; let temp = TempDir::new().unwrap(); let sock_path = temp.path().join("leader.sock"); let _handle = spawn_leader_server(sock_path.clone()).await.unwrap(); wait_for_socket(&sock_path).await; let client = LeaderClient::connect( sock_path, "test-client", ClientMode::Stdio, ClientCapabilities::default(), ) .await .unwrap(); let mut shutdown_reason_rx = client.shutting_down_reason(); // A strictly-newer target is accepted. let payload = client .send_control(ControlCommand::RelaunchForUpdate { to_version: "999.0.0".to_string(), }) .await .expect("control transport ok") .expect("control result ok"); match payload { ControlPayload::Relaunching { to_version, .. } => assert_eq!(to_version, "999.0.0"), other => panic!("expected Relaunching, got {other:?}"), } // Idle leader drains immediately, then broadcasts AutoUpdate. tokio::time::timeout(Duration::from_secs(5), shutdown_reason_rx.changed()) .await .expect("timeout waiting for ShuttingDown after relaunch") .expect("watch sender dropped"); assert_eq!( *shutdown_reason_rx.borrow(), Some(ShutdownReason::AutoUpdate), "relaunch must broadcast AutoUpdate" ); } /// A `RelaunchForUpdate` whose target is not strictly newer than the leader is /// declined (directional guard), and the leader stays up. #[tokio::test] async fn test_relaunch_for_update_declines_when_not_newer() { use kigi_shell::leader::{ ClientCapabilities, ClientMode, ControlCommand, ControlPayload, LeaderClient, }; let temp = TempDir::new().unwrap(); let sock_path = temp.path().join("leader.sock"); let _handle = spawn_leader_server(sock_path.clone()).await.unwrap(); wait_for_socket(&sock_path).await; let client = LeaderClient::connect( sock_path, "test-client", ClientMode::Stdio, ClientCapabilities::default(), ) .await .unwrap(); let shutdown_reason_rx = client.shutting_down_reason(); let payload = client .send_control(ControlCommand::RelaunchForUpdate { to_version: "0.0.0".to_string(), }) .await .expect("control transport ok") .expect("control result ok"); assert!( matches!(payload, ControlPayload::RelaunchDeclined { .. }), "a target that is not strictly newer is declined, got {payload:?}" ); // The leader must NOT begin shutting down. tokio::time::sleep(Duration::from_millis(200)).await; assert_eq!( *shutdown_reason_rx.borrow(), None, "declined relaunch must not shut the leader down" ); } /// The bounded-grace drain waits while the agent is busy and only relaunches /// once it goes idle — so an in-flight turn isn't cut off the instant a /// relaunch is requested. #[tokio::test] async fn test_relaunch_for_update_waits_for_busy_then_exits() { use kigi_shell::leader::{ ClientCapabilities, ClientMode, ControlCommand, ControlPayload, LeaderClient, ShutdownReason, }; use std::sync::atomic::Ordering; let temp = TempDir::new().unwrap(); let sock_path = temp.path().join("leader.sock"); let handle = spawn_leader_server(sock_path.clone()).await.unwrap(); wait_for_socket(&sock_path).await; // Mark the agent busy before requesting the relaunch so the drain must wait. handle.agent_busy.store(true, Ordering::Relaxed); let client = LeaderClient::connect( sock_path, "test-client", ClientMode::Stdio, ClientCapabilities::default(), ) .await .unwrap(); let mut shutdown_reason_rx = client.shutting_down_reason(); let payload = client .send_control(ControlCommand::RelaunchForUpdate { to_version: "999.0.0".to_string(), }) .await .expect("control transport ok") .expect("control result ok"); assert!( matches!(payload, ControlPayload::Relaunching { .. }), "a busy leader still accepts the relaunch, got {payload:?}" ); // While busy, the leader must NOT shut down — the drain is waiting. tokio::time::sleep(Duration::from_millis(300)).await; assert_eq!( *shutdown_reason_rx.borrow(), None, "leader must keep draining while the agent is busy" ); // Going idle lets the drain proceed (it polls every 100ms). handle.agent_busy.store(false, Ordering::Relaxed); tokio::time::timeout(Duration::from_secs(2), shutdown_reason_rx.changed()) .await .expect("timeout waiting for ShuttingDown after going idle") .expect("watch sender dropped"); assert_eq!( *shutdown_reason_rx.borrow(), Some(ShutdownReason::AutoUpdate), "leader must relaunch once the agent is idle" ); } // ── initialize_seen correctness ─────────────────────────────────────── /// Regression test: if the first ACP message is NOT `initialize`, a later /// `initialize` must still receive `clientIdentifier` injection. /// /// Previously, `identity_injected` was set to `true` after the first ACP /// message regardless of its method, so a client whose first message was a /// notification (e.g., `session/cancel`) would never have `clientIdentifier` /// injected into the real `initialize` that followed. #[tokio::test] async fn test_initialize_injected_when_not_first_message() { let temp = TempDir::new().unwrap(); let (sock_path, cancel, mut acp_rx, _response_tx) = setup_test_server(&temp).await; let client = LeaderClient::connect( sock_path, "kigi-tui", ClientMode::Stdio, ClientCapabilities::default(), ) .await .unwrap(); // First message is a notification — NOT initialize. let first_non_init = r#"{"jsonrpc":"2.0","method":"session/cancel","params":{"sessionId":"sess-x","reason":"user"}}"#; client.send(first_non_init.to_string()).unwrap(); let fwd1: serde_json::Value = serde_json::from_str(&acp_rx.recv().await.unwrap()).unwrap(); // Sanity: the notification must have been forwarded correctly. assert_eq!(fwd1["method"], "session/cancel"); // And it must NOT have had any _meta or clientIdentifier added. assert!(fwd1.get("params").and_then(|p| p.get("_meta")).is_none()); // Second message is the real `initialize`. let init_msg = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"#; client.send(init_msg.to_string()).unwrap(); let fwd2: serde_json::Value = serde_json::from_str(&acp_rx.recv().await.unwrap()).unwrap(); assert_eq!(fwd2["method"], "initialize"); // `clientIdentifier` must be present — the server must not have skipped injection // just because the first message was not initialize. let client_id = fwd2 .get("params") .and_then(|p| p.get("_meta")) .and_then(|m| m.get("clientIdentifier")) .and_then(|v| v.as_str()); assert_eq!( client_id, Some("kigi-tui"), "clientIdentifier must be injected into initialize even when it is not the first message" ); client.cancel(); cancel.cancel(); } /// End-to-end leader test: web client (code-nav capable) and TUI client /// (not capable) both send code-nav extension requests; verify that /// codeNavEnabled is independently injected into each client's session /// and that extension requests are routed with correct params. #[tokio::test] async fn test_leader_code_nav_isolation_end_to_end() { let temp = TempDir::new().unwrap(); let (sock_path, cancel, mut acp_rx, _response_tx) = setup_test_server(&temp).await; // Web client with code-nav capability. let web_client = LeaderClient::connect( sock_path.clone(), "kigi-web", ClientMode::Stdio, ClientCapabilities { code_nav_enabled: true, ..Default::default() }, ) .await .unwrap(); // TUI client without code-nav capability. let tui_client = LeaderClient::connect( sock_path, "kigi-tui", ClientMode::Stdio, ClientCapabilities { code_nav_enabled: false, ..Default::default() }, ) .await .unwrap(); let session_new = r#"{"jsonrpc":"2.0","id":1,"method":"session/new","params":{"cwd":"/repo","mcpServers":[]}}"#; // Both clients send session/new; verify independent codeNavEnabled injection. web_client.send(session_new.to_string()).unwrap(); let web_fwd = acp_rx.recv().await.unwrap(); let web_json: serde_json::Value = serde_json::from_str(&web_fwd).unwrap(); assert_eq!( web_json["params"]["_meta"]["codeNavEnabled"], serde_json::json!(true) ); tui_client.send(session_new.to_string()).unwrap(); let tui_fwd = acp_rx.recv().await.unwrap(); let tui_json: serde_json::Value = serde_json::from_str(&tui_fwd).unwrap(); assert_eq!( tui_json["params"]["_meta"]["codeNavEnabled"], serde_json::json!(false) ); // Web client sends kigi/code/status (the primary non-starting code-nav call). let status_with_session = r#"{"jsonrpc":"2.0","id":10,"method":"extensions/ext","params":{"method":"kigi/code/status","params":{"sessionId":"web-session","cwd":"/repo"}}}"#; web_client.send(status_with_session.to_string()).unwrap(); let status_fwd = acp_rx.recv().await.unwrap(); let status_json: serde_json::Value = serde_json::from_str(&status_fwd).unwrap(); assert_eq!(status_json["params"]["method"], "kigi/code/status"); assert_eq!(status_json["params"]["params"]["sessionId"], "web-session"); web_client.cancel(); tui_client.cancel(); cancel.cancel(); } /// Regression test for a deadlock in `connect_or_spawn`. /// /// The bug: the spawner held a file lock while doing a full /// `LeaderClient::connect`, which blocks in `register()` waiting for /// `LeaderReady`. The leader needs the same lock to reach readiness. /// /// This test couples readiness to a file lock (matching production) so /// holding the lock while connecting would deadlock. Existing tests missed /// this because they drove readiness via a bare `watch::channel`. #[tokio::test] async fn test_lock_released_before_connect_prevents_deadlock() { use fs2::FileExt; use kigi_shell::leader::run_leader_server; use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicUsize}; use tokio::sync::{mpsc, watch}; use tokio_util::sync::CancellationToken; let temp = TempDir::new().unwrap(); let sock_path = temp.path().join("leader.sock"); let lock_path = temp.path().join("leader.lock"); // Spawner acquires the file lock (mirrors connect_or_spawn). let lock_file = std::fs::OpenOptions::new() .read(true) .write(true) .create(true) .truncate(true) .open(&lock_path) .unwrap(); lock_file.lock_exclusive().unwrap(); let (acp_tx, _acp_rx) = mpsc::unbounded_channel::(); let (_response_tx, response_rx) = mpsc::unbounded_channel::(); let cancel = CancellationToken::new(); let (ready_tx, ready_rx) = watch::channel(false); let sock_clone = sock_path.clone(); let cancel_clone = cancel.clone(); tokio::spawn(async move { let control_state = LeaderServerControlState::new(LeaderServerMetadata { pid: std::process::id(), socket_path: sock_clone.clone(), lock_path: sock_clone.with_extension("lock"), socket_suffix: String::new(), leader_binary_version: env!("CARGO_PKG_VERSION").to_string(), }); let _ = run_leader_server( sock_clone, acp_tx, response_rx, cancel_clone, true, Arc::new(AtomicUsize::new(0)), Arc::new(AtomicBool::new(false)), kigi_shell::agent::activity::AgentActivity::default(), ready_rx, watch::channel(kigi_shell::leader::ShutdownReason::Manual).0, None, control_state, ) .await; }); wait_for_socket(&sock_path).await; // Simulate the leader: acquire lock → signal readiness. // Blocks until the spawner releases the lock above. tokio::task::spawn_blocking(move || { let f = std::fs::File::open(&lock_path).unwrap(); f.lock_exclusive().unwrap(); ready_tx.send(true).unwrap(); }); // Release lock BEFORE connecting (the fix). Without this, // connect blocks on LeaderReady, which needs the lock → deadlock. lock_file.unlock().unwrap(); let result = tokio::time::timeout( Duration::from_secs(5), LeaderClient::connect( sock_path, "test", ClientMode::Stdio, ClientCapabilities::default(), ), ) .await; assert!( result.is_ok(), "connect timed out — deadlock regression (lock held during connect)" ); result.unwrap().unwrap().cancel(); cancel.cancel(); } // ============================================================================= // Hung-agent + sever-mid-RPC scenarios // // The fake agent in these tests is the test body itself (acp_rx/response_tx), // so "agent hangs" and "agent completes after the client is gone" are driven // deterministically. Reconnects use fresh raw `UnixStream`s so the sever is // an abrupt socket close, not a graceful `Disconnect`. // ============================================================================= /// Server that survives client disconnects (`no_exit_on_disconnect = true`), /// for sever/reconnect scenarios. Same wiring as /// `test_session_ownership_cleanup_on_disconnect`. async fn setup_persistent_test_server( temp: &TempDir, ) -> ( std::path::PathBuf, tokio_util::sync::CancellationToken, tokio::sync::mpsc::UnboundedReceiver, tokio::sync::mpsc::UnboundedSender, ) { use kigi_shell::leader::run_leader_server; let sock_path = temp.path().join("leader.sock"); let (acp_tx, acp_rx) = tokio::sync::mpsc::unbounded_channel(); let (response_tx, response_rx) = tokio::sync::mpsc::unbounded_channel(); let cancel = tokio_util::sync::CancellationToken::new(); let cancel_clone = cancel.clone(); let sock_clone = sock_path.clone(); tokio::spawn(async move { let control_state = LeaderServerControlState::new(LeaderServerMetadata { pid: std::process::id(), socket_path: sock_clone.clone(), lock_path: sock_clone.with_extension("lock"), socket_suffix: String::new(), leader_binary_version: env!("CARGO_PKG_VERSION").to_string(), }); let _ = run_leader_server( sock_clone, acp_tx, response_rx, cancel_clone, true, std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)), std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), kigi_shell::agent::activity::AgentActivity::default(), tokio::sync::watch::channel(true).1, tokio::sync::watch::channel(kigi_shell::leader::ShutdownReason::Manual).0, None, control_state, ) .await; }); wait_for_socket(&sock_path).await; (sock_path, cancel, acp_rx, response_tx) } type RawClient = ( tokio::io::ReadHalf, tokio::io::WriteHalf, ); /// Connect a raw socket client and complete registration. Raw (not /// `LeaderClient`) so dropping the halves is an abrupt sever with no /// `Disconnect` handshake. async fn raw_register(sock_path: &std::path::Path, client_type: &str) -> RawClient { let stream = UnixStream::connect(sock_path).await.unwrap(); let (mut reader, mut writer) = tokio::io::split(stream); write_message( &mut writer, &ClientMessage::Register { client_type: client_type.into(), mode: ClientMode::Stdio, capabilities: ClientCapabilities::default(), }, ) .await .unwrap(); let response: ServerMessage = read_message(&mut reader).await.unwrap(); assert!(matches!(response, ServerMessage::Registered { .. })); (reader, writer) } /// Read `ServerMessage`s until an ACP payload arrives; return the parsed JSON. async fn raw_recv_acp(reader: &mut tokio::io::ReadHalf) -> serde_json::Value { loop { let msg: ServerMessage = tokio::time::timeout(Duration::from_secs(10), read_message(reader)) .await .expect("timeout waiting for ACP message") .expect("read error"); match msg { ServerMessage::Acp { payload } => return serde_json::from_str(&payload).unwrap(), // Skip keepalive/readiness noise. _ => continue, } } } /// Count `unified.jsonl` orphan-drop entries for `request_id`. Namespaced /// request ids are unique per process (global `ClientId` counter) and the pid /// filter fences off other test processes appending to the same shared log. /// /// This binary does not sandbox KIGI_SHARE_DIR, so on a dev machine these entries /// land in the real `~/.kigi` log — accepted: the server already writes /// `leader.client.*` lines there from every test in this file, and the /// pid+request-id fence keeps the counting sound regardless of what else is /// in the file. (Bazel sandboxes HOME, so CI writes stay test-scoped.) fn orphan_log_count(request_id: &str) -> usize { let Some(bytes) = kigi_log::unified_log::snapshot_log() else { return 0; }; String::from_utf8_lossy(&bytes) .lines() .filter(|line| { serde_json::from_str::(line).is_ok_and(|entry| { entry["msg"] == "leader.response.orphaned" && entry["ctx"]["request_id"] == request_id && entry["pid"] == std::process::id() }) }) .count() } /// Poll until `orphan_log_count(request_id) >= 1` or the budget elapses. async fn wait_for_orphan_log(request_id: &str) -> usize { let deadline = tokio::time::Instant::now() + Duration::from_secs(5); loop { let count = orphan_log_count(request_id); if count > 0 || tokio::time::Instant::now() >= deadline { return count; } tokio::time::sleep(Duration::from_millis(50)).await; } } /// Poll until the server logged `leader.client.disconnected` for `client_id`. /// The deterministic disconnect signal for sessions that still have other /// subscribers (no `evict_sessions` is emitted for those). Same /// real-home-write caveat and pid fence as [`orphan_log_count`]. async fn wait_for_client_disconnected_log(client_id: u64) -> bool { let deadline = tokio::time::Instant::now() + Duration::from_secs(5); loop { let seen = kigi_log::unified_log::snapshot_log().is_some_and(|bytes| { String::from_utf8_lossy(&bytes).lines().any(|line| { serde_json::from_str::(line).is_ok_and(|entry| { entry["msg"] == "leader.client.disconnected" && entry["ctx"]["client_id"] == client_id && entry["pid"] == std::process::id() }) }) }); if seen || tokio::time::Instant::now() >= deadline { return seen; } tokio::time::sleep(Duration::from_millis(50)).await; } } /// Hung agent: a prompt is forwarded but the agent never replies. The client /// must not receive a fabricated response, the transport must stay healthy /// (a later cancel still reaches the agent), and other traffic still flows. /// This is the client-visible state of the hung-turn class; deadman semantics /// on top of it are a product change asserted by the ignored test below. #[tokio::test] async fn test_hung_agent_leaves_transport_healthy_and_forwards_cancel() { let temp = TempDir::new().unwrap(); let (sock_path, cancel, mut acp_rx, response_tx) = setup_test_server(&temp).await; let mut client = LeaderClient::connect( sock_path, "test-hung", ClientMode::Stdio, ClientCapabilities::default(), ) .await .unwrap(); // Prompt reaches the (hung) agent. client .send(r#"{"jsonrpc":"2.0","id":7,"method":"session/prompt","params":{"sessionId":"sess-hung","prompt":[]}}"#.to_string()) .unwrap(); let forwarded = acp_rx.recv().await.unwrap(); let json: serde_json::Value = serde_json::from_str(&forwarded).unwrap(); assert_eq!(json["method"], "session/prompt"); // The agent hangs: no response. The client must see nothing (no error // synthesis, no disconnect) within a bounded observation window. let quiet = tokio::time::timeout(Duration::from_millis(500), client.recv()).await; assert!( quiet.is_err(), "no response should be synthesized for a hung agent, got {quiet:?}" ); assert_eq!( *client.disconnect_reason().borrow(), kigi_shell::leader::DisconnectReason::Connected, "a hung agent must not sever the client connection" ); // The escape hatch still works: a cancel is forwarded while the turn hangs. client .send( r#"{"jsonrpc":"2.0","method":"session/cancel","params":{"sessionId":"sess-hung"}}"# .to_string(), ) .unwrap(); let cancel_fwd = acp_rx.recv().await.unwrap(); let cancel_json: serde_json::Value = serde_json::from_str(&cancel_fwd).unwrap(); assert_eq!(cancel_json["method"], "session/cancel"); // Unrelated traffic still round-trips on the same connection. let probe = r#"{"jsonrpc":"2.0","method":"kigi/probe","params":{"ping":true}}"#; response_tx.send(probe.to_string()).unwrap(); let recv = tokio::time::timeout(Duration::from_secs(2), client.recv()) .await .expect("timeout") .expect("closed"); let recv_json: serde_json::Value = serde_json::from_str(&recv).unwrap(); assert_eq!(recv_json["params"]["ping"], true); client.cancel(); cancel.cancel(); } /// Sever mid-RPC, agent completes anyway, a fresh client recovers via /// `session/load`: the response to the dead client is dropped exactly once /// (`leader.response.orphaned`), and post-load notifications for the session /// reach the new owner — the durable-terminal recovery path. #[tokio::test] async fn test_sever_mid_rpc_orphans_response_and_replay_recovers() { let temp = TempDir::new().unwrap(); let (sock_path, cancel, mut acp_rx, response_tx) = setup_persistent_test_server(&temp).await; // Client 1: create the session, then leave a prompt in flight. let (mut reader1, mut writer1) = raw_register(&sock_path, "sever-client-1").await; write_message( &mut writer1, &ClientMessage::Acp { payload: r#"{"jsonrpc":"2.0","id":1,"method":"session/new","params":{"cwd":"/tmp","mcpServers":[]}}"#.into(), }, ) .await .unwrap(); let new_fwd = acp_rx.recv().await.unwrap(); let new_json: serde_json::Value = serde_json::from_str(&new_fwd).unwrap(); let new_id = new_json["id"].as_str().unwrap().to_string(); response_tx .send(format!( r#"{{"jsonrpc":"2.0","result":{{"sessionId":"sess-sever"}},"id":"{new_id}"}}"# )) .unwrap(); let new_resp = raw_recv_acp(&mut reader1).await; assert_eq!(new_resp["result"]["sessionId"], "sess-sever"); write_message( &mut writer1, &ClientMessage::Acp { payload: r#"{"jsonrpc":"2.0","id":2,"method":"session/prompt","params":{"sessionId":"sess-sever","prompt":[]}}"#.into(), }, ) .await .unwrap(); let prompt_fwd = acp_rx.recv().await.unwrap(); let prompt_json: serde_json::Value = serde_json::from_str(&prompt_fwd).unwrap(); let prompt_id = prompt_json["id"].as_str().unwrap().to_string(); // Sever: abrupt socket close with the prompt RPC still in flight. drop(reader1); drop(writer1); // The eviction notification on the agent channel is the deterministic // signal that the server processed the disconnect. let evict = acp_rx.recv().await.unwrap(); let evict_json: serde_json::Value = serde_json::from_str(&evict).unwrap(); assert_eq!(evict_json["method"], "kigi/internal/evict_sessions"); // The agent completes the turn anyway: durable terminal notification plus // the RPC response addressed to the dead client. response_tx .send(format!( r#"{{"jsonrpc":"2.0","result":{{"stopReason":"end_turn"}},"id":"{prompt_id}"}}"# )) .unwrap(); assert_eq!( wait_for_orphan_log(&prompt_id).await, 1, "the response to the severed client's RPC must be orphan-dropped exactly once" ); // Fresh client recovers the session via session/load. let (mut reader2, mut writer2) = raw_register(&sock_path, "sever-client-2").await; write_message( &mut writer2, &ClientMessage::Acp { payload: r#"{"jsonrpc":"2.0","id":1,"method":"session/load","params":{"sessionId":"sess-sever","cwd":"/tmp","mcpServers":[]}}"#.into(), }, ) .await .unwrap(); let load_fwd = acp_rx.recv().await.unwrap(); let load_json: serde_json::Value = serde_json::from_str(&load_fwd).unwrap(); assert_eq!(load_json["method"], "session/load"); let load_id = load_json["id"].as_str().unwrap().to_string(); response_tx .send(format!( r#"{{"jsonrpc":"2.0","result":{{}},"id":"{load_id}"}}"# )) .unwrap(); let load_resp = raw_recv_acp(&mut reader2).await; assert_eq!(load_resp["id"], 1); // Post-load, the durable terminal state reaches the new owner (in // production this line comes from the agent's updates.jsonl replay). let terminal = r#"{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"sess-sever","update":{"sessionUpdate":"turn_completed","stopReason":"end_turn"}}}"#; response_tx.send(terminal.to_string()).unwrap(); let update = raw_recv_acp(&mut reader2).await; assert_eq!(update["method"], "session/update"); assert_eq!(update["params"]["update"]["stopReason"], "end_turn"); // Still exactly one orphan record for the severed RPC. assert_eq!(orphan_log_count(&prompt_id), 1); drop(reader2); drop(writer2); cancel.cancel(); } /// Acceptance: a `session/cancel` composed just before the leader dies is /// currently lost in the reconnect swap window — the send lands in a dead /// channel and nothing re-delivers it, so the turn runs on. This asserts the /// DESIRED behavior (the cancel reaches the agent after recovery) and stays /// ignored until a cancel-ack/resend protocol ships. #[tokio::test] #[ignore = "leader-acceptance: cancel severed in reconnect swap window is not resent; un-ignore with cancel-ack"] async fn test_cancel_severed_in_swap_window_reaches_agent_after_recovery() { let temp = TempDir::new().unwrap(); let (sock_path, cancel, mut acp_rx, response_tx) = setup_persistent_test_server(&temp).await; let client = LeaderClient::connect( sock_path.clone(), "swap-window-client", ClientMode::Stdio, ClientCapabilities::default(), ) .await .unwrap(); // Session with a prompt in flight. client .send(r#"{"jsonrpc":"2.0","id":1,"method":"session/new","params":{"cwd":"/tmp","mcpServers":[]}}"#.to_string()) .unwrap(); let new_fwd = acp_rx.recv().await.unwrap(); let new_json: serde_json::Value = serde_json::from_str(&new_fwd).unwrap(); let new_id = new_json["id"].as_str().unwrap().to_string(); response_tx .send(format!( r#"{{"jsonrpc":"2.0","result":{{"sessionId":"sess-swap"}},"id":"{new_id}"}}"# )) .unwrap(); let (tx, mut rx) = client.into_channels(); let _ = tokio::time::timeout(Duration::from_secs(2), rx.recv()).await; tx.send(r#"{"jsonrpc":"2.0","id":2,"method":"session/prompt","params":{"sessionId":"sess-swap","prompt":[]}}"#.to_string()) .unwrap(); let _prompt_fwd = acp_rx.recv().await.unwrap(); // Leader dies; the cancel is composed while the connection is already // dead (the swap window), so it is silently eaten today. cancel.cancel(); // Wait until the old server finished its socket cleanup so the same-path // respawn below cannot have its fresh socket deleted from under it. let deadline = tokio::time::Instant::now() + Duration::from_secs(5); while sock_path.exists() && tokio::time::Instant::now() < deadline { tokio::time::sleep(Duration::from_millis(10)).await; } let _ = tx.send( r#"{"jsonrpc":"2.0","method":"session/cancel","params":{"sessionId":"sess-swap"}}"# .to_string(), ); // Recovery: a fresh leader at the same path, a fresh client reloading the // session (what the pager bridge does on reconnect). let (sock_path2, cancel2, mut acp_rx2, response_tx2) = setup_persistent_test_server(&temp).await; let client2 = LeaderClient::connect( sock_path2, "swap-window-client-reconnect", ClientMode::Stdio, ClientCapabilities::default(), ) .await .unwrap(); client2 .send(r#"{"jsonrpc":"2.0","id":1,"method":"session/load","params":{"sessionId":"sess-swap","cwd":"/tmp","mcpServers":[]}}"#.to_string()) .unwrap(); let load_fwd = acp_rx2.recv().await.unwrap(); let load_json: serde_json::Value = serde_json::from_str(&load_fwd).unwrap(); let load_id = load_json["id"].as_str().unwrap().to_string(); response_tx2 .send(format!( r#"{{"jsonrpc":"2.0","result":{{}},"id":"{load_id}"}}"# )) .unwrap(); // DESIRED: the severed cancel intent is re-delivered after recovery so // the turn does not run on. Today nothing arrives and this times out. let cancelled = tokio::time::timeout(Duration::from_secs(5), async { loop { let fwd = acp_rx2.recv().await.expect("agent channel closed"); let json: serde_json::Value = serde_json::from_str(&fwd).unwrap(); if json["method"] == "session/cancel" && json["params"]["sessionId"] == "sess-swap" { return; } } }) .await; assert!( cancelled.is_ok(), "the cancel severed in the swap window must reach the agent after recovery" ); client2.cancel(); cancel2.cancel(); } /// Two clients on one session; the driver severs mid-turn. The viewer must /// keep receiving the stream and the durable terminal, while the driver's /// in-flight RPC response is orphan-dropped (not misrouted to the viewer). #[tokio::test] async fn test_driver_sever_mid_turn_viewer_sees_durable_terminal() { let temp = TempDir::new().unwrap(); let (sock_path, cancel, mut acp_rx, response_tx) = setup_persistent_test_server(&temp).await; // Driver creates the session and starts a turn. let (mut driver_reader, mut driver_writer) = raw_register(&sock_path, "driver").await; write_message( &mut driver_writer, &ClientMessage::Acp { payload: r#"{"jsonrpc":"2.0","id":1,"method":"session/new","params":{"cwd":"/tmp","mcpServers":[]}}"#.into(), }, ) .await .unwrap(); let new_fwd = acp_rx.recv().await.unwrap(); let new_json: serde_json::Value = serde_json::from_str(&new_fwd).unwrap(); let new_id = new_json["id"].as_str().unwrap().to_string(); response_tx .send(format!( r#"{{"jsonrpc":"2.0","result":{{"sessionId":"sess-handoff"}},"id":"{new_id}"}}"# )) .unwrap(); let new_resp = raw_recv_acp(&mut driver_reader).await; assert_eq!(new_resp["result"]["sessionId"], "sess-handoff"); // Viewer attaches to the same session via session/load. let (mut viewer_reader, mut viewer_writer) = raw_register(&sock_path, "viewer").await; write_message( &mut viewer_writer, &ClientMessage::Acp { payload: r#"{"jsonrpc":"2.0","id":1,"method":"session/load","params":{"sessionId":"sess-handoff","cwd":"/tmp","mcpServers":[]}}"#.into(), }, ) .await .unwrap(); let load_fwd = acp_rx.recv().await.unwrap(); let load_json: serde_json::Value = serde_json::from_str(&load_fwd).unwrap(); let load_id = load_json["id"].as_str().unwrap().to_string(); response_tx .send(format!( r#"{{"jsonrpc":"2.0","result":{{}},"id":"{load_id}"}}"# )) .unwrap(); let load_resp = raw_recv_acp(&mut viewer_reader).await; assert_eq!(load_resp["id"], 1); // Driver starts the turn. write_message( &mut driver_writer, &ClientMessage::Acp { payload: r#"{"jsonrpc":"2.0","id":2,"method":"session/prompt","params":{"sessionId":"sess-handoff","prompt":[]}}"#.into(), }, ) .await .unwrap(); let prompt_fwd = acp_rx.recv().await.unwrap(); let prompt_json: serde_json::Value = serde_json::from_str(&prompt_fwd).unwrap(); let prompt_id = prompt_json["id"].as_str().unwrap().to_string(); // A first streamed chunk reaches BOTH subscribers. let chunk1 = r#"{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"sess-handoff","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"one"}}}}"#; response_tx.send(chunk1.to_string()).unwrap(); let driver_chunk = raw_recv_acp(&mut driver_reader).await; assert_eq!(driver_chunk["params"]["update"]["content"]["text"], "one"); let viewer_chunk = raw_recv_acp(&mut viewer_reader).await; assert_eq!(viewer_chunk["params"]["update"]["content"]["text"], "one"); // Driver severs mid-turn. The viewer still subscribes to the session, so // no evict_sessions fires — wait on the disconnect log entry instead. let (driver_client_id, _) = parse_namespaced_id(&prompt_id).expect("namespaced prompt id parses"); drop(driver_reader); drop(driver_writer); assert!( wait_for_client_disconnected_log(driver_client_id).await, "server never recorded the driver disconnect" ); // The turn keeps streaming; the viewer still receives it. let chunk2 = r#"{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"sess-handoff","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"two"}}}}"#; response_tx.send(chunk2.to_string()).unwrap(); let viewer_chunk2 = raw_recv_acp(&mut viewer_reader).await; assert_eq!(viewer_chunk2["params"]["update"]["content"]["text"], "two"); // Durable terminal reaches the viewer; the driver's RPC response is // orphan-dropped, not misrouted. let terminal = r#"{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"sess-handoff","update":{"sessionUpdate":"turn_completed","stopReason":"end_turn"}}}"#; response_tx.send(terminal.to_string()).unwrap(); response_tx .send(format!( r#"{{"jsonrpc":"2.0","result":{{"stopReason":"end_turn"}},"id":"{prompt_id}"}}"# )) .unwrap(); let viewer_terminal = raw_recv_acp(&mut viewer_reader).await; assert_eq!( viewer_terminal["params"]["update"]["sessionUpdate"], "turn_completed" ); assert_eq!( wait_for_orphan_log(&prompt_id).await, 1, "the severed driver's prompt response must be orphan-dropped exactly once" ); // The viewer must NOT have been handed the driver's RPC response: the next // message it sees (if any) is not a response with the driver's original id. let stray = tokio::time::timeout(Duration::from_millis(300), async { raw_recv_acp(&mut viewer_reader).await }) .await; if let Ok(msg) = stray { assert!( msg.get("result").is_none() || msg["id"] != 2, "driver's orphaned RPC response leaked to the viewer: {msg}" ); } drop(viewer_reader); drop(viewer_writer); cancel.cancel(); }