diff --git a/Cargo.lock b/Cargo.lock index fd7f5d235be1..3fcc3ee56f0d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4574,6 +4574,7 @@ dependencies = [ "futures", "goose-acp-macros", "goose-mcp", + "goose-providers", "goose-sdk-types", "goose-test-support", "http 1.4.1", @@ -4581,6 +4582,7 @@ dependencies = [ "icu_calendar", "icu_locale", "ignore", + "image 0.24.9", "include_dir", "indexmap 2.14.0", "indoc", @@ -4762,6 +4764,26 @@ dependencies = [ "url", ] +[[package]] +name = "goose-providers" +version = "1.37.0" +dependencies = [ + "anyhow", + "base64 0.22.1", + "chrono", + "once_cell", + "regex", + "rmcp", + "serde", + "serde_json", + "test-case", + "thiserror 1.0.69", + "tracing", + "unicode-normalization", + "utoipa 4.2.3", + "uuid", +] + [[package]] name = "goose-sdk" version = "1.37.0" diff --git a/Justfile b/Justfile index 8e8f6e49f354..017f0c509862 100644 --- a/Justfile +++ b/Justfile @@ -330,8 +330,8 @@ prepare-release version: ui/desktop/package.json \ ui/pnpm-lock.yaml \ ui/desktop/openapi.json \ - crates/goose/src/providers/canonical/data/canonical_models.json \ - crates/goose/src/providers/canonical/data/provider_metadata.json + crates/goose-providers/src/canonical/data/canonical_models.json \ + crates/goose-providers/src/canonical/data/provider_metadata.json @git commit --message "chore(release): release version {{ version }}" set-openapi-version version: diff --git a/crates/goose-providers/Cargo.toml b/crates/goose-providers/Cargo.toml new file mode 100644 index 000000000000..e51dea54d0bd --- /dev/null +++ b/crates/goose-providers/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "goose-providers" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +description.workspace = true + +[lints] +workspace = true + +[dependencies] +anyhow = { workspace = true } +base64 = { workspace = true } +chrono = { workspace = true } +once_cell = { workspace = true } +regex = { workspace = true } +rmcp = { workspace = true, features = ["server"] } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +unicode-normalization = { version = "0.1.22", default-features = false, features = ["std"] } +utoipa = { workspace = true, features = ["chrono"] } +uuid = { workspace = true, features = ["v4", "std"] } + +[dev-dependencies] +test-case = { workspace = true } diff --git a/crates/goose/src/providers/canonical/README.md b/crates/goose-providers/src/canonical/README.md similarity index 74% rename from crates/goose/src/providers/canonical/README.md rename to crates/goose-providers/src/canonical/README.md index 410d102c2841..f0c682f2ce4b 100644 --- a/crates/goose/src/providers/canonical/README.md +++ b/crates/goose-providers/src/canonical/README.md @@ -13,11 +13,11 @@ cargo run --bin build_canonical_models --no-check # Build only, skip checker This script performs two operations by default: 1. **Builds canonical models** - Fetches from OpenRouter API and updates the registry - - Writes to: `src/providers/canonical/data/canonical_models.json` + - Writes to: `crates/goose-providers/src/canonical/data/canonical_models.json` 2. **Checks model mappings** (unless `--no-check` is passed) - Tests provider mappings and tracks changes over time - Reports unmapped models - Compares with previous runs (like a lock file) - Shows changed/added/removed mappings - - Writes to: `src/providers/canonical/data/canonical_mapping_report.json` + - Writes to: `crates/goose-providers/src/canonical/data/canonical_mapping_report.json` -The script is located in this directory: `build_canonical_models.rs` +The script is currently built from `crates/goose/src/bin/build_canonical_models.rs` and writes into this crate's `src/canonical/data` directory. diff --git a/crates/goose/src/providers/catalog.rs b/crates/goose-providers/src/canonical/catalog.rs similarity index 86% rename from crates/goose/src/providers/catalog.rs rename to crates/goose-providers/src/canonical/catalog.rs index 05b032ccb3ed..d7e3617d91a7 100644 --- a/crates/goose/src/providers/catalog.rs +++ b/crates/goose-providers/src/canonical/catalog.rs @@ -1,13 +1,10 @@ use once_cell::sync::Lazy; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; -use super::{ - base::{ConfigKey, ProviderMetadata}, - canonical::CanonicalModelRegistry, -}; +use super::CanonicalModelRegistry; -const PROVIDER_METADATA_JSON: &str = include_str!("canonical/data/provider_metadata.json"); +const PROVIDER_METADATA_JSON: &str = include_str!("data/provider_metadata.json"); #[derive(Debug, Clone, Serialize, Deserialize)] struct ProviderMetadataEntry { @@ -175,6 +172,24 @@ pub struct ProviderSetupCatalogEntry { pub show_only_when_installed: bool, } +#[derive(Debug, Clone)] +pub struct ProviderSetupMetadata { + pub name: String, + pub display_name: String, + pub description: String, + pub model_doc_link: String, + pub config_keys: Vec, +} + +#[derive(Debug, Clone)] +pub struct ProviderSetupConfigKey { + pub name: String, + pub required: bool, + pub secret: bool, + pub default: Option, + pub primary: bool, +} + #[derive(Debug, Clone, Copy)] struct CuratedSetupMetadata { provider_id: &'static str, @@ -900,7 +915,7 @@ fn field_label(key: &str) -> String { fn field_override<'a>( key: &str, - config_key: &ConfigKey, + config_key: &ProviderSetupConfigKey, curated: &'a CuratedSetupMetadata, ) -> Option<&'a CuratedFieldMetadata> { if let Some(field) = curated @@ -918,7 +933,10 @@ fn field_override<'a>( None } -fn setup_field(config_key: &ConfigKey, curated: &CuratedSetupMetadata) -> ProviderSetupField { +fn setup_field( + config_key: &ProviderSetupConfigKey, + curated: &CuratedSetupMetadata, +) -> ProviderSetupField { let field_override = field_override(&config_key.name, config_key, curated); ProviderSetupField { key: config_key.name.clone(), @@ -936,7 +954,7 @@ fn setup_field(config_key: &ConfigKey, curated: &CuratedSetupMetadata) -> Provid fn setup_entry_from_metadata( curated: &CuratedSetupMetadata, - metadata: &ProviderMetadata, + metadata: &ProviderSetupMetadata, ) -> ProviderSetupCatalogEntry { ProviderSetupCatalogEntry { provider_id: curated.provider_id.to_string(), @@ -994,13 +1012,10 @@ fn synthetic_goose_setup_entry(curated: &CuratedSetupMetadata) -> ProviderSetupC } } -pub async fn get_providers_by_format(format: ProviderFormat) -> Vec { - let native_provider_ids = super::init::providers() - .await - .into_iter() - .map(|(metadata, _)| metadata.name) - .collect::>(); - +pub fn get_providers_by_format( + format: ProviderFormat, + native_provider_ids: &HashSet, +) -> Vec { let mut entries: Vec = PROVIDER_METADATA .values() .filter_map(|metadata| { @@ -1038,13 +1053,9 @@ pub async fn get_providers_by_format(format: ProviderFormat) -> Vec Vec { - let registry_metadata = super::providers() - .await - .into_iter() - .map(|(metadata, _)| (metadata.name.clone(), metadata)) - .collect::>(); - +pub fn get_setup_catalog_entries( + registry_metadata: &HashMap, +) -> Vec { SETUP_METADATA .iter() .filter_map(|curated| { @@ -1122,144 +1133,3 @@ pub fn get_provider_template(provider_id: &str) -> Option { doc_url: metadata.doc.clone().unwrap_or_default(), }) } - -#[cfg(test)] -mod tests { - use super::*; - use crate::providers::base::ProviderType; - - #[tokio::test] - async fn test_zai_provider() { - let zai = crate::providers::get_from_registry("zai") - .await - .expect("z.ai should be registered as a declarative provider"); - assert_eq!(zai.provider_type(), ProviderType::Declarative); - - let metadata = zai.metadata(); - assert_eq!(metadata.display_name, "Z.AI"); - assert!( - !metadata.known_models.is_empty(), - "z.ai should have known models" - ); - assert!( - metadata - .config_keys - .iter() - .any(|key| key.name == "ZHIPU_API_KEY"), - "z.ai should expose its API key config" - ); - - let setup_entries = get_setup_catalog_entries().await; - let setup_entry = setup_entries - .iter() - .find(|entry| entry.provider_id == "zai") - .expect("z.ai should be in the setup catalog"); - assert_eq!(setup_entry.setup_method, ProviderSetupMethod::SingleApiKey); - - let template = get_provider_template("zai"); - assert!(template.is_some(), "z.ai should have a template"); - - let template = template.unwrap(); - println!("Z.AI template: {} models", template.models.len()); - for model in template.models.iter().take(3) { - println!( - " - {} ({}K context)", - model.name, - model.context_limit / 1000 - ); - } - assert!( - !template.models.is_empty(), - "z.ai template should have models" - ); - } - - #[tokio::test] - async fn setup_catalog_includes_goose_and_curated_fields() { - let entries = get_setup_catalog_entries().await; - - let goose = entries - .iter() - .find(|entry| entry.provider_id == "goose") - .expect("setup catalog should include synthetic goose"); - assert_eq!(goose.category, ProviderSetupCategory::Agent); - assert_eq!(goose.setup_method, ProviderSetupMethod::None); - assert!(goose.fields.is_empty()); - - let ollama = entries - .iter() - .find(|entry| entry.provider_id == "ollama") - .expect("setup catalog should include ollama"); - assert_eq!(ollama.setup_method, ProviderSetupMethod::ConfigFields); - assert_eq!(ollama.fields.len(), 1); - assert_eq!(ollama.fields[0].key, "OLLAMA_HOST"); - assert_eq!(ollama.fields[0].label, "Host"); - assert_eq!( - ollama.fields[0].default_value.as_deref(), - Some("http://localhost:11434") - ); - - let databricks = entries - .iter() - .find(|entry| entry.provider_id == "databricks") - .expect("setup catalog should include databricks"); - assert_eq!( - databricks.setup_method, - ProviderSetupMethod::HostWithOauthFallback - ); - assert_eq!( - databricks - .fields - .iter() - .map(|field| field.key.as_str()) - .collect::>(), - ["DATABRICKS_HOST", "DATABRICKS_TOKEN"] - ); - - let huggingface = entries - .iter() - .find(|entry| entry.provider_id == "huggingface") - .expect("setup catalog should include huggingface"); - assert_eq!(huggingface.setup_method, ProviderSetupMethod::SingleApiKey); - assert_eq!( - huggingface - .fields - .iter() - .map(|field| field.key.as_str()) - .collect::>(), - ["HF_TOKEN"] - ); - - let atomic_chat = entries - .iter() - .find(|entry| entry.provider_id == "atomic_chat") - .expect("setup catalog should include atomic_chat declarative provider"); - assert_eq!(atomic_chat.setup_method, ProviderSetupMethod::ConfigFields); - let host_field = atomic_chat - .fields - .iter() - .find(|field| field.key == "ATOMIC_CHAT_HOST") - .expect("atomic_chat should expose ATOMIC_CHAT_HOST"); - assert_eq!(host_field.label, "Host URL"); - assert_eq!( - host_field.default_value.as_deref(), - Some("http://localhost:1337") - ); - } - - #[tokio::test] - async fn setup_catalog_excludes_uncurated_deprecated_providers() { - let provider_ids = get_setup_catalog_entries() - .await - .into_iter() - .map(|entry| entry.provider_id) - .collect::>(); - - assert!(provider_ids.contains("claude-acp")); - assert!(provider_ids.contains("codex-acp")); - assert!(provider_ids.contains("atomic_chat")); - assert!(!provider_ids.contains("claude_code")); - assert!(!provider_ids.contains("codex")); - assert!(!provider_ids.contains("gemini_cli")); - } -} diff --git a/crates/goose/src/providers/canonical/data/canonical_mapping_report.json b/crates/goose-providers/src/canonical/data/canonical_mapping_report.json similarity index 100% rename from crates/goose/src/providers/canonical/data/canonical_mapping_report.json rename to crates/goose-providers/src/canonical/data/canonical_mapping_report.json diff --git a/crates/goose/src/providers/canonical/data/canonical_models.json b/crates/goose-providers/src/canonical/data/canonical_models.json similarity index 100% rename from crates/goose/src/providers/canonical/data/canonical_models.json rename to crates/goose-providers/src/canonical/data/canonical_models.json diff --git a/crates/goose/src/providers/canonical/data/provider_metadata.json b/crates/goose-providers/src/canonical/data/provider_metadata.json similarity index 100% rename from crates/goose/src/providers/canonical/data/provider_metadata.json rename to crates/goose-providers/src/canonical/data/provider_metadata.json diff --git a/crates/goose/src/providers/canonical/mod.rs b/crates/goose-providers/src/canonical/mod.rs similarity index 99% rename from crates/goose/src/providers/canonical/mod.rs rename to crates/goose-providers/src/canonical/mod.rs index ab7bc81a4ab7..cc0fceecc292 100644 --- a/crates/goose/src/providers/canonical/mod.rs +++ b/crates/goose-providers/src/canonical/mod.rs @@ -1,3 +1,4 @@ +pub mod catalog; mod model; mod name_builder; mod registry; diff --git a/crates/goose/src/providers/canonical/model.rs b/crates/goose-providers/src/canonical/model.rs similarity index 100% rename from crates/goose/src/providers/canonical/model.rs rename to crates/goose-providers/src/canonical/model.rs diff --git a/crates/goose/src/providers/canonical/name_builder.rs b/crates/goose-providers/src/canonical/name_builder.rs similarity index 100% rename from crates/goose/src/providers/canonical/name_builder.rs rename to crates/goose-providers/src/canonical/name_builder.rs diff --git a/crates/goose/src/providers/canonical/registry.rs b/crates/goose-providers/src/canonical/registry.rs similarity index 100% rename from crates/goose/src/providers/canonical/registry.rs rename to crates/goose-providers/src/canonical/registry.rs diff --git a/crates/goose/src/conversation/message.rs b/crates/goose-providers/src/conversation/message.rs similarity index 99% rename from crates/goose/src/conversation/message.rs rename to crates/goose-providers/src/conversation/message.rs index 83ab93211909..a89d4760b1e5 100644 --- a/crates/goose/src/conversation/message.rs +++ b/crates/goose-providers/src/conversation/message.rs @@ -1,5 +1,5 @@ use crate::conversation::tool_result_serde; -use crate::mcp_utils::{extract_text_from_resource, ToolResult}; +use crate::mcp_utils::extract_text_from_resource; use crate::utils::sanitize_unicode_tags; use chrono::Utc; use rmcp::model::{ @@ -74,6 +74,7 @@ where /// Provider-specific metadata for tool requests/responses. /// Allows providers to store custom data without polluting the core model. pub type ProviderMetadata = serde_json::Map; +pub type ToolResult = Result; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/crates/goose/src/conversation/mod.rs b/crates/goose-providers/src/conversation/mod.rs similarity index 100% rename from crates/goose/src/conversation/mod.rs rename to crates/goose-providers/src/conversation/mod.rs diff --git a/crates/goose/src/conversation/tool_result_serde.rs b/crates/goose-providers/src/conversation/tool_result_serde.rs similarity index 99% rename from crates/goose/src/conversation/tool_result_serde.rs rename to crates/goose-providers/src/conversation/tool_result_serde.rs index 54578bcc08ac..b0d6c737860f 100644 --- a/crates/goose/src/conversation/tool_result_serde.rs +++ b/crates/goose-providers/src/conversation/tool_result_serde.rs @@ -1,4 +1,4 @@ -use crate::mcp_utils::ToolResult; +use crate::conversation::message::ToolResult; use rmcp::model::{CallToolRequestParams, ErrorCode, ErrorData, JsonObject}; use serde::ser::SerializeStruct; use serde::{Deserialize, Deserializer, Serialize, Serializer}; diff --git a/crates/goose-providers/src/lib.rs b/crates/goose-providers/src/lib.rs new file mode 100644 index 000000000000..7b77b3a95464 --- /dev/null +++ b/crates/goose-providers/src/lib.rs @@ -0,0 +1,4 @@ +pub mod canonical; +pub mod conversation; +mod mcp_utils; +mod utils; diff --git a/crates/goose-providers/src/mcp_utils.rs b/crates/goose-providers/src/mcp_utils.rs new file mode 100644 index 000000000000..ad70764222d5 --- /dev/null +++ b/crates/goose-providers/src/mcp_utils.rs @@ -0,0 +1,105 @@ +use base64::Engine; +use rmcp::model::ResourceContents; + +pub fn extract_text_from_resource(resource: &ResourceContents) -> String { + match resource { + ResourceContents::TextResourceContents { text, .. } => text.clone(), + ResourceContents::BlobResourceContents { + blob, mime_type, .. + } => match base64::engine::general_purpose::STANDARD.decode(blob) { + Ok(bytes) => { + let byte_len = bytes.len(); + match String::from_utf8(bytes) { + Ok(text) => text, + Err(_) => { + let mime = mime_type + .as_ref() + .map(|m| m.as_str()) + .unwrap_or("application/octet-stream"); + format!("[Binary content ({}) - {} bytes]", mime, byte_len) + } + } + } + Err(_) => blob.clone(), + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use test_case::test_case; + + #[test_case("Hello, World!", "Hello, World!" ; "simple text")] + #[test_case("Hello from GitHub!", "Hello from GitHub!" ; "github content")] + #[test_case("", "" ; "empty text")] + fn test_extract_text_from_text_resource(input: &str, expected: &str) { + let resource = ResourceContents::TextResourceContents { + uri: "file:///test.txt".to_string(), + mime_type: Some("text/plain".to_string()), + text: input.to_string(), + meta: None, + }; + assert_eq!(extract_text_from_resource(&resource), expected); + } + + #[test_case("Hello from GitHub!", "Hello from GitHub!" ; "utf8 markdown")] + #[test_case("Simple text", "Simple text" ; "utf8 plain")] + fn test_extract_text_from_blob_utf8(input: &str, expected: &str) { + let blob = base64::engine::general_purpose::STANDARD.encode(input.as_bytes()); + let resource = ResourceContents::BlobResourceContents { + uri: "github://repo/file.md".to_string(), + mime_type: Some("text/markdown".to_string()), + blob, + meta: None, + }; + assert_eq!(extract_text_from_resource(&resource), expected); + } + + #[test] + fn test_extract_text_from_blob_binary() { + let binary_data: Vec = vec![0xFF, 0xFE, 0x00, 0x01, 0x89, 0x50, 0x4E, 0x47]; + let blob = base64::engine::general_purpose::STANDARD.encode(&binary_data); + + let resource = ResourceContents::BlobResourceContents { + uri: "file:///image.png".to_string(), + mime_type: Some("image/png".to_string()), + blob, + meta: None, + }; + + assert_eq!( + extract_text_from_resource(&resource), + "[Binary content (image/png) - 8 bytes]" + ); + } + + #[test] + fn test_extract_text_from_blob_binary_no_mime_type() { + let binary_data: Vec = vec![0xFF, 0xFE]; + let blob = base64::engine::general_purpose::STANDARD.encode(&binary_data); + + let resource = ResourceContents::BlobResourceContents { + uri: "file:///unknown".to_string(), + mime_type: None, + blob, + meta: None, + }; + + assert_eq!( + extract_text_from_resource(&resource), + "[Binary content (application/octet-stream) - 2 bytes]" + ); + } + + #[test] + fn test_extract_text_from_blob_invalid_base64() { + let resource = ResourceContents::BlobResourceContents { + uri: "file:///test.txt".to_string(), + mime_type: Some("text/plain".to_string()), + blob: "not valid base64!!!".to_string(), + meta: None, + }; + assert_eq!(extract_text_from_resource(&resource), "not valid base64!!!"); + } +} diff --git a/crates/goose-providers/src/utils.rs b/crates/goose-providers/src/utils.rs new file mode 100644 index 000000000000..3768ad6a30dc --- /dev/null +++ b/crates/goose-providers/src/utils.rs @@ -0,0 +1,14 @@ +use unicode_normalization::UnicodeNormalization; + +fn is_in_unicode_tag_range(c: char) -> bool { + matches!(c, '\u{E0000}'..='\u{E007F}') +} + +pub fn sanitize_unicode_tags(text: &str) -> String { + let normalized: String = text.nfc().collect(); + + normalized + .chars() + .filter(|&c| !is_in_unicode_tag_range(c)) + .collect() +} diff --git a/crates/goose-sdk-types/src/custom_requests.rs b/crates/goose-sdk-types/src/custom_requests.rs index 672ff8b09003..3ef2df1ce76e 100644 --- a/crates/goose-sdk-types/src/custom_requests.rs +++ b/crates/goose-sdk-types/src/custom_requests.rs @@ -1,3 +1,4 @@ +use agent_client_protocol::schema::McpServer; use agent_client_protocol::{JsonRpcRequest, JsonRpcResponse}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -147,30 +148,104 @@ pub struct DeleteSessionRequest { pub session_id: String, } +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum GooseExtension { + Builtin { + name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + display_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + timeout: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + bundled: Option, + }, + Platform { + name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + display_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + bundled: Option, + }, + Mcp { + server: McpServer, + #[serde(default, rename = "envKeys", skip_serializing_if = "Vec::is_empty")] + env_keys: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + timeout: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + socket: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + bundled: Option, + }, +} + +impl Default for GooseExtension { + fn default() -> Self { + Self::Builtin { + name: String::new(), + description: None, + display_name: None, + timeout: None, + bundled: None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct GooseExtensionEntry { + pub extension: GooseExtension, + pub enabled: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config_key: Option, +} + +/// List Goose-owned extension definitions available to configure or enable. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request( + method = "_goose/unstable/extensions/available", + response = GetAvailableExtensionsResponse +)] +pub struct GetAvailableExtensionsRequest {} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +#[serde(rename_all = "camelCase")] +pub struct GetAvailableExtensionsResponse { + pub extensions: Vec, +} + /// List configured extensions and any warnings. #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request(method = "_goose/unstable/config/extensions/list", response = GetExtensionsResponse)] -pub struct GetExtensionsRequest {} +#[request( + method = "_goose/unstable/config/extensions/list", + response = GetConfigExtensionsResponse +)] +pub struct GetConfigExtensionsRequest {} /// List configured extensions and any warnings. #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] -pub struct GetExtensionsResponse { - /// Array of ExtensionEntry objects with `enabled` flag, `configKey`, and flattened config details. - pub extensions: Vec, +pub struct GetConfigExtensionsResponse { + pub extensions: Vec, + #[serde(default)] pub warnings: Vec, } +pub type GetExtensionsRequest = GetConfigExtensionsRequest; +pub type GetExtensionsResponse = GetConfigExtensionsResponse; + /// Persist a new extension to the user's global goose config. #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] #[request(method = "_goose/unstable/config/extensions/add", response = EmptyResponse)] #[serde(rename_all = "camelCase")] pub struct AddConfigExtensionRequest { - pub name: String, - /// Extension configuration. Must be a JSON object matching one of the - /// `ExtensionConfig` variants (e.g. `stdio`, `streamable_http`, `builtin`). - /// `name` and `enabled` are injected server-side. - #[serde(default)] - pub extension_config: serde_json::Value, + pub extension: GooseExtension, #[serde(default)] pub enabled: bool, } @@ -183,11 +258,14 @@ pub struct RemoveConfigExtensionRequest { pub config_key: String, } -/// Toggle the `enabled` flag for a persisted extension in the user's global goose config. +/// Set the `enabled` flag for a persisted extension in the user's global goose config. #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request(method = "_goose/unstable/config/extensions/toggle", response = EmptyResponse)] +#[request( + method = "_goose/unstable/config/extensions/set-enabled", + response = EmptyResponse +)] #[serde(rename_all = "camelCase")] -pub struct ToggleConfigExtensionRequest { +pub struct SetConfigExtensionEnabledRequest { pub config_key: String, pub enabled: bool, } diff --git a/crates/goose/Cargo.toml b/crates/goose/Cargo.toml index 90d12ae00ab8..af827e430443 100644 --- a/crates/goose/Cargo.toml +++ b/crates/goose/Cargo.toml @@ -122,6 +122,7 @@ strum = { workspace = true } once_cell = { workspace = true } etcetera = { workspace = true } fs-err = { version = "3.1", default-features = false } +goose-providers = { path = "../goose-providers", default-features = false } goose-sdk-types = { path = "../goose-sdk-types" } rand = { workspace = true } utoipa = { workspace = true, features = ["chrono"] } @@ -213,6 +214,7 @@ pctx_code_mode = { version = "0.3", default-features = false, optional = true } icu_calendar = { version = "=2.1.1", default-features = false } icu_locale = { version = "=2.1.1", default-features = false } llama-cpp-sys-2 = { workspace = true, optional = true } +image = { version = "0.24.9", default-features = false, features = ["png", "jpeg", "gif", "webp"] } [target.'cfg(target_os = "windows")'.dependencies] winapi = { workspace = true } @@ -267,7 +269,7 @@ path = "src/bin/analyze_cli.rs" [[bin]] name = "build_canonical_models" -path = "src/providers/canonical/build_canonical_models.rs" +path = "src/bin/build_canonical_models.rs" [[bin]] name = "generate-acp-schema" diff --git a/crates/goose/acp-meta.json b/crates/goose/acp-meta.json index e72f437015fd..7be80cc9d4cc 100644 --- a/crates/goose/acp-meta.json +++ b/crates/goose/acp-meta.json @@ -42,8 +42,13 @@ }, { "method": "_goose/unstable/config/extensions/list", - "requestType": "GetExtensionsRequest_unstable", - "responseType": "GetExtensionsResponse_unstable" + "requestType": "GetConfigExtensionsRequest_unstable", + "responseType": "GetConfigExtensionsResponse_unstable" + }, + { + "method": "_goose/unstable/extensions/available", + "requestType": "GetAvailableExtensionsRequest_unstable", + "responseType": "GetAvailableExtensionsResponse_unstable" }, { "method": "_goose/unstable/config/extensions/add", @@ -56,8 +61,8 @@ "responseType": "EmptyResponse" }, { - "method": "_goose/unstable/config/extensions/toggle", - "requestType": "ToggleConfigExtensionRequest_unstable", + "method": "_goose/unstable/config/extensions/set-enabled", + "requestType": "SetConfigExtensionEnabledRequest_unstable", "responseType": "EmptyResponse" }, { diff --git a/crates/goose/acp-schema.json b/crates/goose/acp-schema.json index 5980ff34e68d..ebad7d1bc9c3 100644 --- a/crates/goose/acp-schema.json +++ b/crates/goose/acp-schema.json @@ -224,44 +224,419 @@ "x-side": "agent", "x-method": "session/delete" }, - "GetExtensionsRequest_unstable": { + "GetConfigExtensionsRequest_unstable": { "type": "object", "description": "List configured extensions and any warnings.", "x-side": "agent", "x-method": "_goose/unstable/config/extensions/list" }, - "GetExtensionsResponse_unstable": { + "GetConfigExtensionsResponse_unstable": { "type": "object", "properties": { "extensions": { "type": "array", - "items": {}, - "description": "Array of ExtensionEntry objects with `enabled` flag, `configKey`, and flattened config details." + "items": { + "$ref": "#/$defs/GooseExtensionEntry" + } }, "warnings": { "type": "array", "items": { "type": "string" - } + }, + "default": [] } }, "required": [ - "extensions", - "warnings" + "extensions" ], "description": "List configured extensions and any warnings.", "x-side": "agent", "x-method": "_goose/unstable/config/extensions/list" }, - "AddConfigExtensionRequest_unstable": { + "GooseExtensionEntry": { + "type": "object", + "properties": { + "extension": { + "$ref": "#/$defs/GooseExtension" + }, + "enabled": { + "type": "boolean" + }, + "configKey": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "extension", + "enabled" + ] + }, + "GooseExtension": { + "oneOf": [ + { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "display_name": { + "type": [ + "string", + "null" + ] + }, + "timeout": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "bundled": { + "type": [ + "boolean", + "null" + ] + }, + "type": { + "type": "string", + "const": "builtin" + } + }, + "required": [ + "type", + "name" + ] + }, + { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "display_name": { + "type": [ + "string", + "null" + ] + }, + "bundled": { + "type": [ + "boolean", + "null" + ] + }, + "type": { + "type": "string", + "const": "platform" + } + }, + "required": [ + "type", + "name" + ] + }, + { + "type": "object", + "properties": { + "server": { + "$ref": "#/$defs/McpServer" + }, + "envKeys": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "timeout": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "socket": { + "type": [ + "string", + "null" + ] + }, + "bundled": { + "type": [ + "boolean", + "null" + ] + }, + "type": { + "type": "string", + "const": "mcp" + } + }, + "required": [ + "type", + "server" + ] + } + ] + }, + "McpServer": { + "anyOf": [ + { + "$ref": "#/$defs/McpServerHttp", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "http" + } + }, + "required": [ + "type" + ], + "description": "HTTP transport configuration\n\nOnly available when the Agent capabilities indicate `mcp_capabilities.http` is `true`." + }, + { + "$ref": "#/$defs/McpServerSse", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sse" + } + }, + "required": [ + "type" + ], + "description": "SSE transport configuration\n\nOnly available when the Agent capabilities indicate `mcp_capabilities.sse` is `true`." + }, + { + "$ref": "#/$defs/McpServerStdio", + "description": "Stdio transport configuration\n\nAll Agents MUST support this transport." + } + ], + "description": "Configuration for connecting to an MCP (Model Context Protocol) server.\n\nMCP servers provide tools and context that the agent can use when\nprocessing prompts.\n\nSee protocol docs: [MCP Servers](https://agentclientprotocol.com/protocol/session-setup#mcp-servers)" + }, + "HttpHeader": { "type": "object", "properties": { "name": { - "type": "string" + "type": "string", + "description": "The name of the HTTP header." }, - "extensionConfig": { - "description": "Extension configuration. Must be a JSON object matching one of the\n`ExtensionConfig` variants (e.g. `stdio`, `streamable_http`, `builtin`).\n`name` and `enabled` are injected server-side.", - "default": null + "value": { + "type": "string", + "description": "The value to set for the HTTP header." + }, + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": {}, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)" + } + }, + "required": [ + "name", + "value" + ], + "description": "An HTTP header to set when making requests to the MCP server." + }, + "McpServerHttp": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Human-readable name identifying this MCP server." + }, + "url": { + "type": "string", + "description": "URL to the MCP server." + }, + "headers": { + "type": "array", + "items": { + "$ref": "#/$defs/HttpHeader" + }, + "description": "HTTP headers to set when making requests to the MCP server." + }, + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": {}, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)" + }, + "type": { + "type": "string", + "const": "http" + } + }, + "required": [ + "type", + "name", + "url", + "headers" + ], + "description": "HTTP transport configuration for MCP." + }, + "McpServerSse": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Human-readable name identifying this MCP server." + }, + "url": { + "type": "string", + "description": "URL to the MCP server." + }, + "headers": { + "type": "array", + "items": { + "$ref": "#/$defs/HttpHeader" + }, + "description": "HTTP headers to set when making requests to the MCP server." + }, + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": {}, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)" + }, + "type": { + "type": "string", + "const": "sse" + } + }, + "required": [ + "type", + "name", + "url", + "headers" + ], + "description": "SSE transport configuration for MCP." + }, + "McpServerStdio": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Human-readable name identifying this MCP server." + }, + "command": { + "type": "string", + "description": "Path to the MCP server executable." + }, + "args": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Command-line arguments to pass to the MCP server." + }, + "env": { + "type": "array", + "items": { + "$ref": "#/$defs/EnvVariable" + }, + "description": "Environment variables to set when launching the MCP server." + }, + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": {}, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)" + } + }, + "required": [ + "name", + "command", + "args", + "env" + ], + "description": "Stdio transport configuration for MCP." + }, + "EnvVariable": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the environment variable." + }, + "value": { + "type": "string", + "description": "The value to set for the environment variable." + }, + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": {}, + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)" + } + }, + "required": [ + "name", + "value" + ], + "description": "An environment variable to set when launching an MCP server." + }, + "GetAvailableExtensionsRequest_unstable": { + "type": "object", + "description": "List Goose-owned extension definitions available to configure or enable.", + "x-side": "agent", + "x-method": "_goose/unstable/extensions/available" + }, + "GetAvailableExtensionsResponse_unstable": { + "type": "object", + "properties": { + "extensions": { + "type": "array", + "items": { + "$ref": "#/$defs/GooseExtension" + } + } + }, + "required": [ + "extensions" + ], + "x-side": "agent", + "x-method": "_goose/unstable/extensions/available" + }, + "AddConfigExtensionRequest_unstable": { + "type": "object", + "properties": { + "extension": { + "$ref": "#/$defs/GooseExtension" }, "enabled": { "type": "boolean", @@ -269,7 +644,7 @@ } }, "required": [ - "name" + "extension" ], "description": "Persist a new extension to the user's global goose config.", "x-side": "agent", @@ -289,7 +664,7 @@ "x-side": "agent", "x-method": "_goose/unstable/config/extensions/remove" }, - "ToggleConfigExtensionRequest_unstable": { + "SetConfigExtensionEnabledRequest_unstable": { "type": "object", "properties": { "configKey": { @@ -303,9 +678,9 @@ "configKey", "enabled" ], - "description": "Toggle the `enabled` flag for a persisted extension in the user's global goose config.", + "description": "Set the `enabled` flag for a persisted extension in the user's global goose config.", "x-side": "agent", - "x-method": "_goose/unstable/config/extensions/toggle" + "x-method": "_goose/unstable/config/extensions/set-enabled" }, "GetSessionExtensionsRequest_unstable": { "type": "object", @@ -545,9 +920,8 @@ "integer", "null" ], - "format": "uint", - "minimum": 0, - "description": "Context window size in tokens." + "description": "Context window size in tokens.", + "minimum": 0 }, "reasoning": { "type": [ @@ -2962,11 +3336,20 @@ { "allOf": [ { - "$ref": "#/$defs/GetExtensionsRequest_unstable" + "$ref": "#/$defs/GetConfigExtensionsRequest_unstable" } ], "description": "Params for _goose/unstable/config/extensions/list", - "title": "GetExtensionsRequest_unstable" + "title": "GetConfigExtensionsRequest_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/GetAvailableExtensionsRequest_unstable" + } + ], + "description": "Params for _goose/unstable/extensions/available", + "title": "GetAvailableExtensionsRequest_unstable" }, { "allOf": [ @@ -2989,11 +3372,11 @@ { "allOf": [ { - "$ref": "#/$defs/ToggleConfigExtensionRequest_unstable" + "$ref": "#/$defs/SetConfigExtensionEnabledRequest_unstable" } ], - "description": "Params for _goose/unstable/config/extensions/toggle", - "title": "ToggleConfigExtensionRequest_unstable" + "description": "Params for _goose/unstable/config/extensions/set-enabled", + "title": "SetConfigExtensionEnabledRequest_unstable" }, { "allOf": [ @@ -3474,10 +3857,18 @@ { "allOf": [ { - "$ref": "#/$defs/GetExtensionsResponse_unstable" + "$ref": "#/$defs/GetConfigExtensionsResponse_unstable" + } + ], + "title": "GetConfigExtensionsResponse_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/GetAvailableExtensionsResponse_unstable" } ], - "title": "GetExtensionsResponse_unstable" + "title": "GetAvailableExtensionsResponse_unstable" }, { "allOf": [ diff --git a/crates/goose/src/acp/server/custom_dispatch.rs b/crates/goose/src/acp/server/custom_dispatch.rs index 65c23aa8f6ed..fe0df9040662 100644 --- a/crates/goose/src/acp/server/custom_dispatch.rs +++ b/crates/goose/src/acp/server/custom_dispatch.rs @@ -75,11 +75,18 @@ impl GooseAcpAgent { self.on_delete_session(req).await } - #[custom_method(GetExtensionsRequest)] - async fn dispatch_get_extensions( + #[custom_method(GetConfigExtensionsRequest)] + async fn dispatch_get_config_extensions( &self, - ) -> Result { - self.on_get_extensions().await + ) -> Result { + self.on_get_config_extensions().await + } + + #[custom_method(GetAvailableExtensionsRequest)] + async fn dispatch_get_available_extensions( + &self, + ) -> Result { + self.on_get_available_extensions().await } #[custom_method(AddConfigExtensionRequest)] @@ -98,12 +105,12 @@ impl GooseAcpAgent { self.on_remove_config_extension(req).await } - #[custom_method(ToggleConfigExtensionRequest)] - async fn dispatch_toggle_config_extension( + #[custom_method(SetConfigExtensionEnabledRequest)] + async fn dispatch_set_config_extension_enabled( &self, - req: ToggleConfigExtensionRequest, + req: SetConfigExtensionEnabledRequest, ) -> Result { - self.on_toggle_config_extension(req).await + self.on_set_config_extension_enabled(req).await } #[custom_method(GetSessionExtensionsRequest)] diff --git a/crates/goose/src/acp/server/extensions.rs b/crates/goose/src/acp/server/extensions.rs index c44b446d36d3..ff58cb21d358 100644 --- a/crates/goose/src/acp/server/extensions.rs +++ b/crates/goose/src/acp/server/extensions.rs @@ -1,4 +1,7 @@ use super::*; +use crate::agents::extension::Envs; +use crate::config::extensions::ExtensionEntry; +use agent_client_protocol::schema::{HttpHeader, McpServer, McpServerHttp, McpServerStdio}; impl GooseAcpAgent { pub(super) async fn on_add_extension( @@ -30,9 +33,9 @@ impl GooseAcpAgent { Ok(EmptyResponse {}) } - pub(super) async fn on_get_extensions( + pub(super) async fn on_get_config_extensions( &self, - ) -> Result { + ) -> Result { let extensions = crate::config::extensions::get_all_extensions() .into_iter() .filter(|ext| { @@ -40,51 +43,46 @@ impl GooseAcpAgent { }) .collect::>(); let warnings = crate::config::extensions::get_warnings(); - let extensions_json = extensions + let extensions = extensions .into_iter() - .map(|e| { - let config_key = e.config.key(); - let mut value = serde_json::to_value(&e)?; - if let Some(obj) = value.as_object_mut() { - obj.insert( - "config_key".to_string(), - serde_json::Value::String(config_key), - ); - } - Ok::<_, serde_json::Error>(value) - }) - .collect::, _>>() - .internal_err()?; - Ok(GetExtensionsResponse { - extensions: extensions_json, + .map(config_entry_to_goose_entry) + .collect::, _>>()? + .into_iter() + .flatten() + .collect::>(); + Ok(GetConfigExtensionsResponse { + extensions, warnings, }) } + pub(super) async fn on_get_available_extensions( + &self, + ) -> Result { + let extensions = crate::config::get_available_extensions() + .into_iter() + .map(|config| config_to_goose_extension(&config)) + .collect::, _>>()? + .into_iter() + .flatten() + .collect::>(); + + Ok(GetAvailableExtensionsResponse { extensions }) + } + pub(super) async fn on_add_config_extension( &self, req: AddConfigExtensionRequest, ) -> Result { - let mut obj = match req.extension_config { - serde_json::Value::Object(obj) => obj, - _ => { - return Err(agent_client_protocol::Error::invalid_params() - .data("extensionConfig must be a JSON object")); - } - }; - obj.insert( - "name".to_string(), - serde_json::Value::String(req.name.clone()), - ); + let conversion = goose_extension_to_config(req.extension)?; - let config: crate::agents::ExtensionConfig = - serde_json::from_value(serde_json::Value::Object(obj)).map_err(|e| { - agent_client_protocol::Error::invalid_params().data(format!("bad config: {e}")) - })?; + Config::global() + .set_secret_values(&conversion.secret_updates) + .internal_err_ctx("Failed to save extension env secrets")?; - crate::config::extensions::set_extension(crate::config::extensions::ExtensionEntry { + crate::config::extensions::set_extension(ExtensionEntry { enabled: req.enabled, - config, + config: conversion.config, }); Ok(EmptyResponse {}) } @@ -93,27 +91,21 @@ impl GooseAcpAgent { &self, req: RemoveConfigExtensionRequest, ) -> Result { - let key = crate::config::extensions::name_to_key(&req.config_key); - let keys = crate::config::extensions::get_all_extension_names(); - if !keys.iter().any(|k| k == &key) { - return Err(agent_client_protocol::Error::invalid_params() - .data(format!("Extension '{}' not found", req.config_key))); - } - crate::config::extensions::remove_extension(&key); + crate::config::extensions::remove_extension(&req.config_key); Ok(EmptyResponse {}) } - pub(super) async fn on_toggle_config_extension( + pub(super) async fn on_set_config_extension_enabled( &self, - req: ToggleConfigExtensionRequest, + req: SetConfigExtensionEnabledRequest, ) -> Result { - let key = crate::config::extensions::name_to_key(&req.config_key); - let keys = crate::config::extensions::get_all_extension_names(); - if !keys.iter().any(|k| k == &key) { + let updated = + crate::config::extensions::set_extension_enabled(&req.config_key, req.enabled); + if !updated { return Err(agent_client_protocol::Error::invalid_params() .data(format!("Extension '{}' not found", req.config_key))); } - crate::config::extensions::set_extension_enabled(&key, req.enabled); + Ok(EmptyResponse {}) } @@ -144,3 +136,655 @@ impl GooseAcpAgent { }) } } + +fn config_to_goose_extension( + config: &ExtensionConfig, +) -> Result, agent_client_protocol::Error> { + let extension = match config { + ExtensionConfig::Builtin { + name, + description, + display_name, + timeout, + bundled, + .. + } => GooseExtension::Builtin { + name: name.clone(), + description: empty_string_to_none(description), + display_name: display_name.clone(), + timeout: *timeout, + bundled: *bundled, + }, + ExtensionConfig::Platform { + name, + description, + display_name, + bundled, + .. + } => GooseExtension::Platform { + name: name.clone(), + description: empty_string_to_none(description), + display_name: display_name.clone(), + bundled: *bundled, + }, + ExtensionConfig::Stdio { + name, + description, + cmd, + args, + env_keys, + timeout, + bundled, + .. + } => GooseExtension::Mcp { + server: McpServer::Stdio(McpServerStdio::new(name, cmd).args(args.clone())), + env_keys: env_keys.clone(), + description: empty_string_to_none(description), + timeout: *timeout, + socket: None, + bundled: *bundled, + }, + ExtensionConfig::StreamableHttp { + name, + description, + uri, + env_keys, + headers, + timeout, + socket, + bundled, + .. + } => { + let headers = headers + .iter() + .map(|(key, value)| HttpHeader::new(key, value)) + .collect(); + GooseExtension::Mcp { + server: McpServer::Http(McpServerHttp::new(name, uri).headers(headers)), + env_keys: env_keys.clone(), + description: empty_string_to_none(description), + timeout: *timeout, + socket: socket.clone(), + bundled: *bundled, + } + } + ExtensionConfig::Frontend { .. } + | ExtensionConfig::InlinePython { .. } + | ExtensionConfig::Sse { .. } => return Ok(None), + }; + Ok(Some(extension)) +} + +struct ConfigExtensionConversion { + config: ExtensionConfig, + secret_updates: Vec<(String, serde_json::Value)>, +} + +fn goose_extension_to_config( + extension: GooseExtension, +) -> Result { + let mut secret_updates = Vec::new(); + let config = match extension { + GooseExtension::Builtin { + name, + description, + display_name, + timeout, + bundled, + } => ExtensionConfig::Builtin { + name, + description: description.unwrap_or_default(), + display_name, + timeout, + bundled, + available_tools: Vec::new(), + }, + GooseExtension::Platform { + name, + description, + display_name, + bundled, + } => ExtensionConfig::Platform { + name, + description: description.unwrap_or_default(), + display_name, + bundled, + available_tools: Vec::new(), + }, + GooseExtension::Mcp { + server, + env_keys, + description, + timeout, + socket, + bundled, + } => match server { + McpServer::Stdio(stdio) => { + if socket.is_some() { + return Err(agent_client_protocol::Error::invalid_params() + .data("socket is only supported for streamable_http MCP extensions")); + } + let mut env_keys = env_keys; + for env in stdio.env { + if !env_keys.contains(&env.name) { + env_keys.push(env.name.clone()); + } + secret_updates.push((env.name, serde_json::Value::String(env.value))); + } + ExtensionConfig::Stdio { + name: stdio.name, + description: description.unwrap_or_default(), + cmd: stdio.command.to_string_lossy().to_string(), + args: stdio.args, + envs: Envs::default(), + env_keys, + timeout, + bundled, + available_tools: Vec::new(), + } + } + McpServer::Http(http) => ExtensionConfig::StreamableHttp { + name: http.name, + description: description.unwrap_or_default(), + uri: http.url, + envs: Envs::default(), + env_keys, + headers: http + .headers + .into_iter() + .map(|header| (header.name, header.value)) + .collect(), + timeout, + socket, + bundled, + available_tools: Vec::new(), + }, + McpServer::Sse(_) => { + return Err(agent_client_protocol::Error::invalid_params() + .data("SSE is unsupported, migrate to streamable_http")); + } + _ => { + return Err( + agent_client_protocol::Error::invalid_params().data("unsupported MCP server") + ); + } + }, + }; + Ok(ConfigExtensionConversion { + config, + secret_updates, + }) +} + +fn config_entry_to_goose_entry( + entry: ExtensionEntry, +) -> Result, agent_client_protocol::Error> { + let config_key = entry.config.key(); + let Some(extension) = config_to_goose_extension(&entry.config)? else { + return Ok(None); + }; + Ok(Some(GooseExtensionEntry { + extension, + enabled: entry.enabled, + config_key: Some(config_key), + })) +} + +fn empty_string_to_none(value: &str) -> Option { + if value.is_empty() { + None + } else { + Some(value.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agents::extension::Envs; + use agent_client_protocol::schema::{McpServer, McpServerSse}; + use std::collections::HashMap; + + #[test] + fn builtin_config_converts_to_goose_builtin_extension() { + let config = ExtensionConfig::Builtin { + name: "developer".to_string(), + description: "Developer tools".to_string(), + display_name: Some("Developer".to_string()), + timeout: Some(30), + bundled: Some(true), + available_tools: vec!["shell".to_string()], + }; + + let extension = config_to_goose_extension(&config) + .expect("conversion should succeed") + .expect("builtin should be supported"); + + let GooseExtension::Builtin { + name, + description, + display_name, + timeout, + bundled, + } = extension + else { + panic!("expected builtin extension"); + }; + + assert_eq!(name, "developer"); + assert_eq!(description.as_deref(), Some("Developer tools")); + assert_eq!(display_name.as_deref(), Some("Developer")); + assert_eq!(timeout, Some(30)); + assert_eq!(bundled, Some(true)); + } + + #[test] + fn platform_config_converts_to_goose_platform_extension() { + let config = ExtensionConfig::Platform { + name: "todo".to_string(), + description: "Todo tools".to_string(), + display_name: Some("Todo".to_string()), + bundled: Some(true), + available_tools: vec!["write_todos".to_string()], + }; + + let extension = config_to_goose_extension(&config) + .expect("conversion should succeed") + .expect("platform should be supported"); + + let GooseExtension::Platform { + name, + description, + display_name, + bundled, + } = extension + else { + panic!("expected platform extension"); + }; + + assert_eq!(name, "todo"); + assert_eq!(description.as_deref(), Some("Todo tools")); + assert_eq!(display_name.as_deref(), Some("Todo")); + assert_eq!(bundled, Some(true)); + } + + #[test] + fn stdio_config_converts_to_goose_mcp_extension_without_literal_envs() { + let config = ExtensionConfig::Stdio { + name: "test-stdio".to_string(), + description: "Test stdio".to_string(), + cmd: "test-command".to_string(), + args: vec!["--flag".to_string(), "value".to_string()], + envs: Envs::new(HashMap::from([( + "SECRET_TOKEN".to_string(), + "literal-secret".to_string(), + )])), + env_keys: vec!["SECRET_TOKEN".to_string()], + timeout: Some(42), + bundled: None, + available_tools: vec![], + }; + + let extension = config_to_goose_extension(&config) + .expect("conversion should succeed") + .expect("stdio should be supported"); + + let GooseExtension::Mcp { + server, + env_keys, + description, + timeout, + socket, + bundled, + } = extension + else { + panic!("expected mcp extension"); + }; + + assert_eq!(env_keys, vec!["SECRET_TOKEN"]); + assert_eq!(description.as_deref(), Some("Test stdio")); + assert_eq!(timeout, Some(42)); + assert_eq!(socket, None); + assert_eq!(bundled, None); + + let McpServer::Stdio(stdio) = server else { + panic!("expected stdio server"); + }; + + assert_eq!(stdio.name, "test-stdio"); + assert_eq!(stdio.command.to_string_lossy(), "test-command"); + assert_eq!(stdio.args, vec!["--flag", "value"]); + assert!(stdio.env.is_empty(), "literal envs should not be exposed"); + } + + #[test] + fn streamable_http_config_converts_to_goose_mcp_extension_without_literal_envs() { + let config = ExtensionConfig::StreamableHttp { + name: "test-http".to_string(), + description: "Test HTTP".to_string(), + uri: "https://example.com/mcp".to_string(), + envs: Envs::new(HashMap::from([( + "API_TOKEN".to_string(), + "literal-secret".to_string(), + )])), + env_keys: vec!["API_TOKEN".to_string()], + headers: HashMap::from([( + "Authorization".to_string(), + "Bearer ${API_TOKEN}".to_string(), + )]), + timeout: Some(99), + socket: Some("@egress.sock".to_string()), + bundled: None, + available_tools: vec![], + }; + + let extension = config_to_goose_extension(&config) + .expect("conversion should succeed") + .expect("streamable http should be supported"); + + let GooseExtension::Mcp { + server, + env_keys, + description, + timeout, + socket, + bundled, + } = extension + else { + panic!("expected mcp extension"); + }; + + assert_eq!(env_keys, vec!["API_TOKEN"]); + assert_eq!(description.as_deref(), Some("Test HTTP")); + assert_eq!(timeout, Some(99)); + assert_eq!(socket.as_deref(), Some("@egress.sock")); + assert_eq!(bundled, None); + + let McpServer::Http(http) = server else { + panic!("expected http server"); + }; + + assert_eq!(http.name, "test-http"); + assert_eq!(http.url, "https://example.com/mcp"); + assert_eq!(http.headers.len(), 1); + assert_eq!(http.headers[0].name, "Authorization"); + assert_eq!(http.headers[0].value, "Bearer ${API_TOKEN}"); + } + + #[test] + fn inline_python_config_is_skipped() { + let config = ExtensionConfig::InlinePython { + name: "python-tools".to_string(), + description: "Python tools".to_string(), + code: "print('hello')".to_string(), + timeout: Some(12), + dependencies: Some(vec!["requests".to_string()]), + available_tools: vec!["fetch".to_string()], + }; + + let extension = config_to_goose_extension(&config).expect("conversion should succeed"); + + assert!(extension.is_none()); + } + + #[test] + fn frontend_config_is_skipped() { + let tool = rmcp::model::Tool::new( + "pick_color", + "Pick a color", + serde_json::json!({ + "type": "object", + "properties": { + "hex": { "type": "string" } + } + }) + .as_object() + .expect("schema should be object") + .clone(), + ); + let config = ExtensionConfig::Frontend { + name: "frontend-tools".to_string(), + description: "Frontend tools".to_string(), + tools: vec![tool], + instructions: Some("Use frontend tools carefully".to_string()), + bundled: None, + available_tools: vec!["pick_color".to_string()], + }; + + let extension = config_to_goose_extension(&config).expect("conversion should succeed"); + + assert!(extension.is_none()); + } + + #[test] + fn sse_config_is_skipped() { + let config = ExtensionConfig::Sse { + name: "legacy-sse".to_string(), + description: "Legacy SSE".to_string(), + uri: Some("https://example.com/sse".to_string()), + }; + + let extension = config_to_goose_extension(&config).expect("conversion should succeed"); + + assert!(extension.is_none()); + } + + #[test] + fn goose_mcp_stdio_extension_converts_to_config_without_literal_envs() { + let extension = GooseExtension::Mcp { + server: McpServer::Stdio( + McpServerStdio::new("test-stdio", "test-command") + .args(vec!["--flag".to_string(), "value".to_string()]), + ), + env_keys: vec!["SECRET_TOKEN".to_string()], + description: Some("Test stdio".to_string()), + timeout: Some(42), + socket: None, + bundled: Some(true), + }; + + let conversion = goose_extension_to_config(extension).expect("conversion should succeed"); + assert!(conversion.secret_updates.is_empty()); + + let ExtensionConfig::Stdio { + name, + description, + cmd, + args, + envs, + env_keys, + timeout, + bundled, + available_tools, + } = conversion.config + else { + panic!("expected stdio config"); + }; + + assert_eq!(name, "test-stdio"); + assert_eq!(description, "Test stdio"); + assert_eq!(cmd, "test-command"); + assert_eq!(args, vec!["--flag", "value"]); + assert!( + envs.get_env().is_empty(), + "literal envs should not be persisted" + ); + assert_eq!(env_keys, vec!["SECRET_TOKEN"]); + assert_eq!(timeout, Some(42)); + assert_eq!(bundled, Some(true)); + assert!(available_tools.is_empty()); + } + + #[test] + fn goose_mcp_stdio_extension_extracts_literal_envs_for_config_add() { + let extension = GooseExtension::Mcp { + server: McpServer::Stdio(McpServerStdio::new("test-stdio", "test-command").env(vec![ + agent_client_protocol::schema::EnvVariable::new("SECRET_TOKEN", "literal-secret"), + agent_client_protocol::schema::EnvVariable::new("OTHER_TOKEN", "other-secret"), + ])), + env_keys: vec!["SECRET_TOKEN".to_string()], + description: Some("Test stdio".to_string()), + timeout: Some(42), + socket: None, + bundled: Some(true), + }; + + let conversion = goose_extension_to_config(extension).expect("conversion should succeed"); + + assert_eq!( + conversion.secret_updates, + vec![ + ( + "SECRET_TOKEN".to_string(), + serde_json::Value::String("literal-secret".to_string()) + ), + ( + "OTHER_TOKEN".to_string(), + serde_json::Value::String("other-secret".to_string()) + ) + ] + ); + + let ExtensionConfig::Stdio { envs, env_keys, .. } = conversion.config else { + panic!("expected stdio config"); + }; + + assert!( + envs.get_env().is_empty(), + "literal envs should not be persisted" + ); + assert_eq!(env_keys, vec!["SECRET_TOKEN", "OTHER_TOKEN"]); + } + + #[test] + fn goose_mcp_streamable_http_extension_converts_to_config_without_literal_envs() { + let extension = GooseExtension::Mcp { + server: McpServer::Http( + McpServerHttp::new("test-http", "https://example.com/mcp").headers(vec![ + HttpHeader::new("Authorization", "Bearer ${API_TOKEN}"), + ]), + ), + env_keys: vec!["API_TOKEN".to_string()], + description: Some("Test HTTP".to_string()), + timeout: Some(99), + socket: Some("@egress.sock".to_string()), + bundled: Some(true), + }; + + let conversion = goose_extension_to_config(extension).expect("conversion should succeed"); + assert!(conversion.secret_updates.is_empty()); + + let ExtensionConfig::StreamableHttp { + name, + description, + uri, + envs, + env_keys, + headers, + timeout, + socket, + bundled, + available_tools, + } = conversion.config + else { + panic!("expected streamable http config"); + }; + + assert_eq!(name, "test-http"); + assert_eq!(description, "Test HTTP"); + assert_eq!(uri, "https://example.com/mcp"); + assert!( + envs.get_env().is_empty(), + "literal envs should not be persisted" + ); + assert_eq!(env_keys, vec!["API_TOKEN"]); + assert_eq!( + headers, + HashMap::from([( + "Authorization".to_string(), + "Bearer ${API_TOKEN}".to_string() + )]) + ); + assert_eq!(timeout, Some(99)); + assert_eq!(socket.as_deref(), Some("@egress.sock")); + assert_eq!(bundled, Some(true)); + assert!(available_tools.is_empty()); + } + + #[test] + fn goose_builtin_extension_converts_to_config() { + let builtin = GooseExtension::Builtin { + name: "developer".to_string(), + description: Some("Developer tools".to_string()), + display_name: Some("Developer".to_string()), + timeout: Some(30), + bundled: Some(true), + }; + + let conversion = goose_extension_to_config(builtin).expect("conversion should succeed"); + assert!(conversion.secret_updates.is_empty()); + + let ExtensionConfig::Builtin { + name, + description, + display_name, + timeout, + bundled, + available_tools, + } = conversion.config + else { + panic!("expected builtin config"); + }; + + assert_eq!(name, "developer"); + assert_eq!(description, "Developer tools"); + assert_eq!(display_name.as_deref(), Some("Developer")); + assert_eq!(timeout, Some(30)); + assert_eq!(bundled, Some(true)); + assert!(available_tools.is_empty()); + } + + #[test] + fn goose_platform_extension_converts_to_config() { + let platform = GooseExtension::Platform { + name: "todo".to_string(), + description: Some("Todo tools".to_string()), + display_name: Some("Todo".to_string()), + bundled: Some(true), + }; + + let conversion = goose_extension_to_config(platform).expect("conversion should succeed"); + assert!(conversion.secret_updates.is_empty()); + + let ExtensionConfig::Platform { + name, + description, + display_name, + bundled, + available_tools, + } = conversion.config + else { + panic!("expected platform config"); + }; + + assert_eq!(name, "todo"); + assert_eq!(description, "Todo tools"); + assert_eq!(display_name.as_deref(), Some("Todo")); + assert_eq!(bundled, Some(true)); + assert!(available_tools.is_empty()); + } + + #[test] + fn goose_mcp_sse_extension_is_rejected_for_config_add() { + let extension = GooseExtension::Mcp { + server: McpServer::Sse(McpServerSse::new("legacy-sse", "https://example.com/sse")), + env_keys: Vec::new(), + description: None, + timeout: None, + socket: None, + bundled: None, + }; + + assert!(goose_extension_to_config(extension).is_err()); + } +} diff --git a/crates/goose/src/agents/platform_extensions/developer/image.rs b/crates/goose/src/agents/platform_extensions/developer/image.rs new file mode 100644 index 000000000000..83ea7b39bef6 --- /dev/null +++ b/crates/goose/src/agents/platform_extensions/developer/image.rs @@ -0,0 +1,255 @@ +use std::io::Cursor; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use base64::Engine; +use image::GenericImageView; +use rmcp::model::{CallToolResult, Content}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +use super::edit::resolve_path; + +const MAX_IMAGE_BYTES: u64 = 20 * 1024 * 1024; + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ImageReadParams { + /// Local file path or http(s) URL of the image to load. + pub source: String, + /// Optional crop rectangle in pixels. Coordinates are measured from the top-left corner. + /// use to zoom in and get more details. + #[serde(default)] + pub crop: Option, +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +pub struct CropParams { + /// Left edge of the crop rectangle in pixels. + pub x: u32, + /// Top edge of the crop rectangle in pixels. + pub y: u32, + /// Width of the crop rectangle in pixels. + pub width: u32, + /// Height of the crop rectangle in pixels. + pub height: u32, +} + +pub struct ImageTool; + +impl ImageTool { + pub fn new() -> Self { + Self + } + + pub async fn image_read_with_cwd( + &self, + params: ImageReadParams, + working_dir: Option<&Path>, + ) -> CallToolResult { + match load_image(¶ms, working_dir).await { + Ok(loaded) => { + let mut result = CallToolResult::success(vec![ + Content::text(loaded.summary(¶ms.source)).with_priority(0.0), + Content::image(loaded.data, loaded.mime_type.clone()).with_priority(0.0), + ]); + result.structured_content = Some(json!({ + "source": params.source, + "mimeType": loaded.mime_type, + "width": loaded.width, + "height": loaded.height, + "bytes": loaded.bytes_len, + "originalWidth": loaded.original_width, + "originalHeight": loaded.original_height, + "crop": params.crop, + })); + result + } + Err(error) => CallToolResult::error(vec![ + Content::text(format!("Error: {error}")).with_priority(0.0) + ]), + } + } +} + +impl Default for ImageTool { + fn default() -> Self { + Self::new() + } +} + +#[derive(Debug)] +struct LoadedImage { + data: String, + mime_type: String, + bytes_len: usize, + width: u32, + height: u32, + original_width: u32, + original_height: u32, + cropped: bool, +} + +impl LoadedImage { + fn summary(&self, source: &str) -> String { + let crop_note = if self.cropped { + format!( + " Cropped from {}x{} to {}x{}.", + self.original_width, self.original_height, self.width, self.height + ) + } else { + String::new() + }; + + format!( + "Loaded image from {source} ({} bytes, {}, {}x{}).{crop_note}", + self.bytes_len, self.mime_type, self.width, self.height + ) + } +} + +async fn load_image( + params: &ImageReadParams, + working_dir: Option<&Path>, +) -> Result { + if params.source.trim().is_empty() { + return Err("source cannot be empty".to_string()); + } + + let bytes = load_image_bytes(¶ms.source, working_dir).await?; + ensure_image_size(bytes.len() as u64)?; + + let format = image::guess_format(&bytes).map_err(|_| { + "unsupported image format; supported formats are png, jpeg, gif, and webp".to_string() + })?; + let mime_type = mime_type(format)?; + let image = image::load_from_memory_with_format(&bytes, format) + .map_err(|error| format!("failed to decode image: {error}"))?; + let (original_width, original_height) = image.dimensions(); + + let Some(crop) = ¶ms.crop else { + return Ok(LoadedImage { + data: base64::prelude::BASE64_STANDARD.encode(&bytes), + mime_type: mime_type.to_string(), + bytes_len: bytes.len(), + width: original_width, + height: original_height, + original_width, + original_height, + cropped: false, + }); + }; + + validate_crop(crop, original_width, original_height)?; + let cropped = image.crop_imm(crop.x, crop.y, crop.width, crop.height); + let mut cropped_bytes = Cursor::new(Vec::new()); + cropped + .write_to(&mut cropped_bytes, image::ImageFormat::Png) + .map_err(|error| format!("failed to encode cropped image: {error}"))?; + let cropped_bytes = cropped_bytes.into_inner(); + ensure_image_size(cropped_bytes.len() as u64)?; + + Ok(LoadedImage { + data: base64::prelude::BASE64_STANDARD.encode(&cropped_bytes), + mime_type: "image/png".to_string(), + bytes_len: cropped_bytes.len(), + width: crop.width, + height: crop.height, + original_width, + original_height, + cropped: true, + }) +} + +async fn load_image_bytes(source: &str, working_dir: Option<&Path>) -> Result, String> { + if let Ok(url) = url::Url::parse(source) { + match url.scheme() { + "http" | "https" => load_url_bytes(url).await, + "file" => { + let path = url + .to_file_path() + .map_err(|_| "invalid file URL".to_string())?; + load_file_bytes(path) + } + _ => load_file_bytes(resolve_path(source, working_dir)), + } + } else { + load_file_bytes(resolve_path(source, working_dir)) + } +} + +async fn load_url_bytes(url: url::Url) -> Result, String> { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .map_err(|error| format!("failed to create HTTP client: {error}"))?; + + let response = client + .get(url) + .send() + .await + .map_err(|error| format!("failed to download image: {error}"))? + .error_for_status() + .map_err(|error| format!("failed to download image: {error}"))?; + + if let Some(len) = response.content_length() { + ensure_image_size(len)?; + } + + let bytes = response + .bytes() + .await + .map_err(|error| format!("failed to read image response: {error}"))?; + + Ok(bytes.to_vec()) +} + +fn load_file_bytes(path: PathBuf) -> Result, String> { + std::fs::read(path).map_err(|error| format!("failed to read image file: {error}")) +} + +fn validate_crop(crop: &CropParams, image_width: u32, image_height: u32) -> Result<(), String> { + if crop.width == 0 || crop.height == 0 { + return Err("crop width and height must be greater than zero".to_string()); + } + + let right = crop + .x + .checked_add(crop.width) + .ok_or_else(|| "crop rectangle is out of bounds".to_string())?; + let bottom = crop + .y + .checked_add(crop.height) + .ok_or_else(|| "crop rectangle is out of bounds".to_string())?; + + if right > image_width || bottom > image_height { + return Err(format!( + "crop rectangle {}x{} at {},{} exceeds image bounds {}x{}", + crop.width, crop.height, crop.x, crop.y, image_width, image_height + )); + } + + Ok(()) +} + +fn ensure_image_size(len: u64) -> Result<(), String> { + if len > MAX_IMAGE_BYTES { + Err(format!( + "image is too large: {len} bytes exceeds {MAX_IMAGE_BYTES} byte limit" + )) + } else { + Ok(()) + } +} + +fn mime_type(format: image::ImageFormat) -> Result<&'static str, String> { + match format { + image::ImageFormat::Png => Ok("image/png"), + image::ImageFormat::Jpeg => Ok("image/jpeg"), + image::ImageFormat::Gif => Ok("image/gif"), + image::ImageFormat::WebP => Ok("image/webp"), + _ => Err( + "unsupported image format; supported formats are png, jpeg, gif, and webp".to_string(), + ), + } +} diff --git a/crates/goose/src/agents/platform_extensions/developer/mod.rs b/crates/goose/src/agents/platform_extensions/developer/mod.rs index 2a171fca7cef..71607aa3850a 100644 --- a/crates/goose/src/agents/platform_extensions/developer/mod.rs +++ b/crates/goose/src/agents/platform_extensions/developer/mod.rs @@ -1,4 +1,5 @@ pub mod edit; +pub mod image; pub mod shell; pub mod tree; @@ -8,6 +9,7 @@ use crate::agents::ToolCallContext; use anyhow::Result; use async_trait::async_trait; use edit::{EditTools, FileEditParams, FileWriteParams}; +use image::{ImageReadParams, ImageTool}; use indoc::indoc; use rmcp::model::{ CallToolResult, Content, Implementation, InitializeResult, JsonObject, ListToolsResult, @@ -27,6 +29,7 @@ pub struct DeveloperClient { shell_tool: Arc, edit_tools: Arc, tree_tool: Arc, + image_tool: Arc, } fn developer_instructions() -> &'static str { @@ -74,6 +77,7 @@ impl DeveloperClient { shell_tool: Arc::new(ShellTool::new(context.use_login_shell_path)?), edit_tools: Arc::new(EditTools::new()), tree_tool: Arc::new(TreeTool::new()), + image_tool: Arc::new(ImageTool::new()), }) } @@ -152,6 +156,18 @@ impl DeveloperClient { Some(true), Some(false), )), + Tool::new( + "read_image".to_string(), + "Read an image from a local file path or http(s) URL and return it as image content for the model to inspect. Supports png, jpeg, gif, and webp.".to_string(), + Self::schema::(), + ) + .annotate(ToolAnnotations::from_raw( + Some("Read Image".to_string()), + Some(true), + Some(false), + Some(true), + Some(false), + )), ] } } @@ -205,6 +221,16 @@ impl McpClientTrait for DeveloperClient { )) .with_priority(0.0)])), }, + "read_image" => match Self::parse_args::(arguments) { + Ok(params) => Ok(self + .image_tool + .image_read_with_cwd(params, working_dir) + .await), + Err(error) => Ok(CallToolResult::error(vec![Content::text(format!( + "Error: {error}" + )) + .with_priority(0.0)])), + }, _ => Ok(CallToolResult::error(vec![Content::text(format!( "Error: Unknown tool: {name}" )) @@ -232,7 +258,7 @@ mod tests { .map(|t| t.name.to_string()) .collect(); - assert_eq!(names, vec!["write", "edit", "shell", "tree"]); + assert_eq!(names, vec!["write", "edit", "shell", "tree", "read_image"]); } fn test_context(data_dir: std::path::PathBuf) -> PlatformExtensionContext { diff --git a/crates/goose/src/providers/canonical/build_canonical_models.rs b/crates/goose/src/bin/build_canonical_models.rs similarity index 99% rename from crates/goose/src/providers/canonical/build_canonical_models.rs rename to crates/goose/src/bin/build_canonical_models.rs index cea82f16431f..042917f26225 100644 --- a/crates/goose/src/providers/canonical/build_canonical_models.rs +++ b/crates/goose/src/bin/build_canonical_models.rs @@ -9,10 +9,11 @@ /// use anyhow::{Context, Result}; use clap::Parser; -use goose::providers::canonical::{ - canonical_name, CanonicalModel, CanonicalModelRegistry, Limit, Modalities, Modality, Pricing, +use goose::providers::create_with_named_model; +use goose_providers::canonical::{ + canonical_name, CanonicalModel, CanonicalModelRegistry, Limit, Modalities, Modality, + ModelMapping, Pricing, }; -use goose::providers::{canonical::ModelMapping, create_with_named_model}; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::{BTreeMap, BTreeSet, HashMap}; @@ -320,7 +321,7 @@ impl MappingReport { fn data_file_path(filename: &str) -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("src/providers/canonical/data") + .join("../goose-providers/src/canonical/data") .join(filename) } diff --git a/crates/goose/src/bin/generate_acp_schema.rs b/crates/goose/src/bin/generate_acp_schema.rs index 90daed775662..bfbf1f7a90ce 100644 --- a/crates/goose/src/bin/generate_acp_schema.rs +++ b/crates/goose/src/bin/generate_acp_schema.rs @@ -74,6 +74,8 @@ fn main() { strip_integer_formats(def); } + add_mcp_server_transport_discriminants(&mut defs); + // Annotate $defs entries with x-method/x-side. Only set x-method for types // used by exactly one method (shared types like EmptyResponse skip x-method). for (name, methods_list) in &type_methods { @@ -316,6 +318,42 @@ fn rewrite_unstable_schema_refs(value: &mut Value, unstable_type_names: &BTreeSe } } +fn add_mcp_server_transport_discriminants(defs: &mut Map) { + add_object_discriminant(defs, "McpServerHttp", "http"); + add_object_discriminant(defs, "McpServerSse", "sse"); +} + +fn add_object_discriminant(defs: &mut Map, def_name: &str, tag: &str) { + let def = defs + .get_mut(def_name) + .unwrap_or_else(|| panic!("missing {def_name} schema definition")); + let obj = def + .as_object_mut() + .unwrap_or_else(|| panic!("{def_name} schema definition must be an object")); + + let properties = obj + .entry("properties") + .or_insert_with(|| json!({})) + .as_object_mut() + .unwrap_or_else(|| panic!("{def_name}.properties must be an object")); + properties.insert( + "type".into(), + json!({ + "type": "string", + "const": tag, + }), + ); + + let required = obj + .entry("required") + .or_insert_with(|| json!([])) + .as_array_mut() + .unwrap_or_else(|| panic!("{def_name}.required must be an array")); + if !required.iter().any(|item| item.as_str() == Some("type")) { + required.insert(0, json!("type")); + } +} + /// Recursively strip `"format"` from integer-typed schemas. /// /// schemars emits `"format": "uint64"` / `"int64"` etc. for Rust integer types. @@ -323,7 +361,15 @@ fn rewrite_unstable_schema_refs(value: &mut Value, unstable_type_names: &BTreeSe fn strip_integer_formats(value: &mut Value) { match value { Value::Object(map) => { - let is_integer = map.get("type").and_then(|v| v.as_str()) == Some("integer"); + let is_integer = match map.get("type") { + Some(Value::String(schema_type)) => schema_type == "integer", + Some(Value::Array(schema_types)) => schema_types.iter().any(|schema_type| { + schema_type + .as_str() + .is_some_and(|schema_type| schema_type == "integer") + }), + _ => false, + }; if is_integer { map.remove("format"); } @@ -367,3 +413,105 @@ fn replace_true_schemas(value: &mut Value) { _ => {} } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn adds_http_and_sse_discriminants_without_tagging_stdio() { + let mut defs = Map::from_iter([ + ( + "McpServerHttp".into(), + json!({ + "type": "object", + "properties": { + "name": { "type": "string" } + }, + "required": ["name"] + }), + ), + ( + "McpServerSse".into(), + json!({ + "type": "object", + "properties": { + "name": { "type": "string" } + }, + "required": ["name"] + }), + ), + ( + "McpServerStdio".into(), + json!({ + "type": "object", + "properties": { + "name": { "type": "string" } + }, + "required": ["name"] + }), + ), + ]); + + add_mcp_server_transport_discriminants(&mut defs); + + assert_eq!( + defs["McpServerHttp"]["properties"]["type"], + json!({ "type": "string", "const": "http" }) + ); + assert_eq!( + defs["McpServerSse"]["properties"]["type"], + json!({ "type": "string", "const": "sse" }) + ); + assert_eq!(defs["McpServerStdio"]["properties"].get("type"), None); + assert!(defs["McpServerHttp"]["required"] + .as_array() + .unwrap() + .contains(&json!("type"))); + assert!(defs["McpServerSse"]["required"] + .as_array() + .unwrap() + .contains(&json!("type"))); + } + + #[test] + fn strips_integer_formats_from_nullable_integer_schemas() { + let mut schema = json!({ + "type": "object", + "properties": { + "timeout": { + "type": ["integer", "null"], + "format": "uint64", + "minimum": 0 + }, + "count": { + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "name": { + "type": "string", + "format": "custom" + } + } + }); + + strip_integer_formats(&mut schema); + + assert_eq!( + schema["properties"]["timeout"].get("format"), + None, + "nullable integer formats should be stripped" + ); + assert_eq!( + schema["properties"]["count"].get("format"), + None, + "integer formats should be stripped" + ); + assert_eq!( + schema["properties"]["name"]["format"], + json!("custom"), + "non-integer formats should be preserved" + ); + } +} diff --git a/crates/goose/src/builtin_extension.rs b/crates/goose/src/builtin_extension.rs index c69a44cf1cb5..5dfa5984d10a 100644 --- a/crates/goose/src/builtin_extension.rs +++ b/crates/goose/src/builtin_extension.rs @@ -22,3 +22,7 @@ pub fn register_builtin_extensions(extensions: HashMap<&'static str, SpawnServer pub fn get_builtin_extension(name: &str) -> Option { BUILTIN_REGISTRY.read().unwrap().get(name).cloned() } + +pub fn get_builtin_extension_names() -> Vec<&'static str> { + BUILTIN_REGISTRY.read().unwrap().keys().copied().collect() +} diff --git a/crates/goose/src/config/extensions.rs b/crates/goose/src/config/extensions.rs index 460a6e93ebf5..237ee8b68ad3 100644 --- a/crates/goose/src/config/extensions.rs +++ b/crates/goose/src/config/extensions.rs @@ -106,12 +106,16 @@ pub fn remove_extension(key: &str) { save_extensions_map(extensions); } -pub fn set_extension_enabled(key: &str, enabled: bool) { +/// Returns true when an existing extension was updated, false when the key was missing. +pub fn set_extension_enabled(key: &str, enabled: bool) -> bool { let mut extensions = get_extensions_map(); - if let Some(entry) = extensions.get_mut(key) { - entry.enabled = enabled; - save_extensions_map(extensions); - } + let Some(entry) = extensions.get_mut(key) else { + return false; + }; + + entry.enabled = enabled; + save_extensions_map(extensions); + true } pub fn get_all_extensions() -> Vec { @@ -145,6 +149,40 @@ pub fn get_enabled_extensions_with_config(config: &Config) -> Vec Vec { + let mut builtin_names = crate::builtin_extension::get_builtin_extension_names(); + builtin_names.sort_unstable(); + + let mut platform_definitions = PLATFORM_EXTENSIONS + .values() + .filter(|definition| !definition.hidden) + .collect::>(); + platform_definitions.sort_unstable_by_key(|definition| definition.name); + + builtin_names + .into_iter() + .map(|name| ExtensionConfig::Builtin { + name: name.to_string(), + description: String::new(), + display_name: Some(name.to_string()), + timeout: None, + bundled: Some(true), + available_tools: Vec::new(), + }) + .chain( + platform_definitions + .into_iter() + .map(|definition| ExtensionConfig::Platform { + name: definition.name.to_string(), + description: definition.description.to_string(), + display_name: Some(definition.display_name.to_string()), + bundled: Some(true), + available_tools: Vec::new(), + }), + ) + .collect() +} + pub fn get_warnings() -> Vec { let raw: Mapping = Config::global() .get_param(EXTENSIONS_CONFIG_KEY) diff --git a/crates/goose/src/config/mod.rs b/crates/goose/src/config/mod.rs index 402c835f8783..ae6c46265fc2 100644 --- a/crates/goose/src/config/mod.rs +++ b/crates/goose/src/config/mod.rs @@ -17,9 +17,9 @@ pub use base::{merge_config_values, Config, ConfigError}; pub use declarative_providers::DeclarativeProviderConfig; pub use experiments::ExperimentManager; pub use extensions::{ - get_all_extension_names, get_all_extensions, get_enabled_extensions, get_extension_by_name, - get_warnings, is_extension_enabled, remove_extension, resolve_extensions_for_new_session, - set_extension, set_extension_enabled, ExtensionEntry, + get_all_extension_names, get_all_extensions, get_available_extensions, get_enabled_extensions, + get_extension_by_name, get_warnings, is_extension_enabled, remove_extension, + resolve_extensions_for_new_session, set_extension, set_extension_enabled, ExtensionEntry, }; pub use goose_mode::GooseMode; pub use permission::PermissionManager; diff --git a/crates/goose/src/lib.rs b/crates/goose/src/lib.rs index 4b19247d8239..c610d4a78e02 100644 --- a/crates/goose/src/lib.rs +++ b/crates/goose/src/lib.rs @@ -9,7 +9,9 @@ pub mod builtin_extension; pub mod checks; pub mod config; pub mod context_mgmt; -pub mod conversation; +pub mod conversation { + pub use goose_providers::conversation::*; +} pub mod dictation; pub mod doctor; pub mod download_manager; diff --git a/crates/goose/src/providers/catalog_util.rs b/crates/goose/src/providers/catalog_util.rs new file mode 100644 index 000000000000..8e306e0c4c14 --- /dev/null +++ b/crates/goose/src/providers/catalog_util.rs @@ -0,0 +1,205 @@ +pub use goose_providers::canonical::catalog::{ + ModelCapabilities, ModelTemplate, ProviderCatalogEntry, ProviderFormat, + ProviderSetupCapabilities, ProviderSetupCatalogEntry, ProviderSetupCategory, + ProviderSetupConfigKey, ProviderSetupField, ProviderSetupGroup, ProviderSetupMetadata, + ProviderSetupMethod, ProviderTemplate, +}; +use std::collections::{HashMap, HashSet}; + +use super::base::{ConfigKey, ProviderMetadata}; + +fn setup_config_key(config_key: ConfigKey) -> ProviderSetupConfigKey { + ProviderSetupConfigKey { + name: config_key.name, + required: config_key.required, + secret: config_key.secret, + default: config_key.default, + primary: config_key.primary, + } +} + +fn setup_metadata(metadata: ProviderMetadata) -> ProviderSetupMetadata { + ProviderSetupMetadata { + name: metadata.name, + display_name: metadata.display_name, + description: metadata.description, + model_doc_link: metadata.model_doc_link, + config_keys: metadata + .config_keys + .into_iter() + .map(setup_config_key) + .collect(), + } +} + +pub async fn get_providers_by_format(format: ProviderFormat) -> Vec { + let native_provider_ids = super::init::providers() + .await + .into_iter() + .map(|(metadata, _)| metadata.name) + .collect::>(); + + goose_providers::canonical::catalog::get_providers_by_format(format, &native_provider_ids) +} + +pub async fn get_setup_catalog_entries() -> Vec { + let registry_metadata = super::providers() + .await + .into_iter() + .map(|(metadata, _)| { + let name = metadata.name.clone(); + (name, setup_metadata(metadata)) + }) + .collect::>(); + + goose_providers::canonical::catalog::get_setup_catalog_entries(®istry_metadata) +} + +pub fn get_provider_setup_category(provider_id: &str) -> Option { + goose_providers::canonical::catalog::get_provider_setup_category(provider_id) +} + +pub fn get_provider_template(provider_id: &str) -> Option { + goose_providers::canonical::catalog::get_provider_template(provider_id) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::providers::base::ProviderType; + + #[tokio::test] + async fn test_zai_provider() { + let zai = crate::providers::get_from_registry("zai") + .await + .expect("z.ai should be registered as a declarative provider"); + assert_eq!(zai.provider_type(), ProviderType::Declarative); + + let metadata = zai.metadata(); + assert_eq!(metadata.display_name, "Z.AI"); + assert!( + !metadata.known_models.is_empty(), + "z.ai should have known models" + ); + assert!( + metadata + .config_keys + .iter() + .any(|key| key.name == "ZHIPU_API_KEY"), + "z.ai should expose its API key config" + ); + + let setup_entries = get_setup_catalog_entries().await; + let setup_entry = setup_entries + .iter() + .find(|entry| entry.provider_id == "zai") + .expect("z.ai should be in the setup catalog"); + assert_eq!(setup_entry.setup_method, ProviderSetupMethod::SingleApiKey); + + let template = get_provider_template("zai"); + assert!(template.is_some(), "z.ai should have a template"); + + let template = template.unwrap(); + println!("Z.AI template: {} models", template.models.len()); + for model in template.models.iter().take(3) { + println!( + " - {} ({}K context)", + model.name, + model.context_limit / 1000 + ); + } + assert!( + !template.models.is_empty(), + "z.ai template should have models" + ); + } + + #[tokio::test] + async fn setup_catalog_includes_goose_and_curated_fields() { + let entries = get_setup_catalog_entries().await; + + let goose = entries + .iter() + .find(|entry| entry.provider_id == "goose") + .expect("setup catalog should include synthetic goose"); + assert_eq!(goose.category, ProviderSetupCategory::Agent); + assert_eq!(goose.setup_method, ProviderSetupMethod::None); + assert!(goose.fields.is_empty()); + + let ollama = entries + .iter() + .find(|entry| entry.provider_id == "ollama") + .expect("setup catalog should include ollama"); + assert_eq!(ollama.setup_method, ProviderSetupMethod::ConfigFields); + assert_eq!(ollama.fields.len(), 1); + assert_eq!(ollama.fields[0].key, "OLLAMA_HOST"); + assert_eq!(ollama.fields[0].label, "Host"); + assert_eq!( + ollama.fields[0].default_value.as_deref(), + Some("http://localhost:11434") + ); + + let databricks = entries + .iter() + .find(|entry| entry.provider_id == "databricks") + .expect("setup catalog should include databricks"); + assert_eq!( + databricks.setup_method, + ProviderSetupMethod::HostWithOauthFallback + ); + assert_eq!( + databricks + .fields + .iter() + .map(|field| field.key.as_str()) + .collect::>(), + ["DATABRICKS_HOST", "DATABRICKS_TOKEN"] + ); + + let huggingface = entries + .iter() + .find(|entry| entry.provider_id == "huggingface") + .expect("setup catalog should include huggingface"); + assert_eq!(huggingface.setup_method, ProviderSetupMethod::SingleApiKey); + assert_eq!( + huggingface + .fields + .iter() + .map(|field| field.key.as_str()) + .collect::>(), + ["HF_TOKEN"] + ); + + let atomic_chat = entries + .iter() + .find(|entry| entry.provider_id == "atomic_chat") + .expect("setup catalog should include atomic_chat declarative provider"); + assert_eq!(atomic_chat.setup_method, ProviderSetupMethod::ConfigFields); + let host_field = atomic_chat + .fields + .iter() + .find(|field| field.key == "ATOMIC_CHAT_HOST") + .expect("atomic_chat should expose ATOMIC_CHAT_HOST"); + assert_eq!(host_field.label, "Host URL"); + assert_eq!( + host_field.default_value.as_deref(), + Some("http://localhost:1337") + ); + } + + #[tokio::test] + async fn setup_catalog_excludes_uncurated_deprecated_providers() { + let provider_ids = get_setup_catalog_entries() + .await + .into_iter() + .map(|entry| entry.provider_id) + .collect::>(); + + assert!(provider_ids.contains("claude-acp")); + assert!(provider_ids.contains("codex-acp")); + assert!(provider_ids.contains("atomic_chat")); + assert!(!provider_ids.contains("claude_code")); + assert!(!provider_ids.contains("codex")); + assert!(!provider_ids.contains("gemini_cli")); + } +} diff --git a/crates/goose/src/providers/mod.rs b/crates/goose/src/providers/mod.rs index 1ca4d7938f33..359d0465901c 100644 --- a/crates/goose/src/providers/mod.rs +++ b/crates/goose/src/providers/mod.rs @@ -8,8 +8,13 @@ pub mod azureauth; pub mod base; #[cfg(feature = "aws-providers")] pub mod bedrock; -pub mod canonical; -pub mod catalog; +pub mod canonical { + pub use goose_providers::canonical::*; +} +mod catalog_util; +pub mod catalog { + pub use super::catalog_util::*; +} pub mod chatgpt_codex; pub mod claude_acp; pub mod claude_code; diff --git a/crates/goose/tests/acp_custom_requests_test.rs b/crates/goose/tests/acp_custom_requests_test.rs index 659a820eeb98..9db97bc817d6 100644 --- a/crates/goose/tests/acp_custom_requests_test.rs +++ b/crates/goose/tests/acp_custom_requests_test.rs @@ -18,15 +18,21 @@ use std::sync::{Arc, LazyLock, Mutex}; use common_tests::fixtures::OpenAiFixture; -const DEFAULT_ACP_TEST_CONFIG: &str = "GOOSE_MODEL: gpt-4o\nGOOSE_PROVIDER: openai\n"; +const DEFAULT_ACP_TEST_CONFIG: &str = + "GOOSE_MODEL: gpt-4o\nGOOSE_PROVIDER: openai\nGOOSE_DISABLE_KEYRING: true\n"; static ACP_CONFIG_ROOT: LazyLock = 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, @@ -120,6 +126,132 @@ fn test_custom_get_tools() { #[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::("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; @@ -127,20 +259,36 @@ fn test_custom_get_extensions() { let result = send_custom( conn.cx(), - "_goose/unstable/config/extensions/list", + "_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!( - response.get("extensions").is_some(), - "missing 'extensions' field" + extensions.iter().any(|extension| { + extension["type"] == "platform" && extension["name"] == "developer" + }), + "developer platform extension should be available" ); assert!( - response.get("warnings").is_some(), - "missing 'warnings' field" + !extensions.iter().any(|extension| { + extension["type"] == "platform" && extension["name"] == "orchestrator" + }), + "hidden orchestrator platform extension should not be available" ); }); } diff --git a/ui/desktop/src/__tests__/sessions.test.ts b/ui/desktop/src/__tests__/sessions.test.ts index 3bb48febcf6b..c5baf703de40 100644 --- a/ui/desktop/src/__tests__/sessions.test.ts +++ b/ui/desktop/src/__tests__/sessions.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { shouldShowNewChatTitle } from '../sessions'; -import { getSessionDisplayName } from '../hooks/useNavigationSessions'; +import { getSessionDisplayName, sortAndTrim, prependUnique } from '../hooks/useNavigationSessions'; import type { Session } from '../api'; // Helper to build a minimal Session object for testing. @@ -43,70 +43,6 @@ describe('shouldShowNewChatTitle', () => { }); }); -describe('session reuse scoping (fix for #7601)', () => { - // Simulates the core logic extracted from handleNewChat in useNavigationSessions.ts. - // Before the fix: `sessions.find(s => shouldShowNewChatTitle(s))` picked the - // first global empty session regardless of which window called it. - // After the fix: only the current window's activeSessionId is considered. - function findReusableSession( - sessions: Session[], - activeSessionId: string | undefined - ): Session | undefined { - const currentActive = activeSessionId - ? sessions.find((s) => s.id === activeSessionId) - : undefined; - if (currentActive && shouldShowNewChatTitle(currentActive)) { - return currentActive; - } - return undefined; - } - - const emptySessionA = makeSession({ id: 'empty-a', message_count: 0, user_set_name: false }); - const emptySessionB = makeSession({ id: 'empty-b', message_count: 0, user_set_name: false }); - const usedSession = makeSession({ id: 'used-c', message_count: 5, user_set_name: true }); - - const allSessions = [emptySessionA, emptySessionB, usedSession]; - - it("window A only reuses its own active empty session, not window B's", () => { - // Window A has emptySessionA active, Window B has emptySessionB active. - // Under the old logic, both would grab emptySessionA (the first in the list). - const windowAResult = findReusableSession(allSessions, 'empty-a'); - const windowBResult = findReusableSession(allSessions, 'empty-b'); - - expect(windowAResult?.id).toBe('empty-a'); - expect(windowBResult?.id).toBe('empty-b'); - // They never collide on the same session. - expect(windowAResult?.id).not.toBe(windowBResult?.id); - }); - - it('does not reuse a session that has messages even if it is active', () => { - const result = findReusableSession(allSessions, 'used-c'); - expect(result).toBeUndefined(); - }); - - it('returns undefined when there is no active session id', () => { - const result = findReusableSession(allSessions, undefined); - expect(result).toBeUndefined(); - }); - - it('returns undefined when the active session id is not in the list', () => { - const result = findReusableSession(allSessions, 'nonexistent'); - expect(result).toBeUndefined(); - }); - - it('demonstrates the old bug: global find would give same session to both windows', () => { - // Old logic (before fix) - both windows get the same session. - const oldLogicFind = (sessions: Session[]) => sessions.find((s) => shouldShowNewChatTitle(s)); - - const windowAOld = oldLogicFind(allSessions); - const windowBOld = oldLogicFind(allSessions); - - // Both windows would grab the exact same session - the bug. - expect(windowAOld?.id).toBe(windowBOld?.id); - expect(windowAOld?.id).toBe('empty-a'); - }); -}); - describe('getSessionDisplayName (fix for #8865)', () => { it('returns the user-set name for a recipe session that has been renamed', () => { const session = makeSession({ @@ -128,3 +64,63 @@ describe('getSessionDisplayName (fix for #8865)', () => { expect(getSessionDisplayName(session)).toBe('Some Recipe'); }); }); + +describe('sortAndTrim', () => { + it('sorts by updated_at descending', () => { + const result = sortAndTrim([ + makeSession({ + id: 'old-but-active', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-03-01T00:00:00Z', + }), + makeSession({ + id: 'newer-but-idle', + created_at: '2024-03-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }), + makeSession({ + id: 'mid', + created_at: '2024-02-01T00:00:00Z', + updated_at: '2024-02-01T00:00:00Z', + }), + ]); + expect(result.map((s) => s.id)).toEqual(['old-but-active', 'mid', 'newer-but-idle']); + }); + + it('caps the list at 25 sessions', () => { + const sessions = Array.from({ length: 40 }, (_, i) => + makeSession({ id: `s-${i}`, created_at: new Date(2024, 0, i + 1).toISOString() }) + ); + expect(sortAndTrim(sessions)).toHaveLength(25); + }); + + it('does not mutate the input array', () => { + const input = [ + makeSession({ id: 'a', updated_at: '2024-01-01T00:00:00Z' }), + makeSession({ id: 'b', updated_at: '2024-02-01T00:00:00Z' }), + ]; + sortAndTrim(input); + expect(input.map((s) => s.id)).toEqual(['a', 'b']); + }); +}); + +describe('prependUnique', () => { + it('prepends a new session to the front', () => { + const prev = [makeSession({ id: 'a' })]; + const result = prependUnique(prev, makeSession({ id: 'b' })); + expect(result.map((s) => s.id)).toEqual(['b', 'a']); + }); + + it('returns the same reference when the session is already present', () => { + const prev = [makeSession({ id: 'a' }), makeSession({ id: 'b' })]; + const result = prependUnique(prev, makeSession({ id: 'a' })); + expect(result).toBe(prev); + }); + + it('caps the list at 25 sessions', () => { + const prev = Array.from({ length: 25 }, (_, i) => makeSession({ id: `s-${i}` })); + const result = prependUnique(prev, makeSession({ id: 'new' })); + expect(result).toHaveLength(25); + expect(result[0].id).toBe('new'); + }); +}); diff --git a/ui/desktop/src/acp/extensions.ts b/ui/desktop/src/acp/extensions.ts index 076a7a3d4467..7964210b8538 100644 --- a/ui/desktop/src/acp/extensions.ts +++ b/ui/desktop/src/acp/extensions.ts @@ -1,96 +1,77 @@ -import type { ExtensionEntry, ExtensionConfig } from '../api'; +import type { ExtensionResponse, ExtensionEntry } from '../api'; +import type { GooseExtensionEntry, McpServer } from '@aaif/goose-sdk'; import { getAcpClient } from './acpConnection'; -export interface ConfiguredExtensionsResponse { - extensions: ExtensionEntry[]; - warnings: string[]; +function headersToRecord(headers: { name: string; value: string }[] = []) { + return Object.fromEntries(headers.map(({ name, value }) => [name, value])); } -/** - * Fetch all configured extensions via ACP (`_goose/unstable/config/extensions/list`). - */ -export async function getConfiguredExtensions(): Promise { - const client = await getAcpClient(); - const response = await client.goose.configExtensionsList_unstable({}); - return { - extensions: response.extensions as ExtensionEntry[], - warnings: response.warnings ?? [], - }; -} +function mcpServerToExtension( + server: McpServer, + entry: GooseExtensionEntry +): ExtensionEntry | null { + const extension = entry.extension; + if (extension.type !== 'mcp') { + return null; + } -/** - * Add (or update) an extension in the user's global goose config via ACP - * (`_goose/unstable/config/extensions/add`). - */ -export async function addConfiguredExtension( - name: string, - config: ExtensionConfig, - enabled: boolean -): Promise { - const client = await getAcpClient(); - // Server expects a JSON object matching one of the ExtensionConfig variants, - // and injects `name` itself. We strip `name` from the body to match that shape. - const extensionConfig = { ...config } as Record; - delete extensionConfig.name; + if ('command' in server) { + return { + type: 'stdio', + enabled: entry.enabled, + name: server.name, + description: extension.description ?? '', + cmd: server.command, + args: server.args, + env_keys: extension.envKeys ?? [], + timeout: extension.timeout, + bundled: extension.bundled, + }; + } - await client.goose.configExtensionsAdd_unstable({ - name, - extensionConfig, - enabled, - }); -} + if ('url' in server) { + return { + type: 'streamable_http', + enabled: entry.enabled, + name: server.name, + description: extension.description ?? '', + uri: server.url, + headers: headersToRecord(server.headers), + env_keys: extension.envKeys ?? [], + timeout: extension.timeout, + socket: extension.socket, + bundled: extension.bundled, + }; + } -/** - * Remove an extension from the user's global goose config via ACP - * (`_goose/unstable/config/extensions/remove`). The server normalizes the - * supplied `configKey` via `name_to_key`, so passing the raw extension name - * is sufficient and matches how the previous REST route worked. - */ -export async function removeConfiguredExtension(name: string): Promise { - const client = await getAcpClient(); - await client.goose.configExtensionsRemove_unstable({ - configKey: name, - }); + return null; } -/** - * Add an extension to a running session's agent via ACP - * (`_goose/unstable/session/extensions/add`). - */ -export async function addSessionExtension( - sessionId: string, - config: ExtensionConfig -): Promise { - const client = await getAcpClient(); - await client.goose.sessionExtensionsAdd_unstable({ - sessionId, - config, - }); -} +function gooseExtensionEntryToExtensionEntry(entry: GooseExtensionEntry): ExtensionEntry | null { + const extension = entry.extension; -/** - * Remove an extension from a running session's agent via ACP - * (`_goose/unstable/session/extensions/remove`). - */ -export async function removeSessionExtension( - sessionId: string, - name: string -): Promise { - const client = await getAcpClient(); - await client.goose.sessionExtensionsRemove_unstable({ - sessionId, - name, - }); + switch (extension.type) { + case 'builtin': + case 'platform': + return { + ...extension, + description: extension.description ?? '', + enabled: entry.enabled, + }; + case 'mcp': + return mcpServerToExtension(extension.server, entry); + } + + return null; } -/** - * Fetch the list of extensions associated with a given session via ACP - * (`_goose/unstable/session/extensions/list`). - */ -export async function getSessionExtensions( - sessionId: string -): Promise { +export async function getConfiguredExtensions(): Promise { const client = await getAcpClient(); - const response = await client.goose.sessionExtensionsList_unstable({ sessionId }); - return response.extensions as ExtensionEntry[]; + const response = await client.goose.configExtensionsList_unstable({}); + return { + extensions: response.extensions + .map(gooseExtensionEntryToExtensionEntry) + .filter((entry): entry is ExtensionEntry => entry !== null), + warnings: response.warnings ?? [], + }; } diff --git a/ui/desktop/src/components/bottom_menu/BottomMenuModeSelection.test.tsx b/ui/desktop/src/components/bottom_menu/BottomMenuModeSelection.test.tsx deleted file mode 100644 index 67eeafbfbe95..000000000000 --- a/ui/desktop/src/components/bottom_menu/BottomMenuModeSelection.test.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, type RenderOptions, screen, waitFor, fireEvent } from '@testing-library/react'; -import { BottomMenuModeSelection } from './BottomMenuModeSelection'; -import { IntlTestWrapper } from '../../i18n/test-utils'; - -const renderWithIntl = (ui: React.ReactElement, options?: RenderOptions) => - render(ui, { wrapper: IntlTestWrapper, ...options }); - -let mockConfig: Record = {}; -const mockUpdateSession = vi.fn().mockResolvedValue({}); -const mockGetSession = vi.fn().mockResolvedValue({ data: null }); - -vi.mock('../ConfigContext', () => ({ - useConfig: () => ({ - config: mockConfig, - }), -})); - -vi.mock('../../utils/analytics', () => ({ - trackModeChanged: vi.fn(), -})); - -vi.mock('../../api', () => ({ - updateSession: (...args: unknown[]) => mockUpdateSession(...args), - getSession: (...args: unknown[]) => mockGetSession(...args), -})); - -// Radix dropdown doesn't open in jsdom — render children directly -vi.mock('../ui/dropdown-menu', () => ({ - DropdownMenu: ({ children }: { children: React.ReactNode }) =>
{children}
, - DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) =>
{children}
, - DropdownMenuContent: ({ children }: { children: React.ReactNode }) =>
{children}
, - DropdownMenuItem: ({ children }: { children: React.ReactNode }) =>
{children}
, -})); - -describe('BottomMenuModeSelection', () => { - beforeEach(() => { - vi.clearAllMocks(); - mockConfig = {}; - }); - - it('displays mode from config when no session', async () => { - mockConfig.GOOSE_MODE = 'approve'; - renderWithIntl(); - await waitFor(() => { - expect(screen.getByText('manual')).toBeInTheDocument(); - }); - }); - - it('defaults to auto when config has no mode', async () => { - mockConfig.GOOSE_MODE = undefined; - renderWithIntl(); - await waitFor(() => { - expect(screen.getByText('autonomous')).toBeInTheDocument(); - }); - }); - - it('fetches mode from session when sessionId is present', async () => { - mockConfig.GOOSE_MODE = 'auto'; - mockGetSession.mockResolvedValue({ data: { goose_mode: 'approve' } }); - renderWithIntl(); - await waitFor(() => { - expect(screen.getByText('manual')).toBeInTheDocument(); - }); - expect(mockGetSession).toHaveBeenCalledWith({ - path: { session_id: 'test-session-123' }, - }); - }); - - it('calls updateSession and does not write global config', async () => { - mockConfig.GOOSE_MODE = 'auto'; - renderWithIntl(); - - fireEvent.click(screen.getByText('Manual')); - - await waitFor(() => { - expect(mockUpdateSession).toHaveBeenCalledWith({ - body: { session_id: 'test-session-123', goose_mode: 'approve' }, - }); - }); - }); - - it('does not call updateSession when sessionId is null', async () => { - mockConfig.GOOSE_MODE = 'auto'; - renderWithIntl(); - - fireEvent.click(screen.getByText('Manual')); - - await waitFor(() => { - expect(screen.getByText('manual')).toBeInTheDocument(); - }); - expect(mockUpdateSession).not.toHaveBeenCalled(); - }); - - it('ignores stale session fetch after sessionId changes', async () => { - let resolveA: (value: unknown) => void; - const promiseA = new Promise((resolve) => { - resolveA = resolve; - }); - - mockGetSession - .mockImplementationOnce(() => promiseA) - .mockResolvedValueOnce({ data: { goose_mode: 'auto' } }); - - const { rerender } = renderWithIntl(); - rerender(); - - await waitFor(() => { - expect(screen.getByText('autonomous')).toBeInTheDocument(); - }); - - resolveA!({ data: { goose_mode: 'approve' } }); - - await waitFor(() => { - expect(screen.getByText('autonomous')).toBeInTheDocument(); - }); - expect(screen.queryByText('manual')).not.toBeInTheDocument(); - }); -}); diff --git a/ui/desktop/src/components/bottom_menu/BottomMenuModeSelection.tsx b/ui/desktop/src/components/bottom_menu/BottomMenuModeSelection.tsx deleted file mode 100644 index ae7a6f7ca2cc..000000000000 --- a/ui/desktop/src/components/bottom_menu/BottomMenuModeSelection.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { useEffect, useState } from 'react'; -import { Tornado } from 'lucide-react'; -import { all_goose_modes, ModeSelectionItem } from '../settings/mode/ModeSelectionItem'; -import { useConfig } from '../ConfigContext'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '../ui/dropdown-menu'; -import { trackModeChanged } from '../../utils/analytics'; -import { getSession, updateSession } from '../../api'; -import { defineMessages, useIntl } from '../../i18n'; - -const i18n = defineMessages({ - autoFallback: { - id: 'bottomMenuModeSelection.autoFallback', - defaultMessage: 'auto', - }, - automaticModeDescription: { - id: 'bottomMenuModeSelection.automaticModeDescription', - defaultMessage: 'Automatic mode selection', - }, - currentModeTitle: { - id: 'bottomMenuModeSelection.currentModeTitle', - defaultMessage: 'Current mode: {label} - {description}', - }, -}); - -export const BottomMenuModeSelection = ({ sessionId }: { sessionId: string | null }) => { - const intl = useIntl(); - const [gooseMode, setGooseMode] = useState('auto'); - const { config } = useConfig(); - - useEffect(() => { - let cancelled = false; - if (sessionId) { - getSession({ path: { session_id: sessionId } }).then((res) => { - if (!cancelled && res.data?.goose_mode) { - setGooseMode(res.data.goose_mode); - } - }); - } else { - const mode = config.GOOSE_MODE as string | undefined; - if (mode) { - setGooseMode(mode); - } - } - return () => { - cancelled = true; - }; - }, [sessionId, config.GOOSE_MODE]); - - const handleModeChange = async (newMode: string) => { - if (gooseMode === newMode) { - return; - } - - try { - if (sessionId) { - await updateSession({ body: { session_id: sessionId, goose_mode: newMode } }); - } - setGooseMode(newMode); - trackModeChanged(gooseMode, newMode); - } catch (error) { - console.error('Error updating goose mode:', error); - throw new Error(`Failed to store new goose mode: ${newMode}`); - } - }; - - function getValueByKey(key: string): string { - const mode = all_goose_modes.find((mode) => mode.key === key); - if (!mode) return intl.formatMessage(i18n.autoFallback); - return intl.formatMessage(mode.labelDescriptor); - } - - function getModeDescription(key: string): string { - const mode = all_goose_modes.find((mode) => mode.key === key); - if (!mode) return intl.formatMessage(i18n.automaticModeDescription); - return intl.formatMessage(mode.descriptionDescriptor); - } - - return ( -
- - - - - {getValueByKey(gooseMode).toLowerCase()} - - - - {all_goose_modes.map((mode) => ( - - - - ))} - - -
- ); -}; diff --git a/ui/desktop/src/components/sessions/SessionItem.tsx b/ui/desktop/src/components/sessions/SessionItem.tsx deleted file mode 100644 index 3c8070310600..000000000000 --- a/ui/desktop/src/components/sessions/SessionItem.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import React from 'react'; -import { defineMessages, useIntl } from '../../i18n'; -import { Card } from '../ui/card'; -import { formatDate } from '../../utils/date'; -import { Session } from '../../api'; -import { shouldShowNewChatTitle } from '../../sessions'; -import { DEFAULT_CHAT_TITLE } from '../../contexts/ChatContext'; - -const i18n = defineMessages({ - messageCount: { - id: 'sessionItem.messageCount', - defaultMessage: '{count} messages', - }, -}); - -interface SessionItemProps { - session: Session; - extraActions?: React.ReactNode; -} - -const SessionItem: React.FC = ({ session, extraActions }) => { - const intl = useIntl(); - const displayName = shouldShowNewChatTitle(session) ? DEFAULT_CHAT_TITLE : session.name; - - return ( - -
-
{displayName}
-
- {formatDate(session.updated_at)} • {intl.formatMessage(i18n.messageCount, { count: session.message_count })} -
-
{session.working_dir}
-
- {extraActions &&
{extraActions}
} -
- ); -}; - -export default SessionItem; diff --git a/ui/desktop/src/hooks/useNavigationSessions.ts b/ui/desktop/src/hooks/useNavigationSessions.ts index 4bd86aed7615..180ff5f434b7 100644 --- a/ui/desktop/src/hooks/useNavigationSessions.ts +++ b/ui/desktop/src/hooks/useNavigationSessions.ts @@ -2,43 +2,36 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { useNavigate, useLocation, useSearchParams } from 'react-router-dom'; import { getSession, listSessions } from '../api'; import { useChatContext } from '../contexts/ChatContext'; -import { useConfig } from '../components/ConfigContext'; -import { useNavigation } from './useNavigation'; -import { startNewSession, resumeSession, shouldShowNewChatTitle } from '../sessions'; -import { getInitialWorkingDir } from '../utils/workingDir'; +import { shouldShowNewChatTitle } from '../sessions'; import { AppEvents } from '../constants/events'; import type { Session } from '../api'; const MAX_RECENT_SESSIONS = 25; -interface UseNavigationSessionsOptions { - onNavigate?: () => void; - fetchOnMount?: boolean; +export function sortAndTrim(sessions: Session[]): Session[] { + return [...sessions] + .sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()) + .slice(0, MAX_RECENT_SESSIONS); } -export function useNavigationSessions(options: UseNavigationSessionsOptions = {}) { - const { onNavigate, fetchOnMount = false } = options; +export function prependUnique(prev: Session[], session: Session): Session[] { + if (prev.some((s) => s.id === session.id)) return prev; + return [session, ...prev].slice(0, MAX_RECENT_SESSIONS); +} +export function useNavigationSessions() { const navigate = useNavigate(); const location = useLocation(); const [searchParams] = useSearchParams(); const chatContext = useChatContext(); - const { extensionsList } = useConfig(); - const setView = useNavigation(); const [recentSessions, setRecentSessions] = useState([]); - const sessionsRef = useRef([]); const lastSessionIdRef = useRef(null); - const isCreatingSessionRef = useRef(false); const activeSessionId = searchParams.get('resumeSessionId') ?? undefined; const currentSessionId = location.pathname === '/pair' ? searchParams.get('resumeSessionId') : null; - useEffect(() => { - sessionsRef.current = recentSessions; - }, [recentSessions]); - useEffect(() => { if (currentSessionId) { lastSessionIdRef.current = currentSessionId; @@ -49,33 +42,21 @@ export function useNavigationSessions(options: UseNavigationSessionsOptions = {} try { const response = await listSessions({ throwOnError: false }); if (response.data) { - const sorted = [...response.data.sessions] - .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()) - .slice(0, MAX_RECENT_SESSIONS); - setRecentSessions(sorted); - sessionsRef.current = response.data.sessions; + const apiSessions = sortAndTrim(response.data.sessions); + setRecentSessions(apiSessions); } } catch (error) { console.error('Failed to fetch sessions:', error); } }, []); - useEffect(() => { - if (fetchOnMount) { - fetchSessions(); - } - }, [fetchOnMount, fetchSessions]); - useEffect(() => { if (!activeSessionId) return; if (recentSessions.some((s) => s.id === activeSessionId)) return; getSession({ path: { session_id: activeSessionId }, throwOnError: false }).then((response) => { if (!response.data) return; - setRecentSessions((prev) => { - if (prev.some((s) => s.id === activeSessionId)) return prev; - return [response.data as Session, ...prev].slice(0, MAX_RECENT_SESSIONS); - }); + setRecentSessions((prev) => prependUnique(prev, response.data as Session)); }); }, [activeSessionId, recentSessions]); @@ -86,11 +67,7 @@ export function useNavigationSessions(options: UseNavigationSessionsOptions = {} const handleSessionCreated = (event: Event) => { const { session } = (event as CustomEvent<{ session?: Session }>).detail || {}; if (session) { - setRecentSessions((prev) => { - if (prev.some((s) => s.id === session.id)) return prev; - return [session, ...prev].slice(0, MAX_RECENT_SESSIONS); - }); - sessionsRef.current = [session, ...sessionsRef.current.filter((s) => s.id !== session.id)]; + setRecentSessions((prev) => prependUnique(prev, session)); } if (isPolling) return; @@ -106,15 +83,8 @@ export function useNavigationSessions(options: UseNavigationSessionsOptions = {} try { const response = await listSessions({ throwOnError: false }); if (response.data) { - const apiSessions = response.data.sessions.slice(0, MAX_RECENT_SESSIONS); - setRecentSessions((prev) => { - const emptyLocalSessions = prev.filter( - (local) => - local.message_count === 0 && !apiSessions.some((api) => api.id === local.id) - ); - return [...emptyLocalSessions, ...apiSessions].slice(0, MAX_RECENT_SESSIONS); - }); - sessionsRef.current = response.data.sessions; + const apiSessions = sortAndTrim(response.data.sessions); + setRecentSessions(apiSessions); } } catch (error) { console.error('Failed to poll sessions:', error); @@ -145,7 +115,6 @@ export function useNavigationSessions(options: UseNavigationSessionsOptions = {} const { sessionId } = (event as CustomEvent<{ sessionId: string }>).detail; setRecentSessions((prev) => prev.filter((session) => session.id !== sessionId)); - sessionsRef.current = sessionsRef.current.filter((session) => session.id !== sessionId); if (lastSessionIdRef.current === sessionId) { lastSessionIdRef.current = null; @@ -154,17 +123,8 @@ export function useNavigationSessions(options: UseNavigationSessionsOptions = {} listSessions({ throwOnError: false }) .then((response) => { if (version !== fetchVersion || !response.data) return; - const apiSessions = [...response.data.sessions] - .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()) - .slice(0, MAX_RECENT_SESSIONS); - setRecentSessions((prev) => { - const emptyLocalSessions = prev.filter( - (local) => - local.message_count === 0 && !apiSessions.some((api) => api.id === local.id) - ); - return [...emptyLocalSessions, ...apiSessions].slice(0, MAX_RECENT_SESSIONS); - }); - sessionsRef.current = response.data.sessions; + const apiSessions = sortAndTrim(response.data.sessions); + setRecentSessions(apiSessions); }) .catch((error) => console.error('Failed to fetch sessions:', error)); }; @@ -176,9 +136,6 @@ export function useNavigationSessions(options: UseNavigationSessionsOptions = {} setRecentSessions((prev) => prev.map((session) => (session.id === sessionId ? { ...session, name: newName } : session)) ); - sessionsRef.current = sessionsRef.current.map((session) => - session.id === sessionId ? { ...session, name: newName } : session - ); }; window.addEventListener(AppEvents.SESSION_DELETED, handleSessionDeleted); @@ -203,54 +160,22 @@ export function useNavigationSessions(options: UseNavigationSessionsOptions = {} } else { navigate(path); } - onNavigate?.(); }, - [navigate, currentSessionId, chatContext?.chat?.sessionId, onNavigate] + [navigate, currentSessionId, chatContext?.chat?.sessionId] ); - const handleNewChat = useCallback(async () => { - if (isCreatingSessionRef.current) return; - - // Only reuse the current window's own active session if it is empty. - // Previously this grabbed the first empty session globally, which caused - // multiple windows to claim the same empty session after a restart/upgrade. - const currentActiveSession = activeSessionId - ? sessionsRef.current.find((s) => s.id === activeSessionId) - : undefined; - const canReuseActive = currentActiveSession && shouldShowNewChatTitle(currentActiveSession); - - if (canReuseActive) { - resumeSession(currentActiveSession, setView); - } else { - isCreatingSessionRef.current = true; - try { - await startNewSession('', setView, getInitialWorkingDir(), { - allExtensions: extensionsList, - }); - } finally { - setTimeout(() => { - isCreatingSessionRef.current = false; - }, 1000); - } - } - onNavigate?.(); - }, [setView, onNavigate, extensionsList, activeSessionId]); - const handleSessionClick = useCallback( (sessionId: string) => { navigate(`/pair?resumeSessionId=${sessionId}`); - onNavigate?.(); }, - [navigate, onNavigate] + [navigate] ); return { recentSessions, activeSessionId, - currentSessionId, fetchSessions, handleNavClick, - handleNewChat, handleSessionClick, }; } @@ -267,8 +192,3 @@ export function getSessionDisplayName(session: Session): string { } return session.name; } - -export function truncateMessage(msg?: string, maxLen = 20): string { - if (!msg) return 'New Chat'; - return msg.length > maxLen ? msg.substring(0, maxLen) + '...' : msg; -} diff --git a/ui/desktop/src/i18n/messages/en.json b/ui/desktop/src/i18n/messages/en.json index aa3a5a206d52..0e418fa8faeb 100644 --- a/ui/desktop/src/i18n/messages/en.json +++ b/ui/desktop/src/i18n/messages/en.json @@ -155,15 +155,6 @@ "bottomMenuExtensionSelection.searchExtensions": { "defaultMessage": "search extensions..." }, - "bottomMenuModeSelection.autoFallback": { - "defaultMessage": "auto" - }, - "bottomMenuModeSelection.automaticModeDescription": { - "defaultMessage": "Automatic mode selection" - }, - "bottomMenuModeSelection.currentModeTitle": { - "defaultMessage": "Current mode: {label} - {description}" - }, "cardButtons.configure": { "defaultMessage": "Configure" }, @@ -3800,9 +3791,6 @@ "sessionIndicators.streaming": { "defaultMessage": "Streaming" }, - "sessionItem.messageCount": { - "defaultMessage": "{count} messages" - }, "sessionSharingSection.alreadyConfigured": { "defaultMessage": "Session sharing has already been configured" }, diff --git a/ui/desktop/src/i18n/messages/ru.json b/ui/desktop/src/i18n/messages/ru.json index 85bf1005b7cc..808b499c5282 100644 --- a/ui/desktop/src/i18n/messages/ru.json +++ b/ui/desktop/src/i18n/messages/ru.json @@ -95,15 +95,6 @@ "bottomMenuExtensionSelection.searchExtensions": { "defaultMessage": "поиск расширений..." }, - "bottomMenuModeSelection.autoFallback": { - "defaultMessage": "авто" - }, - "bottomMenuModeSelection.automaticModeDescription": { - "defaultMessage": "Автоматический выбор режима" - }, - "bottomMenuModeSelection.currentModeTitle": { - "defaultMessage": "Текущий режим: {label} - {description}" - }, "cardButtons.configure": { "defaultMessage": "Настроить" }, @@ -3728,9 +3719,6 @@ "sessionIndicators.streaming": { "defaultMessage": "Потоковая передача" }, - "sessionItem.messageCount": { - "defaultMessage": "Сообщений: {count}" - }, "sessionSharingSection.alreadyConfigured": { "defaultMessage": "Общий доступ к сеансам уже настроен" }, diff --git a/ui/desktop/src/i18n/messages/tr.json b/ui/desktop/src/i18n/messages/tr.json index 6e0720e45699..ad2ea8c689a8 100644 --- a/ui/desktop/src/i18n/messages/tr.json +++ b/ui/desktop/src/i18n/messages/tr.json @@ -98,15 +98,6 @@ "bottomMenuExtensionSelection.searchExtensions": { "defaultMessage": "uzantıları ara..." }, - "bottomMenuModeSelection.autoFallback": { - "defaultMessage": "otomatik" - }, - "bottomMenuModeSelection.automaticModeDescription": { - "defaultMessage": "Otomatik mod seçimi" - }, - "bottomMenuModeSelection.currentModeTitle": { - "defaultMessage": "Geçerli mod: {label} - {description}" - }, "cardButtons.configure": { "defaultMessage": "Yapılandır" }, @@ -3842,9 +3833,6 @@ "sessionIndicators.streaming": { "defaultMessage": "Akış" }, - "sessionItem.messageCount": { - "defaultMessage": "{count} mesajları" - }, "sessionSharingSection.alreadyConfigured": { "defaultMessage": "Oturum paylaşımı zaten yapılandırılmış" }, diff --git a/ui/desktop/src/i18n/messages/zh-CN.json b/ui/desktop/src/i18n/messages/zh-CN.json index 5f8592974af0..25cce9996271 100644 --- a/ui/desktop/src/i18n/messages/zh-CN.json +++ b/ui/desktop/src/i18n/messages/zh-CN.json @@ -95,15 +95,6 @@ "bottomMenuExtensionSelection.searchExtensions": { "defaultMessage": "搜索扩展…" }, - "bottomMenuModeSelection.autoFallback": { - "defaultMessage": "自动" - }, - "bottomMenuModeSelection.automaticModeDescription": { - "defaultMessage": "自动模式选择" - }, - "bottomMenuModeSelection.currentModeTitle": { - "defaultMessage": "当前模式:{label} - {description}" - }, "cardButtons.configure": { "defaultMessage": "配置" }, @@ -3659,9 +3650,6 @@ "sessionIndicators.streaming": { "defaultMessage": "流式传输中" }, - "sessionItem.messageCount": { - "defaultMessage": "{count} 条消息" - }, "sessionSharingSection.alreadyConfigured": { "defaultMessage": "会话分享已配置" }, diff --git a/ui/sdk/src/generated/client.gen.ts b/ui/sdk/src/generated/client.gen.ts index 9467dd0dcc4d..a11a411cd94c 100644 --- a/ui/sdk/src/generated/client.gen.ts +++ b/ui/sdk/src/generated/client.gen.ts @@ -46,8 +46,10 @@ import type { ExportSessionResponse_unstable, ExportSourceRequest_unstable, ExportSourceResponse_unstable, - GetExtensionsRequest_unstable, - GetExtensionsResponse_unstable, + GetAvailableExtensionsRequest_unstable, + GetAvailableExtensionsResponse_unstable, + GetConfigExtensionsRequest_unstable, + GetConfigExtensionsResponse_unstable, GetSessionExtensionsRequest_unstable, GetSessionExtensionsResponse_unstable, GetToolsRequest_unstable, @@ -94,8 +96,8 @@ import type { RemoveConfigExtensionRequest_unstable, RemoveExtensionRequest_unstable, RenameSessionRequest_unstable, + SetConfigExtensionEnabledRequest_unstable, SetSessionSystemPromptRequest_unstable, - ToggleConfigExtensionRequest_unstable, UnarchiveSessionRequest_unstable, UpdateSessionProjectRequest_unstable, UpdateSourceRequest_unstable, @@ -115,7 +117,8 @@ import { zDictationTranscribeResponse_unstable, zExportSessionResponse_unstable, zExportSourceResponse_unstable, - zGetExtensionsResponse_unstable, + zGetAvailableExtensionsResponse_unstable, + zGetConfigExtensionsResponse_unstable, zGetSessionExtensionsResponse_unstable, zGetToolsResponse_unstable, zGooseSessionNotification_unstable, @@ -208,15 +211,27 @@ export class GooseExtClient { } async configExtensionsList_unstable( - params: GetExtensionsRequest_unstable, - ): Promise { + params: GetConfigExtensionsRequest_unstable, + ): Promise { const raw = await this.conn.extMethod( "_goose/unstable/config/extensions/list", params, ); - return zGetExtensionsResponse_unstable.parse( + return zGetConfigExtensionsResponse_unstable.parse( raw, - ) as GetExtensionsResponse_unstable; + ) as GetConfigExtensionsResponse_unstable; + } + + async extensionsAvailable_unstable( + params: GetAvailableExtensionsRequest_unstable, + ): Promise { + const raw = await this.conn.extMethod( + "_goose/unstable/extensions/available", + params, + ); + return zGetAvailableExtensionsResponse_unstable.parse( + raw, + ) as GetAvailableExtensionsResponse_unstable; } async configExtensionsAdd_unstable( @@ -234,11 +249,11 @@ export class GooseExtClient { ); } - async configExtensionsToggle_unstable( - params: ToggleConfigExtensionRequest_unstable, + async configExtensionsSetEnabled_unstable( + params: SetConfigExtensionEnabledRequest_unstable, ): Promise { await this.conn.extMethod( - "_goose/unstable/config/extensions/toggle", + "_goose/unstable/config/extensions/set-enabled", params, ); } diff --git a/ui/sdk/src/generated/index.ts b/ui/sdk/src/generated/index.ts index 6e3488320a5e..ea3d436b478d 100644 --- a/ui/sdk/src/generated/index.ts +++ b/ui/sdk/src/generated/index.ts @@ -1,6 +1,6 @@ // This file is auto-generated by @hey-api/openapi-ts -export type { AddConfigExtensionRequest_unstable, AddExtensionRequest_unstable, ArchiveSessionRequest_unstable, CreateSourceRequest_unstable, CreateSourceResponse_unstable, CustomProviderConfigDto, CustomProviderCreateRequest_unstable, CustomProviderCreateResponse_unstable, CustomProviderDeleteRequest_unstable, CustomProviderDeleteResponse_unstable, CustomProviderReadRequest_unstable, CustomProviderReadResponse_unstable, CustomProviderUpdateRequest_unstable, CustomProviderUpdateResponse_unstable, DefaultsReadRequest_unstable, DefaultsReadResponse_unstable, DefaultsSaveRequest_unstable, DeleteSessionRequest, DeleteSourceRequest_unstable, DictationConfigRequest_unstable, DictationConfigResponse_unstable, DictationDownloadProgress, DictationLocalModelStatus, DictationModelCancelRequest_unstable, DictationModelDeleteRequest_unstable, DictationModelDownloadProgressRequest_unstable, DictationModelDownloadProgressResponse_unstable, DictationModelDownloadRequest_unstable, DictationModelOption, DictationModelSelectRequest_unstable, DictationModelsListRequest_unstable, DictationModelsListResponse_unstable, DictationProviderStatusEntry, DictationSecretDeleteRequest_unstable, DictationSecretSaveRequest_unstable, DictationTranscribeRequest_unstable, DictationTranscribeResponse_unstable, ElicitationRespondRequest_unstable, EmptyResponse, ExportSessionRequest_unstable, ExportSessionResponse_unstable, ExportSourceRequest_unstable, ExportSourceResponse_unstable, ExtNotification, ExtRequest, ExtResponse, GetExtensionsRequest_unstable, GetExtensionsResponse_unstable, GetSessionExtensionsRequest_unstable, GetSessionExtensionsResponse_unstable, GetToolsRequest_unstable, GetToolsResponse_unstable, GooseSessionNotification_unstable, GooseSessionUpdate, GooseToolCallRequest_unstable, GooseToolCallResponse_unstable, ImportSessionRequest_unstable, ImportSessionResponse_unstable, ImportSourcesRequest_unstable, ImportSourcesResponse_unstable, Interaction, InteractionState, InteractionUpdate, ListProvidersRequest_unstable, ListProvidersResponse_unstable, ListSourcesRequest_unstable, ListSourcesResponse_unstable, OnboardingImportApplyRequest_unstable, OnboardingImportApplyResponse_unstable, OnboardingImportCandidate, OnboardingImportCounts, OnboardingImportScanRequest_unstable, OnboardingImportScanResponse_unstable, OnboardingImportSourceKind, PreferenceKey, PreferencesReadRequest_unstable, PreferencesReadResponse_unstable, PreferencesRemoveRequest_unstable, PreferencesSaveRequest_unstable, PreferenceValue, ProviderCatalogListRequest_unstable, ProviderCatalogListResponse_unstable, ProviderCatalogTemplateRequest_unstable, ProviderCatalogTemplateResponse_unstable, ProviderConfigAuthenticateRequest_unstable, ProviderConfigChangeResponse_unstable, ProviderConfigDeleteRequest_unstable, ProviderConfigFieldUpdate, ProviderConfigFieldValueDto, ProviderConfigKey, ProviderConfigReadRequest_unstable, ProviderConfigReadResponse_unstable, ProviderConfigSaveRequest_unstable, ProviderConfigStatusDto, ProviderConfigStatusRequest_unstable, ProviderConfigStatusResponse_unstable, ProviderInventoryEntryDto, ProviderInventoryModelDto, ProviderSetupCatalogEntryDto, ProviderSetupCatalogListRequest_unstable, ProviderSetupCatalogListResponse_unstable, ProviderSetupCategoryDto, ProviderSetupFieldDto, ProviderSetupGroupDto, ProviderSetupMethodDto, ProviderSupportedModelsListRequest_unstable, ProviderSupportedModelsListResponse_unstable, ProviderTemplateCapabilitiesDto, ProviderTemplateCatalogEntryDto, ProviderTemplateDto, ProviderTemplateModelDto, ReadResourceRequest_unstable, ReadResourceResponse_unstable, RefreshProviderInventoryRequest_unstable, RefreshProviderInventoryResponse_unstable, RefreshProviderInventorySkipDto, RefreshProviderInventorySkipReasonDto, RemoveConfigExtensionRequest_unstable, RemoveExtensionRequest_unstable, RenameSessionRequest_unstable, SessionSystemPromptMode, SessionUsageUpdate, SetSessionSystemPromptRequest_unstable, SourceEntry, SourceScope, SourceType, StatusMessage, StatusMessageUpdate, ToggleConfigExtensionRequest_unstable, UnarchiveSessionRequest_unstable, UpdateSessionProjectRequest_unstable, UpdateSourceRequest_unstable, UpdateSourceResponse_unstable, UpdateWorkingDirRequest_unstable } from './types.gen.js'; +export type { AddConfigExtensionRequest_unstable, AddExtensionRequest_unstable, ArchiveSessionRequest_unstable, CreateSourceRequest_unstable, CreateSourceResponse_unstable, CustomProviderConfigDto, CustomProviderCreateRequest_unstable, CustomProviderCreateResponse_unstable, CustomProviderDeleteRequest_unstable, CustomProviderDeleteResponse_unstable, CustomProviderReadRequest_unstable, CustomProviderReadResponse_unstable, CustomProviderUpdateRequest_unstable, CustomProviderUpdateResponse_unstable, DefaultsReadRequest_unstable, DefaultsReadResponse_unstable, DefaultsSaveRequest_unstable, DeleteSessionRequest, DeleteSourceRequest_unstable, DictationConfigRequest_unstable, DictationConfigResponse_unstable, DictationDownloadProgress, DictationLocalModelStatus, DictationModelCancelRequest_unstable, DictationModelDeleteRequest_unstable, DictationModelDownloadProgressRequest_unstable, DictationModelDownloadProgressResponse_unstable, DictationModelDownloadRequest_unstable, DictationModelOption, DictationModelSelectRequest_unstable, DictationModelsListRequest_unstable, DictationModelsListResponse_unstable, DictationProviderStatusEntry, DictationSecretDeleteRequest_unstable, DictationSecretSaveRequest_unstable, DictationTranscribeRequest_unstable, DictationTranscribeResponse_unstable, ElicitationRespondRequest_unstable, EmptyResponse, EnvVariable, ExportSessionRequest_unstable, ExportSessionResponse_unstable, ExportSourceRequest_unstable, ExportSourceResponse_unstable, ExtNotification, ExtRequest, ExtResponse, GetAvailableExtensionsRequest_unstable, GetAvailableExtensionsResponse_unstable, GetConfigExtensionsRequest_unstable, GetConfigExtensionsResponse_unstable, GetSessionExtensionsRequest_unstable, GetSessionExtensionsResponse_unstable, GetToolsRequest_unstable, GetToolsResponse_unstable, GooseExtension, GooseExtensionEntry, GooseSessionNotification_unstable, GooseSessionUpdate, GooseToolCallRequest_unstable, GooseToolCallResponse_unstable, HttpHeader, ImportSessionRequest_unstable, ImportSessionResponse_unstable, ImportSourcesRequest_unstable, ImportSourcesResponse_unstable, Interaction, InteractionState, InteractionUpdate, ListProvidersRequest_unstable, ListProvidersResponse_unstable, ListSourcesRequest_unstable, ListSourcesResponse_unstable, McpServer, McpServerHttp, McpServerSse, McpServerStdio, OnboardingImportApplyRequest_unstable, OnboardingImportApplyResponse_unstable, OnboardingImportCandidate, OnboardingImportCounts, OnboardingImportScanRequest_unstable, OnboardingImportScanResponse_unstable, OnboardingImportSourceKind, PreferenceKey, PreferencesReadRequest_unstable, PreferencesReadResponse_unstable, PreferencesRemoveRequest_unstable, PreferencesSaveRequest_unstable, PreferenceValue, ProviderCatalogListRequest_unstable, ProviderCatalogListResponse_unstable, ProviderCatalogTemplateRequest_unstable, ProviderCatalogTemplateResponse_unstable, ProviderConfigAuthenticateRequest_unstable, ProviderConfigChangeResponse_unstable, ProviderConfigDeleteRequest_unstable, ProviderConfigFieldUpdate, ProviderConfigFieldValueDto, ProviderConfigKey, ProviderConfigReadRequest_unstable, ProviderConfigReadResponse_unstable, ProviderConfigSaveRequest_unstable, ProviderConfigStatusDto, ProviderConfigStatusRequest_unstable, ProviderConfigStatusResponse_unstable, ProviderInventoryEntryDto, ProviderInventoryModelDto, ProviderSetupCatalogEntryDto, ProviderSetupCatalogListRequest_unstable, ProviderSetupCatalogListResponse_unstable, ProviderSetupCategoryDto, ProviderSetupFieldDto, ProviderSetupGroupDto, ProviderSetupMethodDto, ProviderSupportedModelsListRequest_unstable, ProviderSupportedModelsListResponse_unstable, ProviderTemplateCapabilitiesDto, ProviderTemplateCatalogEntryDto, ProviderTemplateDto, ProviderTemplateModelDto, ReadResourceRequest_unstable, ReadResourceResponse_unstable, RefreshProviderInventoryRequest_unstable, RefreshProviderInventoryResponse_unstable, RefreshProviderInventorySkipDto, RefreshProviderInventorySkipReasonDto, RemoveConfigExtensionRequest_unstable, RemoveExtensionRequest_unstable, RenameSessionRequest_unstable, SessionSystemPromptMode, SessionUsageUpdate, SetConfigExtensionEnabledRequest_unstable, SetSessionSystemPromptRequest_unstable, SourceEntry, SourceScope, SourceType, StatusMessage, StatusMessageUpdate, UnarchiveSessionRequest_unstable, UpdateSessionProjectRequest_unstable, UpdateSourceRequest_unstable, UpdateSourceResponse_unstable, UpdateWorkingDirRequest_unstable } from './types.gen.js'; export const GOOSE_EXT_METHODS = [ { @@ -45,8 +45,13 @@ export const GOOSE_EXT_METHODS = [ }, { method: "_goose/unstable/config/extensions/list", - requestType: "GetExtensionsRequest_unstable", - responseType: "GetExtensionsResponse_unstable", + requestType: "GetConfigExtensionsRequest_unstable", + responseType: "GetConfigExtensionsResponse_unstable", + }, + { + method: "_goose/unstable/extensions/available", + requestType: "GetAvailableExtensionsRequest_unstable", + responseType: "GetAvailableExtensionsResponse_unstable", }, { method: "_goose/unstable/config/extensions/add", @@ -59,8 +64,8 @@ export const GOOSE_EXT_METHODS = [ responseType: "EmptyResponse", }, { - method: "_goose/unstable/config/extensions/toggle", - requestType: "ToggleConfigExtensionRequest_unstable", + method: "_goose/unstable/config/extensions/set-enabled", + requestType: "SetConfigExtensionEnabledRequest_unstable", responseType: "EmptyResponse", }, { diff --git a/ui/sdk/src/generated/types.gen.ts b/ui/sdk/src/generated/types.gen.ts index cfec115f598e..bf7bf3112e2a 100644 --- a/ui/sdk/src/generated/types.gen.ts +++ b/ui/sdk/src/generated/types.gen.ts @@ -119,32 +119,211 @@ export type DeleteSessionRequest = { /** * List configured extensions and any warnings. */ -export type GetExtensionsRequest_unstable = { +export type GetConfigExtensionsRequest_unstable = { [key: string]: unknown; }; /** * List configured extensions and any warnings. */ -export type GetExtensionsResponse_unstable = { +export type GetConfigExtensionsResponse_unstable = { + extensions: Array; + warnings?: Array; +}; + +export type GooseExtensionEntry = { + extension: GooseExtension; + enabled: boolean; + configKey?: string | null; +}; + +export type GooseExtension = { + name: string; + description?: string | null; + display_name?: string | null; + timeout?: number | null; + bundled?: boolean | null; + type: 'builtin'; +} | { + name: string; + description?: string | null; + display_name?: string | null; + bundled?: boolean | null; + type: 'platform'; +} | { + server: McpServer; + envKeys?: Array; + description?: string | null; + timeout?: number | null; + socket?: string | null; + bundled?: boolean | null; + type: 'mcp'; +}; + +/** + * Configuration for connecting to an MCP (Model Context Protocol) server. + * + * MCP servers provide tools and context that the agent can use when + * processing prompts. + * + * See protocol docs: [MCP Servers](https://agentclientprotocol.com/protocol/session-setup#mcp-servers) + */ +export type McpServer = McpServerHttp | McpServerSse | McpServerStdio; + +/** + * An HTTP header to set when making requests to the MCP server. + */ +export type HttpHeader = { /** - * Array of ExtensionEntry objects with `enabled` flag, `configKey`, and flattened config details. + * The name of the HTTP header. */ - extensions: Array; - warnings: Array; + name: string; + /** + * The value to set for the HTTP header. + */ + value: string; + /** + * The _meta property is reserved by ACP to allow clients and agents to attach additional + * metadata to their interactions. Implementations MUST NOT make assumptions about values at + * these keys. + * + * See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + */ + _meta?: { + [key: string]: unknown; + } | null; }; /** - * Persist a new extension to the user's global goose config. + * HTTP transport configuration for MCP. */ -export type AddConfigExtensionRequest_unstable = { +export type McpServerHttp = { + /** + * Human-readable name identifying this MCP server. + */ + name: string; + /** + * URL to the MCP server. + */ + url: string; + /** + * HTTP headers to set when making requests to the MCP server. + */ + headers: Array; + /** + * The _meta property is reserved by ACP to allow clients and agents to attach additional + * metadata to their interactions. Implementations MUST NOT make assumptions about values at + * these keys. + * + * See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + */ + _meta?: { + [key: string]: unknown; + } | null; + type: 'http'; +}; + +/** + * SSE transport configuration for MCP. + */ +export type McpServerSse = { + /** + * Human-readable name identifying this MCP server. + */ + name: string; + /** + * URL to the MCP server. + */ + url: string; + /** + * HTTP headers to set when making requests to the MCP server. + */ + headers: Array; + /** + * The _meta property is reserved by ACP to allow clients and agents to attach additional + * metadata to their interactions. Implementations MUST NOT make assumptions about values at + * these keys. + * + * See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + */ + _meta?: { + [key: string]: unknown; + } | null; + type: 'sse'; +}; + +/** + * Stdio transport configuration for MCP. + */ +export type McpServerStdio = { + /** + * Human-readable name identifying this MCP server. + */ name: string; /** - * Extension configuration. Must be a JSON object matching one of the - * `ExtensionConfig` variants (e.g. `stdio`, `streamable_http`, `builtin`). - * `name` and `enabled` are injected server-side. + * Path to the MCP server executable. + */ + command: string; + /** + * Command-line arguments to pass to the MCP server. + */ + args: Array; + /** + * Environment variables to set when launching the MCP server. + */ + env: Array; + /** + * The _meta property is reserved by ACP to allow clients and agents to attach additional + * metadata to their interactions. Implementations MUST NOT make assumptions about values at + * these keys. + * + * See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) */ - extensionConfig?: unknown; + _meta?: { + [key: string]: unknown; + } | null; +}; + +/** + * An environment variable to set when launching an MCP server. + */ +export type EnvVariable = { + /** + * The name of the environment variable. + */ + name: string; + /** + * The value to set for the environment variable. + */ + value: string; + /** + * The _meta property is reserved by ACP to allow clients and agents to attach additional + * metadata to their interactions. Implementations MUST NOT make assumptions about values at + * these keys. + * + * See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + */ + _meta?: { + [key: string]: unknown; + } | null; +}; + +/** + * List Goose-owned extension definitions available to configure or enable. + */ +export type GetAvailableExtensionsRequest_unstable = { + [key: string]: unknown; +}; + +export type GetAvailableExtensionsResponse_unstable = { + extensions: Array; +}; + +/** + * Persist a new extension to the user's global goose config. + */ +export type AddConfigExtensionRequest_unstable = { + extension: GooseExtension; enabled?: boolean; }; @@ -156,9 +335,9 @@ export type RemoveConfigExtensionRequest_unstable = { }; /** - * Toggle the `enabled` flag for a persisted extension in the user's global goose config. + * Set the `enabled` flag for a persisted extension in the user's global goose config. */ -export type ToggleConfigExtensionRequest_unstable = { +export type SetConfigExtensionEnabledRequest_unstable = { configKey: string; enabled: boolean; }; @@ -1166,14 +1345,14 @@ export type InteractionUpdate = { export type ExtRequest = { id: string; method: string; - params?: AddExtensionRequest_unstable | RemoveExtensionRequest_unstable | GetToolsRequest_unstable | GooseToolCallRequest_unstable | ReadResourceRequest_unstable | UpdateWorkingDirRequest_unstable | SetSessionSystemPromptRequest_unstable | DeleteSessionRequest | GetExtensionsRequest_unstable | AddConfigExtensionRequest_unstable | RemoveConfigExtensionRequest_unstable | ToggleConfigExtensionRequest_unstable | GetSessionExtensionsRequest_unstable | ListProvidersRequest_unstable | ProviderSupportedModelsListRequest_unstable | ProviderCatalogListRequest_unstable | ProviderSetupCatalogListRequest_unstable | ProviderCatalogTemplateRequest_unstable | CustomProviderCreateRequest_unstable | CustomProviderReadRequest_unstable | CustomProviderUpdateRequest_unstable | CustomProviderDeleteRequest_unstable | RefreshProviderInventoryRequest_unstable | ProviderConfigReadRequest_unstable | ProviderConfigStatusRequest_unstable | ProviderConfigSaveRequest_unstable | ProviderConfigDeleteRequest_unstable | ProviderConfigAuthenticateRequest_unstable | PreferencesReadRequest_unstable | PreferencesSaveRequest_unstable | PreferencesRemoveRequest_unstable | DefaultsReadRequest_unstable | DefaultsSaveRequest_unstable | OnboardingImportScanRequest_unstable | OnboardingImportApplyRequest_unstable | ExportSessionRequest_unstable | ImportSessionRequest_unstable | ElicitationRespondRequest_unstable | UpdateSessionProjectRequest_unstable | RenameSessionRequest_unstable | ArchiveSessionRequest_unstable | UnarchiveSessionRequest_unstable | CreateSourceRequest_unstable | ListSourcesRequest_unstable | UpdateSourceRequest_unstable | DeleteSourceRequest_unstable | ExportSourceRequest_unstable | ImportSourcesRequest_unstable | DictationTranscribeRequest_unstable | DictationConfigRequest_unstable | DictationSecretSaveRequest_unstable | DictationSecretDeleteRequest_unstable | DictationModelsListRequest_unstable | DictationModelDownloadRequest_unstable | DictationModelDownloadProgressRequest_unstable | DictationModelCancelRequest_unstable | DictationModelDeleteRequest_unstable | DictationModelSelectRequest_unstable | { + params?: AddExtensionRequest_unstable | RemoveExtensionRequest_unstable | GetToolsRequest_unstable | GooseToolCallRequest_unstable | ReadResourceRequest_unstable | UpdateWorkingDirRequest_unstable | SetSessionSystemPromptRequest_unstable | DeleteSessionRequest | GetConfigExtensionsRequest_unstable | GetAvailableExtensionsRequest_unstable | AddConfigExtensionRequest_unstable | RemoveConfigExtensionRequest_unstable | SetConfigExtensionEnabledRequest_unstable | GetSessionExtensionsRequest_unstable | ListProvidersRequest_unstable | ProviderSupportedModelsListRequest_unstable | ProviderCatalogListRequest_unstable | ProviderSetupCatalogListRequest_unstable | ProviderCatalogTemplateRequest_unstable | CustomProviderCreateRequest_unstable | CustomProviderReadRequest_unstable | CustomProviderUpdateRequest_unstable | CustomProviderDeleteRequest_unstable | RefreshProviderInventoryRequest_unstable | ProviderConfigReadRequest_unstable | ProviderConfigStatusRequest_unstable | ProviderConfigSaveRequest_unstable | ProviderConfigDeleteRequest_unstable | ProviderConfigAuthenticateRequest_unstable | PreferencesReadRequest_unstable | PreferencesSaveRequest_unstable | PreferencesRemoveRequest_unstable | DefaultsReadRequest_unstable | DefaultsSaveRequest_unstable | OnboardingImportScanRequest_unstable | OnboardingImportApplyRequest_unstable | ExportSessionRequest_unstable | ImportSessionRequest_unstable | ElicitationRespondRequest_unstable | UpdateSessionProjectRequest_unstable | RenameSessionRequest_unstable | ArchiveSessionRequest_unstable | UnarchiveSessionRequest_unstable | CreateSourceRequest_unstable | ListSourcesRequest_unstable | UpdateSourceRequest_unstable | DeleteSourceRequest_unstable | ExportSourceRequest_unstable | ImportSourcesRequest_unstable | DictationTranscribeRequest_unstable | DictationConfigRequest_unstable | DictationSecretSaveRequest_unstable | DictationSecretDeleteRequest_unstable | DictationModelsListRequest_unstable | DictationModelDownloadRequest_unstable | DictationModelDownloadProgressRequest_unstable | DictationModelCancelRequest_unstable | DictationModelDeleteRequest_unstable | DictationModelSelectRequest_unstable | { [key: string]: unknown; } | null; }; export type ExtResponse = { id: string; - result?: EmptyResponse | GetToolsResponse_unstable | GooseToolCallResponse_unstable | ReadResourceResponse_unstable | GetExtensionsResponse_unstable | GetSessionExtensionsResponse_unstable | ListProvidersResponse_unstable | ProviderSupportedModelsListResponse_unstable | ProviderCatalogListResponse_unstable | ProviderSetupCatalogListResponse_unstable | ProviderCatalogTemplateResponse_unstable | CustomProviderCreateResponse_unstable | CustomProviderReadResponse_unstable | CustomProviderUpdateResponse_unstable | CustomProviderDeleteResponse_unstable | RefreshProviderInventoryResponse_unstable | ProviderConfigReadResponse_unstable | ProviderConfigStatusResponse_unstable | ProviderConfigChangeResponse_unstable | PreferencesReadResponse_unstable | DefaultsReadResponse_unstable | OnboardingImportScanResponse_unstable | OnboardingImportApplyResponse_unstable | ExportSessionResponse_unstable | ImportSessionResponse_unstable | CreateSourceResponse_unstable | ListSourcesResponse_unstable | UpdateSourceResponse_unstable | ExportSourceResponse_unstable | ImportSourcesResponse_unstable | DictationTranscribeResponse_unstable | DictationConfigResponse_unstable | DictationModelsListResponse_unstable | DictationModelDownloadProgressResponse_unstable | unknown; + result?: EmptyResponse | GetToolsResponse_unstable | GooseToolCallResponse_unstable | ReadResourceResponse_unstable | GetConfigExtensionsResponse_unstable | GetAvailableExtensionsResponse_unstable | GetSessionExtensionsResponse_unstable | ListProvidersResponse_unstable | ProviderSupportedModelsListResponse_unstable | ProviderCatalogListResponse_unstable | ProviderSetupCatalogListResponse_unstable | ProviderCatalogTemplateResponse_unstable | CustomProviderCreateResponse_unstable | CustomProviderReadResponse_unstable | CustomProviderUpdateResponse_unstable | CustomProviderDeleteResponse_unstable | RefreshProviderInventoryResponse_unstable | ProviderConfigReadResponse_unstable | ProviderConfigStatusResponse_unstable | ProviderConfigChangeResponse_unstable | PreferencesReadResponse_unstable | DefaultsReadResponse_unstable | OnboardingImportScanResponse_unstable | OnboardingImportApplyResponse_unstable | ExportSessionResponse_unstable | ImportSessionResponse_unstable | CreateSourceResponse_unstable | ListSourcesResponse_unstable | UpdateSourceResponse_unstable | ExportSourceResponse_unstable | ImportSourcesResponse_unstable | DictationTranscribeResponse_unstable | DictationConfigResponse_unstable | DictationModelsListResponse_unstable | DictationModelDownloadProgressResponse_unstable | unknown; } | { error: { code: number; diff --git a/ui/sdk/src/generated/zod.gen.ts b/ui/sdk/src/generated/zod.gen.ts index 0597c7f716d0..a73387f5d26d 100644 --- a/ui/sdk/src/generated/zod.gen.ts +++ b/ui/sdk/src/generated/zod.gen.ts @@ -115,22 +115,179 @@ export const zDeleteSessionRequest = z.object({ /** * List configured extensions and any warnings. */ -export const zGetExtensionsRequest_unstable = z.record(z.unknown()); +export const zGetConfigExtensionsRequest_unstable = z.record(z.unknown()); + +/** + * An HTTP header to set when making requests to the MCP server. + */ +export const zHttpHeader = z.object({ + name: z.string(), + value: z.string(), + _meta: z.union([ + z.record(z.unknown()), + z.null() + ]).optional() +}); + +/** + * HTTP transport configuration for MCP. + */ +export const zMcpServerHttp = z.object({ + name: z.string(), + url: z.string(), + headers: z.array(zHttpHeader), + _meta: z.union([ + z.record(z.unknown()), + z.null() + ]).optional(), + type: z.literal('http') +}); + +/** + * SSE transport configuration for MCP. + */ +export const zMcpServerSse = z.object({ + name: z.string(), + url: z.string(), + headers: z.array(zHttpHeader), + _meta: z.union([ + z.record(z.unknown()), + z.null() + ]).optional(), + type: z.literal('sse') +}); + +/** + * An environment variable to set when launching an MCP server. + */ +export const zEnvVariable = z.object({ + name: z.string(), + value: z.string(), + _meta: z.union([ + z.record(z.unknown()), + z.null() + ]).optional() +}); + +/** + * Stdio transport configuration for MCP. + */ +export const zMcpServerStdio = z.object({ + name: z.string(), + command: z.string(), + args: z.array(z.string()), + env: z.array(zEnvVariable), + _meta: z.union([ + z.record(z.unknown()), + z.null() + ]).optional() +}); + +/** + * Configuration for connecting to an MCP (Model Context Protocol) server. + * + * MCP servers provide tools and context that the agent can use when + * processing prompts. + * + * See protocol docs: [MCP Servers](https://agentclientprotocol.com/protocol/session-setup#mcp-servers) + */ +export const zMcpServer = z.union([ + zMcpServerHttp, + zMcpServerSse, + zMcpServerStdio +]); + +export const zGooseExtension = z.union([ + z.object({ + name: z.string(), + description: z.union([ + z.string(), + z.null() + ]).optional(), + display_name: z.union([ + z.string(), + z.null() + ]).optional(), + timeout: z.union([ + z.number().int().gte(0), + z.null() + ]).optional(), + bundled: z.union([ + z.boolean(), + z.null() + ]).optional(), + type: z.literal('builtin') + }), + z.object({ + name: z.string(), + description: z.union([ + z.string(), + z.null() + ]).optional(), + display_name: z.union([ + z.string(), + z.null() + ]).optional(), + bundled: z.union([ + z.boolean(), + z.null() + ]).optional(), + type: z.literal('platform') + }), + z.object({ + server: zMcpServer, + envKeys: z.array(z.string()).optional(), + description: z.union([ + z.string(), + z.null() + ]).optional(), + timeout: z.union([ + z.number().int().gte(0), + z.null() + ]).optional(), + socket: z.union([ + z.string(), + z.null() + ]).optional(), + bundled: z.union([ + z.boolean(), + z.null() + ]).optional(), + type: z.literal('mcp') + }) +]); + +export const zGooseExtensionEntry = z.object({ + extension: zGooseExtension, + enabled: z.boolean(), + configKey: z.union([ + z.string(), + z.null() + ]).optional() +}); /** * List configured extensions and any warnings. */ -export const zGetExtensionsResponse_unstable = z.object({ - extensions: z.array(z.unknown()), - warnings: z.array(z.string()) +export const zGetConfigExtensionsResponse_unstable = z.object({ + extensions: z.array(zGooseExtensionEntry), + warnings: z.array(z.string()).optional().default([]) +}); + +/** + * List Goose-owned extension definitions available to configure or enable. + */ +export const zGetAvailableExtensionsRequest_unstable = z.record(z.unknown()); + +export const zGetAvailableExtensionsResponse_unstable = z.object({ + extensions: z.array(zGooseExtension) }); /** * Persist a new extension to the user's global goose config. */ export const zAddConfigExtensionRequest_unstable = z.object({ - name: z.string(), - extensionConfig: z.unknown().optional().default(null), + extension: zGooseExtension, enabled: z.boolean().optional().default(false) }); @@ -142,9 +299,9 @@ export const zRemoveConfigExtensionRequest_unstable = z.object({ }); /** - * Toggle the `enabled` flag for a persisted extension in the user's global goose config. + * Set the `enabled` flag for a persisted extension in the user's global goose config. */ -export const zToggleConfigExtensionRequest_unstable = z.object({ +export const zSetConfigExtensionEnabledRequest_unstable = z.object({ configKey: z.string(), enabled: z.boolean() }); @@ -1190,10 +1347,11 @@ export const zExtRequest = z.object({ zUpdateWorkingDirRequest_unstable, zSetSessionSystemPromptRequest_unstable, zDeleteSessionRequest, - zGetExtensionsRequest_unstable, + zGetConfigExtensionsRequest_unstable, + zGetAvailableExtensionsRequest_unstable, zAddConfigExtensionRequest_unstable, zRemoveConfigExtensionRequest_unstable, - zToggleConfigExtensionRequest_unstable, + zSetConfigExtensionEnabledRequest_unstable, zGetSessionExtensionsRequest_unstable, zListProvidersRequest_unstable, zProviderSupportedModelsListRequest_unstable, @@ -1257,7 +1415,8 @@ export const zExtResponse = z.union([ zGetToolsResponse_unstable, zGooseToolCallResponse_unstable, zReadResourceResponse_unstable, - zGetExtensionsResponse_unstable, + zGetConfigExtensionsResponse_unstable, + zGetAvailableExtensionsResponse_unstable, zGetSessionExtensionsResponse_unstable, zListProvidersResponse_unstable, zProviderSupportedModelsListResponse_unstable,