Skip to content

Commit 58c14dd

Browse files
feat: add AcpProvider for ACP agent integration
Add Provider implementation that connects to ACP agents (claude-code-acp, codex-acp) over stdio transport with support for MCP extensions, permissions, model listing, and model switching. Signed-off-by: Adrian Cole <adrian@tetrate.io>
1 parent 7f0af1b commit 58c14dd

20 files changed

Lines changed: 1943 additions & 12 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ string_slice = "warn"
1616

1717
[workspace.dependencies]
1818
rmcp = { version = "0.15.0", features = ["schemars", "auth"] }
19+
sacp = "10.1.0"
1920
anyhow = "1.0"
2021
async-stream = "0.3"
2122
async-trait = "0.1"

crates/goose-acp/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ workspace = true
1818
goose = { path = "../goose" }
1919
goose-mcp = { path = "../goose-mcp" }
2020
rmcp = { workspace = true }
21-
sacp = "10.1.0"
21+
sacp = { workspace = true }
2222
agent-client-protocol-schema = { version = "0.10", features = ["unstable_session_model"] }
2323
anyhow = { workspace = true }
2424
tokio = { workspace = true }

crates/goose-acp/tests/common_tests/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ pub async fn run_config_mcp<C: Connection>() {
5959
expected_session_id.assert_matches(&session.session_id().0);
6060
}
6161

62+
#[allow(dead_code)]
6263
pub async fn run_initialize_without_provider() {
6364
let temp_dir = tempfile::tempdir().unwrap();
6465

@@ -86,6 +87,7 @@ pub async fn run_initialize_without_provider() {
8687
.any(|m| &*m.id.0 == "goose-provider"));
8788
}
8889

