From 317b7bdf3e935b98358cef5b5668f57f4517410b Mon Sep 17 00:00:00 2001 From: Aaron Longwell Date: Tue, 24 Feb 2026 15:36:04 -0800 Subject: [PATCH] feat: add mixtape-acp crate for ACP (Agent Client Protocol) support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bridge mixtape agents to the Agent Client Protocol so editors and IDEs (VS Code, Zed, Neovim, JetBrains, Emacs) can use them as coding agents. The crate handles the !Send bridge between the ACP SDK (which returns !Send futures) and mixtape-core's Send + Sync Agent by running the ACP protocol loop on a LocalSet while dispatching Agent::run() via tokio::spawn(). Events flow back through mpsc channels to a spawn_local relay task that drives notifications and permission dialogs. Key components: - MixtapeAcpBuilder: configure agent factory, name, and version - MixtapeAcpAgent: implements acp::Agent trait (initialize, authenticate, new_session, prompt, cancel) - SessionManager: maps ACP sessions to dedicated Agent instances - Event conversion: AgentEvent → ACP SessionUpdate notifications - Permission bridge: routes IDE permission dialogs back to the agent's request_authorization() channel via Arc - serve_stdio(): main entry point for stdio-based ACP servers Includes 76 unit tests, 2 doc tests, and two examples (mock and Bedrock). --- Cargo.toml | 6 +- mixtape-acp/Cargo.toml | 38 ++ mixtape-acp/examples/echo_agent.rs | 25 ++ mixtape-acp/examples/echo_agent_bedrock.rs | 27 ++ mixtape-acp/src/adapter.rs | 459 +++++++++++++++++++++ mixtape-acp/src/builder.rs | 115 ++++++ mixtape-acp/src/builder_tests.rs | 175 ++++++++ mixtape-acp/src/convert.rs | 96 +++++ mixtape-acp/src/convert_tests.rs | 373 +++++++++++++++++ mixtape-acp/src/error.rs | 39 ++ mixtape-acp/src/error_tests.rs | 187 +++++++++ mixtape-acp/src/lib.rs | 144 +++++++ mixtape-acp/src/permission.rs | 91 ++++ mixtape-acp/src/permission_tests.rs | 201 +++++++++ mixtape-acp/src/session.rs | 39 ++ mixtape-acp/src/session_tests.rs | 177 ++++++++ mixtape-acp/src/types.rs | 22 + 17 files changed, 2213 insertions(+), 1 deletion(-) create mode 100644 mixtape-acp/Cargo.toml create mode 100644 mixtape-acp/examples/echo_agent.rs create mode 100644 mixtape-acp/examples/echo_agent_bedrock.rs create mode 100644 mixtape-acp/src/adapter.rs create mode 100644 mixtape-acp/src/builder.rs create mode 100644 mixtape-acp/src/builder_tests.rs create mode 100644 mixtape-acp/src/convert.rs create mode 100644 mixtape-acp/src/convert_tests.rs create mode 100644 mixtape-acp/src/error.rs create mode 100644 mixtape-acp/src/error_tests.rs create mode 100644 mixtape-acp/src/lib.rs create mode 100644 mixtape-acp/src/permission.rs create mode 100644 mixtape-acp/src/permission_tests.rs create mode 100644 mixtape-acp/src/session.rs create mode 100644 mixtape-acp/src/session_tests.rs create mode 100644 mixtape-acp/src/types.rs 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, +}