feat: ACP providers for claude code and codex (#6605)
Signed-off-by: Adrian Cole <adrian@tetrate.io>
This commit is contained in:
@@ -108,7 +108,7 @@ jobs:
|
|||||||
node-version: '22'
|
node-version: '22'
|
||||||
|
|
||||||
- name: Install agentic providers
|
- name: Install agentic providers
|
||||||
run: npm install -g @anthropic-ai/claude-code @openai/codex @google/gemini-cli
|
run: npm install -g @anthropic-ai/claude-code @openai/codex @google/gemini-cli @zed-industries/claude-agent-acp @zed-industries/codex-acp
|
||||||
|
|
||||||
- name: Run Smoke Tests with Provider Script
|
- name: Run Smoke Tests with Provider Script
|
||||||
env:
|
env:
|
||||||
|
|||||||
Generated
+2
@@ -4270,6 +4270,7 @@ dependencies = [
|
|||||||
name = "goose"
|
name = "goose"
|
||||||
version = "1.27.0"
|
version = "1.27.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"agent-client-protocol-schema",
|
||||||
"ahash",
|
"ahash",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-stream",
|
"async-stream",
|
||||||
@@ -4329,6 +4330,7 @@ dependencies = [
|
|||||||
"reqwest 0.13.2",
|
"reqwest 0.13.2",
|
||||||
"rmcp 1.1.0",
|
"rmcp 1.1.0",
|
||||||
"rubato",
|
"rubato",
|
||||||
|
"sacp",
|
||||||
"schemars 1.2.1",
|
"schemars 1.2.1",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ string_slice = "warn"
|
|||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
rmcp = { version = "1.1.0", features = ["schemars", "auth"] }
|
rmcp = { version = "1.1.0", features = ["schemars", "auth"] }
|
||||||
|
sacp = "10.1.0"
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
async-stream = "0.3"
|
async-stream = "0.3"
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ workspace = true
|
|||||||
goose = { path = "../goose", default-features = false }
|
goose = { path = "../goose", default-features = false }
|
||||||
goose-mcp = { path = "../goose-mcp" }
|
goose-mcp = { path = "../goose-mcp" }
|
||||||
rmcp = { workspace = true }
|
rmcp = { workspace = true }
|
||||||
sacp = "10.1.0"
|
sacp = { workspace = true }
|
||||||
agent-client-protocol-schema = { version = "0.10", features = ["unstable_session_model", "unstable_session_list"] }
|
agent-client-protocol-schema = { version = "0.10", features = ["unstable"] }
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
tokio-util = { workspace = true, features = ["compat", "rt"] }
|
tokio-util = { workspace = true, features = ["compat", "rt"] }
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use crate::custom_requests::*;
|
use crate::custom_requests::*;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use fs_err as fs;
|
use fs_err as fs;
|
||||||
|
use goose::acp::PermissionDecision;
|
||||||
use goose::agents::extension::{Envs, PLATFORM_EXTENSIONS};
|
use goose::agents::extension::{Envs, PLATFORM_EXTENSIONS};
|
||||||
use goose::agents::{Agent, AgentConfig, ExtensionConfig, GoosePlatform, SessionConfig};
|
use goose::agents::{Agent, AgentConfig, ExtensionConfig, GoosePlatform, SessionConfig};
|
||||||
use goose::builtin_extension::register_builtin_extensions;
|
use goose::builtin_extension::register_builtin_extensions;
|
||||||
@@ -591,24 +592,9 @@ impl GooseAcpAgent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn outcome_to_confirmation(outcome: &RequestPermissionOutcome) -> PermissionConfirmation {
|
fn outcome_to_confirmation(outcome: &RequestPermissionOutcome) -> PermissionConfirmation {
|
||||||
let permission = match outcome {
|
|
||||||
RequestPermissionOutcome::Cancelled => Permission::Cancel,
|
|
||||||
RequestPermissionOutcome::Selected(selected) => {
|
|
||||||
match serde_json::from_value::<PermissionOptionKind>(serde_json::Value::String(
|
|
||||||
selected.option_id.0.to_string(),
|
|
||||||
)) {
|
|
||||||
Ok(PermissionOptionKind::AllowAlways) => Permission::AlwaysAllow,
|
|
||||||
Ok(PermissionOptionKind::AllowOnce) => Permission::AllowOnce,
|
|
||||||
Ok(PermissionOptionKind::RejectOnce) => Permission::DenyOnce,
|
|
||||||
Ok(PermissionOptionKind::RejectAlways) => Permission::AlwaysDeny,
|
|
||||||
_ => Permission::Cancel,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => Permission::Cancel,
|
|
||||||
};
|
|
||||||
PermissionConfirmation {
|
PermissionConfirmation {
|
||||||
principal_type: PrincipalType::Tool,
|
principal_type: PrincipalType::Tool,
|
||||||
permission,
|
permission: Permission::from(PermissionDecision::from(outcome)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,15 +4,12 @@
|
|||||||
|
|
||||||
#[path = "../fixtures/mod.rs"]
|
#[path = "../fixtures/mod.rs"]
|
||||||
pub mod fixtures;
|
pub mod fixtures;
|
||||||
use fixtures::{
|
use fixtures::{Connection, OpenAiFixture, PermissionDecision, Session, TestConnectionConfig};
|
||||||
initialize_agent, Connection, OpenAiFixture, PermissionDecision, Session, TestConnectionConfig,
|
|
||||||
};
|
|
||||||
use fs_err as fs;
|
use fs_err as fs;
|
||||||
use goose::config::base::CONFIG_YAML_NAME;
|
use goose::config::base::CONFIG_YAML_NAME;
|
||||||
use goose::config::GooseMode;
|
use goose::config::GooseMode;
|
||||||
use goose::providers::provider_registry::ProviderConstructor;
|
use goose::providers::provider_registry::ProviderConstructor;
|
||||||
use goose_acp::server::GooseAcpAgent;
|
use goose_test_support::{ExpectedSessionId, McpFixture, FAKE_CODE, TEST_IMAGE_B64, TEST_MODEL};
|
||||||
use goose_test_support::{ExpectedSessionId, McpFixture, FAKE_CODE, TEST_MODEL};
|
|
||||||
use sacp::schema::{McpServer, McpServerHttp, ModelId, ToolCallStatus};
|
use sacp::schema::{McpServer, McpServerHttp, ModelId, ToolCallStatus};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -57,29 +54,20 @@ pub async fn run_config_mcp<C: Connection>() {
|
|||||||
expected_session_id.assert_matches(&session.session_id().0);
|
expected_session_id.assert_matches(&session.session_id().0);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn run_initialize_without_provider() {
|
pub async fn run_initialize_doesnt_hit_provider<C: Connection>() {
|
||||||
let temp_dir = tempfile::tempdir().unwrap();
|
|
||||||
|
|
||||||
let provider_factory: ProviderConstructor =
|
let provider_factory: ProviderConstructor =
|
||||||
Arc::new(|_, _| Box::pin(async { Err(anyhow::anyhow!("no provider configured")) }));
|
Arc::new(|_, _| Box::pin(async { Err(anyhow::anyhow!("no provider configured")) }));
|
||||||
|
|
||||||
let agent = Arc::new(
|
let openai = OpenAiFixture::new(vec![], ExpectedSessionId::default()).await;
|
||||||
GooseAcpAgent::new(
|
let config = TestConnectionConfig {
|
||||||
provider_factory,
|
provider_factory: Some(provider_factory),
|
||||||
vec![],
|
..Default::default()
|
||||||
temp_dir.path().to_path_buf(),
|
};
|
||||||
temp_dir.path().to_path_buf(),
|
|
||||||
GooseMode::Auto,
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap(),
|
|
||||||
);
|
|
||||||
|
|
||||||
let resp = initialize_agent(agent).await;
|
let conn = C::new(config, openai).await;
|
||||||
assert!(!resp.auth_methods.is_empty());
|
assert!(!conn.auth_methods().is_empty());
|
||||||
assert!(resp
|
assert!(conn
|
||||||
.auth_methods
|
.auth_methods()
|
||||||
.iter()
|
.iter()
|
||||||
.any(|m| &*m.id.0 == "goose-provider"));
|
.any(|m| &*m.id.0 == "goose-provider"));
|
||||||
}
|
}
|
||||||
@@ -334,6 +322,33 @@ pub async fn run_prompt_image<C: Connection>() {
|
|||||||
expected_session_id.assert_matches(&session.session_id().0);
|
expected_session_id.assert_matches(&session.session_id().0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn run_prompt_image_attachment<C: Connection>() {
|
||||||
|
let expected_session_id = ExpectedSessionId::default();
|
||||||
|
let openai = OpenAiFixture::new(
|
||||||
|
vec![(
|
||||||
|
r#""type":"image_url""#.into(),
|
||||||
|
include_str!("../test_data/openai_image_attachment.txt"),
|
||||||
|
)],
|
||||||
|
expected_session_id.clone(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let mut conn = C::new(TestConnectionConfig::default(), openai).await;
|
||||||
|
let (mut session, _) = conn.new_session().await;
|
||||||
|
expected_session_id.set(session.session_id().0.to_string());
|
||||||
|
|
||||||
|
let output = session
|
||||||
|
.prompt_with_image(
|
||||||
|
"Describe what you see in this image",
|
||||||
|
TEST_IMAGE_B64,
|
||||||
|
"image/png",
|
||||||
|
PermissionDecision::Cancel,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(output.text.contains("Hello Goose!"));
|
||||||
|
expected_session_id.assert_matches(&session.session_id().0);
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn run_prompt_mcp<C: Connection>() {
|
pub async fn run_prompt_mcp<C: Connection>() {
|
||||||
let expected_session_id = ExpectedSessionId::default();
|
let expected_session_id = ExpectedSessionId::default();
|
||||||
let mcp = McpFixture::new(Some(expected_session_id.clone())).await;
|
let mcp = McpFixture::new(Some(expected_session_id.clone())).await;
|
||||||
|
|||||||
+13
-49
@@ -3,19 +3,17 @@
|
|||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use fs_err as fs;
|
use fs_err as fs;
|
||||||
|
pub use goose::acp::{map_permission_response, PermissionDecision, PermissionMapping};
|
||||||
use goose::builtin_extension::register_builtin_extensions;
|
use goose::builtin_extension::register_builtin_extensions;
|
||||||
use goose::config::{GooseMode, PermissionManager};
|
use goose::config::{GooseMode, PermissionManager};
|
||||||
use goose::providers::api_client::{ApiClient, AuthMethod};
|
use goose::providers::api_client::{ApiClient, AuthMethod as ApiAuthMethod};
|
||||||
use goose::providers::base::Provider;
|
use goose::providers::base::Provider;
|
||||||
use goose::providers::openai::OpenAiProvider;
|
use goose::providers::openai::OpenAiProvider;
|
||||||
use goose::providers::provider_registry::ProviderConstructor;
|
use goose::providers::provider_registry::ProviderConstructor;
|
||||||
use goose::session_context::SESSION_ID_HEADER;
|
use goose::session_context::SESSION_ID_HEADER;
|
||||||
use goose_acp::server::{serve, GooseAcpAgent};
|
use goose_acp::server::{serve, GooseAcpAgent};
|
||||||
use goose_test_support::{ExpectedSessionId, TEST_MODEL};
|
use goose_test_support::{ExpectedSessionId, TEST_MODEL};
|
||||||
use sacp::schema::{
|
use sacp::schema::{AuthMethod, McpServer, SessionModelState, ToolCallStatus};
|
||||||
McpServer, PermissionOptionKind, RequestPermissionOutcome, RequestPermissionRequest,
|
|
||||||
RequestPermissionResponse, SelectedPermissionOutcome, SessionModelState, ToolCallStatus,
|
|
||||||
};
|
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
@@ -26,49 +24,6 @@ use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
|
|||||||
use wiremock::matchers::{method, path};
|
use wiremock::matchers::{method, path};
|
||||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||||
|
|
||||||
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
|
|
||||||
pub enum PermissionDecision {
|
|
||||||
AllowAlways,
|
|
||||||
AllowOnce,
|
|
||||||
RejectOnce,
|
|
||||||
RejectAlways,
|
|
||||||
Cancel,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
pub struct PermissionMapping;
|
|
||||||
|
|
||||||
pub fn map_permission_response(
|
|
||||||
_mapping: &PermissionMapping,
|
|
||||||
req: &RequestPermissionRequest,
|
|
||||||
decision: PermissionDecision,
|
|
||||||
) -> RequestPermissionResponse {
|
|
||||||
let outcome = match decision {
|
|
||||||
PermissionDecision::Cancel => RequestPermissionOutcome::Cancelled,
|
|
||||||
PermissionDecision::AllowAlways => select_option(req, PermissionOptionKind::AllowAlways),
|
|
||||||
PermissionDecision::AllowOnce => select_option(req, PermissionOptionKind::AllowOnce),
|
|
||||||
PermissionDecision::RejectOnce => select_option(req, PermissionOptionKind::RejectOnce),
|
|
||||||
PermissionDecision::RejectAlways => select_option(req, PermissionOptionKind::RejectAlways),
|
|
||||||
};
|
|
||||||
|
|
||||||
RequestPermissionResponse::new(outcome)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn select_option(
|
|
||||||
req: &RequestPermissionRequest,
|
|
||||||
kind: PermissionOptionKind,
|
|
||||||
) -> RequestPermissionOutcome {
|
|
||||||
req.options
|
|
||||||
.iter()
|
|
||||||
.find(|opt| opt.kind == kind)
|
|
||||||
.map(|opt| {
|
|
||||||
RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new(
|
|
||||||
opt.option_id.clone(),
|
|
||||||
))
|
|
||||||
})
|
|
||||||
.unwrap_or(RequestPermissionOutcome::Cancelled)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct OpenAiFixture {
|
pub struct OpenAiFixture {
|
||||||
_server: MockServer,
|
_server: MockServer,
|
||||||
base_url: String,
|
base_url: String,
|
||||||
@@ -211,7 +166,7 @@ pub async fn spawn_acp_server_in_process(
|
|||||||
let base_url = base_url.clone();
|
let base_url = base_url.clone();
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
let api_client =
|
let api_client =
|
||||||
ApiClient::new(base_url, AuthMethod::BearerToken("test-key".to_string()))
|
ApiClient::new(base_url, ApiAuthMethod::BearerToken("test-key".to_string()))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let provider: Arc<dyn Provider> =
|
let provider: Arc<dyn Provider> =
|
||||||
Arc::new(OpenAiProvider::new(api_client, model_config));
|
Arc::new(OpenAiProvider::new(api_client, model_config));
|
||||||
@@ -273,6 +228,7 @@ pub trait Connection: Sized {
|
|||||||
&mut self,
|
&mut self,
|
||||||
session_id: &str,
|
session_id: &str,
|
||||||
) -> (Self::Session, Option<SessionModelState>);
|
) -> (Self::Session, Option<SessionModelState>);
|
||||||
|
fn auth_methods(&self) -> &[AuthMethod];
|
||||||
fn reset_openai(&self);
|
fn reset_openai(&self);
|
||||||
fn reset_permissions(&self);
|
fn reset_permissions(&self);
|
||||||
}
|
}
|
||||||
@@ -281,6 +237,13 @@ pub trait Connection: Sized {
|
|||||||
pub trait Session {
|
pub trait Session {
|
||||||
fn session_id(&self) -> &sacp::schema::SessionId;
|
fn session_id(&self) -> &sacp::schema::SessionId;
|
||||||
async fn prompt(&mut self, text: &str, decision: PermissionDecision) -> TestOutput;
|
async fn prompt(&mut self, text: &str, decision: PermissionDecision) -> TestOutput;
|
||||||
|
async fn prompt_with_image(
|
||||||
|
&mut self,
|
||||||
|
text: &str,
|
||||||
|
image_b64: &str,
|
||||||
|
mime_type: &str,
|
||||||
|
decision: PermissionDecision,
|
||||||
|
) -> TestOutput;
|
||||||
async fn set_model(&self, model_id: &str);
|
async fn set_model(&self, model_id: &str);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,4 +295,5 @@ pub async fn initialize_agent(agent: Arc<GooseAcpAgent>) -> sacp::schema::Initia
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub mod provider;
|
||||||
pub mod server;
|
pub mod server;
|
||||||
|
|||||||
+232
@@ -0,0 +1,232 @@
|
|||||||
|
use super::{
|
||||||
|
spawn_acp_server_in_process, Connection, OpenAiFixture, PermissionDecision, Session,
|
||||||
|
TestConnectionConfig, TestOutput,
|
||||||
|
};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use futures::StreamExt;
|
||||||
|
use goose::acp::{AcpProvider, AcpProviderConfig, PermissionMapping};
|
||||||
|
use goose::config::PermissionManager;
|
||||||
|
use goose::conversation::message::{ActionRequiredData, Message, MessageContent};
|
||||||
|
use goose::model::ModelConfig;
|
||||||
|
use goose::permission::permission_confirmation::PrincipalType;
|
||||||
|
use goose::permission::{Permission, PermissionConfirmation};
|
||||||
|
use goose::providers::base::Provider;
|
||||||
|
use goose_test_support::TEST_MODEL;
|
||||||
|
use sacp::schema::{AuthMethod, SessionModelState, ToolCallStatus};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct ClientToProviderConnection {
|
||||||
|
provider: Arc<Mutex<AcpProvider>>,
|
||||||
|
permission_manager: Arc<PermissionManager>,
|
||||||
|
auth_methods: Vec<AuthMethod>,
|
||||||
|
session_counter: usize,
|
||||||
|
_openai: OpenAiFixture,
|
||||||
|
_temp_dir: Option<tempfile::TempDir>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct ClientToProviderSession {
|
||||||
|
provider: Arc<Mutex<AcpProvider>>,
|
||||||
|
acp_session_id: sacp::schema::SessionId,
|
||||||
|
session_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClientToProviderSession {
|
||||||
|
#[allow(dead_code)]
|
||||||
|
async fn send_message(&mut self, message: Message, decision: PermissionDecision) -> TestOutput {
|
||||||
|
let session_id = self.session_id.clone();
|
||||||
|
let provider = self.provider.lock().await;
|
||||||
|
let model_config = provider.get_model_config();
|
||||||
|
let mut stream = provider
|
||||||
|
.stream(&model_config, &session_id, "", &[message], &[])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let mut text = String::new();
|
||||||
|
let mut tool_error = false;
|
||||||
|
let mut saw_tool = false;
|
||||||
|
|
||||||
|
while let Some(item) = stream.next().await {
|
||||||
|
let (msg, _) = item.unwrap();
|
||||||
|
if let Some(msg) = msg {
|
||||||
|
for content in msg.content {
|
||||||
|
match content {
|
||||||
|
MessageContent::Text(t) => {
|
||||||
|
text.push_str(&t.text);
|
||||||
|
}
|
||||||
|
MessageContent::ToolResponse(resp) => {
|
||||||
|
saw_tool = true;
|
||||||
|
if let Ok(result) = resp.tool_result {
|
||||||
|
tool_error |= result.is_error.unwrap_or(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MessageContent::ActionRequired(action) => {
|
||||||
|
if let ActionRequiredData::ToolConfirmation { id, .. } = action.data {
|
||||||
|
saw_tool = true;
|
||||||
|
tool_error |= decision.should_record_rejection();
|
||||||
|
|
||||||
|
let confirmation = PermissionConfirmation {
|
||||||
|
principal_type: PrincipalType::Tool,
|
||||||
|
permission: Permission::from(decision),
|
||||||
|
};
|
||||||
|
|
||||||
|
let handled = provider
|
||||||
|
.handle_permission_confirmation(&id, &confirmation)
|
||||||
|
.await;
|
||||||
|
assert!(handled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let tool_status = if saw_tool {
|
||||||
|
Some(if tool_error {
|
||||||
|
ToolCallStatus::Failed
|
||||||
|
} else {
|
||||||
|
ToolCallStatus::Completed
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
TestOutput { text, tool_status }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Connection for ClientToProviderConnection {
|
||||||
|
type Session = ClientToProviderSession;
|
||||||
|
|
||||||
|
async fn new(config: TestConnectionConfig, openai: OpenAiFixture) -> Self {
|
||||||
|
let (data_root, temp_dir) = match config.data_root.as_os_str().is_empty() {
|
||||||
|
true => {
|
||||||
|
let temp_dir = tempfile::tempdir().unwrap();
|
||||||
|
(temp_dir.path().to_path_buf(), Some(temp_dir))
|
||||||
|
}
|
||||||
|
false => (config.data_root.clone(), None),
|
||||||
|
};
|
||||||
|
|
||||||
|
let goose_mode = config.goose_mode;
|
||||||
|
let mcp_servers = config.mcp_servers;
|
||||||
|
|
||||||
|
let (transport, _handle, permission_manager) = spawn_acp_server_in_process(
|
||||||
|
openai.uri(),
|
||||||
|
&config.builtins,
|
||||||
|
data_root.as_path(),
|
||||||
|
goose_mode,
|
||||||
|
config.provider_factory,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let provider_config = AcpProviderConfig {
|
||||||
|
command: "unused".into(),
|
||||||
|
args: vec![],
|
||||||
|
env: vec![],
|
||||||
|
env_remove: vec![],
|
||||||
|
work_dir: data_root,
|
||||||
|
mcp_servers,
|
||||||
|
session_mode_id: None,
|
||||||
|
permission_mapping: PermissionMapping::default(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let provider = AcpProvider::connect_with_transport(
|
||||||
|
"acp-test".to_string(),
|
||||||
|
ModelConfig::new(TEST_MODEL).unwrap(),
|
||||||
|
goose_mode,
|
||||||
|
provider_config,
|
||||||
|
transport.incoming,
|
||||||
|
transport.outgoing,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let auth_methods = provider.auth_methods().to_vec();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
provider: Arc::new(Mutex::new(provider)),
|
||||||
|
permission_manager,
|
||||||
|
auth_methods,
|
||||||
|
session_counter: 0,
|
||||||
|
_openai: openai,
|
||||||
|
_temp_dir: temp_dir,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn new_session(&mut self) -> (ClientToProviderSession, Option<SessionModelState>) {
|
||||||
|
// Tests like run_model_set call new_session() multiple times on the same
|
||||||
|
// connection, so each needs a distinct key to avoid returning a cached session.
|
||||||
|
self.session_counter += 1;
|
||||||
|
let goose_id = format!("test-session-{}", self.session_counter);
|
||||||
|
let response = self
|
||||||
|
.provider
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.ensure_session(Some(&goose_id))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let session = ClientToProviderSession {
|
||||||
|
provider: Arc::clone(&self.provider),
|
||||||
|
acp_session_id: response.session_id,
|
||||||
|
session_id: goose_id,
|
||||||
|
};
|
||||||
|
(session, response.models)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_session(
|
||||||
|
&mut self,
|
||||||
|
_session_id: &str,
|
||||||
|
) -> (ClientToProviderSession, Option<SessionModelState>) {
|
||||||
|
unimplemented!("TODO: implement load_session in ACP provider")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn auth_methods(&self) -> &[AuthMethod] {
|
||||||
|
&self.auth_methods
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset_openai(&self) {
|
||||||
|
self._openai.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset_permissions(&self) {
|
||||||
|
self.permission_manager.remove_extension("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Session for ClientToProviderSession {
|
||||||
|
fn session_id(&self) -> &sacp::schema::SessionId {
|
||||||
|
&self.acp_session_id
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn prompt(&mut self, prompt: &str, decision: PermissionDecision) -> TestOutput {
|
||||||
|
self.send_message(Message::user().with_text(prompt), decision)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn prompt_with_image(
|
||||||
|
&mut self,
|
||||||
|
prompt: &str,
|
||||||
|
image_b64: &str,
|
||||||
|
mime_type: &str,
|
||||||
|
decision: PermissionDecision,
|
||||||
|
) -> TestOutput {
|
||||||
|
let message = Message::user()
|
||||||
|
.with_image(image_b64, mime_type)
|
||||||
|
.with_text(prompt);
|
||||||
|
self.send_message(message, decision).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_model(&self, model_id: &str) {
|
||||||
|
self.provider
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.set_model(&self.acp_session_id, model_id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
+71
-34
@@ -5,8 +5,8 @@ use super::{
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use goose::config::PermissionManager;
|
use goose::config::PermissionManager;
|
||||||
use sacp::schema::{
|
use sacp::schema::{
|
||||||
ContentBlock, InitializeRequest, LoadSessionRequest, McpServer, NewSessionRequest,
|
AuthMethod, ContentBlock, ImageContent, InitializeRequest, LoadSessionRequest, McpServer,
|
||||||
PromptRequest, ProtocolVersion, RequestPermissionRequest, SessionModelState,
|
NewSessionRequest, PromptRequest, ProtocolVersion, RequestPermissionRequest, SessionModelState,
|
||||||
SessionNotification, SessionUpdate, StopReason, TextContent, ToolCallStatus,
|
SessionNotification, SessionUpdate, StopReason, TextContent, ToolCallStatus,
|
||||||
};
|
};
|
||||||
use sacp::{ClientToAgent, JrConnectionCx};
|
use sacp::{ClientToAgent, JrConnectionCx};
|
||||||
@@ -22,6 +22,7 @@ pub struct ClientToAgentConnection {
|
|||||||
permission: Arc<Mutex<PermissionDecision>>,
|
permission: Arc<Mutex<PermissionDecision>>,
|
||||||
notify: Arc<Notify>,
|
notify: Arc<Notify>,
|
||||||
permission_manager: Arc<PermissionManager>,
|
permission_manager: Arc<PermissionManager>,
|
||||||
|
auth_methods: Vec<AuthMethod>,
|
||||||
_openai: super::OpenAiFixture,
|
_openai: super::OpenAiFixture,
|
||||||
_temp_dir: Option<tempfile::TempDir>,
|
_temp_dir: Option<tempfile::TempDir>,
|
||||||
}
|
}
|
||||||
@@ -34,6 +35,42 @@ pub struct ClientToAgentSession {
|
|||||||
notify: Arc<Notify>,
|
notify: Arc<Notify>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl ClientToAgentSession {
|
||||||
|
async fn send_prompt(
|
||||||
|
&mut self,
|
||||||
|
content: Vec<ContentBlock>,
|
||||||
|
decision: PermissionDecision,
|
||||||
|
) -> TestOutput {
|
||||||
|
*self.permission.lock().unwrap() = decision;
|
||||||
|
self.updates.lock().unwrap().clear();
|
||||||
|
|
||||||
|
let response = self
|
||||||
|
.cx
|
||||||
|
.send_request(PromptRequest::new(self.session_id.clone(), content))
|
||||||
|
.block_task()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.stop_reason, StopReason::EndTurn);
|
||||||
|
|
||||||
|
let mut updates_len = self.updates.lock().unwrap().len();
|
||||||
|
while updates_len == 0 {
|
||||||
|
self.notify.notified().await;
|
||||||
|
updates_len = self.updates.lock().unwrap().len();
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = collect_agent_text(&self.updates);
|
||||||
|
let deadline = tokio::time::Instant::now() + Duration::from_millis(500);
|
||||||
|
let mut tool_status = extract_tool_status(&self.updates);
|
||||||
|
while tool_status.is_none() && tokio::time::Instant::now() < deadline {
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
tool_status = extract_tool_status(&self.updates);
|
||||||
|
}
|
||||||
|
|
||||||
|
TestOutput { text, tool_status }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl ClientToAgentConnection {
|
impl ClientToAgentConnection {
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub fn cx(&self) -> &JrConnectionCx<ClientToAgent> {
|
pub fn cx(&self) -> &JrConnectionCx<ClientToAgent> {
|
||||||
@@ -67,7 +104,7 @@ impl Connection for ClientToAgentConnection {
|
|||||||
let notify = Arc::new(Notify::new());
|
let notify = Arc::new(Notify::new());
|
||||||
let permission = Arc::new(Mutex::new(PermissionDecision::Cancel));
|
let permission = Arc::new(Mutex::new(PermissionDecision::Cancel));
|
||||||
|
|
||||||
let cx = {
|
let (cx, auth_methods) = {
|
||||||
let updates_clone = updates.clone();
|
let updates_clone = updates.clone();
|
||||||
let notify_clone = notify.clone();
|
let notify_clone = notify.clone();
|
||||||
let permission_clone = permission.clone();
|
let permission_clone = permission.clone();
|
||||||
@@ -75,11 +112,13 @@ impl Connection for ClientToAgentConnection {
|
|||||||
let cx_holder: Arc<Mutex<Option<JrConnectionCx<ClientToAgent>>>> =
|
let cx_holder: Arc<Mutex<Option<JrConnectionCx<ClientToAgent>>>> =
|
||||||
Arc::new(Mutex::new(None));
|
Arc::new(Mutex::new(None));
|
||||||
let cx_holder_clone = cx_holder.clone();
|
let cx_holder_clone = cx_holder.clone();
|
||||||
|
let auth_holder: Arc<Mutex<Vec<AuthMethod>>> = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let auth_holder_clone = auth_holder.clone();
|
||||||
|
|
||||||
let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
|
let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let permission_mapping = PermissionMapping;
|
let permission_mapping = PermissionMapping::default();
|
||||||
|
|
||||||
let result = ClientToAgent::builder()
|
let result = ClientToAgent::builder()
|
||||||
.on_receive_notification(
|
.on_receive_notification(
|
||||||
@@ -112,12 +151,15 @@ impl Connection for ClientToAgentConnection {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.run_until({
|
.run_until({
|
||||||
let cx_holder = cx_holder_clone;
|
let cx_holder = cx_holder_clone;
|
||||||
|
let auth_holder = auth_holder_clone;
|
||||||
move |cx: JrConnectionCx<ClientToAgent>| async move {
|
move |cx: JrConnectionCx<ClientToAgent>| async move {
|
||||||
cx.send_request(InitializeRequest::new(ProtocolVersion::LATEST))
|
let resp = cx
|
||||||
|
.send_request(InitializeRequest::new(ProtocolVersion::LATEST))
|
||||||
.block_task()
|
.block_task()
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
*auth_holder.lock().unwrap() = resp.auth_methods;
|
||||||
*cx_holder.lock().unwrap() = Some(cx.clone());
|
*cx_holder.lock().unwrap() = Some(cx.clone());
|
||||||
let _ = ready_tx.send(());
|
let _ = ready_tx.send(());
|
||||||
|
|
||||||
@@ -133,7 +175,8 @@ impl Connection for ClientToAgentConnection {
|
|||||||
|
|
||||||
ready_rx.await.unwrap();
|
ready_rx.await.unwrap();
|
||||||
let cx = cx_holder.lock().unwrap().take().unwrap();
|
let cx = cx_holder.lock().unwrap().take().unwrap();
|
||||||
cx
|
let auth = std::mem::take(&mut *auth_holder.lock().unwrap());
|
||||||
|
(cx, auth)
|
||||||
};
|
};
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
@@ -143,6 +186,7 @@ impl Connection for ClientToAgentConnection {
|
|||||||
permission,
|
permission,
|
||||||
notify,
|
notify,
|
||||||
permission_manager,
|
permission_manager,
|
||||||
|
auth_methods,
|
||||||
_openai: openai,
|
_openai: openai,
|
||||||
_temp_dir: temp_dir,
|
_temp_dir: temp_dir,
|
||||||
}
|
}
|
||||||
@@ -190,6 +234,10 @@ impl Connection for ClientToAgentConnection {
|
|||||||
(session, response.models)
|
(session, response.models)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn auth_methods(&self) -> &[AuthMethod] {
|
||||||
|
&self.auth_methods
|
||||||
|
}
|
||||||
|
|
||||||
fn reset_openai(&self) {
|
fn reset_openai(&self) {
|
||||||
self._openai.reset();
|
self._openai.reset();
|
||||||
}
|
}
|
||||||
@@ -206,36 +254,25 @@ impl Session for ClientToAgentSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn prompt(&mut self, text: &str, decision: PermissionDecision) -> TestOutput {
|
async fn prompt(&mut self, text: &str, decision: PermissionDecision) -> TestOutput {
|
||||||
*self.permission.lock().unwrap() = decision;
|
self.send_prompt(vec![ContentBlock::Text(TextContent::new(text))], decision)
|
||||||
self.updates.lock().unwrap().clear();
|
|
||||||
|
|
||||||
let response = self
|
|
||||||
.cx
|
|
||||||
.send_request(PromptRequest::new(
|
|
||||||
self.session_id.clone(),
|
|
||||||
vec![ContentBlock::Text(TextContent::new(text))],
|
|
||||||
))
|
|
||||||
.block_task()
|
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
}
|
||||||
|
|
||||||
assert_eq!(response.stop_reason, StopReason::EndTurn);
|
async fn prompt_with_image(
|
||||||
|
&mut self,
|
||||||
let mut updates_len = self.updates.lock().unwrap().len();
|
text: &str,
|
||||||
while updates_len == 0 {
|
image_b64: &str,
|
||||||
self.notify.notified().await;
|
mime_type: &str,
|
||||||
updates_len = self.updates.lock().unwrap().len();
|
decision: PermissionDecision,
|
||||||
}
|
) -> TestOutput {
|
||||||
|
self.send_prompt(
|
||||||
let text = collect_agent_text(&self.updates);
|
vec![
|
||||||
let deadline = tokio::time::Instant::now() + Duration::from_millis(500);
|
ContentBlock::Image(ImageContent::new(image_b64, mime_type)),
|
||||||
let mut tool_status = extract_tool_status(&self.updates);
|
ContentBlock::Text(TextContent::new(text)),
|
||||||
while tool_status.is_none() && tokio::time::Instant::now() < deadline {
|
],
|
||||||
tokio::task::yield_now().await;
|
decision,
|
||||||
tool_status = extract_tool_status(&self.updates);
|
)
|
||||||
}
|
.await
|
||||||
|
|
||||||
TestOutput { text, tool_status }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// HACK: sacp doesn't support session/set_model yet, so we send it as untyped JSON.
|
// HACK: sacp doesn't support session/set_model yet, so we send it as untyped JSON.
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
#![recursion_limit = "256"]
|
||||||
|
|
||||||
|
mod common_tests;
|
||||||
|
use common_tests::fixtures::provider::ClientToProviderConnection;
|
||||||
|
use common_tests::fixtures::run_test;
|
||||||
|
use common_tests::{
|
||||||
|
run_config_mcp, run_initialize_doesnt_hit_provider, run_load_model, run_model_list,
|
||||||
|
run_model_set, run_permission_persistence, run_prompt_basic, run_prompt_codemode,
|
||||||
|
run_prompt_image, run_prompt_image_attachment, run_prompt_mcp,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_provider_config_mcp() {
|
||||||
|
run_test(async { run_config_mcp::<ClientToProviderConnection>().await });
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_provider_initialize_doesnt_hit_provider() {
|
||||||
|
run_test(async { run_initialize_doesnt_hit_provider::<ClientToProviderConnection>().await });
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "TODO: implement load_session in ACP provider"]
|
||||||
|
fn test_provider_load_model() {
|
||||||
|
run_test(async { run_load_model::<ClientToProviderConnection>().await });
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_provider_model_list() {
|
||||||
|
run_test(async { run_model_list::<ClientToProviderConnection>().await });
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_provider_model_set() {
|
||||||
|
run_test(async { run_model_set::<ClientToProviderConnection>().await });
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_provider_permission_persistence() {
|
||||||
|
run_test(async { run_permission_persistence::<ClientToProviderConnection>().await });
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_provider_prompt_basic() {
|
||||||
|
run_test(async { run_prompt_basic::<ClientToProviderConnection>().await });
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_provider_prompt_codemode() {
|
||||||
|
run_test(async { run_prompt_codemode::<ClientToProviderConnection>().await });
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_provider_prompt_image() {
|
||||||
|
run_test(async { run_prompt_image::<ClientToProviderConnection>().await });
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_provider_prompt_image_attachment() {
|
||||||
|
run_test(async { run_prompt_image_attachment::<ClientToProviderConnection>().await });
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_provider_prompt_mcp() {
|
||||||
|
run_test(async { run_prompt_mcp::<ClientToProviderConnection>().await });
|
||||||
|
}
|
||||||
@@ -2,9 +2,9 @@ mod common_tests;
|
|||||||
use common_tests::fixtures::run_test;
|
use common_tests::fixtures::run_test;
|
||||||
use common_tests::fixtures::server::ClientToAgentConnection;
|
use common_tests::fixtures::server::ClientToAgentConnection;
|
||||||
use common_tests::{
|
use common_tests::{
|
||||||
run_config_mcp, run_initialize_without_provider, run_load_model, run_model_list, run_model_set,
|
run_config_mcp, run_initialize_doesnt_hit_provider, run_load_model, run_model_list,
|
||||||
run_permission_persistence, run_prompt_basic, run_prompt_codemode, run_prompt_image,
|
run_model_set, run_permission_persistence, run_prompt_basic, run_prompt_codemode,
|
||||||
run_prompt_mcp,
|
run_prompt_image, run_prompt_image_attachment, run_prompt_mcp,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -13,8 +13,8 @@ fn test_config_mcp() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_initialize_without_provider() {
|
fn test_initialize_doesnt_hit_provider() {
|
||||||
run_test(async { run_initialize_without_provider().await });
|
run_test(async { run_initialize_doesnt_hit_provider::<ClientToAgentConnection>().await });
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -52,6 +52,11 @@ fn test_prompt_image() {
|
|||||||
run_test(async { run_prompt_image::<ClientToAgentConnection>().await });
|
run_test(async { run_prompt_image::<ClientToAgentConnection>().await });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_prompt_image_attachment() {
|
||||||
|
run_test(async { run_prompt_image_attachment::<ClientToAgentConnection>().await });
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_prompt_mcp() {
|
fn test_prompt_mcp() {
|
||||||
run_test(async { run_prompt_mcp::<ClientToAgentConnection>().await });
|
run_test(async { run_prompt_mcp::<ClientToAgentConnection>().await });
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"role":"assistant","content":"","refusal":null},"finish_reason":null}],"usage":null,"obfuscation":"7E7t8"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"Here"},"finish_reason":null}],"usage":null,"obfuscation":"C7q"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"’s"},"finish_reason":null}],"usage":null,"obfuscation":"2YLin"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" what"},"finish_reason":null}],"usage":null,"obfuscation":"X0"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" I"},"finish_reason":null}],"usage":null,"obfuscation":"cWHjE"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" can"},"finish_reason":null}],"usage":null,"obfuscation":"sSB"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" derive"},"finish_reason":null}],"usage":null,"obfuscation":""}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" from"},"finish_reason":null}],"usage":null,"obfuscation":"wh"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" the"},"finish_reason":null}],"usage":null,"obfuscation":"pdc"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" image"},"finish_reason":null}],"usage":null,"obfuscation":"6"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":":\n\n"},"finish_reason":null}],"usage":null,"obfuscation":"sf"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"-"},"finish_reason":null}],"usage":null,"obfuscation":"8yQy1S"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" OCR"},"finish_reason":null}],"usage":null,"obfuscation":"8A1"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"/text"},"finish_reason":null}],"usage":null,"obfuscation":"9R"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" transcription"},"finish_reason":null}],"usage":null,"obfuscation":"Ehc21BJqv"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":":\n"},"finish_reason":null}],"usage":null,"obfuscation":"9ab4"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" "},"finish_reason":null}],"usage":null,"obfuscation":"u2EwQi"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" Hello"},"finish_reason":null}],"usage":null,"obfuscation":"5"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" Goose"},"finish_reason":null}],"usage":null,"obfuscation":"b"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"!\n"},"finish_reason":null}],"usage":null,"obfuscation":"EsVy"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" "},"finish_reason":null}],"usage":null,"obfuscation":"Va8f84"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" This"},"finish_reason":null}],"usage":null,"obfuscation":"IR"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" is"},"finish_reason":null}],"usage":null,"obfuscation":"eaRL"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" a"},"finish_reason":null}],"usage":null,"obfuscation":"cdTOW"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" test"},"finish_reason":null}],"usage":null,"obfuscation":"WN"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" image"},"finish_reason":null}],"usage":null,"obfuscation":"l"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":".\n\n"},"finish_reason":null}],"usage":null,"obfuscation":"lx"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"-"},"finish_reason":null}],"usage":null,"obfuscation":"ABF429"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" Observ"},"finish_reason":null}],"usage":null,"obfuscation":""}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"ations"},"finish_reason":null}],"usage":null,"obfuscation":"J"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":":\n"},"finish_reason":null}],"usage":null,"obfuscation":"iWhB"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" "},"finish_reason":null}],"usage":null,"obfuscation":"IFJGlV"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" -"},"finish_reason":null}],"usage":null,"obfuscation":"C8bxY"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" The"},"finish_reason":null}],"usage":null,"obfuscation":"UNZ"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" text"},"finish_reason":null}],"usage":null,"obfuscation":"aO"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" appears"},"finish_reason":null}],"usage":null,"obfuscation":"fMW5Ik2fIuCxYIi"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" in"},"finish_reason":null}],"usage":null,"obfuscation":"6Csr"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" two"},"finish_reason":null}],"usage":null,"obfuscation":"1OA"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" lines"},"finish_reason":null}],"usage":null,"obfuscation":"6"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" on"},"finish_reason":null}],"usage":null,"obfuscation":"MfK8"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" a"},"finish_reason":null}],"usage":null,"obfuscation":"Xn4NP"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" white"},"finish_reason":null}],"usage":null,"obfuscation":"Z"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" background"},"finish_reason":null}],"usage":null,"obfuscation":"xJbYCha0CxG5"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" with"},"finish_reason":null}],"usage":null,"obfuscation":"pN"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" black"},"finish_reason":null}],"usage":null,"obfuscation":"7"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" sans"},"finish_reason":null}],"usage":null,"obfuscation":"ni"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"-serif"},"finish_reason":null}],"usage":null,"obfuscation":"X"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" style"},"finish_reason":null}],"usage":null,"obfuscation":"W"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":".\n"},"finish_reason":null}],"usage":null,"obfuscation":"TPOZ"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" "},"finish_reason":null}],"usage":null,"obfuscation":"SpMYza"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" -"},"finish_reason":null}],"usage":null,"obfuscation":"CSuHP"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" Lik"},"finish_reason":null}],"usage":null,"obfuscation":"Wgi"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"ely"},"finish_reason":null}],"usage":null,"obfuscation":"dhDR"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" centered"},"finish_reason":null}],"usage":null,"obfuscation":"ai50dSkUIc0bS6"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" alignment"},"finish_reason":null}],"usage":null,"obfuscation":"8sTNdzGIIYmdW"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":","},"finish_reason":null}],"usage":null,"obfuscation":"Fby3dv"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" two"},"finish_reason":null}],"usage":null,"obfuscation":"Lpl"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"-line"},"finish_reason":null}],"usage":null,"obfuscation":"xf"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" layout"},"finish_reason":null}],"usage":null,"obfuscation":""}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":".\n\n"},"finish_reason":null}],"usage":null,"obfuscation":"CM"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"What"},"finish_reason":null}],"usage":null,"obfuscation":"zy9"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" would"},"finish_reason":null}],"usage":null,"obfuscation":"d"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" you"},"finish_reason":null}],"usage":null,"obfuscation":"qyo"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" like"},"finish_reason":null}],"usage":null,"obfuscation":"tG"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" me"},"finish_reason":null}],"usage":null,"obfuscation":"KJ3J"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" to"},"finish_reason":null}],"usage":null,"obfuscation":"jTJN"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" do"},"finish_reason":null}],"usage":null,"obfuscation":"Kvuu"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" next"},"finish_reason":null}],"usage":null,"obfuscation":"Q4"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"?\n"},"finish_reason":null}],"usage":null,"obfuscation":"MKya"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"-"},"finish_reason":null}],"usage":null,"obfuscation":"FGV2M3"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" Translate"},"finish_reason":null}],"usage":null,"obfuscation":"ojudhthRN0wje"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" the"},"finish_reason":null}],"usage":null,"obfuscation":"j1d"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" text"},"finish_reason":null}],"usage":null,"obfuscation":"IN"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"\n"},"finish_reason":null}],"usage":null,"obfuscation":"5GPdt"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"-"},"finish_reason":null}],"usage":null,"obfuscation":"GdygSH"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" Extract"},"finish_reason":null}],"usage":null,"obfuscation":"CocHxzrTqsaRFbq"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" more"},"finish_reason":null}],"usage":null,"obfuscation":"LN"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" metadata"},"finish_reason":null}],"usage":null,"obfuscation":"52g08uQUy8fzv2"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" ("},"finish_reason":null}],"usage":null,"obfuscation":"AmD59"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"font"},"finish_reason":null}],"usage":null,"obfuscation":"Qvr"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":","},"finish_reason":null}],"usage":null,"obfuscation":"DKTZR4"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" size"},"finish_reason":null}],"usage":null,"obfuscation":"sa"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":","},"finish_reason":null}],"usage":null,"obfuscation":"kQknkh"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" contrast"},"finish_reason":null}],"usage":null,"obfuscation":"CzLtt2a3ZKZRsk"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":")\n"},"finish_reason":null}],"usage":null,"obfuscation":"10ts"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"-"},"finish_reason":null}],"usage":null,"obfuscation":"BkGfhE"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" Generate"},"finish_reason":null}],"usage":null,"obfuscation":"kRp8kkudiLXyPr"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" alt"},"finish_reason":null}],"usage":null,"obfuscation":"naE"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" text"},"finish_reason":null}],"usage":null,"obfuscation":"WO"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" or"},"finish_reason":null}],"usage":null,"obfuscation":"wjCo"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" accessibility"},"finish_reason":null}],"usage":null,"obfuscation":"HgWk0MkQP"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" description"},"finish_reason":null}],"usage":null,"obfuscation":"2RkUmSr6mK5"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"\n"},"finish_reason":null}],"usage":null,"obfuscation":"qCmgA"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"-"},"finish_reason":null}],"usage":null,"obfuscation":"dDYudm"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" Create"},"finish_reason":null}],"usage":null,"obfuscation":""}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" a"},"finish_reason":null}],"usage":null,"obfuscation":"Uco4R"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" caption"},"finish_reason":null}],"usage":null,"obfuscation":"cAPhTd1DoHVetWW"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" or"},"finish_reason":null}],"usage":null,"obfuscation":"qK8N"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" use"},"finish_reason":null}],"usage":null,"obfuscation":"wGO"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" it"},"finish_reason":null}],"usage":null,"obfuscation":"DKP3"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" in"},"finish_reason":null}],"usage":null,"obfuscation":"0qux"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" code"},"finish_reason":null}],"usage":null,"obfuscation":"FI"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" ("},"finish_reason":null}],"usage":null,"obfuscation":"BXnPm"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"HTML"},"finish_reason":null}],"usage":null,"obfuscation":"lal"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"/C"},"finish_reason":null}],"usage":null,"obfuscation":"2bBrS"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"SS"},"finish_reason":null}],"usage":null,"obfuscation":"kM3Jx"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":")\n"},"finish_reason":null}],"usage":null,"obfuscation":"H7Et"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"-"},"finish_reason":null}],"usage":null,"obfuscation":"EbOoyr"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" Run"},"finish_reason":null}],"usage":null,"obfuscation":"h74"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" OCR"},"finish_reason":null}],"usage":null,"obfuscation":"3vs"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" on"},"finish_reason":null}],"usage":null,"obfuscation":"N3y0"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" similar"},"finish_reason":null}],"usage":null,"obfuscation":"mMnr4ffD7zygIYv"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" images"},"finish_reason":null}],"usage":null,"obfuscation":""}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" for"},"finish_reason":null}],"usage":null,"obfuscation":"TJp"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" batch"},"finish_reason":null}],"usage":null,"obfuscation":"6"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" processing"},"finish_reason":null}],"usage":null,"obfuscation":"ls6HOZgAV1ez"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":null,"obfuscation":"j"}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[],"usage":{"prompt_tokens":2768,"completion_tokens":572,"total_tokens":3340,"prompt_tokens_details":{"cached_tokens":0,"audio_tokens":0},"completion_tokens_details":{"reasoning_tokens":448,"audio_tokens":0,"accepted_prediction_tokens":0,"rejected_prediction_tokens":0}},"obfuscation":"EvMBUZOQw2ps4DX"}
|
||||||
|
|
||||||
|
data: [DONE]
|
||||||
|
|
||||||
@@ -102,6 +102,8 @@ tempfile = { workspace = true }
|
|||||||
dashmap = "6.1"
|
dashmap = "6.1"
|
||||||
ahash = "0.8"
|
ahash = "0.8"
|
||||||
tokio-util = { workspace = true, features = ["compat"] }
|
tokio-util = { workspace = true, features = ["compat"] }
|
||||||
|
sacp = { workspace = true }
|
||||||
|
agent-client-protocol-schema = { version = "0.10", features = ["unstable"] }
|
||||||
unicode-normalization = "0.1"
|
unicode-normalization = "0.1"
|
||||||
|
|
||||||
# For local Whisper transcription
|
# For local Whisper transcription
|
||||||
|
|||||||
@@ -0,0 +1,326 @@
|
|||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use crate::permission::Permission;
|
||||||
|
use sacp::schema::{
|
||||||
|
PermissionOption, PermissionOptionKind, RequestPermissionOutcome, RequestPermissionRequest,
|
||||||
|
RequestPermissionResponse, SelectedPermissionOutcome, ToolCallStatus,
|
||||||
|
};
|
||||||
|
use strum::{Display, EnumString};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct PermissionMapping {
|
||||||
|
pub allow_option_id: Option<String>,
|
||||||
|
pub reject_option_id: Option<String>,
|
||||||
|
pub rejected_tool_status: ToolCallStatus,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PermissionMapping {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
allow_option_id: None,
|
||||||
|
reject_option_id: None,
|
||||||
|
rejected_tool_status: ToolCallStatus::Failed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, Display, EnumString)]
|
||||||
|
#[strum(serialize_all = "snake_case")]
|
||||||
|
pub enum PermissionDecision {
|
||||||
|
AllowAlways,
|
||||||
|
AllowOnce,
|
||||||
|
RejectAlways,
|
||||||
|
RejectOnce,
|
||||||
|
Cancel,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PermissionDecision {
|
||||||
|
pub fn should_record_rejection(self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self,
|
||||||
|
PermissionDecision::RejectAlways
|
||||||
|
| PermissionDecision::RejectOnce
|
||||||
|
| PermissionDecision::Cancel
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Permission> for PermissionDecision {
|
||||||
|
fn from(p: Permission) -> Self {
|
||||||
|
match p {
|
||||||
|
Permission::AlwaysAllow => Self::AllowAlways,
|
||||||
|
Permission::AllowOnce => Self::AllowOnce,
|
||||||
|
Permission::DenyOnce => Self::RejectOnce,
|
||||||
|
Permission::AlwaysDeny => Self::RejectAlways,
|
||||||
|
Permission::Cancel => Self::Cancel,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<PermissionDecision> for Permission {
|
||||||
|
fn from(d: PermissionDecision) -> Self {
|
||||||
|
match d {
|
||||||
|
PermissionDecision::AllowAlways => Self::AlwaysAllow,
|
||||||
|
PermissionDecision::AllowOnce => Self::AllowOnce,
|
||||||
|
PermissionDecision::RejectOnce => Self::DenyOnce,
|
||||||
|
PermissionDecision::RejectAlways => Self::AlwaysDeny,
|
||||||
|
PermissionDecision::Cancel => Self::Cancel,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&RequestPermissionOutcome> for PermissionDecision {
|
||||||
|
fn from(outcome: &RequestPermissionOutcome) -> Self {
|
||||||
|
match outcome {
|
||||||
|
RequestPermissionOutcome::Cancelled => Self::Cancel,
|
||||||
|
RequestPermissionOutcome::Selected(selected) => {
|
||||||
|
Self::from_str(&selected.option_id.0).unwrap_or(Self::Cancel)
|
||||||
|
}
|
||||||
|
_ => Self::Cancel,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn map_permission_response(
|
||||||
|
mapping: &PermissionMapping,
|
||||||
|
request: &RequestPermissionRequest,
|
||||||
|
decision: PermissionDecision,
|
||||||
|
) -> RequestPermissionResponse {
|
||||||
|
let selected_id = match decision {
|
||||||
|
PermissionDecision::AllowAlways => select_option_id(
|
||||||
|
&request.options,
|
||||||
|
&mapping.allow_option_id,
|
||||||
|
PermissionOptionKind::AllowAlways,
|
||||||
|
)
|
||||||
|
.or_else(|| {
|
||||||
|
select_option_id(
|
||||||
|
&request.options,
|
||||||
|
&mapping.allow_option_id,
|
||||||
|
PermissionOptionKind::AllowOnce,
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
PermissionDecision::AllowOnce => select_option_id(
|
||||||
|
&request.options,
|
||||||
|
&mapping.allow_option_id,
|
||||||
|
PermissionOptionKind::AllowOnce,
|
||||||
|
)
|
||||||
|
.or_else(|| {
|
||||||
|
select_option_id(
|
||||||
|
&request.options,
|
||||||
|
&mapping.allow_option_id,
|
||||||
|
PermissionOptionKind::AllowAlways,
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
PermissionDecision::RejectAlways => select_option_id(
|
||||||
|
&request.options,
|
||||||
|
&mapping.reject_option_id,
|
||||||
|
PermissionOptionKind::RejectAlways,
|
||||||
|
)
|
||||||
|
.or_else(|| {
|
||||||
|
select_option_id(
|
||||||
|
&request.options,
|
||||||
|
&mapping.reject_option_id,
|
||||||
|
PermissionOptionKind::RejectOnce,
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
PermissionDecision::RejectOnce => select_option_id(
|
||||||
|
&request.options,
|
||||||
|
&mapping.reject_option_id,
|
||||||
|
PermissionOptionKind::RejectOnce,
|
||||||
|
)
|
||||||
|
.or_else(|| {
|
||||||
|
select_option_id(
|
||||||
|
&request.options,
|
||||||
|
&mapping.reject_option_id,
|
||||||
|
PermissionOptionKind::RejectAlways,
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
PermissionDecision::Cancel => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(option_id) = selected_id {
|
||||||
|
RequestPermissionResponse::new(RequestPermissionOutcome::Selected(
|
||||||
|
SelectedPermissionOutcome::new(option_id),
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
RequestPermissionResponse::new(RequestPermissionOutcome::Cancelled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_option_id(
|
||||||
|
options: &[PermissionOption],
|
||||||
|
preferred_id: &Option<String>,
|
||||||
|
kind: PermissionOptionKind,
|
||||||
|
) -> Option<String> {
|
||||||
|
if let Some(preferred_id) = preferred_id {
|
||||||
|
let preferred = sacp::schema::PermissionOptionId::new(preferred_id.clone());
|
||||||
|
if options.iter().any(|opt| opt.option_id == preferred) {
|
||||||
|
return Some(preferred_id.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
options
|
||||||
|
.iter()
|
||||||
|
.find(|opt| opt.kind == kind)
|
||||||
|
.map(|opt| opt.option_id.0.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use sacp::schema::{PermissionOptionId, ToolCallId, ToolCallUpdate, ToolCallUpdateFields};
|
||||||
|
use test_case::test_case;
|
||||||
|
|
||||||
|
fn make_request(options: Vec<PermissionOption>) -> RequestPermissionRequest {
|
||||||
|
let tool_call =
|
||||||
|
ToolCallUpdate::new(ToolCallId::new("tool-1"), ToolCallUpdateFields::default());
|
||||||
|
RequestPermissionRequest::new("session-1", tool_call, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn option(id: &str, kind: PermissionOptionKind) -> PermissionOption {
|
||||||
|
PermissionOption::new(
|
||||||
|
PermissionOptionId::new(id.to_string()),
|
||||||
|
id.to_string(),
|
||||||
|
kind,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test_case(
|
||||||
|
Some("allow"),
|
||||||
|
None,
|
||||||
|
PermissionDecision::AllowOnce,
|
||||||
|
"allow",
|
||||||
|
true;
|
||||||
|
"allow_uses_preferred_id"
|
||||||
|
)]
|
||||||
|
#[test_case(
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
PermissionDecision::AllowAlways,
|
||||||
|
"allow_always",
|
||||||
|
false;
|
||||||
|
"allow_always_prefers_kind"
|
||||||
|
)]
|
||||||
|
#[test_case(
|
||||||
|
Some("missing"),
|
||||||
|
None,
|
||||||
|
PermissionDecision::AllowOnce,
|
||||||
|
"allow_once",
|
||||||
|
false;
|
||||||
|
"allow_falls_back_to_kind"
|
||||||
|
)]
|
||||||
|
#[test_case(
|
||||||
|
None,
|
||||||
|
Some("reject"),
|
||||||
|
PermissionDecision::RejectOnce,
|
||||||
|
"reject",
|
||||||
|
true;
|
||||||
|
"reject_uses_preferred_id"
|
||||||
|
)]
|
||||||
|
#[test_case(
|
||||||
|
None,
|
||||||
|
Some("missing"),
|
||||||
|
PermissionDecision::RejectOnce,
|
||||||
|
"reject_once",
|
||||||
|
false;
|
||||||
|
"reject_falls_back_to_kind"
|
||||||
|
)]
|
||||||
|
fn test_permission_mapping(
|
||||||
|
allow_option_id: Option<&str>,
|
||||||
|
reject_option_id: Option<&str>,
|
||||||
|
decision: PermissionDecision,
|
||||||
|
expected_id: &str,
|
||||||
|
include_preferred: bool,
|
||||||
|
) {
|
||||||
|
let mut options = vec![
|
||||||
|
option("allow_once", PermissionOptionKind::AllowOnce),
|
||||||
|
option("allow_always", PermissionOptionKind::AllowAlways),
|
||||||
|
option("reject_once", PermissionOptionKind::RejectOnce),
|
||||||
|
option("reject", PermissionOptionKind::RejectAlways),
|
||||||
|
];
|
||||||
|
|
||||||
|
if include_preferred {
|
||||||
|
if let Some(preferred_allow) = allow_option_id {
|
||||||
|
if !options
|
||||||
|
.iter()
|
||||||
|
.any(|opt| opt.option_id.0.as_ref() == preferred_allow)
|
||||||
|
{
|
||||||
|
options.push(option(preferred_allow, PermissionOptionKind::AllowOnce));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(preferred_reject) = reject_option_id {
|
||||||
|
if !options
|
||||||
|
.iter()
|
||||||
|
.any(|opt| opt.option_id.0.as_ref() == preferred_reject)
|
||||||
|
{
|
||||||
|
options.push(option(preferred_reject, PermissionOptionKind::RejectOnce));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let request = make_request(options);
|
||||||
|
|
||||||
|
let mapping = PermissionMapping {
|
||||||
|
allow_option_id: allow_option_id.map(|s| s.to_string()),
|
||||||
|
reject_option_id: reject_option_id.map(|s| s.to_string()),
|
||||||
|
rejected_tool_status: ToolCallStatus::Failed,
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = map_permission_response(&mapping, &request, decision);
|
||||||
|
match response.outcome {
|
||||||
|
RequestPermissionOutcome::Selected(selected) => {
|
||||||
|
assert_eq!(selected.option_id.0.as_ref(), expected_id);
|
||||||
|
}
|
||||||
|
_ => panic!("expected selected outcome"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test_case(PermissionDecision::Cancel; "cancelled")]
|
||||||
|
fn test_permission_cancelled(decision: PermissionDecision) {
|
||||||
|
let request = make_request(vec![option("allow_once", PermissionOptionKind::AllowOnce)]);
|
||||||
|
let response = map_permission_response(&PermissionMapping::default(), &request, decision);
|
||||||
|
assert!(matches!(
|
||||||
|
response.outcome,
|
||||||
|
RequestPermissionOutcome::Cancelled
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test_case(Permission::AlwaysAllow, PermissionDecision::AllowAlways; "always_allow")]
|
||||||
|
#[test_case(Permission::AllowOnce, PermissionDecision::AllowOnce; "allow_once")]
|
||||||
|
#[test_case(Permission::DenyOnce, PermissionDecision::RejectOnce; "deny_once")]
|
||||||
|
#[test_case(Permission::AlwaysDeny, PermissionDecision::RejectAlways; "always_deny")]
|
||||||
|
#[test_case(Permission::Cancel, PermissionDecision::Cancel; "cancel")]
|
||||||
|
fn test_permission_to_decision(input: Permission, expected: PermissionDecision) {
|
||||||
|
assert_eq!(PermissionDecision::from(input), expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test_case(PermissionDecision::AllowAlways, Permission::AlwaysAllow; "allow_always")]
|
||||||
|
#[test_case(PermissionDecision::AllowOnce, Permission::AllowOnce; "allow_once")]
|
||||||
|
#[test_case(PermissionDecision::RejectOnce, Permission::DenyOnce; "reject_once")]
|
||||||
|
#[test_case(PermissionDecision::RejectAlways, Permission::AlwaysDeny; "reject_always")]
|
||||||
|
#[test_case(PermissionDecision::Cancel, Permission::Cancel; "cancel")]
|
||||||
|
fn test_decision_to_permission(input: PermissionDecision, expected: Permission) {
|
||||||
|
assert_eq!(Permission::from(input), expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test_case("allow_once", PermissionDecision::AllowOnce; "allow_once")]
|
||||||
|
#[test_case("allow_always", PermissionDecision::AllowAlways; "allow_always")]
|
||||||
|
#[test_case("reject_once", PermissionDecision::RejectOnce; "reject_once")]
|
||||||
|
#[test_case("reject_always", PermissionDecision::RejectAlways; "reject_always")]
|
||||||
|
#[test_case("unknown", PermissionDecision::Cancel; "unknown_maps_to_cancel")]
|
||||||
|
fn test_outcome_to_decision(option_id: &str, expected: PermissionDecision) {
|
||||||
|
let outcome = RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new(
|
||||||
|
PermissionOptionId::new(option_id.to_string()),
|
||||||
|
));
|
||||||
|
assert_eq!(PermissionDecision::from(&outcome), expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cancelled_outcome_to_decision() {
|
||||||
|
assert_eq!(
|
||||||
|
PermissionDecision::from(&RequestPermissionOutcome::Cancelled),
|
||||||
|
PermissionDecision::Cancel
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
mod common;
|
||||||
|
mod provider;
|
||||||
|
|
||||||
|
pub use common::{map_permission_response, PermissionDecision, PermissionMapping};
|
||||||
|
pub use provider::{extension_configs_to_mcp_servers, AcpProvider, AcpProviderConfig};
|
||||||
@@ -0,0 +1,962 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
use async_stream::try_stream;
|
||||||
|
use rmcp::model::{Role, Tool};
|
||||||
|
use sacp::schema::{
|
||||||
|
AuthMethod, ContentBlock, ContentChunk, EnvVariable, HttpHeader, ImageContent,
|
||||||
|
InitializeRequest, InitializeResponse, McpCapabilities, McpServer, McpServerHttp,
|
||||||
|
McpServerStdio, NewSessionRequest, NewSessionResponse, PromptRequest, ProtocolVersion,
|
||||||
|
RequestPermissionOutcome, RequestPermissionRequest, RequestPermissionResponse, SessionId,
|
||||||
|
SessionNotification, SessionUpdate, SetSessionModeRequest, StopReason, TextContent,
|
||||||
|
ToolCallContent,
|
||||||
|
};
|
||||||
|
use sacp::{ClientToAgent, JrConnectionCx};
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::Stdio;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use tokio::process::{Child, Command};
|
||||||
|
use tokio::sync::{mpsc, oneshot, Mutex as TokioMutex};
|
||||||
|
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
|
||||||
|
|
||||||
|
use crate::acp::{map_permission_response, PermissionDecision, PermissionMapping};
|
||||||
|
use crate::config::{ExtensionConfig, GooseMode};
|
||||||
|
use crate::conversation::message::{Message, MessageContent};
|
||||||
|
use crate::model::ModelConfig;
|
||||||
|
use crate::permission::permission_confirmation::PrincipalType;
|
||||||
|
use crate::permission::{Permission, PermissionConfirmation};
|
||||||
|
use crate::providers::base::{MessageStream, PermissionRouting, Provider};
|
||||||
|
use crate::providers::errors::ProviderError;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct AcpProviderConfig {
|
||||||
|
pub command: PathBuf,
|
||||||
|
pub args: Vec<String>,
|
||||||
|
pub env: Vec<(String, String)>,
|
||||||
|
pub env_remove: Vec<String>,
|
||||||
|
pub work_dir: PathBuf,
|
||||||
|
pub mcp_servers: Vec<McpServer>,
|
||||||
|
pub session_mode_id: Option<String>,
|
||||||
|
pub permission_mapping: PermissionMapping,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ClientRequest {
|
||||||
|
NewSession {
|
||||||
|
response_tx: oneshot::Sender<Result<NewSessionResponse>>,
|
||||||
|
},
|
||||||
|
SetModel {
|
||||||
|
session_id: SessionId,
|
||||||
|
model_id: String,
|
||||||
|
response_tx: oneshot::Sender<Result<()>>,
|
||||||
|
},
|
||||||
|
Prompt {
|
||||||
|
session_id: SessionId,
|
||||||
|
content: Vec<ContentBlock>,
|
||||||
|
response_tx: mpsc::Sender<AcpUpdate>,
|
||||||
|
},
|
||||||
|
Shutdown,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
enum AcpUpdate {
|
||||||
|
Text(String),
|
||||||
|
Thought(String),
|
||||||
|
ToolCallStart {
|
||||||
|
id: String,
|
||||||
|
},
|
||||||
|
ToolCallComplete {
|
||||||
|
id: String,
|
||||||
|
},
|
||||||
|
PermissionRequest {
|
||||||
|
request: Box<RequestPermissionRequest>,
|
||||||
|
response_tx: oneshot::Sender<RequestPermissionResponse>,
|
||||||
|
},
|
||||||
|
Complete(StopReason),
|
||||||
|
Error(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct AcpProvider {
|
||||||
|
name: String,
|
||||||
|
model: ModelConfig,
|
||||||
|
goose_mode: GooseMode,
|
||||||
|
tx: mpsc::Sender<ClientRequest>,
|
||||||
|
permission_mapping: PermissionMapping,
|
||||||
|
rejected_tool_calls: Arc<TokioMutex<HashSet<String>>>,
|
||||||
|
pending_confirmations:
|
||||||
|
Arc<TokioMutex<HashMap<String, oneshot::Sender<PermissionConfirmation>>>>,
|
||||||
|
goose_to_acp_id: Arc<TokioMutex<HashMap<String, NewSessionResponse>>>,
|
||||||
|
auth_methods: Vec<AuthMethod>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for AcpProvider {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("AcpProvider")
|
||||||
|
.field("name", &self.name)
|
||||||
|
.field("model", &self.model)
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AcpProvider {
|
||||||
|
pub async fn connect(
|
||||||
|
name: String,
|
||||||
|
model: ModelConfig,
|
||||||
|
goose_mode: GooseMode,
|
||||||
|
config: AcpProviderConfig,
|
||||||
|
) -> Result<Self> {
|
||||||
|
let (tx, rx) = mpsc::channel(32);
|
||||||
|
let (init_tx, init_rx) = oneshot::channel();
|
||||||
|
let permission_mapping = config.permission_mapping.clone();
|
||||||
|
let rejected_tool_calls = Arc::new(TokioMutex::new(HashSet::new()));
|
||||||
|
|
||||||
|
tokio::spawn(run_client_loop(config, rx, init_tx));
|
||||||
|
|
||||||
|
let init_response = init_rx
|
||||||
|
.await
|
||||||
|
.context("ACP client initialization cancelled")??;
|
||||||
|
|
||||||
|
Ok(Self::new_with_runtime(
|
||||||
|
name,
|
||||||
|
model,
|
||||||
|
goose_mode,
|
||||||
|
tx,
|
||||||
|
permission_mapping,
|
||||||
|
rejected_tool_calls,
|
||||||
|
init_response.auth_methods,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn connect_with_transport<R, W>(
|
||||||
|
name: String,
|
||||||
|
model: ModelConfig,
|
||||||
|
goose_mode: GooseMode,
|
||||||
|
config: AcpProviderConfig,
|
||||||
|
read: R,
|
||||||
|
write: W,
|
||||||
|
) -> Result<Self>
|
||||||
|
where
|
||||||
|
R: futures::AsyncRead + Unpin + Send + 'static,
|
||||||
|
W: futures::AsyncWrite + Unpin + Send + 'static,
|
||||||
|
{
|
||||||
|
let (tx, mut rx) = mpsc::channel(32);
|
||||||
|
let (init_tx, init_rx) = oneshot::channel();
|
||||||
|
let permission_mapping = config.permission_mapping.clone();
|
||||||
|
let rejected_tool_calls = Arc::new(TokioMutex::new(HashSet::new()));
|
||||||
|
let transport = sacp::ByteStreams::new(write, read);
|
||||||
|
let init_tx = Arc::new(Mutex::new(Some(init_tx)));
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) =
|
||||||
|
run_protocol_loop_with_transport(config, transport, &mut rx, init_tx.clone()).await
|
||||||
|
{
|
||||||
|
tracing::error!("ACP protocol error: {e}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let init_response = init_rx
|
||||||
|
.await
|
||||||
|
.context("ACP client initialization cancelled")??;
|
||||||
|
|
||||||
|
Ok(Self::new_with_runtime(
|
||||||
|
name,
|
||||||
|
model,
|
||||||
|
goose_mode,
|
||||||
|
tx,
|
||||||
|
permission_mapping,
|
||||||
|
rejected_tool_calls,
|
||||||
|
init_response.auth_methods,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new_with_runtime(
|
||||||
|
name: String,
|
||||||
|
model: ModelConfig,
|
||||||
|
goose_mode: GooseMode,
|
||||||
|
tx: mpsc::Sender<ClientRequest>,
|
||||||
|
permission_mapping: PermissionMapping,
|
||||||
|
rejected_tool_calls: Arc<TokioMutex<HashSet<String>>>,
|
||||||
|
auth_methods: Vec<AuthMethod>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
name,
|
||||||
|
model,
|
||||||
|
goose_mode,
|
||||||
|
tx,
|
||||||
|
permission_mapping,
|
||||||
|
rejected_tool_calls,
|
||||||
|
pending_confirmations: Arc::new(TokioMutex::new(HashMap::new())),
|
||||||
|
goose_to_acp_id: Arc::new(TokioMutex::new(HashMap::new())),
|
||||||
|
auth_methods,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn auth_methods(&self) -> &[AuthMethod] {
|
||||||
|
&self.auth_methods
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn new_session(&self) -> Result<NewSessionResponse> {
|
||||||
|
let (response_tx, response_rx) = oneshot::channel();
|
||||||
|
self.tx
|
||||||
|
.send(ClientRequest::NewSession { response_tx })
|
||||||
|
.await
|
||||||
|
.context("ACP client is unavailable")?;
|
||||||
|
response_rx.await.context("ACP session/new cancelled")?
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_model(&self, session_id: &SessionId, model_id: &str) -> Result<()> {
|
||||||
|
let (response_tx, response_rx) = oneshot::channel();
|
||||||
|
self.tx
|
||||||
|
.send(ClientRequest::SetModel {
|
||||||
|
session_id: session_id.clone(),
|
||||||
|
model_id: model_id.to_string(),
|
||||||
|
response_tx,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.context("ACP client is unavailable")?;
|
||||||
|
response_rx
|
||||||
|
.await
|
||||||
|
.context("ACP session/set_model cancelled")?
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_permission_confirmation(
|
||||||
|
&self,
|
||||||
|
request_id: &str,
|
||||||
|
confirmation: &PermissionConfirmation,
|
||||||
|
) -> bool {
|
||||||
|
let mut pending = self.pending_confirmations.lock().await;
|
||||||
|
if let Some(tx) = pending.remove(request_id) {
|
||||||
|
let _ = tx.send(confirmation.clone());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ensure_session(
|
||||||
|
&self,
|
||||||
|
session_id: Option<&str>,
|
||||||
|
) -> Result<NewSessionResponse, ProviderError> {
|
||||||
|
if let Some(session_id) = session_id {
|
||||||
|
if let Some(response) = self.goose_to_acp_id.lock().await.get(session_id) {
|
||||||
|
return Ok(response.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = self.new_session().await.map_err(|e| {
|
||||||
|
ProviderError::RequestFailed(format!("Failed to create ACP session: {e}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if let Some(session_id) = session_id {
|
||||||
|
self.goose_to_acp_id
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.insert(session_id.to_string(), response.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn prompt(
|
||||||
|
&self,
|
||||||
|
session_id: SessionId,
|
||||||
|
content: Vec<ContentBlock>,
|
||||||
|
) -> Result<mpsc::Receiver<AcpUpdate>> {
|
||||||
|
let (response_tx, response_rx) = mpsc::channel(64);
|
||||||
|
self.tx
|
||||||
|
.send(ClientRequest::Prompt {
|
||||||
|
session_id,
|
||||||
|
content,
|
||||||
|
response_tx,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.context("ACP client is unavailable")?;
|
||||||
|
Ok(response_rx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl Provider for AcpProvider {
|
||||||
|
fn get_name(&self) -> &str {
|
||||||
|
&self.name
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_model_config(&self) -> ModelConfig {
|
||||||
|
self.model.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn permission_routing(&self) -> PermissionRouting {
|
||||||
|
PermissionRouting::ActionRequired
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_permission_confirmation(
|
||||||
|
&self,
|
||||||
|
request_id: &str,
|
||||||
|
confirmation: &PermissionConfirmation,
|
||||||
|
) -> bool {
|
||||||
|
AcpProvider::handle_permission_confirmation(self, request_id, confirmation).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stream(
|
||||||
|
&self,
|
||||||
|
_model_config: &ModelConfig,
|
||||||
|
session_id: &str,
|
||||||
|
_system: &str,
|
||||||
|
messages: &[Message],
|
||||||
|
_tools: &[Tool],
|
||||||
|
) -> Result<MessageStream, ProviderError> {
|
||||||
|
let response = self.ensure_session(Some(session_id)).await?;
|
||||||
|
let prompt_blocks = messages_to_prompt(messages);
|
||||||
|
let mut rx = self
|
||||||
|
.prompt(response.session_id, prompt_blocks)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ProviderError::RequestFailed(format!("Failed to send ACP prompt: {e}")))?;
|
||||||
|
|
||||||
|
let pending_confirmations = self.pending_confirmations.clone();
|
||||||
|
let rejected_tool_calls = self.rejected_tool_calls.clone();
|
||||||
|
let permission_mapping = self.permission_mapping.clone();
|
||||||
|
let goose_mode = self.goose_mode;
|
||||||
|
|
||||||
|
let reject_all_tools = goose_mode == GooseMode::Chat;
|
||||||
|
|
||||||
|
Ok(Box::pin(try_stream! {
|
||||||
|
// ACP agents execute tools internally. Goose never dispatches tool calls;
|
||||||
|
// it only sees text, thoughts, and permission requests from the agent.
|
||||||
|
//
|
||||||
|
// In Chat mode (reject_all_tools), we suppress all text after a tool
|
||||||
|
// starts because the agent may send tool results as AcpUpdate::Text,
|
||||||
|
// bypassing the permission response.
|
||||||
|
let mut suppress_text = false;
|
||||||
|
|
||||||
|
while let Some(update) = rx.recv().await {
|
||||||
|
match update {
|
||||||
|
AcpUpdate::Text(text) => {
|
||||||
|
if !suppress_text {
|
||||||
|
let message = Message::assistant().with_text(text);
|
||||||
|
yield (Some(message), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AcpUpdate::Thought(text) => {
|
||||||
|
let message = Message::assistant()
|
||||||
|
.with_thinking(text, "")
|
||||||
|
.with_visibility(true, false);
|
||||||
|
yield (Some(message), None);
|
||||||
|
}
|
||||||
|
AcpUpdate::ToolCallStart { id, .. } => {
|
||||||
|
if reject_all_tools {
|
||||||
|
suppress_text = true;
|
||||||
|
rejected_tool_calls.lock().await.insert(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AcpUpdate::ToolCallComplete { id, .. } => {
|
||||||
|
let is_error = rejected_tool_calls.lock().await.remove(&id);
|
||||||
|
if is_error {
|
||||||
|
let message = Message::assistant().with_text("Tool call was denied.");
|
||||||
|
yield (Some(message), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AcpUpdate::PermissionRequest { request, response_tx } => {
|
||||||
|
if let Some(decision) = permission_decision_from_mode(goose_mode) {
|
||||||
|
if decision.should_record_rejection() {
|
||||||
|
rejected_tool_calls.lock().await.insert(request.tool_call.tool_call_id.0.to_string());
|
||||||
|
}
|
||||||
|
let response = map_permission_response(&permission_mapping, &request, decision);
|
||||||
|
let _ = response_tx.send(response);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let request_id = request.tool_call.tool_call_id.0.to_string();
|
||||||
|
let (tx, rx) = oneshot::channel();
|
||||||
|
|
||||||
|
pending_confirmations
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.insert(request_id.clone(), tx);
|
||||||
|
|
||||||
|
if let Some(action_required) = build_action_required_message(&request) {
|
||||||
|
yield (Some(action_required), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let confirmation = rx.await.unwrap_or(PermissionConfirmation {
|
||||||
|
principal_type: PrincipalType::Tool,
|
||||||
|
permission: Permission::Cancel,
|
||||||
|
});
|
||||||
|
|
||||||
|
pending_confirmations.lock().await.remove(&request_id);
|
||||||
|
|
||||||
|
let decision = PermissionDecision::from(confirmation.permission);
|
||||||
|
if decision.should_record_rejection() {
|
||||||
|
rejected_tool_calls.lock().await.insert(request.tool_call.tool_call_id.0.to_string());
|
||||||
|
}
|
||||||
|
let response = map_permission_response(&permission_mapping, &request, decision);
|
||||||
|
let _ = response_tx.send(response);
|
||||||
|
}
|
||||||
|
AcpUpdate::Complete(_reason) => {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
AcpUpdate::Error(e) => {
|
||||||
|
Err(ProviderError::RequestFailed(e))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_supported_models(&self) -> Result<Vec<String>, ProviderError> {
|
||||||
|
let response = self.ensure_session(None).await?;
|
||||||
|
Ok(response
|
||||||
|
.models
|
||||||
|
.map(|state| {
|
||||||
|
state
|
||||||
|
.available_models
|
||||||
|
.iter()
|
||||||
|
.map(|m| m.model_id.0.to_string())
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for AcpProvider {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let tx = self.tx.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = tx.send(ClientRequest::Shutdown).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_client_loop(
|
||||||
|
config: AcpProviderConfig,
|
||||||
|
mut rx: mpsc::Receiver<ClientRequest>,
|
||||||
|
init_tx: oneshot::Sender<Result<InitializeResponse>>,
|
||||||
|
) {
|
||||||
|
let init_tx = Arc::new(Mutex::new(Some(init_tx)));
|
||||||
|
|
||||||
|
let child = match spawn_acp_process(&config).await {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
let message = e.to_string();
|
||||||
|
send_init_result(&init_tx, Err(anyhow::anyhow!(message.clone())));
|
||||||
|
tracing::error!("failed to spawn ACP process: {message}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match run_protocol_loop_with_child(config, child, &mut rx, init_tx.clone()).await {
|
||||||
|
Ok(()) => tracing::debug!("ACP protocol loop exited cleanly"),
|
||||||
|
Err(e) => {
|
||||||
|
let message = e.to_string();
|
||||||
|
tracing::error!(error = %e, "ACP protocol loop error");
|
||||||
|
send_init_result(&init_tx, Err(anyhow::anyhow!(message)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn spawn_acp_process(config: &AcpProviderConfig) -> Result<Child> {
|
||||||
|
let mut cmd = Command::new(&config.command);
|
||||||
|
cmd.args(&config.args)
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::inherit())
|
||||||
|
.kill_on_drop(true);
|
||||||
|
|
||||||
|
for key in &config.env_remove {
|
||||||
|
cmd.env_remove(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (key, value) in &config.env {
|
||||||
|
cmd.env(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.spawn().context("failed to spawn ACP process")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_protocol_loop_with_child(
|
||||||
|
config: AcpProviderConfig,
|
||||||
|
mut child: Child,
|
||||||
|
rx: &mut mpsc::Receiver<ClientRequest>,
|
||||||
|
init_tx: Arc<Mutex<Option<oneshot::Sender<Result<InitializeResponse>>>>>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let stdin = child.stdin.take().context("no stdin")?;
|
||||||
|
let stdout = child.stdout.take().context("no stdout")?;
|
||||||
|
let transport = sacp::ByteStreams::new(stdin.compat_write(), stdout.compat());
|
||||||
|
run_protocol_loop_with_transport(config, transport, rx, init_tx).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_protocol_loop_with_transport<R, W>(
|
||||||
|
config: AcpProviderConfig,
|
||||||
|
transport: sacp::ByteStreams<W, R>,
|
||||||
|
rx: &mut mpsc::Receiver<ClientRequest>,
|
||||||
|
init_tx: Arc<Mutex<Option<oneshot::Sender<Result<InitializeResponse>>>>>,
|
||||||
|
) -> Result<()>
|
||||||
|
where
|
||||||
|
R: futures::AsyncRead + Unpin + Send + 'static,
|
||||||
|
W: futures::AsyncWrite + Unpin + Send + 'static,
|
||||||
|
{
|
||||||
|
let prompt_response_tx: Arc<Mutex<Option<mpsc::Sender<AcpUpdate>>>> =
|
||||||
|
Arc::new(Mutex::new(None));
|
||||||
|
|
||||||
|
ClientToAgent::builder()
|
||||||
|
.on_receive_notification(
|
||||||
|
{
|
||||||
|
let prompt_response_tx = prompt_response_tx.clone();
|
||||||
|
async move |notification: SessionNotification, _cx| {
|
||||||
|
if let Some(tx) = prompt_response_tx.lock().unwrap().as_ref() {
|
||||||
|
match notification.update {
|
||||||
|
SessionUpdate::AgentMessageChunk(ContentChunk {
|
||||||
|
content: ContentBlock::Text(TextContent { text, .. }),
|
||||||
|
..
|
||||||
|
}) => {
|
||||||
|
let _ = tx.try_send(AcpUpdate::Text(text));
|
||||||
|
}
|
||||||
|
SessionUpdate::AgentThoughtChunk(ContentChunk {
|
||||||
|
content: ContentBlock::Text(TextContent { text, .. }),
|
||||||
|
..
|
||||||
|
}) => {
|
||||||
|
let _ = tx.try_send(AcpUpdate::Thought(text));
|
||||||
|
}
|
||||||
|
SessionUpdate::ToolCall(tool_call) => {
|
||||||
|
let _ = tx.try_send(AcpUpdate::ToolCallStart {
|
||||||
|
id: tool_call.tool_call_id.0.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
SessionUpdate::ToolCallUpdate(update) => {
|
||||||
|
if update.fields.status.is_some() {
|
||||||
|
let _ = tx.try_send(AcpUpdate::ToolCallComplete {
|
||||||
|
id: update.tool_call_id.0.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
sacp::on_receive_notification!(),
|
||||||
|
)
|
||||||
|
.on_receive_request(
|
||||||
|
{
|
||||||
|
let prompt_response_tx = prompt_response_tx.clone();
|
||||||
|
async move |request: RequestPermissionRequest, request_cx, _connection_cx| {
|
||||||
|
let (response_tx, response_rx) = oneshot::channel();
|
||||||
|
|
||||||
|
let handler = prompt_response_tx.lock().unwrap().as_ref().cloned();
|
||||||
|
let tx = handler.ok_or_else(sacp::Error::internal_error)?;
|
||||||
|
|
||||||
|
if tx.is_closed() {
|
||||||
|
return Err(sacp::Error::internal_error());
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.try_send(AcpUpdate::PermissionRequest {
|
||||||
|
request: Box::new(request),
|
||||||
|
response_tx,
|
||||||
|
})
|
||||||
|
.map_err(|_| sacp::Error::internal_error())?;
|
||||||
|
|
||||||
|
let response = response_rx.await.unwrap_or_else(|_| {
|
||||||
|
RequestPermissionResponse::new(RequestPermissionOutcome::Cancelled)
|
||||||
|
});
|
||||||
|
request_cx.respond(response)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
sacp::on_receive_request!(),
|
||||||
|
)
|
||||||
|
.connect_to(transport)?
|
||||||
|
.run_until({
|
||||||
|
let prompt_response_tx = prompt_response_tx.clone();
|
||||||
|
move |cx: JrConnectionCx<ClientToAgent>| {
|
||||||
|
handle_requests(config, cx, rx, prompt_response_tx, init_tx.clone())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_requests(
|
||||||
|
config: AcpProviderConfig,
|
||||||
|
cx: JrConnectionCx<ClientToAgent>,
|
||||||
|
rx: &mut mpsc::Receiver<ClientRequest>,
|
||||||
|
prompt_response_tx: Arc<Mutex<Option<mpsc::Sender<AcpUpdate>>>>,
|
||||||
|
init_tx: Arc<Mutex<Option<oneshot::Sender<Result<InitializeResponse>>>>>,
|
||||||
|
) -> Result<(), sacp::Error> {
|
||||||
|
let init_response = cx
|
||||||
|
.send_request(InitializeRequest::new(ProtocolVersion::LATEST))
|
||||||
|
.block_task()
|
||||||
|
.await
|
||||||
|
.map_err(|err| {
|
||||||
|
let message = format!("ACP initialize failed: {err}");
|
||||||
|
send_init_result(&init_tx, Err(anyhow::anyhow!(message.clone())));
|
||||||
|
sacp::Error::internal_error().data(message)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mcp_capabilities = init_response.agent_capabilities.mcp_capabilities.clone();
|
||||||
|
send_init_result(&init_tx, Ok(init_response));
|
||||||
|
|
||||||
|
while let Some(request) = rx.recv().await {
|
||||||
|
match request {
|
||||||
|
ClientRequest::NewSession { response_tx } => {
|
||||||
|
handle_new_session_request(&config, &cx, &mcp_capabilities, response_tx).await;
|
||||||
|
}
|
||||||
|
ClientRequest::SetModel {
|
||||||
|
session_id,
|
||||||
|
model_id,
|
||||||
|
response_tx,
|
||||||
|
} => {
|
||||||
|
// sacp doesn't support session/set_model as a typed request yet
|
||||||
|
let msg = sacp::UntypedMessage::new(
|
||||||
|
"session/set_model",
|
||||||
|
serde_json::json!({
|
||||||
|
"sessionId": session_id.0,
|
||||||
|
"modelId": model_id
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let result = cx
|
||||||
|
.send_request(msg)
|
||||||
|
.block_task()
|
||||||
|
.await
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|e| anyhow::anyhow!("ACP session/set_model failed: {e}"));
|
||||||
|
let _ = response_tx.send(result);
|
||||||
|
}
|
||||||
|
ClientRequest::Prompt {
|
||||||
|
session_id,
|
||||||
|
content,
|
||||||
|
response_tx,
|
||||||
|
} => {
|
||||||
|
*prompt_response_tx.lock().unwrap() = Some(response_tx.clone());
|
||||||
|
|
||||||
|
let response = cx
|
||||||
|
.send_request(PromptRequest::new(session_id, content))
|
||||||
|
.block_task()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match response {
|
||||||
|
Ok(r) => {
|
||||||
|
let _ = response_tx.try_send(AcpUpdate::Complete(r.stop_reason));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let _ = response_tx.try_send(AcpUpdate::Error(e.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*prompt_response_tx.lock().unwrap() = None;
|
||||||
|
}
|
||||||
|
ClientRequest::Shutdown => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_new_session_request(
|
||||||
|
config: &AcpProviderConfig,
|
||||||
|
cx: &JrConnectionCx<ClientToAgent>,
|
||||||
|
mcp_capabilities: &McpCapabilities,
|
||||||
|
response_tx: oneshot::Sender<Result<NewSessionResponse>>,
|
||||||
|
) {
|
||||||
|
let mcp_servers = filter_supported_servers(&config.mcp_servers, mcp_capabilities);
|
||||||
|
let session = cx
|
||||||
|
.send_request(NewSessionRequest::new(config.work_dir.clone()).mcp_servers(mcp_servers))
|
||||||
|
.block_task()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let result = match session {
|
||||||
|
Ok(session) => apply_session_mode(config, cx, session).await,
|
||||||
|
Err(err) => Err(anyhow::anyhow!("ACP session/new failed: {err}")),
|
||||||
|
};
|
||||||
|
|
||||||
|
let _ = response_tx.send(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn apply_session_mode(
|
||||||
|
config: &AcpProviderConfig,
|
||||||
|
cx: &JrConnectionCx<ClientToAgent>,
|
||||||
|
session: NewSessionResponse,
|
||||||
|
) -> Result<NewSessionResponse> {
|
||||||
|
if let (Some(mode_id), Some(modes)) = (config.session_mode_id.clone(), session.modes.as_ref()) {
|
||||||
|
if modes.current_mode_id.0.as_ref() != mode_id.as_str() {
|
||||||
|
let available: Vec<String> = modes
|
||||||
|
.available_modes
|
||||||
|
.iter()
|
||||||
|
.map(|mode| mode.id.0.to_string())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if !available.iter().any(|id| id == &mode_id) {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Requested mode '{}' not offered by agent. Available modes: {}",
|
||||||
|
mode_id,
|
||||||
|
available.join(", ")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
cx.send_request(SetSessionModeRequest::new(
|
||||||
|
session.session_id.clone(),
|
||||||
|
mode_id,
|
||||||
|
))
|
||||||
|
.block_task()
|
||||||
|
.await
|
||||||
|
.map_err(|err| anyhow::anyhow!("ACP agent rejected session/set_mode: {err}"))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn extension_configs_to_mcp_servers(configs: &[ExtensionConfig]) -> Vec<McpServer> {
|
||||||
|
let mut servers = Vec::new();
|
||||||
|
|
||||||
|
for config in configs {
|
||||||
|
match config {
|
||||||
|
ExtensionConfig::StreamableHttp {
|
||||||
|
name, uri, headers, ..
|
||||||
|
} => {
|
||||||
|
let http_headers = headers
|
||||||
|
.iter()
|
||||||
|
.map(|(key, value)| HttpHeader::new(key, value))
|
||||||
|
.collect();
|
||||||
|
servers.push(McpServer::Http(
|
||||||
|
McpServerHttp::new(name, uri).headers(http_headers),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
ExtensionConfig::Stdio {
|
||||||
|
name,
|
||||||
|
cmd,
|
||||||
|
args,
|
||||||
|
envs,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
let env_vars = envs
|
||||||
|
.get_env()
|
||||||
|
.into_iter()
|
||||||
|
.map(|(key, value)| EnvVariable::new(key, value))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
servers.push(McpServer::Stdio(
|
||||||
|
McpServerStdio::new(name, cmd)
|
||||||
|
.args(args.clone())
|
||||||
|
.env(env_vars),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
ExtensionConfig::Sse { name, .. } => {
|
||||||
|
tracing::debug!(name, "skipping SSE extension, migrate to streamable_http");
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
servers
|
||||||
|
}
|
||||||
|
|
||||||
|
fn filter_supported_servers(
|
||||||
|
servers: &[McpServer],
|
||||||
|
capabilities: &McpCapabilities,
|
||||||
|
) -> Vec<McpServer> {
|
||||||
|
servers
|
||||||
|
.iter()
|
||||||
|
.filter(|server| match server {
|
||||||
|
McpServer::Http(http) => {
|
||||||
|
if !capabilities.http {
|
||||||
|
tracing::debug!(
|
||||||
|
name = http.name,
|
||||||
|
"skipping HTTP server, agent lacks capability"
|
||||||
|
);
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
McpServer::Sse(sse) => {
|
||||||
|
tracing::debug!(name = sse.name, "skipping SSE server, unsupported");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
_ => true,
|
||||||
|
})
|
||||||
|
.cloned()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_init_result(
|
||||||
|
init_tx: &Arc<Mutex<Option<oneshot::Sender<Result<InitializeResponse>>>>>,
|
||||||
|
result: Result<InitializeResponse>,
|
||||||
|
) {
|
||||||
|
if let Some(tx) = init_tx.lock().unwrap().take() {
|
||||||
|
let _ = tx.send(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn messages_to_prompt(messages: &[Message]) -> Vec<ContentBlock> {
|
||||||
|
let mut content_blocks = Vec::new();
|
||||||
|
|
||||||
|
let last_user = messages
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.find(|m| m.role == Role::User && m.is_agent_visible());
|
||||||
|
|
||||||
|
if let Some(message) = last_user {
|
||||||
|
for content in &message.content {
|
||||||
|
match content {
|
||||||
|
MessageContent::Text(text) => {
|
||||||
|
content_blocks.push(ContentBlock::Text(TextContent::new(text.text.clone())));
|
||||||
|
}
|
||||||
|
MessageContent::Image(image) => {
|
||||||
|
content_blocks.push(ContentBlock::Image(ImageContent::new(
|
||||||
|
&image.data,
|
||||||
|
&image.mime_type,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
content_blocks
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_action_required_message(request: &RequestPermissionRequest) -> Option<Message> {
|
||||||
|
let tool_title = request
|
||||||
|
.tool_call
|
||||||
|
.fields
|
||||||
|
.title
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "Tool".to_string());
|
||||||
|
|
||||||
|
let arguments = request
|
||||||
|
.tool_call
|
||||||
|
.fields
|
||||||
|
.raw_input
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|v| v.as_object().cloned())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let prompt = request
|
||||||
|
.tool_call
|
||||||
|
.fields
|
||||||
|
.content
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|content| {
|
||||||
|
content.iter().find_map(|c| match c {
|
||||||
|
ToolCallContent::Content(val) => match &val.content {
|
||||||
|
ContentBlock::Text(text) => Some(text.text.clone()),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
Some(
|
||||||
|
Message::assistant()
|
||||||
|
.with_action_required(
|
||||||
|
request.tool_call.tool_call_id.0.to_string(),
|
||||||
|
tool_title,
|
||||||
|
arguments,
|
||||||
|
prompt,
|
||||||
|
)
|
||||||
|
.user_only(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn permission_decision_from_mode(goose_mode: GooseMode) -> Option<PermissionDecision> {
|
||||||
|
match goose_mode {
|
||||||
|
GooseMode::Auto => Some(PermissionDecision::AllowOnce),
|
||||||
|
GooseMode::Chat => Some(PermissionDecision::RejectOnce),
|
||||||
|
GooseMode::Approve | GooseMode::SmartApprove => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::agents::extension::Envs;
|
||||||
|
use test_case::test_case;
|
||||||
|
|
||||||
|
#[test_case(
|
||||||
|
ExtensionConfig::Stdio {
|
||||||
|
name: "github".into(),
|
||||||
|
description: String::new(),
|
||||||
|
cmd: "/path/to/github-mcp-server".into(),
|
||||||
|
args: vec!["stdio".into()],
|
||||||
|
envs: Envs::new([("GITHUB_PERSONAL_ACCESS_TOKEN".into(), "ghp_xxxxxxxxxxxx".into())].into()),
|
||||||
|
env_keys: vec![],
|
||||||
|
timeout: None,
|
||||||
|
bundled: Some(false),
|
||||||
|
available_tools: vec![],
|
||||||
|
},
|
||||||
|
vec![
|
||||||
|
McpServer::Stdio(
|
||||||
|
McpServerStdio::new("github", "/path/to/github-mcp-server")
|
||||||
|
.args(vec!["stdio".into()])
|
||||||
|
.env(vec![EnvVariable::new("GITHUB_PERSONAL_ACCESS_TOKEN", "ghp_xxxxxxxxxxxx")])
|
||||||
|
)
|
||||||
|
]
|
||||||
|
; "stdio_converts_to_mcpserver_stdio"
|
||||||
|
)]
|
||||||
|
#[test_case(
|
||||||
|
ExtensionConfig::StreamableHttp {
|
||||||
|
name: "github".into(),
|
||||||
|
description: String::new(),
|
||||||
|
uri: "https://api.githubcopilot.com/mcp/".into(),
|
||||||
|
envs: Envs::default(),
|
||||||
|
env_keys: vec![],
|
||||||
|
headers: HashMap::from([("Authorization".into(), "Bearer ghp_xxxxxxxxxxxx".into())]),
|
||||||
|
timeout: None,
|
||||||
|
bundled: Some(false),
|
||||||
|
available_tools: vec![],
|
||||||
|
},
|
||||||
|
vec![
|
||||||
|
McpServer::Http(
|
||||||
|
McpServerHttp::new("github", "https://api.githubcopilot.com/mcp/")
|
||||||
|
.headers(vec![HttpHeader::new("Authorization", "Bearer ghp_xxxxxxxxxxxx")])
|
||||||
|
)
|
||||||
|
]
|
||||||
|
; "streamable_http_converts_to_mcpserver_http_when_capable"
|
||||||
|
)]
|
||||||
|
fn test_extension_configs_to_mcp_servers(config: ExtensionConfig, expected: Vec<McpServer>) {
|
||||||
|
let result = extension_configs_to_mcp_servers(&[config]);
|
||||||
|
assert_eq!(result.len(), expected.len(), "server count mismatch");
|
||||||
|
for (a, e) in result.iter().zip(expected.iter()) {
|
||||||
|
match (a, e) {
|
||||||
|
(McpServer::Stdio(actual), McpServer::Stdio(expected)) => {
|
||||||
|
assert_eq!(actual.name, expected.name);
|
||||||
|
assert_eq!(actual.command, expected.command);
|
||||||
|
assert_eq!(actual.args, expected.args);
|
||||||
|
assert_eq!(actual.env.len(), expected.env.len());
|
||||||
|
}
|
||||||
|
(McpServer::Http(actual), McpServer::Http(expected)) => {
|
||||||
|
assert_eq!(actual.name, expected.name);
|
||||||
|
assert_eq!(actual.url, expected.url);
|
||||||
|
assert_eq!(actual.headers.len(), expected.headers.len());
|
||||||
|
}
|
||||||
|
_ => panic!("server type mismatch"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sse_skips() {
|
||||||
|
let config = ExtensionConfig::Sse {
|
||||||
|
name: "test-sse".into(),
|
||||||
|
description: String::new(),
|
||||||
|
uri: Some("https://example.com/sse".into()),
|
||||||
|
};
|
||||||
|
let result = extension_configs_to_mcp_servers(&[config]);
|
||||||
|
assert!(result.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_filter_supported_servers_skips_http_without_capability() {
|
||||||
|
let config = ExtensionConfig::StreamableHttp {
|
||||||
|
name: "github".into(),
|
||||||
|
description: String::new(),
|
||||||
|
uri: "https://api.githubcopilot.com/mcp/".into(),
|
||||||
|
envs: Envs::default(),
|
||||||
|
env_keys: vec![],
|
||||||
|
headers: HashMap::from([("Authorization".into(), "Bearer ghp_xxxxxxxxxxxx".into())]),
|
||||||
|
timeout: None,
|
||||||
|
bundled: Some(false),
|
||||||
|
available_tools: vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
let servers = extension_configs_to_mcp_servers(&[config]);
|
||||||
|
let filtered = filter_supported_servers(&servers, &McpCapabilities::default());
|
||||||
|
assert!(filtered.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
pub mod acp;
|
||||||
pub mod action_required_manager;
|
pub mod action_required_manager;
|
||||||
pub mod agents;
|
pub mod agents;
|
||||||
pub mod builtin_extension;
|
pub mod builtin_extension;
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use futures::future::BoxFuture;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use crate::acp::{
|
||||||
|
extension_configs_to_mcp_servers, AcpProvider, AcpProviderConfig, PermissionMapping,
|
||||||
|
};
|
||||||
|
use crate::config::search_path::SearchPaths;
|
||||||
|
use crate::config::{Config, GooseMode};
|
||||||
|
use crate::model::ModelConfig;
|
||||||
|
use crate::providers::base::{ProviderDef, ProviderMetadata};
|
||||||
|
|
||||||
|
const CLAUDE_ACP_PROVIDER_NAME: &str = "claude-acp";
|
||||||
|
pub const CLAUDE_ACP_DEFAULT_MODEL: &str = "default";
|
||||||
|
const CLAUDE_ACP_DOC_URL: &str = "https://github.com/zed-industries/claude-agent-acp";
|
||||||
|
const CLAUDE_ACP_BINARY: &str = "claude-agent-acp";
|
||||||
|
|
||||||
|
pub struct ClaudeAcpProvider;
|
||||||
|
|
||||||
|
impl ProviderDef for ClaudeAcpProvider {
|
||||||
|
type Provider = AcpProvider;
|
||||||
|
|
||||||
|
fn metadata() -> ProviderMetadata {
|
||||||
|
ProviderMetadata::new(
|
||||||
|
CLAUDE_ACP_PROVIDER_NAME,
|
||||||
|
"Claude Code",
|
||||||
|
"ACP wrapper for Anthropic's Claude. Install: npm install -g @zed-industries/claude-agent-acp",
|
||||||
|
CLAUDE_ACP_DEFAULT_MODEL,
|
||||||
|
vec![],
|
||||||
|
CLAUDE_ACP_DOC_URL,
|
||||||
|
vec![],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_env(
|
||||||
|
model: ModelConfig,
|
||||||
|
extensions: Vec<crate::config::ExtensionConfig>,
|
||||||
|
) -> BoxFuture<'static, Result<AcpProvider>> {
|
||||||
|
Box::pin(async move {
|
||||||
|
let config = Config::global();
|
||||||
|
// with_npm() includes npm global bin dir (desktop app PATH may not)
|
||||||
|
let resolved_command = SearchPaths::builder()
|
||||||
|
.with_npm()
|
||||||
|
.resolve(CLAUDE_ACP_BINARY)?;
|
||||||
|
let goose_mode = config.get_goose_mode().unwrap_or(GooseMode::Auto);
|
||||||
|
|
||||||
|
// claude-agent-acp permission option_ids
|
||||||
|
let permission_mapping = PermissionMapping {
|
||||||
|
allow_option_id: Some("allow".to_string()),
|
||||||
|
reject_option_id: Some("reject".to_string()),
|
||||||
|
rejected_tool_status: sacp::schema::ToolCallStatus::Failed,
|
||||||
|
};
|
||||||
|
|
||||||
|
let provider_config = AcpProviderConfig {
|
||||||
|
command: resolved_command,
|
||||||
|
args: vec![],
|
||||||
|
env: vec![],
|
||||||
|
// Prevent nested-session detection in claude-agent-acp (wraps Claude Code)
|
||||||
|
env_remove: vec!["CLAUDECODE".to_string()],
|
||||||
|
work_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
|
||||||
|
mcp_servers: extension_configs_to_mcp_servers(&extensions),
|
||||||
|
session_mode_id: Some(map_goose_mode(goose_mode)),
|
||||||
|
permission_mapping,
|
||||||
|
};
|
||||||
|
|
||||||
|
let metadata = Self::metadata();
|
||||||
|
AcpProvider::connect(metadata.name, model, goose_mode, provider_config).await
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_goose_mode(goose_mode: GooseMode) -> String {
|
||||||
|
match goose_mode {
|
||||||
|
GooseMode::Auto => {
|
||||||
|
// Closest to "autonomous": Claude Code's bypassPermissions skips confirmations.
|
||||||
|
"bypassPermissions".to_string()
|
||||||
|
}
|
||||||
|
GooseMode::Approve => {
|
||||||
|
// Claude Code's default matches "ask before risky actions".
|
||||||
|
"default".to_string()
|
||||||
|
}
|
||||||
|
GooseMode::SmartApprove => {
|
||||||
|
// Best-effort: acceptEdits auto-accepts file edits but still prompts for risky ops.
|
||||||
|
"acceptEdits".to_string()
|
||||||
|
}
|
||||||
|
GooseMode::Chat => {
|
||||||
|
// Plan mode disables tool execution, aligning with chat-only intent.
|
||||||
|
"plan".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use futures::future::BoxFuture;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use crate::acp::{
|
||||||
|
extension_configs_to_mcp_servers, AcpProvider, AcpProviderConfig, PermissionMapping,
|
||||||
|
};
|
||||||
|
use crate::config::search_path::SearchPaths;
|
||||||
|
use crate::config::{Config, GooseMode};
|
||||||
|
use crate::model::ModelConfig;
|
||||||
|
use crate::providers::base::{ProviderDef, ProviderMetadata};
|
||||||
|
|
||||||
|
const CODEX_ACP_PROVIDER_NAME: &str = "codex-acp";
|
||||||
|
pub const CODEX_ACP_DEFAULT_MODEL: &str = "gpt-5.2-codex";
|
||||||
|
const CODEX_ACP_DOC_URL: &str = "https://github.com/zed-industries/codex-acp";
|
||||||
|
|
||||||
|
pub struct CodexAcpProvider;
|
||||||
|
|
||||||
|
impl ProviderDef for CodexAcpProvider {
|
||||||
|
type Provider = AcpProvider;
|
||||||
|
|
||||||
|
fn metadata() -> ProviderMetadata {
|
||||||
|
ProviderMetadata::new(
|
||||||
|
CODEX_ACP_PROVIDER_NAME,
|
||||||
|
"Codex CLI",
|
||||||
|
"ACP adapter for OpenAI's coding assistant. Install: npm install -g @zed-industries/codex-acp",
|
||||||
|
CODEX_ACP_DEFAULT_MODEL,
|
||||||
|
vec![],
|
||||||
|
CODEX_ACP_DOC_URL,
|
||||||
|
vec![],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_env(
|
||||||
|
model: ModelConfig,
|
||||||
|
extensions: Vec<crate::config::ExtensionConfig>,
|
||||||
|
) -> BoxFuture<'static, Result<AcpProvider>> {
|
||||||
|
Box::pin(async move {
|
||||||
|
let config = Config::global();
|
||||||
|
// with_npm() includes npm global bin dir (desktop app PATH may not)
|
||||||
|
let resolved_command = SearchPaths::builder()
|
||||||
|
.with_npm()
|
||||||
|
.resolve(CODEX_ACP_PROVIDER_NAME)?;
|
||||||
|
let work_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
||||||
|
let env = vec![];
|
||||||
|
let goose_mode = config.get_goose_mode().unwrap_or(GooseMode::Auto);
|
||||||
|
let mcp_servers = extension_configs_to_mcp_servers(&extensions);
|
||||||
|
|
||||||
|
// fixed goose mode via -c overrides until session/set-mode works
|
||||||
|
let (approval_policy, sandbox_mode) = map_goose_mode(goose_mode);
|
||||||
|
let mut args = vec![
|
||||||
|
"-c".to_string(),
|
||||||
|
format!("approval_policy={approval_policy}"),
|
||||||
|
"-c".to_string(),
|
||||||
|
format!("sandbox_mode={sandbox_mode}"),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Codex sandbox blocks network by default. Enable it when HTTP MCP
|
||||||
|
// servers are configured so codex-acp can connect to them.
|
||||||
|
let has_http_mcp = mcp_servers
|
||||||
|
.iter()
|
||||||
|
.any(|s| matches!(s, sacp::schema::McpServer::Http(_)));
|
||||||
|
if has_http_mcp {
|
||||||
|
args.extend([
|
||||||
|
"-c".to_string(),
|
||||||
|
"sandbox_workspace_write.network_access=true".to_string(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// codex-acp permission option_ids
|
||||||
|
let permission_mapping = PermissionMapping {
|
||||||
|
allow_option_id: Some("approved".to_string()),
|
||||||
|
reject_option_id: Some("abort".to_string()),
|
||||||
|
rejected_tool_status: sacp::schema::ToolCallStatus::Failed,
|
||||||
|
};
|
||||||
|
|
||||||
|
let provider_config = AcpProviderConfig {
|
||||||
|
command: resolved_command,
|
||||||
|
args,
|
||||||
|
env,
|
||||||
|
env_remove: vec![],
|
||||||
|
work_dir,
|
||||||
|
mcp_servers,
|
||||||
|
// Disabled until https://github.com/zed-industries/codex-acp/issues/179 is fixed.
|
||||||
|
session_mode_id: None,
|
||||||
|
permission_mapping,
|
||||||
|
};
|
||||||
|
|
||||||
|
let metadata = Self::metadata();
|
||||||
|
AcpProvider::connect(metadata.name, model, goose_mode, provider_config).await
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Codex sandbox scope determines what needs approval: operations within the
|
||||||
|
// sandbox are auto-approved, operations outside it trigger on-request prompts.
|
||||||
|
// So Approve uses read-only sandbox to force write approvals through goose.
|
||||||
|
fn map_goose_mode(goose_mode: GooseMode) -> (&'static str, &'static str) {
|
||||||
|
match goose_mode {
|
||||||
|
GooseMode::Auto => ("never", "danger-full-access"),
|
||||||
|
GooseMode::SmartApprove => ("on-request", "workspace-write"),
|
||||||
|
GooseMode::Approve => ("on-request", "read-only"),
|
||||||
|
GooseMode::Chat => ("never", "read-only"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use test_case::test_case;
|
||||||
|
|
||||||
|
#[test_case(GooseMode::Auto, "never", "danger-full-access")]
|
||||||
|
#[test_case(GooseMode::SmartApprove, "on-request", "workspace-write")]
|
||||||
|
#[test_case(GooseMode::Approve, "on-request", "read-only")]
|
||||||
|
#[test_case(GooseMode::Chat, "never", "read-only")]
|
||||||
|
fn test_map_goose_mode(mode: GooseMode, expected_approval: &str, expected_sandbox: &str) {
|
||||||
|
let (approval, sandbox) = map_goose_mode(mode);
|
||||||
|
assert_eq!(approval, expected_approval);
|
||||||
|
assert_eq!(sandbox, expected_sandbox);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,8 +7,10 @@ use super::{
|
|||||||
base::{Provider, ProviderMetadata},
|
base::{Provider, ProviderMetadata},
|
||||||
bedrock::BedrockProvider,
|
bedrock::BedrockProvider,
|
||||||
chatgpt_codex::ChatGptCodexProvider,
|
chatgpt_codex::ChatGptCodexProvider,
|
||||||
|
claude_acp::ClaudeAcpProvider,
|
||||||
claude_code::ClaudeCodeProvider,
|
claude_code::ClaudeCodeProvider,
|
||||||
codex::CodexProvider,
|
codex::CodexProvider,
|
||||||
|
codex_acp::CodexAcpProvider,
|
||||||
cursor_agent::CursorAgentProvider,
|
cursor_agent::CursorAgentProvider,
|
||||||
databricks::DatabricksProvider,
|
databricks::DatabricksProvider,
|
||||||
gcpvertexai::GcpVertexAIProvider,
|
gcpvertexai::GcpVertexAIProvider,
|
||||||
@@ -52,7 +54,9 @@ async fn init_registry() -> RwLock<ProviderRegistry> {
|
|||||||
registry.register::<BedrockProvider>(false);
|
registry.register::<BedrockProvider>(false);
|
||||||
registry.register::<LocalInferenceProvider>(false);
|
registry.register::<LocalInferenceProvider>(false);
|
||||||
registry.register::<ChatGptCodexProvider>(true);
|
registry.register::<ChatGptCodexProvider>(true);
|
||||||
|
registry.register::<ClaudeAcpProvider>(false);
|
||||||
registry.register::<ClaudeCodeProvider>(true);
|
registry.register::<ClaudeCodeProvider>(true);
|
||||||
|
registry.register::<CodexAcpProvider>(false);
|
||||||
registry.register::<CodexProvider>(true);
|
registry.register::<CodexProvider>(true);
|
||||||
registry.register::<CursorAgentProvider>(false);
|
registry.register::<CursorAgentProvider>(false);
|
||||||
registry.register::<DatabricksProvider>(true);
|
registry.register::<DatabricksProvider>(true);
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ pub mod bedrock;
|
|||||||
pub mod canonical;
|
pub mod canonical;
|
||||||
pub mod catalog;
|
pub mod catalog;
|
||||||
pub mod chatgpt_codex;
|
pub mod chatgpt_codex;
|
||||||
|
pub mod claude_acp;
|
||||||
pub mod claude_code;
|
pub mod claude_code;
|
||||||
pub(crate) mod cli_common;
|
pub(crate) mod cli_common;
|
||||||
pub mod codex;
|
pub mod codex;
|
||||||
|
pub mod codex_acp;
|
||||||
pub mod cursor_agent;
|
pub mod cursor_agent;
|
||||||
pub mod databricks;
|
pub mod databricks;
|
||||||
pub mod embedding;
|
pub mod embedding;
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ use goose::providers::anthropic::ANTHROPIC_DEFAULT_MODEL;
|
|||||||
use goose::providers::azure::AZURE_DEFAULT_MODEL;
|
use goose::providers::azure::AZURE_DEFAULT_MODEL;
|
||||||
use goose::providers::base::Provider;
|
use goose::providers::base::Provider;
|
||||||
use goose::providers::bedrock::BEDROCK_DEFAULT_MODEL;
|
use goose::providers::bedrock::BEDROCK_DEFAULT_MODEL;
|
||||||
|
use goose::providers::claude_acp::CLAUDE_ACP_DEFAULT_MODEL;
|
||||||
use goose::providers::claude_code::CLAUDE_CODE_DEFAULT_MODEL;
|
use goose::providers::claude_code::CLAUDE_CODE_DEFAULT_MODEL;
|
||||||
use goose::providers::codex::CODEX_DEFAULT_MODEL;
|
use goose::providers::codex::CODEX_DEFAULT_MODEL;
|
||||||
|
use goose::providers::codex_acp::CODEX_ACP_DEFAULT_MODEL;
|
||||||
use goose::providers::create_with_named_model;
|
use goose::providers::create_with_named_model;
|
||||||
use goose::providers::databricks::DATABRICKS_DEFAULT_MODEL;
|
use goose::providers::databricks::DATABRICKS_DEFAULT_MODEL;
|
||||||
use goose::providers::errors::ProviderError;
|
use goose::providers::errors::ProviderError;
|
||||||
@@ -842,6 +844,27 @@ async fn test_codex_provider() -> Result<()> {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Requires: npm install -g @zed-industries/claude-agent-acp
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_claude_acp_provider() -> Result<()> {
|
||||||
|
ProviderTestConfig::with_agentic_provider(
|
||||||
|
"claude-acp",
|
||||||
|
CLAUDE_ACP_DEFAULT_MODEL,
|
||||||
|
"claude-agent-acp",
|
||||||
|
)
|
||||||
|
.model_switch_name("sonnet")
|
||||||
|
.run()
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
// Requires: npm install -g @zed-industries/codex-acp
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_codex_acp_provider() -> Result<()> {
|
||||||
|
ProviderTestConfig::with_agentic_provider("codex-acp", CODEX_ACP_DEFAULT_MODEL, "codex-acp")
|
||||||
|
.run()
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
#[ctor::dtor]
|
#[ctor::dtor]
|
||||||
fn print_test_report() {
|
fn print_test_report() {
|
||||||
TEST_REPORT.print_summary();
|
TEST_REPORT.print_summary();
|
||||||
|
|||||||
Reference in New Issue
Block a user