Files
tkmind_go/crates/goose/tests/acp_custom_requests_test.rs

811 lines
27 KiB
Rust

#[allow(dead_code)]
#[path = "acp_common_tests/mod.rs"]
mod common_tests;
use common_tests::fixtures::server::AcpServerConnection;
use common_tests::fixtures::{
run_test, send_custom, Connection, PermissionDecision, Session, SessionData,
TestConnectionConfig,
};
use goose::acp::server::AcpProviderFactory;
use goose::model::ModelConfig;
use goose::providers::base::{MessageStream, Provider};
use goose_providers::errors::ProviderError;
use goose_test_support::{EnforceSessionId, IgnoreSessionId};
use serial_test::serial;
use std::path::PathBuf;
use std::sync::{Arc, LazyLock, Mutex};
use common_tests::fixtures::OpenAiFixture;
const DEFAULT_ACP_TEST_CONFIG: &str =
"GOOSE_MODEL: gpt-4o\nGOOSE_PROVIDER: openai\nGOOSE_DISABLE_KEYRING: true\n";
static ACP_CONFIG_ROOT: LazyLock<tempfile::TempDir> =
LazyLock::new(|| tempfile::tempdir().unwrap());
fn write_acp_global_config(contents: &str) -> PathBuf {
std::env::set_var("GOOSE_PATH_ROOT", ACP_CONFIG_ROOT.path());
std::env::set_var("GOOSE_DISABLE_KEYRING", "1");
let config_dir = goose::config::paths::Paths::config_dir();
std::fs::create_dir_all(&config_dir).unwrap();
let mut contents = contents.to_string();
if !contents.contains("GOOSE_DISABLE_KEYRING") {
contents.push_str("GOOSE_DISABLE_KEYRING: true\n");
}
std::fs::write(
config_dir.join(goose::config::base::CONFIG_YAML_NAME),
contents,
)
.unwrap();
config_dir
}
struct MockProvider {
name: String,
model_config: ModelConfig,
recommended_models: Vec<String>,
supported_models: Vec<String>,
}
#[async_trait::async_trait]
impl Provider for MockProvider {
fn get_name(&self) -> &str {
&self.name
}
async fn stream(
&self,
_model_config: &ModelConfig,
_session_id: &str,
_system: &str,
_messages: &[goose::conversation::message::Message],
_tools: &[rmcp::model::Tool],
) -> Result<MessageStream, ProviderError> {
unimplemented!()
}
fn get_model_config(&self) -> ModelConfig {
self.model_config.clone()
}
async fn fetch_recommended_models(&self) -> Result<Vec<String>, ProviderError> {
Ok(self.recommended_models.clone())
}
async fn fetch_supported_models(&self) -> Result<Vec<String>, ProviderError> {
Ok(self.supported_models.clone())
}
}
fn mock_provider_factory() -> AcpProviderFactory {
Arc::new(|provider_name, model_config, _extensions, _working_dir| {
Box::pin(async move {
let recommended_models = match provider_name.as_str() {
"anthropic" => vec![
"claude-3-7-sonnet-latest".to_string(),
"claude-3-5-haiku-latest".to_string(),
],
_ => vec!["gpt-4o".to_string(), "o4-mini".to_string()],
};
Ok(Arc::new(MockProvider {
name: provider_name,
model_config,
supported_models: recommended_models.clone(),
recommended_models,
}) as Arc<dyn Provider>)
})
})
}
#[test]
#[serial]
fn test_custom_get_tools() {
write_acp_global_config(DEFAULT_ACP_TEST_CONFIG);
run_test(async move {
let openai = OpenAiFixture::new(vec![], Arc::new(EnforceSessionId::default())).await;
let mut conn = AcpServerConnection::new(TestConnectionConfig::default(), openai).await;
let SessionData { session, .. } = conn.new_session().await.unwrap();
let session_id = session.session_id().0.clone();
let result = send_custom(
conn.cx(),
"_goose/unstable/tools/list",
serde_json::json!({ "sessionId": session_id }),
)
.await;
assert!(result.is_ok(), "expected ok, got: {:?}", result);
let response = result.unwrap();
let tools = response.get("tools").expect("missing 'tools' field");
assert!(tools.is_array(), "tools should be array");
});
}
#[test]
#[serial]
fn test_custom_get_extensions() {
let config_key = "test-stdio-acp-mutation-flow";
let _guard = env_lock::lock_env([("EXTENSIONS", None::<&str>)]);
write_acp_global_config(DEFAULT_ACP_TEST_CONFIG);
run_test(async move {
let openai = OpenAiFixture::new(vec![], Arc::new(EnforceSessionId::default())).await;
let conn = AcpServerConnection::new(TestConnectionConfig::default(), openai).await;
let add_result = send_custom(
conn.cx(),
"_goose/unstable/config/extensions/add",
serde_json::json!({
"enabled": true,
"extension": {
"type": "mcp",
"description": "Test stdio",
"envKeys": ["SECRET_TOKEN"],
"timeout": 42,
"server": {
"type": "stdio",
"name": config_key,
"command": "test-command",
"args": ["--flag", "value"],
"env": [
{ "name": "INLINE_TOKEN", "value": "inline-secret" }
]
}
}
}),
)
.await;
assert!(add_result.is_ok(), "expected ok, got: {:?}", add_result);
let stored_inline_token = goose::config::Config::global()
.get_secret::<String>("INLINE_TOKEN")
.expect("inline env should be saved as a secret");
assert!(
stored_inline_token == "inline-secret",
"inline env secret was not saved correctly"
);
let list_extension = || async {
let result = send_custom(
conn.cx(),
"_goose/unstable/config/extensions/list",
serde_json::json!({}),
)
.await;
assert!(result.is_ok(), "expected ok, got: {:?}", result);
let response = result.unwrap();
let extensions = response
.get("extensions")
.and_then(|extensions| extensions.as_array())
.expect("extensions should be an array");
extensions
.iter()
.find(|entry| entry["configKey"] == config_key)
.cloned()
};
let entry = list_extension()
.await
.unwrap_or_else(|| panic!("missing added extension entry"));
assert_eq!(entry["enabled"], true);
assert_eq!(entry["configKey"], config_key);
let extension = &entry["extension"];
assert_eq!(extension["type"], "mcp");
assert_eq!(
extension["envKeys"],
serde_json::json!(["SECRET_TOKEN", "INLINE_TOKEN"])
);
assert_eq!(extension["description"], "Test stdio");
assert_eq!(extension["timeout"], 42);
assert!(extension.get("socket").is_none());
let server = &extension["server"];
assert_eq!(server["name"], config_key);
assert_eq!(server["command"], "test-command");
assert_eq!(server["args"], serde_json::json!(["--flag", "value"]));
assert_eq!(server["env"], serde_json::json!([]));
let set_enabled_result = send_custom(
conn.cx(),
"_goose/unstable/config/extensions/set-enabled",
serde_json::json!({
"configKey": config_key,
"enabled": false,
}),
)
.await;
assert!(
set_enabled_result.is_ok(),
"expected ok, got: {:?}",
set_enabled_result
);
let entry = list_extension()
.await
.unwrap_or_else(|| panic!("missing disabled extension entry"));
assert_eq!(entry["enabled"], false);
let remove_result = send_custom(
conn.cx(),
"_goose/unstable/config/extensions/remove",
serde_json::json!({
"configKey": config_key,
}),
)
.await;
assert!(
remove_result.is_ok(),
"expected ok, got: {:?}",
remove_result
);
assert!(
list_extension().await.is_none(),
"removed extension should not be listed"
);
});
}
#[test]
#[serial]
fn test_custom_get_available_extensions() {
write_acp_global_config(DEFAULT_ACP_TEST_CONFIG);
run_test(async move {
let openai = OpenAiFixture::new(vec![], Arc::new(EnforceSessionId::default())).await;
let conn = AcpServerConnection::new(TestConnectionConfig::default(), openai).await;
let result = send_custom(
conn.cx(),
"_goose/unstable/extensions/available",
serde_json::json!({}),
)
.await;
assert!(result.is_ok(), "expected ok, got: {:?}", result);
let response = result.unwrap();
let extensions = response
.get("extensions")
.and_then(|extensions| extensions.as_array())
.expect("extensions should be an array");
assert!(!extensions.is_empty(), "extensions should not be empty");
assert!(
extensions.iter().all(|extension| matches!(
extension["type"].as_str(),
Some("builtin" | "platform")
)),
"available extensions should only include builtin and platform entries"
);
assert!(
extensions.iter().any(|extension| {
extension["type"] == "platform" && extension["name"] == "developer"
}),
"developer platform extension should be available"
);
assert!(
!extensions.iter().any(|extension| {
extension["type"] == "platform" && extension["name"] == "orchestrator"
}),
"hidden orchestrator platform extension should not be available"
);
});
}
#[test]
#[serial]
fn test_custom_list_builtin_skill_sources() {
write_acp_global_config(DEFAULT_ACP_TEST_CONFIG);
run_test(async move {
let openai = OpenAiFixture::new(vec![], Arc::new(EnforceSessionId::default())).await;
let conn = AcpServerConnection::new(TestConnectionConfig::default(), openai).await;
let response = send_custom(
conn.cx(),
"_goose/unstable/sources/list",
serde_json::json!({ "type": "builtinSkill" }),
)
.await
.expect("builtin skill sources list should succeed");
let sources = response
.get("sources")
.and_then(|value| value.as_array())
.expect("missing sources array");
let builtin = sources
.iter()
.find(|source| source.get("name") == Some(&serde_json::json!("goose-doc-guide")))
.expect("expected goose-doc-guide builtin skill");
assert_eq!(
builtin.get("type"),
Some(&serde_json::json!("builtinSkill"))
);
assert_eq!(builtin.get("global"), Some(&serde_json::json!(true)));
assert_eq!(
builtin.get("path"),
Some(&serde_json::json!("builtin://skills/goose-doc-guide"))
);
});
}
#[test]
#[serial]
fn test_custom_provider_inventory_includes_metadata() {
write_acp_global_config(DEFAULT_ACP_TEST_CONFIG);
run_test(async {
let openai = OpenAiFixture::new(vec![], Arc::new(EnforceSessionId::default())).await;
let conn = AcpServerConnection::new(TestConnectionConfig::default(), openai).await;
let response = send_custom(
conn.cx(),
"_goose/unstable/providers/list",
serde_json::json!({}),
)
.await
.expect("provider inventory should succeed");
let providers = response
.get("entries")
.and_then(|value| value.as_array())
.expect("missing entries array");
let openai = providers
.iter()
.find(|provider| provider.get("providerId") == Some(&serde_json::json!("openai")))
.expect("expected openai inventory entry");
assert!(openai.get("providerName").is_some(), "missing providerName");
assert!(openai.get("description").is_some(), "missing description");
assert!(openai.get("defaultModel").is_some(), "missing defaultModel");
assert!(openai.get("providerType").is_some(), "missing providerType");
assert!(openai.get("configKeys").is_some(), "missing configKeys");
assert!(openai.get("setupSteps").is_some(), "missing setupSteps");
});
}
#[test]
#[serial]
fn test_custom_preferences_read_save_remove() {
let config_dir = write_acp_global_config(
"GOOSE_MODEL: gpt-4o\nGOOSE_PROVIDER: openai\nGOOSE_AUTO_COMPACT_THRESHOLD: 0.7\nVOICE_AUTO_SUBMIT_PHRASES: send it\n",
);
run_test(async move {
let openai = OpenAiFixture::new(vec![], Arc::new(EnforceSessionId::default())).await;
let config = TestConnectionConfig {
data_root: config_dir,
..Default::default()
};
let conn = AcpServerConnection::new(config, openai).await;
let response = send_custom(
conn.cx(),
"_goose/unstable/preferences/read",
serde_json::json!({
"keys": [
"autoCompactThreshold",
"voiceAutoSubmitPhrases",
"voiceDictationPreferredMic"
],
}),
)
.await
.expect("preferences read should succeed");
assert_eq!(
response.get("values"),
Some(&serde_json::json!([
{ "key": "autoCompactThreshold", "value": 0.7 },
{ "key": "voiceAutoSubmitPhrases", "value": "send it" },
{ "key": "voiceDictationPreferredMic", "value": null },
]))
);
send_custom(
conn.cx(),
"_goose/unstable/preferences/save",
serde_json::json!({
"values": [
{ "key": "voiceDictationProvider", "value": "__disabled__" },
{ "key": "voiceDictationPreferredMic", "value": "mic-1" }
],
}),
)
.await
.expect("preferences save should succeed");
send_custom(
conn.cx(),
"_goose/unstable/preferences/remove",
serde_json::json!({
"keys": ["voiceDictationProvider"],
}),
)
.await
.expect("preferences remove should succeed");
let response = send_custom(
conn.cx(),
"_goose/unstable/preferences/read",
serde_json::json!({
"keys": ["voiceDictationProvider", "voiceDictationPreferredMic"],
}),
)
.await
.expect("preferences read after remove should succeed");
assert_eq!(
response.get("values"),
Some(&serde_json::json!([
{ "key": "voiceDictationProvider", "value": null },
{ "key": "voiceDictationPreferredMic", "value": "mic-1" },
]))
);
});
}
#[test]
#[serial]
fn test_custom_preferences_save_rejects_invalid_values() {
write_acp_global_config(DEFAULT_ACP_TEST_CONFIG);
run_test(async {
let openai = OpenAiFixture::new(vec![], Arc::new(EnforceSessionId::default())).await;
let conn = AcpServerConnection::new(TestConnectionConfig::default(), openai).await;
let invalid_payloads = [
serde_json::json!({
"values": [{ "key": "autoCompactThreshold", "value": 0 }],
}),
serde_json::json!({
"values": [{ "key": "autoCompactThreshold", "value": 1.1 }],
}),
serde_json::json!({
"values": [{ "key": "voiceAutoSubmitPhrases", "value": ["send"] }],
}),
serde_json::json!({
"values": [{ "key": "voiceDictationProvider", "value": "bogus" }],
}),
serde_json::json!({
"values": [{ "key": "voiceDictationPreferredMic", "value": "" }],
}),
];
for payload in invalid_payloads {
let result = send_custom(conn.cx(), "_goose/unstable/preferences/save", payload).await;
assert!(result.is_err(), "expected invalid params error");
}
let result = send_custom(
conn.cx(),
"_goose/unstable/preferences/save",
serde_json::json!({
"values": [
{ "key": "voiceDictationPreferredMic", "value": "mic-1" },
{ "key": "voiceDictationProvider", "value": "bogus" }
],
}),
)
.await;
assert!(result.is_err(), "expected invalid params error");
let response = send_custom(
conn.cx(),
"_goose/unstable/preferences/read",
serde_json::json!({
"keys": ["voiceDictationPreferredMic"],
}),
)
.await
.expect("preferences read should succeed");
assert_eq!(
response.get("values"),
Some(&serde_json::json!([
{ "key": "voiceDictationPreferredMic", "value": null },
]))
);
});
}
#[test]
#[serial]
fn test_custom_defaults_read() {
let config_dir = write_acp_global_config(
"GOOSE_MODEL: claude-3-5-haiku-latest\nGOOSE_PROVIDER: anthropic\n",
);
run_test(async move {
let openai = OpenAiFixture::new(vec![], Arc::new(EnforceSessionId::default())).await;
let config = TestConnectionConfig {
data_root: config_dir,
..Default::default()
};
let conn = AcpServerConnection::new(config, openai).await;
let response = send_custom(
conn.cx(),
"_goose/unstable/defaults/read",
serde_json::json!({}),
)
.await
.expect("defaults read should succeed");
assert_eq!(
response,
serde_json::json!({
"providerId": "anthropic",
"modelId": "claude-3-5-haiku-latest",
})
);
});
}
#[test]
#[serial]
fn test_custom_dictation_secret_save_delete() {
let _env = env_lock::lock_env([
("GOOSE_DISABLE_KEYRING", Some("1")),
("GROQ_API_KEY", None::<&str>),
]);
let config_dir = write_acp_global_config(
"GOOSE_MODEL: gpt-4o\nGOOSE_PROVIDER: openai\nGOOSE_DISABLE_KEYRING: true\n",
);
run_test(async move {
let openai = OpenAiFixture::new(vec![], Arc::new(EnforceSessionId::default())).await;
let config = TestConnectionConfig {
data_root: config_dir.clone(),
..Default::default()
};
let conn = AcpServerConnection::new(config, openai).await;
send_custom(
conn.cx(),
"_goose/unstable/dictation/secret/save",
serde_json::json!({
"provider": "groq",
"value": "groq-key",
}),
)
.await
.expect("dictation secret save should succeed");
let config = send_custom(
conn.cx(),
"_goose/unstable/dictation/config",
serde_json::json!({}),
)
.await
.expect("dictation config should succeed");
assert_eq!(
config
.pointer("/providers/groq/configured")
.and_then(|value| value.as_bool()),
Some(true)
);
let provider_config_result = send_custom(
conn.cx(),
"_goose/unstable/dictation/secret/save",
serde_json::json!({
"provider": "openai",
"value": "openai-key",
}),
)
.await;
assert!(
provider_config_result.is_err(),
"provider-config dictation providers should be rejected"
);
let unknown_result = send_custom(
conn.cx(),
"_goose/unstable/dictation/secret/save",
serde_json::json!({
"provider": "unknown",
"value": "key",
}),
)
.await;
assert!(
unknown_result.is_err(),
"unknown provider should be rejected"
);
send_custom(
conn.cx(),
"_goose/unstable/dictation/secret/delete",
serde_json::json!({
"provider": "groq",
}),
)
.await
.expect("dictation secret delete should succeed");
let config = send_custom(
conn.cx(),
"_goose/unstable/dictation/config",
serde_json::json!({}),
)
.await
.expect("dictation config should succeed");
assert_eq!(
config
.pointer("/providers/groq/configured")
.and_then(|value| value.as_bool()),
Some(false)
);
});
}
#[test]
#[serial]
fn test_raw_config_and_secret_methods_are_removed() {
write_acp_global_config(DEFAULT_ACP_TEST_CONFIG);
run_test(async {
let openai = OpenAiFixture::new(vec![], Arc::new(EnforceSessionId::default())).await;
let conn = AcpServerConnection::new(TestConnectionConfig::default(), openai).await;
for method in [
"_goose/config/read",
"_goose/config/upsert",
"_goose/config/remove",
"_goose/secret/check",
"_goose/secret/upsert",
"_goose/secret/remove",
] {
let result = send_custom(conn.cx(), method, serde_json::json!({})).await;
assert!(result.is_err(), "{method} should be removed");
}
});
}
#[test]
#[serial]
fn test_provider_switching_updates_session_state() {
write_acp_global_config(DEFAULT_ACP_TEST_CONFIG);
run_test(async {
let openai = OpenAiFixture::new(vec![], Arc::new(EnforceSessionId::default())).await;
let config = TestConnectionConfig {
provider_factory: Some(mock_provider_factory()),
current_model: "gpt-4o".to_string(),
..Default::default()
};
let mut conn = AcpServerConnection::new(config, openai).await;
let SessionData { session, .. } = conn.new_session().await.unwrap();
let session_id = session.session_id().0.clone();
conn.set_config_option(&session_id, "provider", "anthropic")
.await
.expect("provider switch to anthropic should succeed");
conn.set_config_option(&session_id, "provider", "openai")
.await
.expect("provider switch to openai should succeed");
conn.set_config_option(&session_id, "provider", "goose")
.await
.expect("provider reset to goose should succeed");
});
}
#[test]
#[serial]
fn test_custom_unknown_method() {
write_acp_global_config(DEFAULT_ACP_TEST_CONFIG);
run_test(async {
let openai = OpenAiFixture::new(vec![], Arc::new(EnforceSessionId::default())).await;
let conn = AcpServerConnection::new(TestConnectionConfig::default(), openai).await;
let result = send_custom(conn.cx(), "_unknown/method", serde_json::json!({})).await;
assert!(result.is_err(), "expected method_not_found error");
});
}
#[test]
#[serial]
fn test_developer_fs_requests_use_acp_session_id() {
run_test(async {
let seen_session_id = Arc::new(Mutex::new(None::<String>));
let seen_session_id_clone = Arc::clone(&seen_session_id);
let openai = OpenAiFixture::new(
vec![
(
"Use the read tool to read /tmp/test_acp_read.txt and output only its contents."
.to_string(),
include_str!("acp_test_data/openai_fs_read_tool_call.txt"),
),
(
r#""content":"test-read-content-12345""#.into(),
include_str!("acp_test_data/openai_fs_read_tool_result.txt"),
),
],
Arc::new(IgnoreSessionId),
)
.await;
let config_dir = write_acp_global_config(&format!(
"GOOSE_MODEL: gpt-4.1\nGOOSE_PROVIDER: openai\nOPENAI_HOST: {}\n",
openai.uri()
));
let config = TestConnectionConfig {
// gpt-5-nano routes to the Responses API; use a Chat Completions
// model so the canned SSE fixtures are parsed correctly.
data_root: config_dir,
current_model: "gpt-4.1".to_string(),
read_text_file: Some(Arc::new(move |req| {
*seen_session_id_clone.lock().unwrap() = Some(req.session_id.0.to_string());
Ok(agent_client_protocol::schema::ReadTextFileResponse::new(
"test-read-content-12345",
))
})),
..Default::default()
};
let mut conn = AcpServerConnection::new(config, openai).await;
let SessionData { mut session, .. } = conn.new_session().await.unwrap();
let acp_session_id = session.session_id().0.to_string();
let output = session
.prompt(
"Use the read tool to read /tmp/test_acp_read.txt and output only its contents.",
PermissionDecision::Cancel,
)
.await
.expect("prompt should succeed");
assert_eq!(output.text, "test-read-content-12345");
assert_eq!(
seen_session_id.lock().unwrap().as_deref(),
Some(acp_session_id.as_str()),
"ACP read request should use the ACP session/thread ID",
);
});
}
#[test]
#[serial]
fn test_custom_provider_supported_models_lists_raw_provider_models() {
write_acp_global_config(DEFAULT_ACP_TEST_CONFIG);
run_test(async move {
let openai = OpenAiFixture::new(vec![], Arc::new(EnforceSessionId::default())).await;
let provider_factory: AcpProviderFactory =
Arc::new(|provider_name, model_config, _extensions, _working_dir| {
Box::pin(async move {
Ok(Arc::new(MockProvider {
name: provider_name,
model_config,
recommended_models: vec!["canonical-filtered-model".to_string()],
supported_models: vec![
"goose-claude-opus-4-8".to_string(),
"raw-databricks-endpoint".to_string(),
],
}) as Arc<dyn Provider>)
})
});
let conn = AcpServerConnection::new(
TestConnectionConfig {
provider_factory: Some(provider_factory),
..Default::default()
},
openai,
)
.await;
let response = send_custom(
conn.cx(),
"_goose/unstable/providers/supported-models/list",
serde_json::json!({ "providerId": "openai" }),
)
.await
.expect("provider supported models list should succeed");
assert_eq!(
response.get("providerId"),
Some(&serde_json::json!("openai"))
);
assert_eq!(
response.get("models"),
Some(&serde_json::json!([
"goose-claude-opus-4-8",
"raw-databricks-endpoint"
]))
);
});
}