90+
#[allow(dead_code)]
8991
pub async fn run_load_model<C: Connection>() {
9092
let expected_session_id = ExpectedSessionId::default();
9193
let openai = OpenAiFixture::new(

crates/goose-acp/tests/fixtures/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,7 @@ pub trait Connection: Sized {
265265

266266
async fn new(config: TestConnectionConfig, openai: OpenAiFixture) -> Self;
267267
async fn new_session(&mut self) -> (Self::Session, Option<SessionModelState>);
268+
#[allow(dead_code)]
268269
async fn load_session(
269270
&mut self,
270271
session_id: &str,
@@ -328,4 +329,5 @@ pub async fn initialize_agent(agent: Arc<GooseAcpAgent>) -> sacp::schema::Initia
328329
.unwrap()
329330
}
330331

332+
pub mod provider;
331333
pub mod server;
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
use super::{
2+
spawn_acp_server_in_process, Connection, OpenAiFixture, PermissionDecision, Session,
3+
TestConnectionConfig, TestOutput,
4+
};
5+
use async_trait::async_trait;
6+
use futures::StreamExt;
7+
use goose::acp::{AcpProvider, AcpProviderConfig, PermissionMapping};
8+
use goose::config::PermissionManager;
9+
use goose::conversation::message::{ActionRequiredData, Message, MessageContent};
10+
use goose::model::ModelConfig;
11+
use goose::permission::permission_confirmation::PrincipalType;
12+
use goose::permission::{Permission, PermissionConfirmation};
13+
use sacp::schema::{SessionModelState, ToolCallStatus};
14+
use std::sync::Arc;
15+
use tokio::sync::Mutex;
16+
17+
#[allow(dead_code)]
18+
pub struct ClientToProviderConnection {
19+
provider: Arc<Mutex<AcpProvider>>,
20+
permission_manager: Arc<PermissionManager>,
21+
_openai: OpenAiFixture,
22+
_temp_dir: Option<tempfile::TempDir>,
23+
}
24+
25+
#[allow(dead_code)]
26+
pub struct ClientToProviderSession {
27+
provider: Arc<Mutex<AcpProvider>>,
28+
session_id: sacp::schema::SessionId,
29+
permission: PermissionDecision,
30+
}
31+
32+
#[async_trait]
33+
impl Connection for ClientToProviderConnection {
34+
type Session = ClientToProviderSession;
35+
36+
async fn new(config: TestConnectionConfig, openai: OpenAiFixture) -> Self {
37+
let (data_root, temp_dir) = match config.data_root.as_os_str().is_empty() {
38+
true => {
39+
let temp_dir = tempfile::tempdir().unwrap();
40+
(temp_dir.path().to_path_buf(), Some(temp_dir))
41+
}
42+
false => (config.data_root.clone(), None),
43+
};
44+
45+
let goose_mode = config.goose_mode;
46+
let mcp_servers = config.mcp_servers;
47+
48+
let (transport, _handle, permission_manager) = spawn_acp_server_in_process(
49+
openai.uri(),
50+
&config.builtins,
51+
data_root.as_path(),
52+
goose_mode,
53+
config.provider_factory,
54+
)
55+
.await;
56+
57+
let provider_config = AcpProviderConfig {
58+
command: "unused".into(),
59+
args: vec![],
60+
env: vec![],
61+
work_dir: data_root,
62+
mcp_servers,
63+
session_mode_id: None,
64+
permission_mapping: PermissionMapping::default(),
65+
};
66+
67+
let provider = AcpProvider::connect_with_transport(
68+
"acp-test".to_string(),
69+
ModelConfig::new("default").unwrap(),
70+
goose_mode,
71+
provider_config,
72+
transport.incoming,
73+
transport.outgoing,
74+
)
75+
.await
76+
.unwrap();
77+
78+
Self {
79+
provider: Arc::new(Mutex::new(provider)),
80+
permission_manager,
81+
_openai: openai,
82+
_temp_dir: temp_dir,
83+
}
84+
}
85+
86+
async fn new_session(&mut self) -> (ClientToProviderSession, Option<SessionModelState>) {
87+
let (session_id, models) = self
88+
.provider
89+
.lock()
90+
.await
91+
.new_session()
92+
.await
93+
.expect("missing ACP session_id");
94+
95+
let session = ClientToProviderSession {
96+
provider: Arc::clone(&self.provider),
97+
session_id,
98+
permission: PermissionDecision::Cancel,
99+
};
100+
(session, models)
101+
}
102+
103+
async fn load_session(
104+
&mut self,
105+
_session_id: &str,
106+
) -> (ClientToProviderSession, Option<SessionModelState>) {
107+
unimplemented!("provider sessions do not support load_session")
108+
}
109+
110+
fn reset_openai(&self) {
111+
self._openai.reset();
112+
}
113+
114+
fn reset_permissions(&self) {
115+
self.permission_manager.remove_extension("");
116+
}
117+
}
118+
119+
#[async_trait]
120+
impl Session for ClientToProviderSession {
121+
fn session_id(&self) -> &sacp::schema::SessionId {
122+
&self.session_id
123+
}
124+
125+
async fn prompt(&mut self, prompt: &str, decision: PermissionDecision) -> TestOutput {
126+
self.permission = decision;
127+
let message = Message::user().with_text(prompt);
128+
let session_id = self.session_id.0.to_string();
129+
let provider = self.provider.lock().await;
130+
let mut stream = provider
131+
.stream(&session_id, "", &[message], &[])
132+
.await
133+
.unwrap();
134+
let mut text = String::new();
135+
let mut tool_error = false;
136+
let mut saw_tool = false;
137+
138+
while let Some(item) = stream.next().await {
139+
let (msg, _) = item.unwrap();
140+
if let Some(msg) = msg {
141+
for content in msg.content {
142+
match content {
143+
MessageContent::Text(t) => {
144+
text.push_str(&t.text);
145+
}
146+
MessageContent::ToolResponse(resp) => {
147+
saw_tool = true;
148+
if let Ok(result) = resp.tool_result {
149+
tool_error |= result.is_error.unwrap_or(false);
150+
}
151+
}
152+
MessageContent::ActionRequired(action) => {
153+
if let ActionRequiredData::ToolConfirmation { id, .. } = action.data {
154+
saw_tool = true;
155+
if matches!(
156+
self.permission,
157+
PermissionDecision::RejectAlways
158+
| PermissionDecision::RejectOnce
159+
| PermissionDecision::Cancel
160+
) {
161+
tool_error = true;
162+
}
163+
164+
let permission = match self.permission {
165+
PermissionDecision::AllowAlways => Permission::AlwaysAllow,
166+
PermissionDecision::AllowOnce => Permission::AllowOnce,
167+
PermissionDecision::RejectAlways => Permission::AlwaysDeny,
168+
PermissionDecision::RejectOnce => Permission::DenyOnce,
169+
PermissionDecision::Cancel => Permission::Cancel,
170+
};
171+
172+
let confirmation = PermissionConfirmation {
173+
principal_type: PrincipalType::Tool,
174+
permission,
175+
};
176+
177+
let handled = provider
178+
.handle_permission_confirmation(&id, &confirmation)
179+
.await;
180+
assert!(handled);
181+
}
182+
}
183+
_ => {}
184+
}
185+
}
186+
}
187+
}
188+
189+
let tool_status = if saw_tool {
190+
Some(if tool_error {
191+
ToolCallStatus::Failed
192+
} else {
193+
ToolCallStatus::Completed
194+
})
195+
} else {
196+
None
197+
};
198+
199+
TestOutput { text, tool_status }
200+
}
201+
202+
async fn set_model(&self, model_id: &str) {
203+
self.provider
204+
.lock()
205+
.await
206+
.set_model(&self.session_id, model_id)
207+
.await
208+
.unwrap();
209+
}
210+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
#![recursion_limit = "256"]
2+
3+
mod common_tests;
4+
use common_tests::fixtures::provider::ClientToProviderConnection;
5+
use common_tests::fixtures::run_test;
6+
use common_tests::fixtures::server::ClientToAgentConnection;
7+
use common_tests::{
8+
run_config_mcp, run_model_list, run_model_set, run_permission_persistence, run_prompt_basic,
9+
run_prompt_codemode, run_prompt_image, run_prompt_mcp,
10+
};
11+
12+
#[test]
13+
fn test_provider_prompt_basic() {
14+
run_test(async { run_prompt_basic::<ClientToProviderConnection>().await });
15+
}
16+
17+
#[test]
18+
fn test_provider_prompt_mcp() {
19+
run_test(async { run_prompt_mcp::<ClientToProviderConnection>().await });
20+
}
21+
22+
#[test]
23+
fn test_provider_prompt_codemode() {
24+
run_test(async { run_prompt_codemode::<ClientToProviderConnection>().await });
25+
}
26+
27+
#[test]
28+
fn test_provider_prompt_image() {
29+
run_test(async { run_prompt_image::<ClientToProviderConnection>().await });
30+
}
31+
32+
#[test]
33+
fn test_provider_config_mcp() {
34+
run_test(async { run_config_mcp::<ClientToProviderConnection>().await });
35+
}
36+
37+
#[test]
38+
fn test_provider_permission_persistence() {
39+
run_test(async { run_permission_persistence::<ClientToProviderConnection>().await });
40+
}
41+
42+
#[test]
43+
fn test_provider_model_list() {
44+
run_test(async { run_model_list::<ClientToProviderConnection>().await });
45+
}
46+
47+
#[test]
48+
fn test_provider_model_set() {
49+
run_test(async { run_model_set::<ClientToProviderConnection>().await });
50+
}
51+
52+
// TODO: run_load_model requires ACP-level session persistence (load_session)
53+
54+
#[test]
55+
fn test_server_basic_completion_via_provider_suite() {
56+
run_test(async { run_prompt_basic::<ClientToAgentConnection>().await });
57+
}

crates/goose/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ tempfile = { workspace = true }
101101
dashmap = "6.1"
102102
ahash = "0.8"
103103
tokio-util = { workspace = true, features = ["compat"] }
104+
sacp = { workspace = true }
104105
unicode-normalization = "0.1"
105106
goose-mcp = { path = "../goose-mcp" }
106107

0 commit comments

Comments
 (0)