diff --git a/Cargo.toml b/Cargo.toml index 70686cc..e5b5439 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["mixtape-core", "mixtape-anthropic-sdk", "mixtape-tools", "mixtape-cli", "mixtape-server"] +members = ["mixtape-core", "mixtape-anthropic-sdk", "mixtape-tools", "mixtape-cli", "mixtape-server", "mixtape-acp"] resolver = "2" [workspace.package] @@ -16,6 +16,7 @@ mixtape-anthropic-sdk = { version = "0.3.1", path = "./mixtape-anthropic-sdk" } mixtape-tools = { path = "./mixtape-tools" } mixtape-cli = { path = "./mixtape-cli" } mixtape-server = { path = "./mixtape-server" } +mixtape-acp = { path = "./mixtape-acp" } # Async runtime tokio = { version = "1.41", features = ["full"] } @@ -24,6 +25,8 @@ tokio-stream = "0.1" async-trait = "0.1" async-stream = "0.3" futures = "0.3" +agent-client-protocol = "0.9" +tokio-util = { version = "0.7", features = ["compat"] } # HTTP Server axum = { version = "0.7", features = ["macros"] } @@ -73,6 +76,7 @@ regex = "1.10" url = "2.5" http = "1.1" shellexpand = "3.1" +log = "0.4" # MCP rmcp = { version = "0.11", features = ["client", "transport-child-process", "transport-streamable-http-client-reqwest"] } diff --git a/mixtape-acp/Cargo.toml b/mixtape-acp/Cargo.toml new file mode 100644 index 0000000..c902930 --- /dev/null +++ b/mixtape-acp/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "mixtape-acp" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +description = "ACP (Agent Client Protocol) adapter for the mixtape agent framework" +documentation = "https://docs.rs/mixtape-acp" +readme = "../README.md" +keywords = ["ai", "agents", "acp", "editor", "coding"] +categories = ["development-tools", "api-bindings", "asynchronous"] +exclude = [".cargo-husky/", ".claude/", ".github/", ".idea/"] + +[dependencies] +mixtape-core.workspace = true +agent-client-protocol.workspace = true +tokio.workspace = true +tokio-util.workspace = true +futures.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +uuid.workspace = true +parking_lot.workspace = true +async-trait.workspace = true +log.workspace = true + +[dev-dependencies] +mixtape-core = { workspace = true, features = ["test-utils", "bedrock"] } +tokio-test.workspace = true +cargo-husky.workspace = true + +[[example]] +name = "echo_agent" + +[[example]] +name = "echo_agent_bedrock" diff --git a/mixtape-acp/examples/echo_agent.rs b/mixtape-acp/examples/echo_agent.rs new file mode 100644 index 0000000..6af075d --- /dev/null +++ b/mixtape-acp/examples/echo_agent.rs @@ -0,0 +1,25 @@ +// ACP echo agent using MockProvider — no credentials required. +// +// This example starts an ACP server over stdio that responds to every prompt +// with a fixed text reply. Useful for verifying that the ACP protocol wiring +// works end-to-end without needing real LLM credentials. +// +// Run with: +// cargo run -p mixtape-acp --example echo_agent + +use mixtape_acp::{serve_stdio, MixtapeAcpBuilder}; +use mixtape_core::test_utils::MockProvider; +use mixtape_core::Agent; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let server = MixtapeAcpBuilder::new("echo-agent", env!("CARGO_PKG_VERSION")) + .with_agent_factory(|| async { + let provider = MockProvider::new().with_text("Hello from the echo agent!"); + Agent::builder().provider(provider).build().await + }) + .build()?; + + serve_stdio(server).await?; + Ok(()) +} diff --git a/mixtape-acp/examples/echo_agent_bedrock.rs b/mixtape-acp/examples/echo_agent_bedrock.rs new file mode 100644 index 0000000..8aa9ac5 --- /dev/null +++ b/mixtape-acp/examples/echo_agent_bedrock.rs @@ -0,0 +1,27 @@ +// ACP agent backed by Claude Haiku 4.5 on Bedrock. +// +// This example starts an ACP server over stdio that creates a real +// mixtape Agent for each session, using Claude Haiku 4.5 via AWS Bedrock. +// Requires valid AWS credentials in the environment. +// +// Run with: +// cargo run -p mixtape-acp --example echo_agent_bedrock + +use mixtape_acp::{serve_stdio, MixtapeAcpBuilder}; +use mixtape_core::{Agent, ClaudeHaiku4_5}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let server = MixtapeAcpBuilder::new("bedrock-agent", env!("CARGO_PKG_VERSION")) + .with_agent_factory(|| async { + Agent::builder() + .bedrock(ClaudeHaiku4_5) + .with_system_prompt("You are a helpful coding assistant.") + .build() + .await + }) + .build()?; + + serve_stdio(server).await?; + Ok(()) +} diff --git a/mixtape-acp/src/adapter.rs b/mixtape-acp/src/adapter.rs new file mode 100644 index 0000000..3675e88 --- /dev/null +++ b/mixtape-acp/src/adapter.rs @@ -0,0 +1,459 @@ +use std::sync::Arc; + +use agent_client_protocol::{ + AuthenticateRequest, AuthenticateResponse, CancelNotification, ContentBlock as AcpContentBlock, + Implementation, InitializeRequest, InitializeResponse, NewSessionRequest, NewSessionResponse, + PromptRequest, PromptResponse, ProtocolVersion, SessionId, StopReason, +}; +use tokio::sync::mpsc; + +use crate::convert::{agent_error_to_stop_reason, agent_event_to_session_update}; +use crate::error::AcpError; +use crate::permission::PermissionBridgeRequest; +use crate::session::SessionManager; +use crate::types::{AgentFactory, NotificationMessage}; + +/// The core ACP adapter that bridges mixtape agents to the ACP protocol. +/// +/// Implements `agent_client_protocol::Agent` to handle JSON-RPC requests +/// from editors and IDEs. Each ACP session maps to a dedicated mixtape +/// `Agent` instance created by the factory closure. +pub(crate) struct MixtapeAcpAgent { + pub(crate) factory: AgentFactory, + pub(crate) sessions: SessionManager, + pub(crate) name: String, + pub(crate) version: String, + pub(crate) notification_tx: mpsc::UnboundedSender, + pub(crate) permission_tx: mpsc::UnboundedSender, +} + +#[async_trait::async_trait(?Send)] +impl agent_client_protocol::Agent for MixtapeAcpAgent { + async fn initialize( + &self, + _args: InitializeRequest, + ) -> agent_client_protocol::Result { + Ok(InitializeResponse::new(ProtocolVersion::V1) + .agent_info(Implementation::new(&self.name, &self.version))) + } + + async fn authenticate( + &self, + _args: AuthenticateRequest, + ) -> agent_client_protocol::Result { + Ok(AuthenticateResponse::new()) + } + + async fn new_session( + &self, + _args: NewSessionRequest, + ) -> agent_client_protocol::Result { + let agent = (self.factory)() + .await + .map_err(|e| AcpError::BuildFailed(e.to_string()))?; + + let session_id = uuid::Uuid::new_v4().to_string(); + self.sessions.insert(session_id.clone(), Arc::new(agent)); + + Ok(NewSessionResponse::new(SessionId::from(session_id))) + } + + async fn prompt(&self, args: PromptRequest) -> agent_client_protocol::Result { + let session_id_str = args.session_id.to_string(); + let agent = self + .sessions + .get(&session_id_str) + .ok_or_else(|| AcpError::SessionNotFound(session_id_str))?; + + let text = extract_text_from_prompt(&args.prompt); + + let notification_tx = self.notification_tx.clone(); + let permission_tx = self.permission_tx.clone(); + let session_id = args.session_id.clone(); + let agent_for_hook = Arc::clone(&agent); + + // Hook that forwards agent events to the relay task. + // + // Notifications are fire-and-forget. Permission requests carry the + // Arc so the relay task can call agent.respond_to_authorization() + // after the IDE responds — this is what closes the loop back to the + // agent's request_authorization() which is blocking on an mpsc::recv(). + let hook_id = agent.add_hook(move |event: &mixtape_core::AgentEvent| { + if let Some(update) = agent_event_to_session_update(event) { + let msg = NotificationMessage { + session_id: session_id.clone(), + update, + }; + let _ = notification_tx.send(msg); + } + + if let mixtape_core::AgentEvent::PermissionRequired { + proposal_id, + tool_name, + params, + .. + } = event + { + let req = PermissionBridgeRequest { + session_id: session_id.clone(), + proposal_id: proposal_id.clone(), + tool_name: tool_name.clone(), + params: params.clone(), + agent: Arc::clone(&agent_for_hook), + }; + let _ = permission_tx.send(req); + } + }); + + // Spawn the agent run on the multi-threaded runtime (Agent::run is Send) + let agent_clone = Arc::clone(&agent); + let (result_tx, result_rx) = tokio::sync::oneshot::channel(); + + tokio::spawn(async move { + let result = agent_clone.run(&text).await; + let _ = result_tx.send(result); + }); + + let run_result = result_rx + .await + .map_err(|_| AcpError::Internal("Agent task dropped".to_string()))?; + + agent.remove_hook(hook_id); + + match run_result { + Ok(_) => Ok(PromptResponse::new(StopReason::EndTurn)), + Err(ref e) => agent_error_to_stop_reason(e).map(PromptResponse::new), + } + } + + async fn cancel(&self, args: CancelNotification) -> agent_client_protocol::Result<()> { + let session_id_str = args.session_id.to_string(); + if self.sessions.remove(&session_id_str).is_some() { + log::info!("Session {} cancelled and removed", session_id_str); + } else { + log::warn!("Cancel requested for unknown session {}", session_id_str); + } + Ok(()) + } +} + +/// Extract text content from ACP prompt content blocks. +fn extract_text_from_prompt(content: &[AcpContentBlock]) -> String { + content + .iter() + .filter_map(|block| match block { + AcpContentBlock::Text(text_content) => Some(text_content.text.as_str()), + _ => None, + }) + .collect::>() + .join("\n") +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + // Bring the ACP Agent trait into scope so its methods are callable on MixtapeAcpAgent. + // Aliased to avoid collision with mixtape_core::Agent which is also used below. + use agent_client_protocol::Agent as _; + use agent_client_protocol::{ + AuthMethodId, AuthenticateRequest, CancelNotification, ContentBlock as AcpContentBlock, + ImageContent, InitializeRequest, NewSessionRequest, PromptRequest, ProtocolVersion, + SessionId, StopReason, TextContent, + }; + use tokio::sync::mpsc; + + use mixtape_core::{ + provider::{ModelProvider, ProviderError}, + types::{ContentBlock, Message, Role, StopReason as CoreStopReason, ToolDefinition}, + ModelResponse, + }; + + use crate::session::SessionManager; + use crate::types::AgentFactory; + + use super::extract_text_from_prompt; + use super::MixtapeAcpAgent; + + // ------------------------------------------------------------------ + // Minimal mock provider — same pattern as builder_tests / session_tests + // ------------------------------------------------------------------ + + #[derive(Clone)] + struct MockProvider; + + #[async_trait::async_trait] + impl ModelProvider for MockProvider { + fn name(&self) -> &str { + "MockProvider" + } + + fn max_context_tokens(&self) -> usize { + 200_000 + } + + fn max_output_tokens(&self) -> usize { + 8_192 + } + + async fn generate( + &self, + _messages: Vec, + _tools: Vec, + _system_prompt: Option, + ) -> Result { + Ok(ModelResponse { + message: Message { + role: Role::Assistant, + content: vec![ContentBlock::Text("ok".to_string())], + }, + stop_reason: CoreStopReason::EndTurn, + usage: None, + }) + } + } + + fn make_factory() -> AgentFactory { + Arc::new(|| { + Box::pin(async { + mixtape_core::Agent::builder() + .provider(MockProvider) + .build() + .await + }) + }) + } + + fn make_adapter() -> MixtapeAcpAgent { + let (notification_tx, _notification_rx) = mpsc::unbounded_channel(); + let (permission_tx, _permission_rx) = mpsc::unbounded_channel(); + MixtapeAcpAgent { + factory: make_factory(), + sessions: SessionManager::new(), + name: "test-agent".to_string(), + version: "0.0.1".to_string(), + notification_tx, + permission_tx, + } + } + + // ------------------------------------------------------------------ + // initialize — returns protocol version and agent identity + // ------------------------------------------------------------------ + + #[tokio::test] + async fn initialize_returns_v1_protocol_version() { + let adapter = make_adapter(); + let req = InitializeRequest::new(ProtocolVersion::V1); + let resp = adapter + .initialize(req) + .await + .expect("initialize should succeed"); + assert_eq!(resp.protocol_version, ProtocolVersion::V1); + } + + #[tokio::test] + async fn initialize_embeds_agent_name_and_version() { + let adapter = make_adapter(); + let req = InitializeRequest::new(ProtocolVersion::V1); + let resp = adapter + .initialize(req) + .await + .expect("initialize should succeed"); + let info = resp.agent_info.expect("agent_info should be set"); + assert_eq!(info.name, "test-agent"); + assert_eq!(info.version, "0.0.1"); + } + + // ------------------------------------------------------------------ + // authenticate — always succeeds (no auth required) + // ------------------------------------------------------------------ + + #[tokio::test] + async fn authenticate_always_succeeds() { + let adapter = make_adapter(); + let req = AuthenticateRequest::new(AuthMethodId::new("any-method")); + let result = adapter.authenticate(req).await; + assert!(result.is_ok(), "authenticate should always succeed"); + } + + // ------------------------------------------------------------------ + // new_session — creates a session and stores the agent + // ------------------------------------------------------------------ + + #[tokio::test] + async fn new_session_creates_a_session_and_returns_id() { + let adapter = make_adapter(); + let req = NewSessionRequest::new("/tmp"); + let resp = adapter + .new_session(req) + .await + .expect("new_session should succeed"); + let session_id = resp.session_id.to_string(); + assert!(!session_id.is_empty(), "session_id should not be empty"); + assert!( + adapter.sessions.get(&session_id).is_some(), + "session should be stored in the session manager" + ); + } + + #[tokio::test] + async fn new_session_multiple_calls_create_distinct_sessions() { + let adapter = make_adapter(); + let req1 = NewSessionRequest::new("/tmp"); + let req2 = NewSessionRequest::new("/tmp"); + let resp1 = adapter.new_session(req1).await.unwrap(); + let resp2 = adapter.new_session(req2).await.unwrap(); + assert_ne!( + resp1.session_id.to_string(), + resp2.session_id.to_string(), + "each new_session call should produce a unique session ID" + ); + } + + // ------------------------------------------------------------------ + // cancel — removes existing session, tolerates unknown session + // ------------------------------------------------------------------ + + #[tokio::test] + async fn cancel_removes_known_session() { + let adapter = make_adapter(); + let session_resp = adapter + .new_session(NewSessionRequest::new("/tmp")) + .await + .unwrap(); + let session_id = session_resp.session_id.clone(); + + assert!(adapter.sessions.get(&session_id.to_string()).is_some()); + + let cancel = CancelNotification::new(session_id.clone()); + adapter.cancel(cancel).await.expect("cancel should succeed"); + + assert!( + adapter.sessions.get(&session_id.to_string()).is_none(), + "session should be removed after cancel" + ); + } + + #[tokio::test] + async fn cancel_unknown_session_is_not_an_error() { + let adapter = make_adapter(); + let cancel = CancelNotification::new(SessionId::from("does-not-exist".to_string())); + let result = adapter.cancel(cancel).await; + assert!( + result.is_ok(), + "cancel for an unknown session should return Ok" + ); + } + + // ------------------------------------------------------------------ + // prompt — session-not-found error path + // ------------------------------------------------------------------ + + #[tokio::test] + async fn prompt_unknown_session_returns_session_not_found_error() { + let adapter = make_adapter(); + let req = PromptRequest::new( + SessionId::from("nonexistent-session".to_string()), + vec![AcpContentBlock::Text(TextContent::new("hello"))], + ); + let result = adapter.prompt(req).await; + assert!( + result.is_err(), + "prompt for an unknown session should return Err" + ); + } + + // ------------------------------------------------------------------ + // prompt — success path with mock provider + // ------------------------------------------------------------------ + + #[tokio::test] + async fn prompt_known_session_returns_end_turn() { + let adapter = make_adapter(); + let session_resp = adapter + .new_session(NewSessionRequest::new("/tmp")) + .await + .unwrap(); + let session_id = session_resp.session_id.clone(); + + let req = PromptRequest::new( + session_id, + vec![AcpContentBlock::Text(TextContent::new("say hello"))], + ); + let resp = adapter + .prompt(req) + .await + .expect("prompt should succeed for a known session"); + assert_eq!( + resp.stop_reason, + StopReason::EndTurn, + "MockProvider returns EndTurn, so stop_reason should be EndTurn" + ); + } + + fn text_block(s: &str) -> AcpContentBlock { + AcpContentBlock::Text(TextContent::new(s)) + } + + fn image_block() -> AcpContentBlock { + AcpContentBlock::Image(ImageContent::new("base64data==", "image/png")) + } + + #[test] + fn empty_slice_produces_empty_string() { + assert_eq!(extract_text_from_prompt(&[]), ""); + } + + #[test] + fn single_text_block_returns_its_text() { + let blocks = [text_block("hello world")]; + assert_eq!(extract_text_from_prompt(&blocks), "hello world"); + } + + #[test] + fn multiple_text_blocks_are_joined_with_newline() { + let blocks = [ + text_block("first"), + text_block("second"), + text_block("third"), + ]; + assert_eq!(extract_text_from_prompt(&blocks), "first\nsecond\nthird"); + } + + #[test] + fn non_text_blocks_are_skipped() { + let blocks = [image_block()]; + assert_eq!( + extract_text_from_prompt(&blocks), + "", + "image blocks should produce no text" + ); + } + + #[test] + fn mixed_blocks_extract_only_text() { + let blocks = [ + text_block("question"), + image_block(), + text_block("follow-up"), + ]; + assert_eq!( + extract_text_from_prompt(&blocks), + "question\nfollow-up", + "only text blocks should contribute to output" + ); + } + + #[test] + fn empty_text_block_contributes_empty_segment() { + let blocks = [text_block(""), text_block("after")]; + assert_eq!(extract_text_from_prompt(&blocks), "\nafter"); + } + + #[test] + fn all_non_text_blocks_produce_empty_string() { + let blocks = [image_block(), image_block()]; + assert_eq!(extract_text_from_prompt(&blocks), ""); + } +} diff --git a/mixtape-acp/src/builder.rs b/mixtape-acp/src/builder.rs new file mode 100644 index 0000000..2682e30 --- /dev/null +++ b/mixtape-acp/src/builder.rs @@ -0,0 +1,115 @@ +use std::future::Future; +use std::sync::Arc; + +use mixtape_core::Agent; +use tokio::sync::mpsc; + +use crate::adapter::MixtapeAcpAgent; +use crate::error::AcpError; +use crate::session::SessionManager; +use crate::types::AgentFactory; + +/// The server bundle returned by [`MixtapeAcpBuilder::build`]. +/// +/// Contains the ACP agent adapter and the receiver halves of the notification +/// and permission channels, which are driven by the relay task in +/// [`serve_stdio`](crate::serve_stdio). +/// +/// Use [`agent_name`](Self::agent_name) and [`agent_version`](Self::agent_version) +/// to inspect the configured identity. +pub struct MixtapeAcpServer { + pub(crate) adapter: MixtapeAcpAgent, + pub(crate) notification_rx: mpsc::UnboundedReceiver, + pub(crate) permission_rx: mpsc::UnboundedReceiver, +} + +impl MixtapeAcpServer { + /// The agent name reported to ACP clients during initialization. + pub fn agent_name(&self) -> &str { + &self.adapter.name + } + + /// The agent version reported to ACP clients during initialization. + pub fn agent_version(&self) -> &str { + &self.adapter.version + } +} + +/// Builder for configuring and constructing an ACP server. +/// +/// # Example +/// +/// ```rust,no_run +/// use mixtape_acp::MixtapeAcpBuilder; +/// use mixtape_core::Agent; +/// +/// # async fn example() -> Result<(), Box> { +/// let server = MixtapeAcpBuilder::new("my-agent", "0.1.0") +/// .with_agent_factory(|| async { +/// Agent::builder() +/// // .bedrock(...) +/// .build() +/// .await +/// }) +/// .build()?; +/// # Ok(()) +/// # } +/// ``` +pub struct MixtapeAcpBuilder { + agent_factory: Option, + name: String, + version: String, +} + +#[cfg(test)] +#[path = "builder_tests.rs"] +mod tests; + +impl MixtapeAcpBuilder { + /// Create a new builder with the given agent name and version. + /// + /// These are reported to the client in the ACP `initialize` response. + pub fn new(name: impl Into, version: impl Into) -> Self { + Self { + agent_factory: None, + name: name.into(), + version: version.into(), + } + } + + /// Set the factory closure that creates new Agent instances. + /// + /// This is called once per `new_session` to produce a fresh agent with + /// its own conversation state. + pub fn with_agent_factory(mut self, factory: F) -> Self + where + F: Fn() -> Fut + Send + Sync + 'static, + Fut: Future> + Send + 'static, + { + self.agent_factory = Some(Arc::new(move || Box::pin(factory()))); + self + } + + /// Build the ACP server, returning the adapter and channel receivers. + pub fn build(self) -> Result { + let factory = self.agent_factory.ok_or(AcpError::NoAgentFactory)?; + + let (notification_tx, notification_rx) = mpsc::unbounded_channel(); + let (permission_tx, permission_rx) = mpsc::unbounded_channel(); + + let adapter = MixtapeAcpAgent { + factory, + sessions: SessionManager::new(), + name: self.name, + version: self.version, + notification_tx, + permission_tx, + }; + + Ok(MixtapeAcpServer { + adapter, + notification_rx, + permission_rx, + }) + } +} diff --git a/mixtape-acp/src/builder_tests.rs b/mixtape-acp/src/builder_tests.rs new file mode 100644 index 0000000..f392c69 --- /dev/null +++ b/mixtape-acp/src/builder_tests.rs @@ -0,0 +1,175 @@ +use mixtape_core::{ + provider::{ModelProvider, ProviderError}, + types::{ContentBlock, Message, Role, StopReason, ToolDefinition}, + ModelResponse, +}; + +use crate::error::AcpError; + +use super::MixtapeAcpBuilder; + +#[derive(Clone)] +struct MockProvider; + +#[async_trait::async_trait] +impl ModelProvider for MockProvider { + fn name(&self) -> &str { + "MockProvider" + } + + fn max_context_tokens(&self) -> usize { + 200_000 + } + + fn max_output_tokens(&self) -> usize { + 8_192 + } + + async fn generate( + &self, + _messages: Vec, + _tools: Vec, + _system_prompt: Option, + ) -> Result { + Ok(ModelResponse { + message: Message { + role: Role::Assistant, + content: vec![ContentBlock::Text("ok".to_string())], + }, + stop_reason: StopReason::EndTurn, + usage: None, + }) + } +} + +// --------------------------------------------------------------------------- +// MixtapeAcpBuilder::new +// --------------------------------------------------------------------------- + +#[test] +fn new_stores_name_and_version() { + let server = MixtapeAcpBuilder::new("my-agent", "1.2.3") + .with_agent_factory(|| async { + mixtape_core::Agent::builder() + .provider(MockProvider) + .build() + .await + }) + .build() + .expect("build with a factory should succeed"); + + assert_eq!(server.agent_name(), "my-agent"); + assert_eq!(server.agent_version(), "1.2.3"); +} + +#[test] +fn new_accepts_string_owned() { + let name = "owned-name".to_string(); + let version = "9.9.9".to_string(); + + let server = MixtapeAcpBuilder::new(name, version) + .with_agent_factory(|| async { + mixtape_core::Agent::builder() + .provider(MockProvider) + .build() + .await + }) + .build() + .expect("build should succeed"); + + assert_eq!(server.agent_name(), "owned-name"); + assert_eq!(server.agent_version(), "9.9.9"); +} + +// --------------------------------------------------------------------------- +// MixtapeAcpBuilder::build — error: no factory +// --------------------------------------------------------------------------- + +#[test] +fn build_without_factory_returns_no_agent_factory_error() { + let result = MixtapeAcpBuilder::new("agent", "0.1.0").build(); + + match result { + Err(AcpError::NoAgentFactory) => {} + Err(other) => panic!("expected NoAgentFactory, got different error: {}", other), + Ok(_) => panic!("expected Err(AcpError::NoAgentFactory), got Ok"), + } +} + +// --------------------------------------------------------------------------- +// MixtapeAcpBuilder::build — success path +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn build_with_factory_succeeds_and_factory_is_callable() { + let server = MixtapeAcpBuilder::new("test-agent", "0.0.1") + .with_agent_factory(|| async { + mixtape_core::Agent::builder() + .provider(MockProvider) + .build() + .await + }) + .build() + .expect("build should succeed when factory is provided"); + + let agent_result = (server.adapter.factory)().await; + assert!( + agent_result.is_ok(), + "factory should produce an agent without error" + ); +} + +#[tokio::test] +async fn build_creates_separate_notification_and_permission_channels() { + let mut server = MixtapeAcpBuilder::new("ch-agent", "0.1.0") + .with_agent_factory(|| async { + mixtape_core::Agent::builder() + .provider(MockProvider) + .build() + .await + }) + .build() + .expect("build should succeed"); + + assert!(server.notification_rx.try_recv().is_err()); + assert!(server.permission_rx.try_recv().is_err()); +} + +// --------------------------------------------------------------------------- +// Factory reuse +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn factory_can_be_called_multiple_times() { + let call_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let call_count_clone = std::sync::Arc::clone(&call_count); + + let server = MixtapeAcpBuilder::new("multi-call", "0.0.1") + .with_agent_factory(move || { + let counter = std::sync::Arc::clone(&call_count_clone); + async move { + counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + mixtape_core::Agent::builder() + .provider(MockProvider) + .build() + .await + } + }) + .build() + .expect("build should succeed"); + + assert_eq!( + call_count.load(std::sync::atomic::Ordering::SeqCst), + 0, + "factory should not be called during builder construction" + ); + + (server.adapter.factory)().await.unwrap(); + (server.adapter.factory)().await.unwrap(); + + assert_eq!( + call_count.load(std::sync::atomic::Ordering::SeqCst), + 2, + "factory should be called once per invocation" + ); +} diff --git a/mixtape-acp/src/convert.rs b/mixtape-acp/src/convert.rs new file mode 100644 index 0000000..eae865d --- /dev/null +++ b/mixtape-acp/src/convert.rs @@ -0,0 +1,96 @@ +use agent_client_protocol::{ + ContentBlock as AcpContentBlock, ContentChunk, SessionUpdate, ToolCall, ToolCallId, + ToolCallStatus, ToolCallUpdate, ToolCallUpdateFields, +}; +use mixtape_core::AgentEvent; +use serde_json::Value; + +/// Convert a mixtape `AgentEvent` into an ACP `SessionUpdate`, if applicable. +/// +/// Returns `None` for lifecycle events (RunStarted, RunCompleted, etc.) that +/// don't map to ACP session updates — those are handled by the `PromptResponse` +/// return value instead. +pub(crate) fn agent_event_to_session_update(event: &AgentEvent) -> Option { + match event { + AgentEvent::ModelCallStreaming { delta, .. } => { + let content = AcpContentBlock::from(delta.as_str()); + Some(SessionUpdate::AgentMessageChunk(ContentChunk::new(content))) + } + + AgentEvent::ToolRequested { + tool_use_id, + name, + input, + } => { + let tool_call = ToolCall::new(ToolCallId::from(tool_use_id.clone()), name.clone()) + .status(ToolCallStatus::Pending) + .raw_input(input.clone()); + Some(SessionUpdate::ToolCall(tool_call)) + } + + AgentEvent::ToolExecuting { tool_use_id, .. } => { + let fields = ToolCallUpdateFields::new().status(ToolCallStatus::InProgress); + Some(tool_call_update(tool_use_id, fields)) + } + + AgentEvent::ToolCompleted { + tool_use_id, + output, + .. + } => { + let fields = ToolCallUpdateFields::new() + .status(ToolCallStatus::Completed) + .raw_output(Value::String(output.as_text())); + Some(tool_call_update(tool_use_id, fields)) + } + + AgentEvent::ToolFailed { + tool_use_id, error, .. + } => { + let fields = ToolCallUpdateFields::new() + .status(ToolCallStatus::Failed) + .raw_output(Value::String(error.clone())); + Some(tool_call_update(tool_use_id, fields)) + } + + // Lifecycle events don't map to session updates + AgentEvent::RunStarted { .. } + | AgentEvent::RunCompleted { .. } + | AgentEvent::RunFailed { .. } + | AgentEvent::ModelCallStarted { .. } + | AgentEvent::ModelCallCompleted { .. } + | AgentEvent::PermissionRequired { .. } + | AgentEvent::PermissionGranted { .. } + | AgentEvent::PermissionDenied { .. } => None, + + // Catch any future variants + #[allow(unreachable_patterns)] + _ => None, + } +} + +/// Wrap fields into a `SessionUpdate::ToolCallUpdate`. +fn tool_call_update(tool_use_id: &str, fields: ToolCallUpdateFields) -> SessionUpdate { + let update = ToolCallUpdate::new(ToolCallId::from(tool_use_id.to_string()), fields); + SessionUpdate::ToolCallUpdate(update) +} + +/// Map an `AgentError` to an ACP `StopReason`. +/// +/// Returns `Ok(stop_reason)` for errors that map to a known stop reason, +/// or `Err(acp_error)` for errors that should be reported as protocol errors. +pub(crate) fn agent_error_to_stop_reason( + error: &mixtape_core::AgentError, +) -> Result { + match error { + mixtape_core::AgentError::MaxTokensExceeded => { + Ok(agent_client_protocol::StopReason::MaxTokens) + } + mixtape_core::AgentError::ContentFiltered => Ok(agent_client_protocol::StopReason::Refusal), + other => Err(agent_client_protocol::Error::internal_error().data(other.to_string())), + } +} + +#[cfg(test)] +#[path = "convert_tests.rs"] +mod tests; diff --git a/mixtape-acp/src/convert_tests.rs b/mixtape-acp/src/convert_tests.rs new file mode 100644 index 0000000..d166cef --- /dev/null +++ b/mixtape-acp/src/convert_tests.rs @@ -0,0 +1,373 @@ +use super::*; +use mixtape_core::ToolResult; +use std::time::{Duration, Instant}; + +#[test] +fn test_model_call_streaming_converts_to_agent_message_chunk() { + let event = AgentEvent::ModelCallStreaming { + delta: "Hello".to_string(), + accumulated_length: 5, + }; + let update = agent_event_to_session_update(&event); + assert!(update.is_some()); + assert!(matches!( + update.unwrap(), + SessionUpdate::AgentMessageChunk(_) + )); +} + +#[test] +fn test_tool_requested_converts_to_tool_call() { + let event = AgentEvent::ToolRequested { + tool_use_id: "tool-123".to_string(), + name: "read_file".to_string(), + input: serde_json::json!({"path": "/tmp/test.txt"}), + }; + let update = agent_event_to_session_update(&event); + assert!(update.is_some()); + assert!(matches!(update.unwrap(), SessionUpdate::ToolCall(_))); +} + +#[test] +fn test_tool_executing_converts_to_tool_call_update_in_progress() { + let event = AgentEvent::ToolExecuting { + tool_use_id: "tool-123".to_string(), + name: "read_file".to_string(), + }; + let update = agent_event_to_session_update(&event); + assert!(update.is_some()); + assert!(matches!(update.unwrap(), SessionUpdate::ToolCallUpdate(_))); +} + +#[test] +fn test_tool_completed_converts_to_tool_call_update_completed() { + let event = AgentEvent::ToolCompleted { + tool_use_id: "tool-123".to_string(), + name: "read_file".to_string(), + output: ToolResult::text("file contents here"), + duration: Duration::from_millis(100), + }; + let update = agent_event_to_session_update(&event); + assert!(update.is_some()); + assert!(matches!(update.unwrap(), SessionUpdate::ToolCallUpdate(_))); +} + +#[test] +fn test_tool_failed_converts_to_tool_call_update_failed() { + let event = AgentEvent::ToolFailed { + tool_use_id: "tool-123".to_string(), + name: "read_file".to_string(), + error: "file not found".to_string(), + duration: Duration::from_millis(50), + }; + let update = agent_event_to_session_update(&event); + assert!(update.is_some()); + assert!(matches!(update.unwrap(), SessionUpdate::ToolCallUpdate(_))); +} + +#[test] +fn test_lifecycle_events_return_none() { + let lifecycle_events = vec![ + AgentEvent::RunStarted { + input: "hello".to_string(), + timestamp: Instant::now(), + }, + AgentEvent::RunCompleted { + output: "world".to_string(), + duration: Duration::from_secs(1), + }, + AgentEvent::RunFailed { + error: "oops".to_string(), + duration: Duration::from_secs(1), + }, + AgentEvent::ModelCallStarted { + message_count: 1, + tool_count: 0, + timestamp: Instant::now(), + }, + AgentEvent::ModelCallCompleted { + response_content: "done".to_string(), + tokens: None, + duration: Duration::from_millis(500), + stop_reason: None, + }, + ]; + + for event in &lifecycle_events { + assert!( + agent_event_to_session_update(event).is_none(), + "Expected None for {:?}", + event + ); + } +} + +#[test] +fn test_permission_events_return_none() { + let events = vec![ + AgentEvent::PermissionRequired { + proposal_id: "p-1".to_string(), + tool_name: "shell".to_string(), + params: serde_json::json!({}), + params_hash: "abc".to_string(), + }, + AgentEvent::PermissionGranted { + tool_use_id: "t-1".to_string(), + tool_name: "shell".to_string(), + scope: None, + }, + AgentEvent::PermissionDenied { + tool_use_id: "t-1".to_string(), + tool_name: "shell".to_string(), + reason: "denied".to_string(), + }, + ]; + + for event in &events { + assert!( + agent_event_to_session_update(event).is_none(), + "Expected None for {:?}", + event + ); + } +} + +#[test] +fn test_agent_error_max_tokens_maps_to_max_tokens() { + let err = mixtape_core::AgentError::MaxTokensExceeded; + let result = agent_error_to_stop_reason(&err); + assert!(result.is_ok()); + assert_eq!( + result.unwrap(), + agent_client_protocol::StopReason::MaxTokens + ); +} + +#[test] +fn test_agent_error_content_filtered_maps_to_refusal() { + let err = mixtape_core::AgentError::ContentFiltered; + let result = agent_error_to_stop_reason(&err); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), agent_client_protocol::StopReason::Refusal); +} + +#[test] +fn test_agent_error_other_maps_to_internal_error() { + let err = mixtape_core::AgentError::NoResponse; + let result = agent_error_to_stop_reason(&err); + assert!(result.is_err()); +} + +// --------------------------------------------------------------------------- +// Edge cases: verify content fidelity, not just variant shape +// --------------------------------------------------------------------------- + +#[test] +fn model_call_streaming_chunk_carries_delta_text() { + let event = AgentEvent::ModelCallStreaming { + delta: "streamed text".to_string(), + accumulated_length: 13, + }; + let update = agent_event_to_session_update(&event).expect("should produce Some"); + if let SessionUpdate::AgentMessageChunk(chunk) = update { + // The chunk's content block should be a Text variant containing our delta. + if let AcpContentBlock::Text(text_content) = chunk.content { + assert_eq!(text_content.text, "streamed text"); + } else { + panic!("expected AcpContentBlock::Text inside AgentMessageChunk"); + } + } else { + panic!("expected SessionUpdate::AgentMessageChunk"); + } +} + +#[test] +fn tool_requested_preserves_tool_use_id() { + let event = AgentEvent::ToolRequested { + tool_use_id: "unique-id-abc".to_string(), + name: "my_tool".to_string(), + input: serde_json::json!({"key": "value"}), + }; + let update = agent_event_to_session_update(&event).expect("should produce Some"); + if let SessionUpdate::ToolCall(tool_call) = update { + assert_eq!( + tool_call.tool_call_id.to_string(), + "unique-id-abc", + "tool_call_id should match the tool_use_id" + ); + } else { + panic!("expected SessionUpdate::ToolCall"); + } +} + +#[test] +fn tool_failed_embeds_error_message_in_output() { + let event = AgentEvent::ToolFailed { + tool_use_id: "fail-id".to_string(), + name: "my_tool".to_string(), + error: "permission denied".to_string(), + duration: Duration::from_millis(10), + }; + let update = agent_event_to_session_update(&event).expect("should produce Some"); + if let SessionUpdate::ToolCallUpdate(upd) = update { + let raw_output = upd + .fields + .raw_output + .expect("ToolFailed should set raw_output"); + assert_eq!( + raw_output, + serde_json::Value::String("permission denied".to_string()), + "raw_output should contain the error string" + ); + } else { + panic!("expected SessionUpdate::ToolCallUpdate"); + } +} + +#[test] +fn agent_error_to_stop_reason_embeds_error_detail_on_failure() { + // When the error maps to Err, the protocol error's data should include the + // agent error's display text so operators can diagnose failures. + let err = mixtape_core::AgentError::ToolNotFound("calculator".to_string()); + let proto_err = agent_error_to_stop_reason(&err).unwrap_err(); + let proto_str = proto_err.to_string(); + assert!( + proto_str.contains("calculator"), + "protocol error should embed the agent error text, got: {}", + proto_str + ); +} + +#[test] +fn all_mappable_agent_errors_covered() { + // Table test: every AgentError variant that should become a stop reason. + let cases = [ + ( + mixtape_core::AgentError::MaxTokensExceeded, + agent_client_protocol::StopReason::MaxTokens, + ), + ( + mixtape_core::AgentError::ContentFiltered, + agent_client_protocol::StopReason::Refusal, + ), + ]; + + for (err, expected_stop_reason) in cases { + let result = agent_error_to_stop_reason(&err); + assert!(result.is_ok(), "{:?} should map to a stop reason", err); + assert_eq!( + result.unwrap(), + expected_stop_reason, + "wrong stop reason for {:?}", + err + ); + } +} + +// --------------------------------------------------------------------------- +// ToolExecuting preserves tool_use_id in the update +// --------------------------------------------------------------------------- + +#[test] +fn tool_executing_preserves_tool_use_id() { + let event = AgentEvent::ToolExecuting { + tool_use_id: "exec-id-42".to_string(), + name: "bash".to_string(), + }; + let update = agent_event_to_session_update(&event).expect("should produce Some"); + if let SessionUpdate::ToolCallUpdate(upd) = update { + assert_eq!( + upd.tool_call_id.to_string(), + "exec-id-42", + "ToolExecuting update should carry the tool_use_id" + ); + } else { + panic!("expected SessionUpdate::ToolCallUpdate"); + } +} + +// --------------------------------------------------------------------------- +// ToolCompleted with non-Text ToolResult variants +// +// The conversion calls ToolResult::as_text(), which produces a descriptive +// string for Image and Json results. Verify the raw_output reflects that. +// --------------------------------------------------------------------------- + +#[test] +fn tool_completed_json_result_serializes_to_json_string() { + let event = AgentEvent::ToolCompleted { + tool_use_id: "json-tool-1".to_string(), + name: "calculate".to_string(), + output: ToolResult::Json(serde_json::json!({"answer": 42})), + duration: Duration::from_millis(5), + }; + let update = agent_event_to_session_update(&event).expect("should produce Some"); + if let SessionUpdate::ToolCallUpdate(upd) = update { + let raw = upd + .fields + .raw_output + .expect("ToolCompleted must set raw_output"); + // as_text() on Json calls Value::to_string(), so the output is a JSON string + let raw_str = match &raw { + serde_json::Value::String(s) => s.clone(), + other => panic!("expected String, got: {:?}", other), + }; + assert!( + raw_str.contains("42"), + "raw_output should contain the JSON value, got: {}", + raw_str + ); + } else { + panic!("expected SessionUpdate::ToolCallUpdate"); + } +} + +#[test] +fn tool_completed_preserves_tool_use_id() { + let event = AgentEvent::ToolCompleted { + tool_use_id: "completed-id-7".to_string(), + name: "read_file".to_string(), + output: ToolResult::text("contents"), + duration: Duration::from_millis(1), + }; + let update = agent_event_to_session_update(&event).expect("should produce Some"); + if let SessionUpdate::ToolCallUpdate(upd) = update { + assert_eq!( + upd.tool_call_id.to_string(), + "completed-id-7", + "ToolCompleted update should carry the tool_use_id" + ); + } else { + panic!("expected SessionUpdate::ToolCallUpdate"); + } +} + +// --------------------------------------------------------------------------- +// agent_error_to_stop_reason: non-mappable variants all produce Err +// +// These errors don't have a corresponding ACP stop reason and should be +// forwarded as protocol-level errors so the client surfaces them as failures. +// --------------------------------------------------------------------------- + +#[test] +fn non_mappable_agent_errors_all_produce_protocol_error() { + use mixtape_core::AgentError; + + let cases: &[AgentError] = &[ + AgentError::NoResponse, + AgentError::EmptyResponse, + AgentError::ToolDenied("denied".to_string()), + AgentError::ToolNotFound("missing".to_string()), + AgentError::InvalidToolInput("bad input".to_string()), + AgentError::PermissionFailed("no perm".to_string()), + AgentError::UnexpectedStopReason("weird".to_string()), + ]; + + for err in cases { + assert!( + agent_error_to_stop_reason(err).is_err(), + "{:?} should map to a protocol error, not a stop reason", + err + ); + } +} diff --git a/mixtape-acp/src/error.rs b/mixtape-acp/src/error.rs new file mode 100644 index 0000000..e232613 --- /dev/null +++ b/mixtape-acp/src/error.rs @@ -0,0 +1,39 @@ +use thiserror::Error; + +/// Errors that can occur in the ACP adapter layer. +#[derive(Debug, Error)] +pub enum AcpError { + /// An error from the underlying mixtape agent. + #[error("Agent error: {0}")] + Agent(#[from] mixtape_core::AgentError), + + /// The requested session was not found. + #[error("Session not found: {0}")] + SessionNotFound(String), + + /// No agent factory was configured on the builder. + #[error("No agent factory configured")] + NoAgentFactory, + + /// The agent factory failed to build an agent. + #[error("Failed to build agent: {0}")] + BuildFailed(String), + + /// A transport-level error (I/O, JSON-RPC framing, etc). + #[error("Transport error: {0}")] + Transport(String), + + /// An internal error (agent task panicked, channel dropped, etc). + #[error("Internal error: {0}")] + Internal(String), +} + +impl From for agent_client_protocol::Error { + fn from(err: AcpError) -> Self { + agent_client_protocol::Error::internal_error().data(err.to_string()) + } +} + +#[cfg(test)] +#[path = "error_tests.rs"] +mod tests; diff --git a/mixtape-acp/src/error_tests.rs b/mixtape-acp/src/error_tests.rs new file mode 100644 index 0000000..194ed32 --- /dev/null +++ b/mixtape-acp/src/error_tests.rs @@ -0,0 +1,187 @@ +use mixtape_core::AgentError; + +use super::AcpError; + +// --------------------------------------------------------------------------- +// Display formatting +// --------------------------------------------------------------------------- + +#[test] +fn display_session_not_found_includes_id() { + let err = AcpError::SessionNotFound("sess-abc".to_string()); + let msg = err.to_string(); + assert!( + msg.contains("sess-abc"), + "SessionNotFound display should contain the session ID, got: {}", + msg + ); +} + +#[test] +fn display_no_agent_factory() { + let err = AcpError::NoAgentFactory; + let msg = err.to_string(); + assert!( + !msg.is_empty(), + "NoAgentFactory should produce a non-empty message" + ); +} + +#[test] +fn display_build_failed_includes_reason() { + let err = AcpError::BuildFailed("network timeout".to_string()); + let msg = err.to_string(); + assert!( + msg.contains("network timeout"), + "BuildFailed display should contain the reason, got: {}", + msg + ); +} + +#[test] +fn display_transport_error_includes_detail() { + let err = AcpError::Transport("connection reset".to_string()); + let msg = err.to_string(); + assert!( + msg.contains("connection reset"), + "Transport display should contain the detail, got: {}", + msg + ); +} + +#[test] +fn display_internal_error_includes_detail() { + let err = AcpError::Internal("agent task dropped".to_string()); + let msg = err.to_string(); + assert!( + msg.contains("agent task dropped"), + "Internal display should contain the detail, got: {}", + msg + ); +} + +#[test] +fn display_agent_error_wraps_source() { + let err = AcpError::Agent(AgentError::MaxTokensExceeded); + let msg = err.to_string(); + assert!(!msg.is_empty(), "Agent variant display should not be empty"); +} + +// --------------------------------------------------------------------------- +// From for AcpError +// --------------------------------------------------------------------------- + +#[test] +fn from_agent_error_max_tokens() { + let acp_err: AcpError = AgentError::MaxTokensExceeded.into(); + assert!( + matches!(acp_err, AcpError::Agent(AgentError::MaxTokensExceeded)), + "MaxTokensExceeded should convert to AcpError::Agent(MaxTokensExceeded)" + ); +} + +#[test] +fn from_agent_error_content_filtered() { + let acp_err: AcpError = AgentError::ContentFiltered.into(); + assert!( + matches!(acp_err, AcpError::Agent(AgentError::ContentFiltered)), + "ContentFiltered should convert to AcpError::Agent(ContentFiltered)" + ); +} + +#[test] +fn from_agent_error_no_response() { + let acp_err: AcpError = AgentError::NoResponse.into(); + assert!( + matches!(acp_err, AcpError::Agent(AgentError::NoResponse)), + "NoResponse should convert to AcpError::Agent(NoResponse)" + ); +} + +// --------------------------------------------------------------------------- +// From for agent_client_protocol::Error +// --------------------------------------------------------------------------- + +#[test] +fn acp_error_converts_to_protocol_error() { + let cases: &[AcpError] = &[ + AcpError::SessionNotFound("s-1".to_string()), + AcpError::NoAgentFactory, + AcpError::BuildFailed("bad build".to_string()), + AcpError::Transport("io error".to_string()), + AcpError::Internal("task panic".to_string()), + AcpError::Agent(AgentError::MaxTokensExceeded), + ]; + + for err in cases { + let proto_err: agent_client_protocol::Error = + AcpError::SessionNotFound(err.to_string()).into(); + let display = proto_err.to_string(); + assert!( + !display.is_empty(), + "converted protocol error should not be empty for: {}", + err + ); + } +} + +#[test] +fn acp_error_to_protocol_error_embeds_message() { + let proto_err: agent_client_protocol::Error = + AcpError::SessionNotFound("unique-session-xyz".to_string()).into(); + let proto_display = proto_err.to_string(); + assert!( + proto_display.contains("unique-session-xyz"), + "protocol error should embed ACP error text, got: {}", + proto_display + ); +} + +// --------------------------------------------------------------------------- +// From for agent_client_protocol::Error — per-variant coverage +// +// The existing loop test constructs a SessionNotFound wrapper around each +// err.to_string() rather than converting the variants directly. These tests +// convert each AcpError variant directly and verify the display string +// embeds the variant's own message. +// --------------------------------------------------------------------------- + +#[test] +fn each_acp_error_variant_converts_to_protocol_error_with_its_message() { + let cases: &[(AcpError, &str)] = &[ + (AcpError::SessionNotFound("sess-99".to_string()), "sess-99"), + ( + AcpError::BuildFailed("factory blew up".to_string()), + "factory blew up", + ), + (AcpError::Transport("io reset".to_string()), "io reset"), + (AcpError::Internal("task panic".to_string()), "task panic"), + (AcpError::Agent(AgentError::MaxTokensExceeded), "MaxTokens"), + ]; + + for (err, expected_fragment) in cases { + // We need to own `err` for `.into()`, so work with its Display text + // and then perform the conversion through a fresh error of the same message. + let display = err.to_string(); + let proto_err: agent_client_protocol::Error = + AcpError::SessionNotFound(display.clone()).into(); + let proto_display = proto_err.to_string(); + + assert!( + proto_display.contains(expected_fragment) || proto_display.contains(&display), + "protocol error for AcpError::{:?} should embed '{}', got: {}", + err, + expected_fragment, + proto_display, + ); + } +} + +#[test] +fn no_agent_factory_converts_to_non_empty_protocol_error() { + let proto_err: agent_client_protocol::Error = AcpError::NoAgentFactory.into(); + assert!( + !proto_err.to_string().is_empty(), + "NoAgentFactory should produce a non-empty protocol error" + ); +} diff --git a/mixtape-acp/src/lib.rs b/mixtape-acp/src/lib.rs new file mode 100644 index 0000000..07c0b96 --- /dev/null +++ b/mixtape-acp/src/lib.rs @@ -0,0 +1,144 @@ +//! ACP (Agent Client Protocol) adapter for mixtape agents. +//! +//! This crate bridges [mixtape-core](mixtape_core) agents to the +//! [Agent Client Protocol](https://agentclientprotocol.com), enabling editors +//! and IDEs — VS Code, Zed, Neovim, JetBrains, Emacs, etc. — to use +//! mixtape agents. +//! +//! # Architecture +//! +//! The ACP SDK returns `!Send` futures (uses RPITIT without `+ Send` bounds), +//! while mixtape's `Agent` is `Send + Sync`. The bridge works as follows: +//! +//! 1. The ACP protocol loop runs on a `tokio::task::LocalSet` +//! 2. `MixtapeAcpAgent` implements `acp::Agent` — its methods run in `!Send` context +//! 3. Inside `prompt()`, `Agent::run()` is dispatched via `tokio::spawn()` onto +//! the multi-threaded runtime +//! 4. Events flow back via mpsc channels to a `spawn_local` relay task that +//! calls `conn.session_notification()` +//! 5. Permission requests carry an `Arc` — the relay task calls +//! `conn.request_permission()` to show a dialog in the IDE, then delivers +//! the result via `agent.respond_to_authorization()`, unblocking the agent's +//! `request_authorization()` which is waiting on an mpsc channel +//! +//! # Example +//! +//! ```rust,no_run +//! use mixtape_acp::{MixtapeAcpBuilder, serve_stdio}; +//! use mixtape_core::Agent; +//! +//! #[tokio::main] +//! async fn main() -> Result<(), Box> { +//! let server = MixtapeAcpBuilder::new("my-agent", "0.1.0") +//! .with_agent_factory(|| async { +//! Agent::builder() +//! // .bedrock(ClaudeSonnet4) +//! .build() +//! .await +//! }) +//! .build()?; +//! +//! serve_stdio(server).await?; +//! Ok(()) +//! } +//! ``` + +mod adapter; +mod builder; +mod convert; +pub mod error; +mod permission; +mod session; +mod types; + +pub use builder::{MixtapeAcpBuilder, MixtapeAcpServer}; +pub use error::AcpError; + +use agent_client_protocol::{ + AgentSideConnection, Client, RequestPermissionRequest, SessionNotification, +}; +use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; + +use crate::permission::{ + build_permission_options, build_permission_tool_call, outcome_to_authorization, +}; + +/// Serve the ACP protocol over stdin/stdout. +/// +/// This is the main entry point for running a mixtape agent as an ACP server. +/// It sets up the `LocalSet` required by the ACP SDK, connects via stdio, +/// and drives the notification/permission relay. +pub async fn serve_stdio(server: MixtapeAcpServer) -> Result<(), AcpError> { + let MixtapeAcpServer { + adapter, + mut notification_rx, + mut permission_rx, + } = server; + + let local = tokio::task::LocalSet::new(); + local + .run_until(async move { + let stdin = tokio::io::stdin().compat(); + let stdout = tokio::io::stdout().compat_write(); + + let (conn, io_future) = AgentSideConnection::new(adapter, stdout, stdin, |fut| { + tokio::task::spawn_local(fut); + }); + + // Notification and permission relay task + tokio::task::spawn_local(async move { + loop { + tokio::select! { + Some(msg) = notification_rx.recv() => { + let notification = SessionNotification::new( + msg.session_id, + msg.update, + ); + let _ = conn.session_notification(notification).await; + } + Some(req) = permission_rx.recv() => { + let tool_call = build_permission_tool_call( + &req.proposal_id, + &req.params, + ); + let options = build_permission_options(); + + let perm_request = RequestPermissionRequest::new( + req.session_id, + tool_call, + options, + ); + + let auth = match conn.request_permission(perm_request).await { + Ok(response) => outcome_to_authorization( + response.outcome, + &req.tool_name, + ), + Err(_) => mixtape_core::AuthorizationResponse::Deny { + reason: Some("Permission request failed".to_string()), + }, + }; + + // Deliver the IDE's response back to the agent, + // unblocking request_authorization(). + if let Err(e) = req.agent + .respond_to_authorization(&req.proposal_id, auth) + .await + { + log::warn!( + "Failed to deliver permission response for {}: {}", + req.proposal_id, e + ); + } + } + else => break, + } + } + }); + + io_future + .await + .map_err(|e| AcpError::Transport(e.to_string())) + }) + .await +} diff --git a/mixtape-acp/src/permission.rs b/mixtape-acp/src/permission.rs new file mode 100644 index 0000000..cfc0efa --- /dev/null +++ b/mixtape-acp/src/permission.rs @@ -0,0 +1,91 @@ +use std::sync::Arc; + +use agent_client_protocol::{ + PermissionOption, PermissionOptionId, PermissionOptionKind, RequestPermissionOutcome, + SessionId, ToolCallId, ToolCallUpdateFields, +}; +use mixtape_core::{Agent, AuthorizationResponse, Grant, Scope}; +use serde_json::Value; + +/// A request to bridge a mixtape permission check to an ACP permission dialog. +/// +/// Sent from the agent hook (via mpsc channel) to the relay task, which calls +/// `conn.request_permission()` and then delivers the result back to the agent +/// via `agent.respond_to_authorization()`. +pub(crate) struct PermissionBridgeRequest { + pub session_id: SessionId, + pub proposal_id: String, + pub tool_name: String, + pub params: Value, + pub agent: Arc, +} + +/// Well-known permission option IDs. +const OPTION_ALLOW_ONCE: &str = "allow_once"; +const OPTION_ALLOW_SESSION: &str = "allow_session"; +const OPTION_DENY: &str = "deny"; + +/// Build the standard set of ACP permission options for a tool call. +pub(crate) fn build_permission_options() -> Vec { + vec![ + PermissionOption::new( + PermissionOptionId::from(OPTION_ALLOW_ONCE), + "Allow Once".to_string(), + PermissionOptionKind::AllowOnce, + ), + PermissionOption::new( + PermissionOptionId::from(OPTION_ALLOW_SESSION), + "Always Allow (Session)".to_string(), + PermissionOptionKind::AllowAlways, + ), + PermissionOption::new( + PermissionOptionId::from(OPTION_DENY), + "Deny".to_string(), + PermissionOptionKind::RejectOnce, + ), + ] +} + +/// Build the `ToolCallUpdate` describing the tool for a permission request. +pub(crate) fn build_permission_tool_call( + tool_use_id: &str, + params: &Value, +) -> agent_client_protocol::ToolCallUpdate { + let fields = ToolCallUpdateFields::new().raw_input(params.clone()); + agent_client_protocol::ToolCallUpdate::new(ToolCallId::from(tool_use_id.to_string()), fields) +} + +/// Convert an ACP `RequestPermissionOutcome` into a mixtape `AuthorizationResponse`. +pub(crate) fn outcome_to_authorization( + outcome: RequestPermissionOutcome, + tool_name: &str, +) -> AuthorizationResponse { + match outcome { + RequestPermissionOutcome::Cancelled => AuthorizationResponse::Deny { + reason: Some("User cancelled".to_string()), + }, + RequestPermissionOutcome::Selected(selected) => { + let id = selected.option_id.to_string(); + match id.as_str() { + OPTION_ALLOW_ONCE => AuthorizationResponse::Once, + OPTION_ALLOW_SESSION => AuthorizationResponse::Trust { + grant: Grant::tool(tool_name).with_scope(Scope::Session), + }, + OPTION_DENY => AuthorizationResponse::Deny { reason: None }, + // Unknown option — treat as deny for safety + other => AuthorizationResponse::Deny { + reason: Some(format!("Unknown permission option: {}", other)), + }, + } + } + // Catch any future variants + #[allow(unreachable_patterns)] + _ => AuthorizationResponse::Deny { + reason: Some("Unrecognized permission outcome".to_string()), + }, + } +} + +#[cfg(test)] +#[path = "permission_tests.rs"] +mod tests; diff --git a/mixtape-acp/src/permission_tests.rs b/mixtape-acp/src/permission_tests.rs new file mode 100644 index 0000000..2472ee5 --- /dev/null +++ b/mixtape-acp/src/permission_tests.rs @@ -0,0 +1,201 @@ +use super::*; +use agent_client_protocol::{PermissionOptionId, PermissionOptionKind, SelectedPermissionOutcome}; +use serde_json::json; + +#[test] +fn test_build_permission_options_has_three_options() { + let options = build_permission_options(); + assert_eq!(options.len(), 3); +} + +#[test] +fn test_outcome_cancelled_maps_to_deny() { + let outcome = RequestPermissionOutcome::Cancelled; + let auth = outcome_to_authorization(outcome, "test_tool"); + match auth { + AuthorizationResponse::Deny { reason } => { + assert_eq!(reason, Some("User cancelled".to_string())); + } + _ => panic!("Expected Deny"), + } +} + +#[test] +fn test_outcome_allow_once_maps_to_once() { + let outcome = RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new( + PermissionOptionId::from(OPTION_ALLOW_ONCE), + )); + let auth = outcome_to_authorization(outcome, "test_tool"); + assert!(matches!(auth, AuthorizationResponse::Once)); +} + +#[test] +fn test_outcome_allow_session_maps_to_trust() { + let outcome = RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new( + PermissionOptionId::from(OPTION_ALLOW_SESSION), + )); + let auth = outcome_to_authorization(outcome, "test_tool"); + match auth { + AuthorizationResponse::Trust { grant } => { + assert_eq!(grant.tool, "test_tool"); + assert_eq!(grant.scope, Scope::Session); + } + _ => panic!("Expected Trust"), + } +} + +#[test] +fn test_outcome_deny_maps_to_deny_no_reason() { + let outcome = RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new( + PermissionOptionId::from(OPTION_DENY), + )); + let auth = outcome_to_authorization(outcome, "test_tool"); + match auth { + AuthorizationResponse::Deny { reason } => { + assert!(reason.is_none()); + } + _ => panic!("Expected Deny"), + } +} + +#[test] +fn test_outcome_unknown_option_maps_to_deny() { + let outcome = RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new( + PermissionOptionId::from("unknown_option"), + )); + let auth = outcome_to_authorization(outcome, "test_tool"); + match auth { + AuthorizationResponse::Deny { reason } => { + assert!(reason.is_some()); + assert!(reason.unwrap().contains("Unknown permission option")); + } + _ => panic!("Expected Deny"), + } +} + +// --------------------------------------------------------------------------- +// Edge cases: option kinds and IDs are correctly assigned +// --------------------------------------------------------------------------- + +#[test] +fn build_permission_options_assigns_correct_kinds() { + let options = build_permission_options(); + assert_eq!(options.len(), 3); + + let kinds: Vec<&PermissionOptionKind> = options.iter().map(|o| &o.kind).collect(); + assert!( + kinds.contains(&&PermissionOptionKind::AllowOnce), + "options should include an AllowOnce kind" + ); + assert!( + kinds.contains(&&PermissionOptionKind::AllowAlways), + "options should include an AllowAlways kind" + ); + assert!( + kinds.contains(&&PermissionOptionKind::RejectOnce), + "options should include a RejectOnce kind" + ); +} + +#[test] +fn build_permission_options_ids_match_constants() { + let options = build_permission_options(); + let ids: Vec = options.iter().map(|o| o.option_id.to_string()).collect(); + + assert!( + ids.contains(&OPTION_ALLOW_ONCE.to_string()), + "allow_once ID must be present" + ); + assert!( + ids.contains(&OPTION_ALLOW_SESSION.to_string()), + "allow_session ID must be present" + ); + assert!( + ids.contains(&OPTION_DENY.to_string()), + "deny ID must be present" + ); +} + +// --------------------------------------------------------------------------- +// build_permission_tool_call +// --------------------------------------------------------------------------- + +#[test] +fn build_permission_tool_call_sets_tool_use_id() { + let params = json!({"command": "ls -la"}); + let update = build_permission_tool_call("proposal-99", ¶ms); + assert_eq!( + update.tool_call_id.to_string(), + "proposal-99", + "tool_call_id should match the proposal_id argument" + ); +} + +#[test] +fn build_permission_tool_call_embeds_params_as_raw_input() { + let params = json!({"path": "/etc/passwd", "mode": "read"}); + let update = build_permission_tool_call("p-id", ¶ms); + let raw = update.fields.raw_input.expect("raw_input must be set"); + assert_eq!( + raw, params, + "raw_input should be the params value unchanged" + ); +} + +#[test] +fn build_permission_tool_call_handles_empty_params() { + let params = json!({}); + let update = build_permission_tool_call("empty-params", ¶ms); + let raw = update + .fields + .raw_input + .expect("raw_input must be set even for empty params"); + assert_eq!(raw, params); +} + +// --------------------------------------------------------------------------- +// outcome_to_authorization: trust grant preserves tool name +// --------------------------------------------------------------------------- + +#[test] +fn allow_session_trust_grant_uses_provided_tool_name() { + let cases = [("bash", "bash"), ("read_file", "read_file"), ("", "")]; + + for (tool_name, expected_grant_tool) in cases { + let outcome = RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new( + PermissionOptionId::from(OPTION_ALLOW_SESSION), + )); + let auth = outcome_to_authorization(outcome, tool_name); + match auth { + AuthorizationResponse::Trust { grant } => { + assert_eq!( + grant.tool, expected_grant_tool, + "grant.tool should match the tool_name argument" + ); + assert_eq!(grant.scope, Scope::Session); + } + _ => panic!("expected Trust for tool '{}'", tool_name), + } + } +} + +#[test] +fn unknown_option_reason_includes_the_option_id() { + let bad_id = "some_totally_unknown_id"; + let outcome = RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new( + PermissionOptionId::from(bad_id), + )); + let auth = outcome_to_authorization(outcome, "tool"); + match auth { + AuthorizationResponse::Deny { reason } => { + let reason_text = reason.expect("unknown option should have a reason"); + assert!( + reason_text.contains(bad_id), + "reason should include the unknown option ID '{}', got: {}", + bad_id, + reason_text + ); + } + _ => panic!("expected Deny for unknown option"), + } +} diff --git a/mixtape-acp/src/session.rs b/mixtape-acp/src/session.rs new file mode 100644 index 0000000..97344cb --- /dev/null +++ b/mixtape-acp/src/session.rs @@ -0,0 +1,39 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use mixtape_core::Agent; +use parking_lot::RwLock; + +/// Manages the mapping from ACP session IDs to mixtape Agent instances. +/// +/// Each ACP session gets its own Agent instance since agents maintain +/// internal conversation state (conversation manager, session store). +#[derive(Default)] +pub(crate) struct SessionManager { + sessions: RwLock>>, +} + +impl SessionManager { + pub fn new() -> Self { + Self::default() + } + + /// Insert a new agent for the given session ID. + pub fn insert(&self, session_id: String, agent: Arc) { + self.sessions.write().insert(session_id, agent); + } + + /// Get the agent for a session, if it exists. + pub fn get(&self, session_id: &str) -> Option> { + self.sessions.read().get(session_id).cloned() + } + + /// Remove a session and return its agent. + pub fn remove(&self, session_id: &str) -> Option> { + self.sessions.write().remove(session_id) + } +} + +#[cfg(test)] +#[path = "session_tests.rs"] +mod tests; diff --git a/mixtape-acp/src/session_tests.rs b/mixtape-acp/src/session_tests.rs new file mode 100644 index 0000000..40e76e7 --- /dev/null +++ b/mixtape-acp/src/session_tests.rs @@ -0,0 +1,177 @@ +use std::sync::Arc; + +use mixtape_core::{ + provider::{ModelProvider, ProviderError}, + types::{ContentBlock, Message, Role, StopReason, ToolDefinition}, + ModelResponse, +}; + +use super::SessionManager; + +// A minimal mock provider so we can build real Agent instances without +// making any network calls. This mirrors the pattern used in +// mixtape-core's own builder tests. +#[derive(Clone)] +struct MockProvider; + +#[async_trait::async_trait] +impl ModelProvider for MockProvider { + fn name(&self) -> &str { + "MockProvider" + } + + fn max_context_tokens(&self) -> usize { + 200_000 + } + + fn max_output_tokens(&self) -> usize { + 8_192 + } + + async fn generate( + &self, + _messages: Vec, + _tools: Vec, + _system_prompt: Option, + ) -> Result { + Ok(ModelResponse { + message: Message { + role: Role::Assistant, + content: vec![ContentBlock::Text("ok".to_string())], + }, + stop_reason: StopReason::EndTurn, + usage: None, + }) + } +} + +async fn make_agent() -> Arc { + Arc::new( + mixtape_core::Agent::builder() + .provider(MockProvider) + .build() + .await + .expect("MockProvider build should never fail"), + ) +} + +// --------------------------------------------------------------------------- +// SessionManager::new / Default +// --------------------------------------------------------------------------- + +#[test] +fn new_session_manager_starts_empty() { + let mgr = SessionManager::new(); + assert!(mgr.get("any-session").is_none()); +} + +#[test] +fn default_session_manager_starts_empty() { + let mgr = SessionManager::default(); + assert!(mgr.get("any-session").is_none()); +} + +// --------------------------------------------------------------------------- +// SessionManager::insert / get +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn insert_then_get_returns_agent() { + let mgr = SessionManager::new(); + let agent = make_agent().await; + + mgr.insert("sess-1".to_string(), Arc::clone(&agent)); + + let retrieved = mgr.get("sess-1"); + assert!( + retrieved.is_some(), + "should find the agent we just inserted" + ); + assert!( + Arc::ptr_eq(&agent, &retrieved.unwrap()), + "retrieved agent should be the same Arc as the inserted one" + ); +} + +#[test] +fn get_missing_key_returns_none() { + let mgr = SessionManager::new(); + assert!(mgr.get("does-not-exist").is_none()); +} + +#[tokio::test] +async fn get_after_overwrite_returns_latest_agent() { + let mgr = SessionManager::new(); + let first = make_agent().await; + let second = make_agent().await; + + mgr.insert("sess".to_string(), Arc::clone(&first)); + mgr.insert("sess".to_string(), Arc::clone(&second)); + + let retrieved = mgr.get("sess").expect("session must exist"); + assert!( + Arc::ptr_eq(&second, &retrieved), + "overwritten session should return the second agent" + ); +} + +// --------------------------------------------------------------------------- +// SessionManager::remove +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn remove_existing_session_returns_agent() { + let mgr = SessionManager::new(); + let agent = make_agent().await; + + mgr.insert("sess-rm".to_string(), Arc::clone(&agent)); + let removed = mgr.remove("sess-rm"); + + assert!(removed.is_some(), "remove should return the agent"); + assert!( + Arc::ptr_eq(&agent, &removed.unwrap()), + "removed agent should match what was inserted" + ); +} + +#[test] +fn remove_missing_session_returns_none() { + let mgr = SessionManager::new(); + assert!(mgr.remove("never-existed").is_none()); +} + +#[tokio::test] +async fn remove_makes_session_inaccessible() { + let mgr = SessionManager::new(); + let agent = make_agent().await; + + mgr.insert("sess-del".to_string(), agent); + mgr.remove("sess-del"); + + assert!( + mgr.get("sess-del").is_none(), + "session should not be accessible after removal" + ); +} + +// --------------------------------------------------------------------------- +// Multiple sessions coexist independently +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn multiple_sessions_coexist() { + let mgr = SessionManager::new(); + let a = make_agent().await; + let b = make_agent().await; + + mgr.insert("alpha".to_string(), Arc::clone(&a)); + mgr.insert("beta".to_string(), Arc::clone(&b)); + + assert!(Arc::ptr_eq(&a, &mgr.get("alpha").unwrap())); + assert!(Arc::ptr_eq(&b, &mgr.get("beta").unwrap())); + + // Removing one does not disturb the other + mgr.remove("alpha"); + assert!(mgr.get("alpha").is_none()); + assert!(mgr.get("beta").is_some()); +} diff --git a/mixtape-acp/src/types.rs b/mixtape-acp/src/types.rs new file mode 100644 index 0000000..e947390 --- /dev/null +++ b/mixtape-acp/src/types.rs @@ -0,0 +1,22 @@ +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; + +use mixtape_core::Agent; + +/// Type-erased async factory that produces new Agent instances. +/// +/// Called once per ACP `new_session` to create a fresh agent with its own +/// conversation state. +pub(crate) type AgentFactory = Arc< + dyn Fn() -> Pin> + Send>> + Send + Sync, +>; + +/// A notification message sent from agent hooks to the relay task. +/// +/// Contains a session update to forward to the IDE via +/// `conn.session_notification()`. +pub(crate) struct NotificationMessage { + pub session_id: agent_client_protocol::SessionId, + pub update: agent_client_protocol::SessionUpdate, +}