diff --git a/crates/goose-sdk/src/custom_requests.rs b/crates/goose-sdk/src/custom_requests.rs index 98312fad45..2ebe70d327 100644 --- a/crates/goose-sdk/src/custom_requests.rs +++ b/crates/goose-sdk/src/custom_requests.rs @@ -173,69 +173,85 @@ pub struct GetSessionExtensionsResponse { pub extensions: Vec, } -/// Read a single non-secret config value. +/// Read allowlisted user preferences. Empty `keys` means all supported preferences. #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request(method = "_goose/config/read", response = ReadConfigResponse)] +#[request(method = "_goose/preferences/read", response = PreferencesReadResponse)] #[serde(rename_all = "camelCase")] -pub struct ReadConfigRequest { - pub key: String, +pub struct PreferencesReadRequest { + #[serde(default)] + pub keys: Vec, } -/// Config read response. -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +/// Save allowlisted user preferences. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/preferences/save", response = EmptyResponse)] #[serde(rename_all = "camelCase")] -pub struct ReadConfigResponse { +pub struct PreferencesSaveRequest { + #[serde(default)] + pub values: Vec, +} + +/// Remove allowlisted user preferences. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/preferences/remove", response = EmptyResponse)] +#[serde(rename_all = "camelCase")] +pub struct PreferencesRemoveRequest { + #[serde(default)] + pub keys: Vec, +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub enum PreferenceKey { + #[default] + AutoCompactThreshold, + VoiceAutoSubmitPhrases, + VoiceDictationProvider, + VoiceDictationPreferredMic, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct PreferenceValue { + pub key: PreferenceKey, #[serde(default)] pub value: serde_json::Value, } -/// Upsert a single non-secret config value. -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request(method = "_goose/config/upsert", response = EmptyResponse)] -#[serde(rename_all = "camelCase")] -pub struct UpsertConfigRequest { - pub key: String, - pub value: serde_json::Value, -} - -/// Remove a single non-secret config value. -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request(method = "_goose/config/remove", response = EmptyResponse)] -#[serde(rename_all = "camelCase")] -pub struct RemoveConfigRequest { - pub key: String, -} - -/// Check whether a secret exists. Never returns the actual value. -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request(method = "_goose/secret/check", response = CheckSecretResponse)] -#[serde(rename_all = "camelCase")] -pub struct CheckSecretRequest { - pub key: String, -} - -/// Secret check response. #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] #[serde(rename_all = "camelCase")] -pub struct CheckSecretResponse { - pub exists: bool, +pub struct PreferencesReadResponse { + pub values: Vec, } -/// Set a secret value (write-only). +/// Read Goose default provider and model configuration. #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request(method = "_goose/secret/upsert", response = EmptyResponse)] +#[request(method = "_goose/defaults/read", response = DefaultsReadResponse)] #[serde(rename_all = "camelCase")] -pub struct UpsertSecretRequest { - pub key: String, - pub value: serde_json::Value, +pub struct DefaultsReadRequest {} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +#[serde(rename_all = "camelCase")] +pub struct DefaultsReadResponse { + pub provider_id: Option, + pub model_id: Option, } -/// Remove a secret. +/// Set a dictation provider secret value. #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request(method = "_goose/secret/remove", response = EmptyResponse)] +#[request(method = "_goose/dictation/secret/save", response = EmptyResponse)] #[serde(rename_all = "camelCase")] -pub struct RemoveSecretRequest { - pub key: String, +pub struct DictationSecretSaveRequest { + pub provider: String, + pub value: String, +} + +/// Remove a dictation provider secret value. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/dictation/secret/delete", response = EmptyResponse)] +#[serde(rename_all = "camelCase")] +pub struct DictationSecretDeleteRequest { + pub provider: String, } /// Update the project association for a session. diff --git a/crates/goose/acp-meta.json b/crates/goose/acp-meta.json index 6e406c5d3a..db2ad918c6 100644 --- a/crates/goose/acp-meta.json +++ b/crates/goose/acp-meta.json @@ -121,34 +121,24 @@ "responseType": "ProviderConfigChangeResponse" }, { - "method": "_goose/config/read", - "requestType": "ReadConfigRequest", - "responseType": "ReadConfigResponse" + "method": "_goose/preferences/read", + "requestType": "PreferencesReadRequest", + "responseType": "PreferencesReadResponse" }, { - "method": "_goose/config/upsert", - "requestType": "UpsertConfigRequest", + "method": "_goose/preferences/save", + "requestType": "PreferencesSaveRequest", "responseType": "EmptyResponse" }, { - "method": "_goose/config/remove", - "requestType": "RemoveConfigRequest", + "method": "_goose/preferences/remove", + "requestType": "PreferencesRemoveRequest", "responseType": "EmptyResponse" }, { - "method": "_goose/secret/check", - "requestType": "CheckSecretRequest", - "responseType": "CheckSecretResponse" - }, - { - "method": "_goose/secret/upsert", - "requestType": "UpsertSecretRequest", - "responseType": "EmptyResponse" - }, - { - "method": "_goose/secret/remove", - "requestType": "RemoveSecretRequest", - "responseType": "EmptyResponse" + "method": "_goose/defaults/read", + "requestType": "DefaultsReadRequest", + "responseType": "DefaultsReadResponse" }, { "method": "_goose/session/export", @@ -220,6 +210,16 @@ "requestType": "DictationConfigRequest", "responseType": "DictationConfigResponse" }, + { + "method": "_goose/dictation/secret/save", + "requestType": "DictationSecretSaveRequest", + "responseType": "EmptyResponse" + }, + { + "method": "_goose/dictation/secret/delete", + "requestType": "DictationSecretDeleteRequest", + "responseType": "EmptyResponse" + }, { "method": "_goose/dictation/models/list", "requestType": "DictationModelsListRequest", diff --git a/crates/goose/acp-schema.json b/crates/goose/acp-schema.json index 7f264d9b60..59be3b71c4 100644 --- a/crates/goose/acp-schema.json +++ b/crates/goose/acp-schema.json @@ -1246,118 +1246,114 @@ "x-side": "agent", "x-method": "_goose/providers/config/delete" }, - "ReadConfigRequest": { + "PreferencesReadRequest": { "type": "object", "properties": { - "key": { - "type": "string" + "keys": { + "type": "array", + "items": { + "$ref": "#/$defs/PreferenceKey" + }, + "default": [] + } + }, + "description": "Read allowlisted user preferences. Empty `keys` means all supported preferences.", + "x-side": "agent", + "x-method": "_goose/preferences/read" + }, + "PreferenceKey": { + "type": "string", + "enum": [ + "autoCompactThreshold", + "voiceAutoSubmitPhrases", + "voiceDictationProvider", + "voiceDictationPreferredMic" + ] + }, + "PreferencesReadResponse": { + "type": "object", + "properties": { + "values": { + "type": "array", + "items": { + "$ref": "#/$defs/PreferenceValue" + } } }, "required": [ - "key" + "values" ], - "description": "Read a single non-secret config value.", "x-side": "agent", - "x-method": "_goose/config/read" + "x-method": "_goose/preferences/read" }, - "ReadConfigResponse": { + "PreferenceValue": { "type": "object", "properties": { + "key": { + "$ref": "#/$defs/PreferenceKey" + }, "value": { "default": null } }, - "description": "Config read response.", - "x-side": "agent", - "x-method": "_goose/config/read" + "required": [ + "key" + ] }, - "UpsertConfigRequest": { + "PreferencesSaveRequest": { "type": "object", "properties": { - "key": { - "type": "string" + "values": { + "type": "array", + "items": { + "$ref": "#/$defs/PreferenceValue" + }, + "default": [] + } + }, + "description": "Save allowlisted user preferences.", + "x-side": "agent", + "x-method": "_goose/preferences/save" + }, + "PreferencesRemoveRequest": { + "type": "object", + "properties": { + "keys": { + "type": "array", + "items": { + "$ref": "#/$defs/PreferenceKey" + }, + "default": [] + } + }, + "description": "Remove allowlisted user preferences.", + "x-side": "agent", + "x-method": "_goose/preferences/remove" + }, + "DefaultsReadRequest": { + "type": "object", + "description": "Read Goose default provider and model configuration.", + "x-side": "agent", + "x-method": "_goose/defaults/read" + }, + "DefaultsReadResponse": { + "type": "object", + "properties": { + "providerId": { + "type": [ + "string", + "null" + ] }, - "value": {} - }, - "required": [ - "key", - "value" - ], - "description": "Upsert a single non-secret config value.", - "x-side": "agent", - "x-method": "_goose/config/upsert" - }, - "RemoveConfigRequest": { - "type": "object", - "properties": { - "key": { - "type": "string" + "modelId": { + "type": [ + "string", + "null" + ] } }, - "required": [ - "key" - ], - "description": "Remove a single non-secret config value.", "x-side": "agent", - "x-method": "_goose/config/remove" - }, - "CheckSecretRequest": { - "type": "object", - "properties": { - "key": { - "type": "string" - } - }, - "required": [ - "key" - ], - "description": "Check whether a secret exists. Never returns the actual value.", - "x-side": "agent", - "x-method": "_goose/secret/check" - }, - "CheckSecretResponse": { - "type": "object", - "properties": { - "exists": { - "type": "boolean" - } - }, - "required": [ - "exists" - ], - "description": "Secret check response.", - "x-side": "agent", - "x-method": "_goose/secret/check" - }, - "UpsertSecretRequest": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "value": {} - }, - "required": [ - "key", - "value" - ], - "description": "Set a secret value (write-only).", - "x-side": "agent", - "x-method": "_goose/secret/upsert" - }, - "RemoveSecretRequest": { - "type": "object", - "properties": { - "key": { - "type": "string" - } - }, - "required": [ - "key" - ], - "description": "Remove a secret.", - "x-side": "agent", - "x-method": "_goose/secret/remove" + "x-method": "_goose/defaults/read" }, "ExportSessionRequest": { "type": "object", @@ -1920,6 +1916,38 @@ "description" ] }, + "DictationSecretSaveRequest": { + "type": "object", + "properties": { + "provider": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "provider", + "value" + ], + "description": "Set a dictation provider secret value.", + "x-side": "agent", + "x-method": "_goose/dictation/secret/save" + }, + "DictationSecretDeleteRequest": { + "type": "object", + "properties": { + "provider": { + "type": "string" + } + }, + "required": [ + "provider" + ], + "description": "Remove a dictation provider secret value.", + "x-side": "agent", + "x-method": "_goose/dictation/secret/delete" + }, "DictationModelsListRequest": { "type": "object", "description": "List available local Whisper models with their download status.", @@ -2330,56 +2358,38 @@ { "allOf": [ { - "$ref": "#/$defs/ReadConfigRequest" + "$ref": "#/$defs/PreferencesReadRequest" } ], - "description": "Params for _goose/config/read", - "title": "ReadConfigRequest" + "description": "Params for _goose/preferences/read", + "title": "PreferencesReadRequest" }, { "allOf": [ { - "$ref": "#/$defs/UpsertConfigRequest" + "$ref": "#/$defs/PreferencesSaveRequest" } ], - "description": "Params for _goose/config/upsert", - "title": "UpsertConfigRequest" + "description": "Params for _goose/preferences/save", + "title": "PreferencesSaveRequest" }, { "allOf": [ { - "$ref": "#/$defs/RemoveConfigRequest" + "$ref": "#/$defs/PreferencesRemoveRequest" } ], - "description": "Params for _goose/config/remove", - "title": "RemoveConfigRequest" + "description": "Params for _goose/preferences/remove", + "title": "PreferencesRemoveRequest" }, { "allOf": [ { - "$ref": "#/$defs/CheckSecretRequest" + "$ref": "#/$defs/DefaultsReadRequest" } ], - "description": "Params for _goose/secret/check", - "title": "CheckSecretRequest" - }, - { - "allOf": [ - { - "$ref": "#/$defs/UpsertSecretRequest" - } - ], - "description": "Params for _goose/secret/upsert", - "title": "UpsertSecretRequest" - }, - { - "allOf": [ - { - "$ref": "#/$defs/RemoveSecretRequest" - } - ], - "description": "Params for _goose/secret/remove", - "title": "RemoveSecretRequest" + "description": "Params for _goose/defaults/read", + "title": "DefaultsReadRequest" }, { "allOf": [ @@ -2507,6 +2517,24 @@ "description": "Params for _goose/dictation/config", "title": "DictationConfigRequest" }, + { + "allOf": [ + { + "$ref": "#/$defs/DictationSecretSaveRequest" + } + ], + "description": "Params for _goose/dictation/secret/save", + "title": "DictationSecretSaveRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/DictationSecretDeleteRequest" + } + ], + "description": "Params for _goose/dictation/secret/delete", + "title": "DictationSecretDeleteRequest" + }, { "allOf": [ { @@ -2730,18 +2758,18 @@ { "allOf": [ { - "$ref": "#/$defs/ReadConfigResponse" + "$ref": "#/$defs/PreferencesReadResponse" } ], - "title": "ReadConfigResponse" + "title": "PreferencesReadResponse" }, { "allOf": [ { - "$ref": "#/$defs/CheckSecretResponse" + "$ref": "#/$defs/DefaultsReadResponse" } ], - "title": "CheckSecretResponse" + "title": "DefaultsReadResponse" }, { "allOf": [ diff --git a/crates/goose/src/acp/server.rs b/crates/goose/src/acp/server.rs index 3bf0c4d4b8..239f2f276c 100644 --- a/crates/goose/src/acp/server.rs +++ b/crates/goose/src/acp/server.rs @@ -74,7 +74,6 @@ mod dispatch; mod extensions; mod providers; mod resources; -mod secrets; mod sessions; mod sources; mod tools; diff --git a/crates/goose/src/acp/server/config.rs b/crates/goose/src/acp/server/config.rs index 7610433f35..3c2684e122 100644 --- a/crates/goose/src/acp/server/config.rs +++ b/crates/goose/src/acp/server/config.rs @@ -1,36 +1,173 @@ use super::*; impl GooseAcpAgent { - pub(super) async fn on_read_config( + pub(super) async fn on_preferences_read( &self, - req: ReadConfigRequest, - ) -> Result { + req: PreferencesReadRequest, + ) -> Result { let config = self.config()?; - let response = match config.get_param::(&req.key) { - Ok(value) => ReadConfigResponse { value }, - Err(crate::config::ConfigError::NotFound(_)) => ReadConfigResponse { - value: serde_json::Value::Null, - }, - Err(e) => return Err(sacp::Error::internal_error().data(e.to_string())), + let keys = if req.keys.is_empty() { + PREFERENCE_DEFS.iter().map(|def| def.key).collect() + } else { + req.keys }; - Ok(response) + let mut values = Vec::with_capacity(keys.len()); + + for key in keys { + let def = preference_def(key)?; + let value = match config.get_param::(def.config_key) { + Ok(value) => value, + Err(crate::config::ConfigError::NotFound(_)) => serde_json::Value::Null, + Err(e) => return Err(sacp::Error::internal_error().data(e.to_string())), + }; + values.push(PreferenceValue { key, value }); + } + + Ok(PreferencesReadResponse { values }) } - pub(super) async fn on_upsert_config( + pub(super) async fn on_preferences_save( &self, - req: UpsertConfigRequest, + req: PreferencesSaveRequest, ) -> Result { let config = self.config()?; - config.set_param(&req.key, &req.value).internal_err()?; + let mut updates = Vec::with_capacity(req.values.len()); + + for preference in &req.values { + let def = preference_def(preference.key)?; + (def.validate)(&preference.value)?; + updates.push((def.config_key.to_string(), preference.value.clone())); + } + + config.set_param_values(&updates).internal_err()?; Ok(EmptyResponse {}) } - pub(super) async fn on_remove_config( + pub(super) async fn on_preferences_remove( &self, - req: RemoveConfigRequest, + req: PreferencesRemoveRequest, ) -> Result { let config = self.config()?; - config.delete(&req.key).internal_err()?; + for key in req.keys { + let def = preference_def(key)?; + config.delete(def.config_key).internal_err()?; + } Ok(EmptyResponse {}) } + + pub(super) async fn on_defaults_read( + &self, + _req: DefaultsReadRequest, + ) -> Result { + let config = self.config()?; + Ok(DefaultsReadResponse { + provider_id: optional_config_string(&config, "GOOSE_PROVIDER")?, + model_id: optional_config_string(&config, "GOOSE_MODEL")?, + }) + } +} + +struct PreferenceDef { + key: PreferenceKey, + config_key: &'static str, + validate: fn(&serde_json::Value) -> Result<(), sacp::Error>, +} + +const PREFERENCE_DEFS: &[PreferenceDef] = &[ + PreferenceDef { + key: PreferenceKey::AutoCompactThreshold, + config_key: "GOOSE_AUTO_COMPACT_THRESHOLD", + validate: validate_auto_compact_threshold, + }, + PreferenceDef { + key: PreferenceKey::VoiceAutoSubmitPhrases, + config_key: "VOICE_AUTO_SUBMIT_PHRASES", + validate: validate_voice_auto_submit_phrases, + }, + PreferenceDef { + key: PreferenceKey::VoiceDictationProvider, + config_key: "VOICE_DICTATION_PROVIDER", + validate: validate_voice_dictation_provider, + }, + PreferenceDef { + key: PreferenceKey::VoiceDictationPreferredMic, + config_key: "VOICE_DICTATION_PREFERRED_MIC", + validate: validate_voice_dictation_preferred_mic, + }, +]; + +fn preference_def(key: PreferenceKey) -> Result<&'static PreferenceDef, sacp::Error> { + PREFERENCE_DEFS + .iter() + .find(|def| def.key == key) + .ok_or_else(|| { + sacp::Error::internal_error().data(format!("Missing preference definition for {key:?}")) + }) +} + +fn validate_auto_compact_threshold(value: &serde_json::Value) -> Result<(), sacp::Error> { + let Some(value) = value.as_f64() else { + return Err(sacp::Error::invalid_params().data("autoCompactThreshold must be a number")); + }; + if !value.is_finite() || value <= 0.0 || value > 1.0 { + return Err(sacp::Error::invalid_params() + .data("autoCompactThreshold must be greater than 0 and at most 1")); + } + + Ok(()) +} + +fn validate_voice_auto_submit_phrases(value: &serde_json::Value) -> Result<(), sacp::Error> { + if !value.is_string() { + return Err(sacp::Error::invalid_params().data("voiceAutoSubmitPhrases must be a string")); + } + + Ok(()) +} + +fn validate_voice_dictation_provider(value: &serde_json::Value) -> Result<(), sacp::Error> { + let Some(value) = value.as_str() else { + return Err(sacp::Error::invalid_params().data("voiceDictationProvider must be a string")); + }; + if !is_supported_voice_dictation_provider(value) { + return Err(sacp::Error::invalid_params().data("voiceDictationProvider is not supported")); + } + + Ok(()) +} + +fn validate_voice_dictation_preferred_mic(value: &serde_json::Value) -> Result<(), sacp::Error> { + let Some(value) = value.as_str() else { + return Err( + sacp::Error::invalid_params().data("voiceDictationPreferredMic must be a string") + ); + }; + if value.is_empty() { + return Err( + sacp::Error::invalid_params().data("voiceDictationPreferredMic must be non-empty") + ); + } + + Ok(()) +} + +fn is_supported_voice_dictation_provider(value: &str) -> bool { + matches!(value, "openai" | "groq" | "elevenlabs" | "__disabled__") || { + #[cfg(feature = "local-inference")] + { + value == "local" + } + #[cfg(not(feature = "local-inference"))] + { + false + } + } +} + +fn optional_config_string(config: &Config, key: &str) -> Result, sacp::Error> { + match config.get_param::(key) { + Ok(value) => Ok(Some(value)), + Err(crate::config::ConfigError::NotFound(_)) => Ok(None), + Err(e) => Err(sacp::Error::internal_error().data(e.to_string())), + } } diff --git a/crates/goose/src/acp/server/custom_dispatch.rs b/crates/goose/src/acp/server/custom_dispatch.rs index d04c532ff5..473814efe9 100644 --- a/crates/goose/src/acp/server/custom_dispatch.rs +++ b/crates/goose/src/acp/server/custom_dispatch.rs @@ -200,52 +200,36 @@ impl GooseAcpAgent { self.on_delete_provider_config(req).await } - #[custom_method(ReadConfigRequest)] - async fn dispatch_read_config( + #[custom_method(PreferencesReadRequest)] + async fn dispatch_preferences_read( &self, - req: ReadConfigRequest, - ) -> Result { - self.on_read_config(req).await + req: PreferencesReadRequest, + ) -> Result { + self.on_preferences_read(req).await } - #[custom_method(UpsertConfigRequest)] - async fn dispatch_upsert_config( + #[custom_method(PreferencesSaveRequest)] + async fn dispatch_preferences_save( &self, - req: UpsertConfigRequest, + req: PreferencesSaveRequest, ) -> Result { - self.on_upsert_config(req).await + self.on_preferences_save(req).await } - #[custom_method(RemoveConfigRequest)] - async fn dispatch_remove_config( + #[custom_method(PreferencesRemoveRequest)] + async fn dispatch_preferences_remove( &self, - req: RemoveConfigRequest, + req: PreferencesRemoveRequest, ) -> Result { - self.on_remove_config(req).await + self.on_preferences_remove(req).await } - #[custom_method(CheckSecretRequest)] - async fn dispatch_check_secret( + #[custom_method(DefaultsReadRequest)] + async fn dispatch_defaults_read( &self, - req: CheckSecretRequest, - ) -> Result { - self.on_check_secret(req).await - } - - #[custom_method(UpsertSecretRequest)] - async fn dispatch_upsert_secret( - &self, - req: UpsertSecretRequest, - ) -> Result { - self.on_upsert_secret(req).await - } - - #[custom_method(RemoveSecretRequest)] - async fn dispatch_remove_secret( - &self, - req: RemoveSecretRequest, - ) -> Result { - self.on_remove_secret(req).await + req: DefaultsReadRequest, + ) -> Result { + self.on_defaults_read(req).await } #[custom_method(ExportSessionRequest)] @@ -360,6 +344,22 @@ impl GooseAcpAgent { self.on_dictation_config(_req).await } + #[custom_method(DictationSecretSaveRequest)] + async fn dispatch_dictation_secret_save( + &self, + req: DictationSecretSaveRequest, + ) -> Result { + self.on_dictation_secret_save(req).await + } + + #[custom_method(DictationSecretDeleteRequest)] + async fn dispatch_dictation_secret_delete( + &self, + req: DictationSecretDeleteRequest, + ) -> Result { + self.on_dictation_secret_delete(req).await + } + #[custom_method(DictationModelsListRequest)] async fn dispatch_dictation_models_list( &self, diff --git a/crates/goose/src/acp/server/dictation.rs b/crates/goose/src/acp/server/dictation.rs index 135d9d4b86..e4e0547684 100644 --- a/crates/goose/src/acp/server/dictation.rs +++ b/crates/goose/src/acp/server/dictation.rs @@ -2,7 +2,7 @@ use super::*; #[cfg(feature = "local-inference")] use crate::dictation::providers::transcribe_local; use crate::dictation::providers::{ - all_providers, is_configured, transcribe_with_provider, DictationProvider, + all_providers, get_provider_def, is_configured, transcribe_with_provider, DictationProvider, }; #[cfg(feature = "local-inference")] use crate::dictation::whisper; @@ -125,6 +125,30 @@ impl GooseAcpAgent { Ok(DictationConfigResponse { providers }) } + pub(super) async fn on_dictation_secret_save( + &self, + req: DictationSecretSaveRequest, + ) -> Result { + let provider = parse_dictation_provider(&req.provider)?; + let key = dictation_secret_config_key(provider)?; + let config = self.config()?; + config.set_secret(key, &req.value).internal_err()?; + Config::global().invalidate_secrets_cache(); + Ok(EmptyResponse {}) + } + + pub(super) async fn on_dictation_secret_delete( + &self, + req: DictationSecretDeleteRequest, + ) -> Result { + let provider = parse_dictation_provider(&req.provider)?; + let key = dictation_secret_config_key(provider)?; + let config = self.config()?; + config.delete_secret(key).internal_err()?; + Config::global().invalidate_secrets_cache(); + Ok(EmptyResponse {}) + } + pub(super) async fn on_dictation_models_list( &self, _req: DictationModelsListRequest, @@ -321,6 +345,29 @@ impl GooseAcpAgent { Ok(EmptyResponse {}) } } + +fn parse_dictation_provider(provider: &str) -> Result { + serde_json::from_value(serde_json::Value::String(provider.to_string())) + .map_err(|_| sacp::Error::invalid_params().data(format!("Unknown provider: {provider}"))) +} + +fn dictation_secret_config_key(provider: DictationProvider) -> Result<&'static str, sacp::Error> { + let def = get_provider_def(provider); + if def.uses_provider_config { + return Err(sacp::Error::invalid_params().data( + "Dictation provider uses the main provider configuration. Configure its credentials in provider settings instead.", + )); + } + + #[cfg(feature = "local-inference")] + if provider == DictationProvider::Local { + return Err(sacp::Error::invalid_params() + .data("Dictation provider does not use an API key or secret.")); + } + + Ok(def.config_key) +} + fn dictation_model_config_key(provider: DictationProvider) -> Option { match provider { DictationProvider::OpenAI => Some(OPENAI_TRANSCRIPTION_MODEL_CONFIG_KEY.to_string()), diff --git a/crates/goose/src/acp/server/secrets.rs b/crates/goose/src/acp/server/secrets.rs deleted file mode 100644 index 16ab2250d4..0000000000 --- a/crates/goose/src/acp/server/secrets.rs +++ /dev/null @@ -1,32 +0,0 @@ -use super::*; - -impl GooseAcpAgent { - pub(super) async fn on_check_secret( - &self, - req: CheckSecretRequest, - ) -> Result { - let config = self.config()?; - let exists = config.get_secret::(&req.key).is_ok(); - Ok(CheckSecretResponse { exists }) - } - - pub(super) async fn on_upsert_secret( - &self, - req: UpsertSecretRequest, - ) -> Result { - let config = self.config()?; - config.set_secret(&req.key, &req.value).internal_err()?; - Config::global().invalidate_secrets_cache(); - Ok(EmptyResponse {}) - } - - pub(super) async fn on_remove_secret( - &self, - req: RemoveSecretRequest, - ) -> Result { - let config = self.config()?; - config.delete_secret(&req.key).internal_err()?; - Config::global().invalidate_secrets_cache(); - Ok(EmptyResponse {}) - } -} diff --git a/crates/goose/src/config/base.rs b/crates/goose/src/config/base.rs index b7fefc8c20..427eb87a27 100644 --- a/crates/goose/src/config/base.rs +++ b/crates/goose/src/config/base.rs @@ -719,6 +719,20 @@ impl Config { self.save_values(&values) } + /// Set multiple configuration values in the config file with one read and one write. + pub fn set_param_values(&self, updates: &[(String, Value)]) -> Result<(), ConfigError> { + if updates.is_empty() { + return Ok(()); + } + + let _guard = self.guard.lock().unwrap(); + let mut values = self.load_write_config()?; + for (key, value) in updates { + values.insert(serde_yaml::to_value(key)?, serde_yaml::to_value(value)?); + } + self.save_values(&values) + } + /// Delete a configuration value in the config file. /// /// This will immediately write the value to the config file. The value diff --git a/crates/goose/tests/acp_custom_requests_test.rs b/crates/goose/tests/acp_custom_requests_test.rs index 5b2eece80a..7e4e8b53cf 100644 --- a/crates/goose/tests/acp_custom_requests_test.rs +++ b/crates/goose/tests/acp_custom_requests_test.rs @@ -69,7 +69,7 @@ fn mock_provider_factory() -> AcpProviderFactory { #[test] fn test_custom_get_tools() { - run_test(async { + run_test(async move { let openai = OpenAiFixture::new(vec![], Arc::new(EnforceSessionId::default())).await; let mut conn = AcpServerConnection::new(TestConnectionConfig::default(), openai).await; @@ -92,7 +92,7 @@ fn test_custom_get_tools() { #[test] fn test_custom_get_extensions() { - run_test(async { + run_test(async move { let openai = OpenAiFixture::new(vec![], Arc::new(EnforceSessionId::default())).await; let conn = AcpServerConnection::new(TestConnectionConfig::default(), openai).await; @@ -140,53 +140,289 @@ fn test_custom_provider_inventory_includes_metadata() { } #[test] -fn test_custom_config_crud() { +fn test_custom_preferences_read_save_remove() { + run_test(async { + let data_root = tempfile::tempdir().unwrap(); + std::fs::write( + data_root + .path() + .join(goose::config::base::CONFIG_YAML_NAME), + "GOOSE_MODEL: gpt-4o\nGOOSE_PROVIDER: openai\nGOOSE_AUTO_COMPACT_THRESHOLD: 0.7\nVOICE_AUTO_SUBMIT_PHRASES: send it\n", + ) + .unwrap(); + let openai = OpenAiFixture::new(vec![], Arc::new(EnforceSessionId::default())).await; + let config = TestConnectionConfig { + data_root: data_root.path().to_path_buf(), + ..Default::default() + }; + let conn = AcpServerConnection::new(config, openai).await; + + let response = send_custom( + conn.cx(), + "_goose/preferences/read", + serde_json::json!({ + "keys": [ + "autoCompactThreshold", + "voiceAutoSubmitPhrases", + "voiceDictationPreferredMic" + ], + }), + ) + .await + .expect("preferences read should succeed"); + assert_eq!( + response.get("values"), + Some(&serde_json::json!([ + { "key": "autoCompactThreshold", "value": 0.7 }, + { "key": "voiceAutoSubmitPhrases", "value": "send it" }, + { "key": "voiceDictationPreferredMic", "value": null }, + ])) + ); + + send_custom( + conn.cx(), + "_goose/preferences/save", + serde_json::json!({ + "values": [ + { "key": "voiceDictationProvider", "value": "__disabled__" }, + { "key": "voiceDictationPreferredMic", "value": "mic-1" } + ], + }), + ) + .await + .expect("preferences save should succeed"); + + send_custom( + conn.cx(), + "_goose/preferences/remove", + serde_json::json!({ + "keys": ["voiceDictationProvider"], + }), + ) + .await + .expect("preferences remove should succeed"); + + let response = send_custom( + conn.cx(), + "_goose/preferences/read", + serde_json::json!({ + "keys": ["voiceDictationProvider", "voiceDictationPreferredMic"], + }), + ) + .await + .expect("preferences read after remove should succeed"); + assert_eq!( + response.get("values"), + Some(&serde_json::json!([ + { "key": "voiceDictationProvider", "value": null }, + { "key": "voiceDictationPreferredMic", "value": "mic-1" }, + ])) + ); + }); +} + +#[test] +fn test_custom_preferences_save_rejects_invalid_values() { run_test(async { let openai = OpenAiFixture::new(vec![], Arc::new(EnforceSessionId::default())).await; let conn = AcpServerConnection::new(TestConnectionConfig::default(), openai).await; + let invalid_payloads = [ + serde_json::json!({ + "values": [{ "key": "autoCompactThreshold", "value": 0 }], + }), + serde_json::json!({ + "values": [{ "key": "autoCompactThreshold", "value": 1.1 }], + }), + serde_json::json!({ + "values": [{ "key": "voiceAutoSubmitPhrases", "value": ["send"] }], + }), + serde_json::json!({ + "values": [{ "key": "voiceDictationProvider", "value": "bogus" }], + }), + serde_json::json!({ + "values": [{ "key": "voiceDictationPreferredMic", "value": "" }], + }), + ]; + + for payload in invalid_payloads { + let result = send_custom(conn.cx(), "_goose/preferences/save", payload).await; + assert!(result.is_err(), "expected invalid params error"); + } + + let result = send_custom( + conn.cx(), + "_goose/preferences/save", + serde_json::json!({ + "values": [ + { "key": "voiceDictationPreferredMic", "value": "mic-1" }, + { "key": "voiceDictationProvider", "value": "bogus" } + ], + }), + ) + .await; + assert!(result.is_err(), "expected invalid params error"); + + let response = send_custom( + conn.cx(), + "_goose/preferences/read", + serde_json::json!({ + "keys": ["voiceDictationPreferredMic"], + }), + ) + .await + .expect("preferences read should succeed"); + assert_eq!( + response.get("values"), + Some(&serde_json::json!([ + { "key": "voiceDictationPreferredMic", "value": null }, + ])) + ); + }); +} + +#[test] +fn test_custom_defaults_read() { + run_test(async { + let data_root = tempfile::tempdir().unwrap(); + std::fs::write( + data_root.path().join(goose::config::base::CONFIG_YAML_NAME), + "GOOSE_MODEL: claude-3-5-haiku-latest\nGOOSE_PROVIDER: anthropic\n", + ) + .unwrap(); + let openai = OpenAiFixture::new(vec![], Arc::new(EnforceSessionId::default())).await; + let config = TestConnectionConfig { + data_root: data_root.path().to_path_buf(), + ..Default::default() + }; + let conn = AcpServerConnection::new(config, openai).await; + + let response = send_custom(conn.cx(), "_goose/defaults/read", serde_json::json!({})) + .await + .expect("defaults read should succeed"); + assert_eq!( + response, + serde_json::json!({ + "providerId": "anthropic", + "modelId": "claude-3-5-haiku-latest", + }) + ); + }); +} + +#[test] +fn test_custom_dictation_secret_save_delete() { + let root = tempfile::tempdir().unwrap(); + let root_path = root.path().to_string_lossy().to_string(); + let _env = env_lock::lock_env([ + ("GOOSE_PATH_ROOT", Some(root_path.as_str())), + ("GOOSE_DISABLE_KEYRING", Some("1")), + ("GROQ_API_KEY", None::<&str>), + ]); + let config_dir = goose::config::paths::Paths::config_dir(); + std::fs::create_dir_all(&config_dir).unwrap(); + std::fs::write( + config_dir.join(goose::config::base::CONFIG_YAML_NAME), + "GOOSE_MODEL: gpt-4o\nGOOSE_PROVIDER: openai\nGOOSE_DISABLE_KEYRING: true\n", + ) + .unwrap(); + + run_test(async move { + let openai = OpenAiFixture::new(vec![], Arc::new(EnforceSessionId::default())).await; + let config = TestConnectionConfig { + data_root: config_dir.clone(), + ..Default::default() + }; + let conn = AcpServerConnection::new(config, openai).await; + send_custom( conn.cx(), + "_goose/dictation/secret/save", + serde_json::json!({ + "provider": "groq", + "value": "groq-key", + }), + ) + .await + .expect("dictation secret save should succeed"); + + let config = send_custom(conn.cx(), "_goose/dictation/config", serde_json::json!({})) + .await + .expect("dictation config should succeed"); + assert_eq!( + config + .pointer("/providers/groq/configured") + .and_then(|value| value.as_bool()), + Some(true) + ); + + let provider_config_result = send_custom( + conn.cx(), + "_goose/dictation/secret/save", + serde_json::json!({ + "provider": "openai", + "value": "openai-key", + }), + ) + .await; + assert!( + provider_config_result.is_err(), + "provider-config dictation providers should be rejected" + ); + + let unknown_result = send_custom( + conn.cx(), + "_goose/dictation/secret/save", + serde_json::json!({ + "provider": "unknown", + "value": "key", + }), + ) + .await; + assert!( + unknown_result.is_err(), + "unknown provider should be rejected" + ); + + send_custom( + conn.cx(), + "_goose/dictation/secret/delete", + serde_json::json!({ + "provider": "groq", + }), + ) + .await + .expect("dictation secret delete should succeed"); + + let config = send_custom(conn.cx(), "_goose/dictation/config", serde_json::json!({})) + .await + .expect("dictation config should succeed"); + assert_eq!( + config + .pointer("/providers/groq/configured") + .and_then(|value| value.as_bool()), + Some(false) + ); + }); +} + +#[test] +fn test_raw_config_and_secret_methods_are_removed() { + run_test(async { + let openai = OpenAiFixture::new(vec![], Arc::new(EnforceSessionId::default())).await; + let conn = AcpServerConnection::new(TestConnectionConfig::default(), openai).await; + + for method in [ + "_goose/config/read", "_goose/config/upsert", - serde_json::json!({ - "key": "GOOSE_PROVIDER", - "value": "anthropic", - }), - ) - .await - .expect("config upsert should succeed"); - - let response = send_custom( - conn.cx(), - "_goose/config/read", - serde_json::json!({ - "key": "GOOSE_PROVIDER", - }), - ) - .await - .expect("config read should succeed"); - assert_eq!(response.get("value"), Some(&serde_json::json!("anthropic"))); - - send_custom( - conn.cx(), "_goose/config/remove", - serde_json::json!({ - "key": "GOOSE_PROVIDER", - }), - ) - .await - .expect("config remove should succeed"); - - let response = send_custom( - conn.cx(), - "_goose/config/read", - serde_json::json!({ - "key": "GOOSE_PROVIDER", - }), - ) - .await - .expect("config read after remove should succeed"); - assert_eq!(response.get("value"), Some(&serde_json::Value::Null)); + "_goose/secret/check", + "_goose/secret/upsert", + "_goose/secret/remove", + ] { + let result = send_custom(conn.cx(), method, serde_json::json!({})).await; + assert!(result.is_err(), "{method} should be removed"); + } }); } diff --git a/crates/goose/tests/acp_secret_cache_invalidation_test.rs b/crates/goose/tests/acp_secret_cache_invalidation_test.rs index 31271431ef..0c7f5e5e05 100644 --- a/crates/goose/tests/acp_secret_cache_invalidation_test.rs +++ b/crates/goose/tests/acp_secret_cache_invalidation_test.rs @@ -79,6 +79,7 @@ fn acp_secret_mutations_and_inventory_refresh_invalidate_global_secret_cache() { ("GOOSE_PATH_ROOT", Some(root_path.as_str())), ("GOOSE_DISABLE_KEYRING", Some("1")), ("ANTHROPIC_API_KEY", None), + ("GROQ_API_KEY", None), ("OPENAI_API_KEY", None), ("XAI_API_KEY", None), ("XAI_HOST", None), @@ -87,12 +88,12 @@ fn acp_secret_mutations_and_inventory_refresh_invalidate_global_secret_cache() { let config_dir = Paths::config_dir(); let data_dir = Paths::data_dir(); write_config(&config_dir); - write_secrets(&config_dir, "OPENAI_API_KEY: stale-key\n"); + write_secrets(&config_dir, "GROQ_API_KEY: stale-key\n"); run_test(async move { assert_eq!( Config::global() - .get_secret::("OPENAI_API_KEY") + .get_secret::("GROQ_API_KEY") .unwrap(), "stale-key" ); @@ -109,43 +110,43 @@ fn acp_secret_mutations_and_inventory_refresh_invalidate_global_secret_cache() { }; let conn = AcpServerConnection::new(config, openai).await; - write_secrets(&config_dir, "OPENAI_API_KEY: fresh-key\n"); + write_secrets(&config_dir, "GROQ_API_KEY: fresh-key\n"); send_custom( conn.cx(), - "_goose/secret/upsert", + "_goose/dictation/secret/save", serde_json::json!({ - "key": "OPENAI_API_KEY", + "provider": "groq", "value": "fresh-key", }), ) .await - .expect("secret upsert should succeed"); + .expect("dictation secret save should succeed"); assert_eq!( Config::global() - .get_secret::("OPENAI_API_KEY") + .get_secret::("GROQ_API_KEY") .unwrap(), "fresh-key", - "ACP secret upsert should invalidate the global secrets cache" + "ACP dictation secret save should invalidate the global secrets cache" ); write_secrets(&config_dir, "{}\n"); send_custom( conn.cx(), - "_goose/secret/remove", + "_goose/dictation/secret/delete", serde_json::json!({ - "key": "OPENAI_API_KEY", + "provider": "groq", }), ) .await - .expect("secret remove should succeed"); + .expect("dictation secret delete should succeed"); assert!( matches!( - Config::global().get_secret::("OPENAI_API_KEY"), + Config::global().get_secret::("GROQ_API_KEY"), Err(ConfigError::NotFound(_)) ), - "ACP secret remove should invalidate the global secrets cache" + "ACP dictation secret delete should invalidate the global secrets cache" ); let save_provider_config = send_custom( diff --git a/ui/goose2/AGENTS.md b/ui/goose2/AGENTS.md index 4f4771675b..99cdcbec88 100644 --- a/ui/goose2/AGENTS.md +++ b/ui/goose2/AGENTS.md @@ -169,6 +169,14 @@ The skills → sources migration in [#8675](https://github.com/block/goose/pull/ For a minimal frontend `api/` wrapper using the typed shape, see `ui/goose2/src/features/providers/api/inventory.ts` — ~30 lines, typed SDK calls, thin adapter. For a fully worked end-to-end feature including OS-keychain handling and progress streaming, see the voice dictation feature ([#8609](https://github.com/block/goose/pull/8609)) and `ui/goose2/src/shared/api/dictation.ts`. +### Typed ACP config contracts + +Build goose2 config flows around typed ACP methods whose contract matches the domain: provider config, preferences, defaults, dictation secrets, or extension config. + +Keep raw Goose storage keys and secret-handling decisions behind backend-owned ACP methods. This lets Goose validate inputs, apply provider metadata, invalidate caches, refresh dependent state, and keep generated SDK types aligned with supported behavior. + +For reference, define contracts in `crates/goose-sdk/src/custom_requests.rs`, implement them in `crates/goose/src/acp/server/`, regenerate `ui/sdk/src/generated/`, then call the generated `client.goose.*` methods from a feature or shared `api/` wrapper. + ### When `invoke()` is still appropriate Tauri commands (`invoke()` from `@tauri-apps/api/core`) are reserved for things that genuinely belong to the desktop shell, not to `goose` core. Provider config or secret mutations that affect the Goose runtime must flow through React → SDK → ACP → goose core so core can validate provider metadata, invalidate secret caches, refresh inventory, and apply provider changes consistently. In practice, Tauri is limited to: diff --git a/ui/goose2/src/features/chat/hooks/__tests__/useAutoCompactPreferences.test.ts b/ui/goose2/src/features/chat/hooks/__tests__/useAutoCompactPreferences.test.ts index 174d4ddf18..25f0e15323 100644 --- a/ui/goose2/src/features/chat/hooks/__tests__/useAutoCompactPreferences.test.ts +++ b/ui/goose2/src/features/chat/hooks/__tests__/useAutoCompactPreferences.test.ts @@ -2,7 +2,6 @@ import { act, renderHook, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { AUTO_COMPACT_PREFERENCES_EVENT, - AUTO_COMPACT_THRESHOLD_CONFIG_KEY, DEFAULT_AUTO_COMPACT_THRESHOLD, } from "../../lib/autoCompact"; @@ -26,8 +25,10 @@ describe("useAutoCompactPreferences", () => { it("hydrates from the stored threshold value", async () => { mockGetClient.mockResolvedValue({ goose: { - GooseConfigRead: vi.fn().mockResolvedValue({ value: 0.65 }), - GooseConfigUpsert: vi.fn().mockResolvedValue({}), + GoosePreferencesRead: vi.fn().mockResolvedValue({ + values: [{ key: "autoCompactThreshold", value: 0.65 }], + }), + GoosePreferencesSave: vi.fn().mockResolvedValue({}), }, }); @@ -42,13 +43,17 @@ describe("useAutoCompactPreferences", () => { const upsert = vi.fn().mockResolvedValue({}); const read = vi .fn() - .mockResolvedValueOnce({ value: null }) - .mockResolvedValue({ value: 0.9 }); + .mockResolvedValueOnce({ + values: [{ key: "autoCompactThreshold", value: null }], + }) + .mockResolvedValue({ + values: [{ key: "autoCompactThreshold", value: 0.9 }], + }); mockGetClient.mockResolvedValue({ goose: { - GooseConfigRead: read, - GooseConfigUpsert: upsert, + GoosePreferencesRead: read, + GoosePreferencesSave: upsert, }, }); @@ -64,8 +69,7 @@ describe("useAutoCompactPreferences", () => { }); expect(upsert).toHaveBeenCalledWith({ - key: AUTO_COMPACT_THRESHOLD_CONFIG_KEY, - value: 0.9, + values: [{ key: "autoCompactThreshold", value: 0.9 }], }); expect(eventListener).toHaveBeenCalledTimes(1); expect(result.current.autoCompactThreshold).toBe(0.9); @@ -73,13 +77,17 @@ describe("useAutoCompactPreferences", () => { window.removeEventListener(AUTO_COMPACT_PREFERENCES_EVENT, eventListener); }); - it("marks the preferences hydrated even when the initial read fails", async () => { + it("does not mark preferences hydrated when the initial read fails", async () => { mockGetClient.mockRejectedValue(new Error("ACP not ready")); const { result } = renderHook(() => useAutoCompactPreferences()); - await waitFor(() => expect(result.current.isHydrated).toBe(true)); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(result.current.isHydrated).toBe(false); expect(result.current.autoCompactThreshold).toBe( DEFAULT_AUTO_COMPACT_THRESHOLD, ); @@ -90,12 +98,14 @@ describe("useAutoCompactPreferences", () => { const read = vi .fn() .mockRejectedValueOnce(new Error("ACP not ready")) - .mockResolvedValueOnce({ value: 0.65 }); + .mockResolvedValueOnce({ + values: [{ key: "autoCompactThreshold", value: 0.65 }], + }); mockGetClient.mockResolvedValue({ goose: { - GooseConfigRead: read, - GooseConfigUpsert: vi.fn().mockResolvedValue({}), + GoosePreferencesRead: read, + GoosePreferencesSave: vi.fn().mockResolvedValue({}), }, }); @@ -106,7 +116,7 @@ describe("useAutoCompactPreferences", () => { await Promise.resolve(); }); - expect(result.current.isHydrated).toBe(true); + expect(result.current.isHydrated).toBe(false); expect(result.current.autoCompactThreshold).toBe( DEFAULT_AUTO_COMPACT_THRESHOLD, ); @@ -116,6 +126,47 @@ describe("useAutoCompactPreferences", () => { }); expect(result.current.autoCompactThreshold).toBe(0.65); + expect(result.current.isHydrated).toBe(true); expect(read).toHaveBeenCalledTimes(2); }); + + it("backs off repeated hydration retries", async () => { + vi.useFakeTimers(); + const read = vi.fn().mockRejectedValue(new Error("ACP not ready")); + + mockGetClient.mockResolvedValue({ + goose: { + GoosePreferencesRead: read, + GoosePreferencesSave: vi.fn().mockResolvedValue({}), + }, + }); + + renderHook(() => useAutoCompactPreferences()); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(read).toHaveBeenCalledTimes(1); + + await act(async () => { + await vi.advanceTimersByTimeAsync(999); + }); + expect(read).toHaveBeenCalledTimes(1); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1); + }); + expect(read).toHaveBeenCalledTimes(2); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1999); + }); + expect(read).toHaveBeenCalledTimes(2); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1); + }); + expect(read).toHaveBeenCalledTimes(3); + }); }); diff --git a/ui/goose2/src/features/chat/hooks/__tests__/useChatSessionController.test.ts b/ui/goose2/src/features/chat/hooks/__tests__/useChatSessionController.test.ts index cd2f7990ae..2928ad1437 100644 --- a/ui/goose2/src/features/chat/hooks/__tests__/useChatSessionController.test.ts +++ b/ui/goose2/src/features/chat/hooks/__tests__/useChatSessionController.test.ts @@ -9,7 +9,7 @@ const mockAcpPrepareSession = vi.fn(); const mockAcpSetModel = vi.fn(); const mockSetSelectedProvider = vi.fn(); const mockResolveSessionCwd = vi.fn(); -const mockGooseConfigRead = vi.fn(); +const mockGooseDefaultsRead = vi.fn(); const mockUseProviderInventory = vi.fn(); const mockPickerState = { pickerAgents: [{ id: "goose", label: "Goose" }], @@ -31,7 +31,7 @@ vi.mock("@/shared/api/acp", () => ({ vi.mock("@/shared/api/acpConnection", () => ({ getClient: async () => ({ goose: { - GooseConfigRead: (...args: unknown[]) => mockGooseConfigRead(...args), + GooseDefaultsRead: (...args: unknown[]) => mockGooseDefaultsRead(...args), }, }), })); @@ -116,7 +116,10 @@ describe("useChatSessionController", () => { mockAcpPrepareSession.mockResolvedValue(undefined); mockAcpSetModel.mockResolvedValue(undefined); mockResolveSessionCwd.mockResolvedValue("/tmp/project"); - mockGooseConfigRead.mockResolvedValue({ value: null }); + mockGooseDefaultsRead.mockResolvedValue({ + providerId: null, + modelId: null, + }); mockUseProviderInventory.mockReturnValue({ getEntry: () => undefined, }); @@ -283,17 +286,10 @@ describe("useChatSessionController", () => { it("falls back to the configured goose default model when no explicit model is stored", async () => { useAgentStore.setState({ selectedProvider: "goose" }); - mockGooseConfigRead.mockImplementation( - async ({ key }: { key: string }): Promise<{ value: string | null }> => { - if (key === "GOOSE_PROVIDER") { - return { value: "databricks" }; - } - if (key === "GOOSE_MODEL") { - return { value: "goose-claude-4-6-opus" }; - } - return { value: null }; - }, - ); + mockGooseDefaultsRead.mockResolvedValue({ + providerId: "databricks", + modelId: "goose-claude-4-6-opus", + }); mockPickerState.availableModels = [ { id: "goose-claude-4-6-opus", diff --git a/ui/goose2/src/features/chat/hooks/__tests__/useResolvedAgentModelPicker.test.ts b/ui/goose2/src/features/chat/hooks/__tests__/useResolvedAgentModelPicker.test.ts index 4e71dd022a..e5ad182aae 100644 --- a/ui/goose2/src/features/chat/hooks/__tests__/useResolvedAgentModelPicker.test.ts +++ b/ui/goose2/src/features/chat/hooks/__tests__/useResolvedAgentModelPicker.test.ts @@ -26,7 +26,10 @@ describe("useResolvedAgentModelPicker", () => { mockGetClient.mockResolvedValue({ goose: { - GooseConfigRead: vi.fn().mockResolvedValue({ value: null }), + GooseDefaultsRead: vi.fn().mockResolvedValue({ + providerId: null, + modelId: null, + }), }, }); diff --git a/ui/goose2/src/features/chat/hooks/__tests__/useVoiceInputPreferences.test.ts b/ui/goose2/src/features/chat/hooks/__tests__/useVoiceInputPreferences.test.ts index 8878ac1195..f758affaa8 100644 --- a/ui/goose2/src/features/chat/hooks/__tests__/useVoiceInputPreferences.test.ts +++ b/ui/goose2/src/features/chat/hooks/__tests__/useVoiceInputPreferences.test.ts @@ -27,17 +27,20 @@ describe("useVoiceInputPreferences", () => { mockGetClient.mockResolvedValue({ goose: { - GooseConfigRead: vi.fn().mockImplementation(({ key }) => { - if (key === "VOICE_DICTATION_PROVIDER") { - if (shouldFailProviderRead) { - return Promise.reject(new Error("temporary acp failure")); - } - return Promise.resolve({ value: "groq" }); + GoosePreferencesRead: vi.fn().mockImplementation(() => { + if (shouldFailProviderRead) { + return Promise.reject(new Error("temporary acp failure")); } - return Promise.resolve({ value: null }); + return Promise.resolve({ + values: [ + { key: "voiceAutoSubmitPhrases", value: null }, + { key: "voiceDictationProvider", value: "groq" }, + { key: "voiceDictationPreferredMic", value: null }, + ], + }); }), - GooseConfigUpsert: vi.fn().mockResolvedValue({}), - GooseConfigRemove: vi.fn().mockResolvedValue({}), + GoosePreferencesSave: vi.fn().mockResolvedValue({}), + GoosePreferencesRemove: vi.fn().mockResolvedValue({}), }, }); @@ -61,21 +64,27 @@ describe("useVoiceInputPreferences", () => { it("broadcasts preference changes only after config persistence settles", async () => { const upsert = vi.fn(); - const providerRead = deferred<{ value?: unknown }>(); + const providerRead = deferred<{ + values: Array<{ key: string; value: unknown }>; + }>(); const pendingWrite = deferred(); mockGetClient.mockResolvedValue({ goose: { - GooseConfigRead: vi + GoosePreferencesRead: vi .fn() - .mockResolvedValueOnce({ value: null }) - .mockResolvedValueOnce({ value: null }) - .mockResolvedValueOnce({ value: null }) + .mockResolvedValueOnce({ + values: [ + { key: "voiceAutoSubmitPhrases", value: null }, + { key: "voiceDictationProvider", value: null }, + { key: "voiceDictationPreferredMic", value: null }, + ], + }) .mockImplementation(() => providerRead.promise), - GooseConfigUpsert: upsert.mockImplementation( + GoosePreferencesSave: upsert.mockImplementation( () => pendingWrite.promise, ), - GooseConfigRemove: vi.fn().mockResolvedValue({}), + GoosePreferencesRemove: vi.fn().mockResolvedValue({}), }, }); @@ -100,7 +109,114 @@ describe("useVoiceInputPreferences", () => { await waitFor(() => expect(eventListener).toHaveBeenCalledTimes(1)); - providerRead.resolve({ value: "openai" }); + providerRead.resolve({ + values: [ + { key: "voiceAutoSubmitPhrases", value: null }, + { key: "voiceDictationProvider", value: "openai" }, + { key: "voiceDictationPreferredMic", value: null }, + ], + }); window.removeEventListener("goose:voice-input-preferences", eventListener); }); + + it("does not broadcast failed preference writes and re-syncs stored state", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const save = vi.fn().mockRejectedValue(new Error("write failed")); + const read = vi.fn().mockResolvedValue({ + values: [ + { key: "voiceAutoSubmitPhrases", value: null }, + { key: "voiceDictationProvider", value: "groq" }, + { key: "voiceDictationPreferredMic", value: null }, + ], + }); + + mockGetClient.mockResolvedValue({ + goose: { + GoosePreferencesRead: read, + GoosePreferencesSave: save, + GoosePreferencesRemove: vi.fn().mockResolvedValue({}), + }, + }); + + const eventListener = vi.fn(); + window.addEventListener("goose:voice-input-preferences", eventListener); + + const { result } = renderHook(() => useVoiceInputPreferences()); + await waitFor(() => expect(result.current.isHydrated).toBe(true)); + + act(() => { + result.current.setSelectedProvider("openai"); + }); + + expect(result.current.selectedProvider).toBe("openai"); + await waitFor(() => expect(read).toHaveBeenCalledTimes(2)); + + expect(eventListener).not.toHaveBeenCalled(); + expect(result.current.selectedProvider).toBe("groq"); + + window.removeEventListener("goose:voice-input-preferences", eventListener); + warn.mockRestore(); + }); + + it("stores the disabled sentinel when provider is set to null", async () => { + const save = vi.fn().mockResolvedValue({}); + + mockGetClient.mockResolvedValue({ + goose: { + GoosePreferencesRead: vi.fn().mockResolvedValue({ + values: [ + { key: "voiceAutoSubmitPhrases", value: null }, + { key: "voiceDictationProvider", value: "groq" }, + { key: "voiceDictationPreferredMic", value: null }, + ], + }), + GoosePreferencesSave: save, + GoosePreferencesRemove: vi.fn().mockResolvedValue({}), + }, + }); + + const { result } = renderHook(() => useVoiceInputPreferences()); + await waitFor(() => expect(result.current.isHydrated).toBe(true)); + + act(() => { + result.current.setSelectedProvider(null); + }); + + await waitFor(() => { + expect(save).toHaveBeenCalledWith({ + values: [{ key: "voiceDictationProvider", value: "__disabled__" }], + }); + }); + }); + + it("clearing selected provider removes only the provider preference", async () => { + const remove = vi.fn().mockResolvedValue({}); + + mockGetClient.mockResolvedValue({ + goose: { + GoosePreferencesRead: vi.fn().mockResolvedValue({ + values: [ + { key: "voiceAutoSubmitPhrases", value: "submit" }, + { key: "voiceDictationProvider", value: "groq" }, + { key: "voiceDictationPreferredMic", value: "mic-1" }, + ], + }), + GoosePreferencesSave: vi.fn().mockResolvedValue({}), + GoosePreferencesRemove: remove, + }, + }); + + const { result } = renderHook(() => useVoiceInputPreferences()); + await waitFor(() => expect(result.current.isHydrated).toBe(true)); + + act(() => { + result.current.clearSelectedProvider(); + }); + + await waitFor(() => { + expect(remove).toHaveBeenCalledWith({ + keys: ["voiceDictationProvider"], + }); + }); + }); }); diff --git a/ui/goose2/src/features/chat/hooks/useAutoCompactPreferences.ts b/ui/goose2/src/features/chat/hooks/useAutoCompactPreferences.ts index 49bae104d4..4108166fa9 100644 --- a/ui/goose2/src/features/chat/hooks/useAutoCompactPreferences.ts +++ b/ui/goose2/src/features/chat/hooks/useAutoCompactPreferences.ts @@ -1,13 +1,14 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { getClient } from "@/shared/api/acpConnection"; import { AUTO_COMPACT_PREFERENCES_EVENT, - AUTO_COMPACT_THRESHOLD_CONFIG_KEY, DEFAULT_AUTO_COMPACT_THRESHOLD, normalizeAutoCompactThreshold, } from "../lib/autoCompact"; -const AUTO_COMPACT_RETRY_DELAY_MS = 1000; +const AUTO_COMPACT_INITIAL_RETRY_DELAY_MS = 1000; +const AUTO_COMPACT_MAX_RETRY_DELAY_MS = 30000; +const AUTO_COMPACT_THRESHOLD_PREFERENCE_KEY = "autoCompactThreshold"; type ConfigReadResult = | { @@ -18,22 +19,29 @@ type ConfigReadResult = ok: false; }; -async function readConfigValue(key: string): Promise { +async function readAutoCompactThreshold(): Promise { try { const client = await getClient(); - const response = await client.goose.GooseConfigRead({ key }); + const response = await client.goose.GoosePreferencesRead({ + keys: [AUTO_COMPACT_THRESHOLD_PREFERENCE_KEY], + }); + const preference = response.values.find( + (value) => value.key === AUTO_COMPACT_THRESHOLD_PREFERENCE_KEY, + ); return { ok: true, - value: response.value ?? null, + value: preference?.value ?? null, }; } catch { return { ok: false }; } } -async function writeConfigValue(key: string, value: number): Promise { +async function writeAutoCompactThreshold(value: number): Promise { const client = await getClient(); - await client.goose.GooseConfigUpsert({ key, value }); + await client.goose.GoosePreferencesSave({ + values: [{ key: AUTO_COMPACT_THRESHOLD_PREFERENCE_KEY, value }], + }); } export function useAutoCompactPreferences() { @@ -41,31 +49,10 @@ export function useAutoCompactPreferences() { DEFAULT_AUTO_COMPACT_THRESHOLD, ); const [isHydrated, setIsHydrated] = useState(false); - const [syncVersion, setSyncVersion] = useState(0); + const retryDelayMsRef = useRef(AUTO_COMPACT_INITIAL_RETRY_DELAY_MS); - const requestSyncFromConfig = useCallback(() => { - setSyncVersion((current) => current + 1); - }, []); - - useEffect(() => { - const handler = () => { - requestSyncFromConfig(); - }; - window.addEventListener( - AUTO_COMPACT_PREFERENCES_EVENT, - handler as EventListener, - ); - return () => { - window.removeEventListener( - AUTO_COMPACT_PREFERENCES_EVENT, - handler as EventListener, - ); - }; - }, [requestSyncFromConfig]); - - const syncFromConfig = useCallback(async (_syncVersion: number) => { - void _syncVersion; - const result = await readConfigValue(AUTO_COMPACT_THRESHOLD_CONFIG_KEY); + const syncFromConfig = useCallback(async () => { + const result = await readAutoCompactThreshold(); return result; }, []); @@ -73,8 +60,16 @@ export function useAutoCompactPreferences() { let cancelled = false; let retryTimer: number | null = null; + const clearRetryTimer = () => { + if (retryTimer !== null) { + window.clearTimeout(retryTimer); + retryTimer = null; + } + }; + const applyConfig = async () => { - const result = await syncFromConfig(syncVersion); + clearRetryTimer(); + const result = await syncFromConfig(); if (cancelled) { return; } @@ -83,24 +78,40 @@ export function useAutoCompactPreferences() { setAutoCompactThresholdState( normalizeAutoCompactThreshold(result.value), ); + setIsHydrated(true); + retryDelayMsRef.current = AUTO_COMPACT_INITIAL_RETRY_DELAY_MS; } else { - retryTimer = window.setTimeout( - requestSyncFromConfig, - AUTO_COMPACT_RETRY_DELAY_MS, + const delayMs = retryDelayMsRef.current; + retryDelayMsRef.current = Math.min( + delayMs * 2, + AUTO_COMPACT_MAX_RETRY_DELAY_MS, ); + retryTimer = window.setTimeout(() => { + void applyConfig(); + }, delayMs); } - setIsHydrated(true); }; + const handler = () => { + retryDelayMsRef.current = AUTO_COMPACT_INITIAL_RETRY_DELAY_MS; + void applyConfig(); + }; + + window.addEventListener( + AUTO_COMPACT_PREFERENCES_EVENT, + handler as EventListener, + ); void applyConfig(); return () => { cancelled = true; - if (retryTimer !== null) { - window.clearTimeout(retryTimer); - } + clearRetryTimer(); + window.removeEventListener( + AUTO_COMPACT_PREFERENCES_EVENT, + handler as EventListener, + ); }; - }, [requestSyncFromConfig, syncFromConfig, syncVersion]); + }, [syncFromConfig]); const dispatchPreferencesEvent = useCallback(() => { window.dispatchEvent(new Event(AUTO_COMPACT_PREFERENCES_EVENT)); @@ -109,7 +120,7 @@ export function useAutoCompactPreferences() { const setAutoCompactThreshold = useCallback( async (value: number) => { const normalized = normalizeAutoCompactThreshold(value); - await writeConfigValue(AUTO_COMPACT_THRESHOLD_CONFIG_KEY, normalized); + await writeAutoCompactThreshold(normalized); setAutoCompactThresholdState(normalized); setIsHydrated(true); dispatchPreferencesEvent(); diff --git a/ui/goose2/src/features/chat/hooks/useResolvedAgentModelPicker.ts b/ui/goose2/src/features/chat/hooks/useResolvedAgentModelPicker.ts index a1638936b8..f0542a38df 100644 --- a/ui/goose2/src/features/chat/hooks/useResolvedAgentModelPicker.ts +++ b/ui/goose2/src/features/chat/hooks/useResolvedAgentModelPicker.ts @@ -15,8 +15,6 @@ import { setStoredModelPreference, } from "../lib/modelPreferences"; -const GOOSE_PROVIDER_CONFIG_KEY = "GOOSE_PROVIDER"; -const GOOSE_MODEL_CONFIG_KEY = "GOOSE_MODEL"; const MODEL_ALIAS_IDS = new Set(["current", "default"]); export type PreferredModelSelection = { @@ -134,23 +132,14 @@ export function useResolvedAgentModelPicker({ const loadGooseDefaultSelection = async () => { try { const client = await getClient(); - const [providerResponse, modelResponse] = await Promise.all([ - client.goose.GooseConfigRead({ key: GOOSE_PROVIDER_CONFIG_KEY }), - client.goose.GooseConfigRead({ key: GOOSE_MODEL_CONFIG_KEY }), - ]); + const defaults = await client.goose.GooseDefaultsRead({}); if (cancelled) { return; } - const providerId = - typeof providerResponse.value === "string" - ? providerResponse.value - : undefined; - const modelId = - typeof modelResponse.value === "string" - ? modelResponse.value - : undefined; + const providerId = defaults.providerId ?? undefined; + const modelId = defaults.modelId ?? undefined; if (!modelId) { setGooseDefaultSelection(null); diff --git a/ui/goose2/src/features/chat/hooks/useVoiceInputPreferences.ts b/ui/goose2/src/features/chat/hooks/useVoiceInputPreferences.ts index e2fc66f247..53830b5d4b 100644 --- a/ui/goose2/src/features/chat/hooks/useVoiceInputPreferences.ts +++ b/ui/goose2/src/features/chat/hooks/useVoiceInputPreferences.ts @@ -3,47 +3,64 @@ import { getClient } from "@/shared/api/acpConnection"; import { DEFAULT_AUTO_SUBMIT_PHRASES_RAW, DISABLED_DICTATION_PROVIDER_CONFIG_VALUE, - VOICE_AUTO_SUBMIT_PHRASES_CONFIG_KEY, - VOICE_DICTATION_PREFERRED_MIC_CONFIG_KEY, - VOICE_DICTATION_PROVIDER_CONFIG_KEY, normalizeDictationProvider, parseAutoSubmitPhrases, } from "../lib/voiceInput"; import type { DictationProvider } from "@/shared/types/dictation"; const VOICE_INPUT_PREFERENCES_EVENT = "goose:voice-input-preferences"; +const VOICE_AUTO_SUBMIT_PHRASES_PREFERENCE_KEY = "voiceAutoSubmitPhrases"; +const VOICE_DICTATION_PROVIDER_PREFERENCE_KEY = "voiceDictationProvider"; +const VOICE_DICTATION_PREFERRED_MIC_PREFERENCE_KEY = + "voiceDictationPreferredMic"; +type VoicePreferenceKey = + | typeof VOICE_AUTO_SUBMIT_PHRASES_PREFERENCE_KEY + | typeof VOICE_DICTATION_PROVIDER_PREFERENCE_KEY + | typeof VOICE_DICTATION_PREFERRED_MIC_PREFERENCE_KEY; type ConfigReadResult = { ok: true; value: string | null } | { ok: false }; -async function readConfigString(key: string): Promise { +async function readPreferenceStrings( + keys: VoicePreferenceKey[], +): Promise> { + const unavailable = Object.fromEntries( + keys.map((key) => [key, { ok: false }]), + ) as Record; + try { const client = await getClient(); - const response = await client.goose.GooseConfigRead({ key }); - return { - ok: true, - value: typeof response.value === "string" ? response.value : null, - }; + const response = await client.goose.GoosePreferencesRead({ keys }); + const values = new Map( + response.values.map((entry) => [entry.key, entry.value]), + ); + return Object.fromEntries( + keys.map((key) => { + const value = values.get(key); + return [ + key, + { + ok: true, + value: typeof value === "string" ? value : null, + }, + ]; + }), + ) as Record; } catch { - return { ok: false }; + return unavailable; } } -async function writeConfigString(key: string, value: string): Promise { - try { - const client = await getClient(); - await client.goose.GooseConfigUpsert({ key, value }); - } catch { - // goose config may be unavailable - } +async function writePreferenceString( + key: VoicePreferenceKey, + value: string, +): Promise { + const client = await getClient(); + await client.goose.GoosePreferencesSave({ values: [{ key, value }] }); } -async function removeConfigKey(key: string): Promise { - try { - const client = await getClient(); - await client.goose.GooseConfigRemove({ key }); - } catch { - // goose config may be unavailable - } +async function removePreferenceKey(key: VoicePreferenceKey): Promise { + const client = await getClient(); + await client.goose.GoosePreferencesRemove({ keys: [key] }); } export function useVoiceInputPreferences() { @@ -65,11 +82,14 @@ export function useVoiceInputPreferences() { const [isHydrated, setIsHydrated] = useState(false); const syncFromConfig = useCallback(async () => { - const [phrasesResult, providerResult, micResult] = await Promise.all([ - readConfigString(VOICE_AUTO_SUBMIT_PHRASES_CONFIG_KEY), - readConfigString(VOICE_DICTATION_PROVIDER_CONFIG_KEY), - readConfigString(VOICE_DICTATION_PREFERRED_MIC_CONFIG_KEY), + const results = await readPreferenceStrings([ + VOICE_AUTO_SUBMIT_PHRASES_PREFERENCE_KEY, + VOICE_DICTATION_PROVIDER_PREFERENCE_KEY, + VOICE_DICTATION_PREFERRED_MIC_PREFERENCE_KEY, ]); + const phrasesResult = results[VOICE_AUTO_SUBMIT_PHRASES_PREFERENCE_KEY]; + const providerResult = results[VOICE_DICTATION_PROVIDER_PREFERENCE_KEY]; + const micResult = results[VOICE_DICTATION_PREFERRED_MIC_PREFERENCE_KEY]; if (phrasesResult.ok) { setRawAutoSubmitPhrasesState( @@ -99,7 +119,9 @@ export function useVoiceInputPreferences() { // through to the default cleanly. setSelectedProviderState(null); setHasStoredProviderPreferenceState(false); - void removeConfigKey(VOICE_DICTATION_PROVIDER_CONFIG_KEY); + void removePreferenceKey(VOICE_DICTATION_PROVIDER_PREFERENCE_KEY).catch( + () => undefined, + ); } } else { setSelectedProviderState(null); @@ -135,18 +157,23 @@ export function useVoiceInputPreferences() { const persistAndBroadcast = useCallback( (operation: Promise) => { - void operation.finally(() => { - dispatchPreferencesEvent(); - }); + void operation + .then(() => { + dispatchPreferencesEvent(); + }) + .catch((error: unknown) => { + console.warn("Failed to persist voice input preferences", error); + void syncFromConfig(); + }); }, - [dispatchPreferencesEvent], + [dispatchPreferencesEvent, syncFromConfig], ); const setRawAutoSubmitPhrases = useCallback( (value: string) => { setRawAutoSubmitPhrasesState(value); persistAndBroadcast( - writeConfigString(VOICE_AUTO_SUBMIT_PHRASES_CONFIG_KEY, value), + writePreferenceString(VOICE_AUTO_SUBMIT_PHRASES_PREFERENCE_KEY, value), ); }, [persistAndBroadcast], @@ -157,8 +184,8 @@ export function useVoiceInputPreferences() { setSelectedProviderState(value); setHasStoredProviderPreferenceState(true); persistAndBroadcast( - writeConfigString( - VOICE_DICTATION_PROVIDER_CONFIG_KEY, + writePreferenceString( + VOICE_DICTATION_PROVIDER_PREFERENCE_KEY, value ?? DISABLED_DICTATION_PROVIDER_CONFIG_VALUE, ), ); @@ -172,7 +199,9 @@ export function useVoiceInputPreferences() { const clearSelectedProvider = useCallback(() => { setSelectedProviderState(null); setHasStoredProviderPreferenceState(false); - persistAndBroadcast(removeConfigKey(VOICE_DICTATION_PROVIDER_CONFIG_KEY)); + persistAndBroadcast( + removePreferenceKey(VOICE_DICTATION_PROVIDER_PREFERENCE_KEY), + ); }, [persistAndBroadcast]); const setPreferredMicrophoneId = useCallback( @@ -180,11 +209,14 @@ export function useVoiceInputPreferences() { setPreferredMicrophoneIdState(value); if (value) { persistAndBroadcast( - writeConfigString(VOICE_DICTATION_PREFERRED_MIC_CONFIG_KEY, value), + writePreferenceString( + VOICE_DICTATION_PREFERRED_MIC_PREFERENCE_KEY, + value, + ), ); } else { persistAndBroadcast( - removeConfigKey(VOICE_DICTATION_PREFERRED_MIC_CONFIG_KEY), + removePreferenceKey(VOICE_DICTATION_PREFERRED_MIC_PREFERENCE_KEY), ); } }, diff --git a/ui/goose2/src/features/chat/lib/voiceInput.ts b/ui/goose2/src/features/chat/lib/voiceInput.ts index b349f89165..94d619a492 100644 --- a/ui/goose2/src/features/chat/lib/voiceInput.ts +++ b/ui/goose2/src/features/chat/lib/voiceInput.ts @@ -3,8 +3,7 @@ import type { DictationProviderStatus, } from "@/shared/types/dictation"; -// goose config keys — stored in the user's goose config.yaml via the -// _goose/config/{read,upsert,remove} ACP methods, not localStorage. +// Stored in the user's goose config.yaml via typed ACP preference methods, not localStorage. export const VOICE_AUTO_SUBMIT_PHRASES_CONFIG_KEY = "VOICE_AUTO_SUBMIT_PHRASES"; export const VOICE_DICTATION_PROVIDER_CONFIG_KEY = "VOICE_DICTATION_PROVIDER"; export const VOICE_DICTATION_PREFERRED_MIC_CONFIG_KEY = diff --git a/ui/goose2/src/features/chat/ui/McpAppView.tsx b/ui/goose2/src/features/chat/ui/McpAppView.tsx index d63d1cb446..fd53c248e9 100644 --- a/ui/goose2/src/features/chat/ui/McpAppView.tsx +++ b/ui/goose2/src/features/chat/ui/McpAppView.tsx @@ -3,7 +3,6 @@ import { type AppRendererProps, type McpUiHostContext, } from "@mcp-ui/client"; -import type { GooseToolCallResponse } from "@aaif/goose-sdk"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import packageJson from "../../../../package.json"; @@ -31,18 +30,13 @@ interface McpAppViewProps { const DEFAULT_APP_HEIGHT = 240; // Goose2 currently only implements inline display mode. -type HostContextDisplayMode = NonNullable< +const GOOSE2_DISPLAY_MODE = "inline" satisfies NonNullable< + McpUiHostContext["displayMode"] +>; +const AVAILABLE_DISPLAY_MODES: NonNullable< McpUiHostContext["availableDisplayModes"] ->[number]; -type AvailableDisplayMode = Extract; -const AVAILABLE_DISPLAY_MODES = [ - "inline", -] satisfies readonly AvailableDisplayMode[]; +> = [GOOSE2_DISPLAY_MODE]; const GOOSE2_USER_AGENT = `${packageJson.name}/${packageJson.version}`; -const GOOSE2_HOST_INFO = { - name: packageJson.name, - version: packageJson.version, -} satisfies NonNullable; const DESKTOP_SAFE_AREA_INSETS = { top: 0, right: 0, @@ -59,6 +53,12 @@ type CallToolResult = Awaited< type ReadResourceResult = Awaited< ReturnType> >; +type GooseToolCallResponse = { + content?: unknown[]; + structuredContent?: unknown; + isError: boolean; + _meta?: unknown; +}; type HostContextToolInfo = NonNullable; type HostContextTool = HostContextToolInfo["tool"]; @@ -272,7 +272,7 @@ export function McpAppView({ const hostContext = useMemo( () => ({ theme: resolvedTheme, - displayMode: "inline", + displayMode: GOOSE2_DISPLAY_MODE, availableDisplayModes: [...AVAILABLE_DISPLAY_MODES], containerDimensions: containerWidth !== null @@ -426,7 +426,6 @@ export function McpAppView({ toolResourceUri={renderableDocument.resourceUri} html={renderableDocument.html} sandbox={sandbox} - hostInfo={GOOSE2_HOST_INFO} toolInput={currentToolInput} toolResult={currentToolResult} hostContext={hostContext} diff --git a/ui/goose2/src/features/chat/ui/__tests__/McpAppView.test.tsx b/ui/goose2/src/features/chat/ui/__tests__/McpAppView.test.tsx index 4182150ecb..105848aa3a 100644 --- a/ui/goose2/src/features/chat/ui/__tests__/McpAppView.test.tsx +++ b/ui/goose2/src/features/chat/ui/__tests__/McpAppView.test.tsx @@ -260,7 +260,7 @@ describe("McpAppView nested tool calls", () => { expect(borderlessChrome?.className).not.toContain("overflow-hidden"); }); - it("passes Goose2 package identity as host info", async () => { + it("passes Goose2 package identity in host context", async () => { render( { expect(screen.getByTestId("mock-app-renderer")).toBeInTheDocument(); }); - expect(getLatestAppRendererProps().hostInfo).toEqual({ - name: packageJson.name, - version: packageJson.version, - }); + expect(getLatestAppRendererProps().hostContext?.userAgent).toBe( + `${packageJson.name}/${packageJson.version}`, + ); }); it("does not install a fallback handler for non-standard app requests", async () => { diff --git a/ui/goose2/src/features/providers/hooks/useCredentials.test.tsx b/ui/goose2/src/features/providers/hooks/useCredentials.test.tsx index b297ba6c16..03795896d1 100644 --- a/ui/goose2/src/features/providers/hooks/useCredentials.test.tsx +++ b/ui/goose2/src/features/providers/hooks/useCredentials.test.tsx @@ -79,6 +79,12 @@ describe("useCredentials", () => { }); it("saves secret fields through the credential API and syncs inventory without requiring restart", async () => { + const voiceConfigListener = vi.fn(); + window.addEventListener( + "goose:voice-dictation-config", + voiceConfigListener, + ); + const { result } = renderHook(() => useCredentials()); await waitFor(() => expect(result.current.loading).toBe(false)); @@ -110,8 +116,14 @@ describe("useCredentials", () => { initialRefresh: saveResponse.refresh, }), ); + expect(voiceConfigListener).toHaveBeenCalledTimes(1); expect(result.current).not.toHaveProperty("needsRestart"); expect(result.current).not.toHaveProperty("restart"); + + window.removeEventListener( + "goose:voice-dictation-config", + voiceConfigListener, + ); }); it("records refresh failure as a provider warning without rejecting the save", async () => { @@ -140,6 +152,12 @@ describe("useCredentials", () => { }); it("suppresses stale refresh errors after deleting provider config", async () => { + const voiceConfigListener = vi.fn(); + window.addEventListener( + "goose:voice-dictation-config", + voiceConfigListener, + ); + mocks.syncProviderInventory.mockResolvedValueOnce({ entries: [ { @@ -162,7 +180,13 @@ describe("useCredentials", () => { await waitFor(() => expect(result.current.syncingProviderIds.has("anthropic")).toBe(false), ); + expect(voiceConfigListener).toHaveBeenCalledTimes(1); expect(result.current.inventoryWarnings.has("anthropic")).toBe(false); + + window.removeEventListener( + "goose:voice-dictation-config", + voiceConfigListener, + ); }); it("invalidates native OAuth secrets before refreshing provider status", async () => { diff --git a/ui/goose2/src/features/providers/hooks/useCredentials.ts b/ui/goose2/src/features/providers/hooks/useCredentials.ts index 0f0a2f4086..24d35b9d67 100644 --- a/ui/goose2/src/features/providers/hooks/useCredentials.ts +++ b/ui/goose2/src/features/providers/hooks/useCredentials.ts @@ -6,6 +6,7 @@ import { type ProviderStatus, checkAllProviderStatus, } from "@/features/providers/api/credentials"; +import { notifyVoiceDictationConfigChanged } from "@/features/chat/lib/voiceInput"; import { syncProviderInventory, type SyncProviderInventoryResult, @@ -208,6 +209,7 @@ export function useCredentials(): UseCredentialsReturn { fields.map(({ key, value }) => ({ key, value })), ); updateProviderStatus(result.status); + notifyVoiceDictationConfigChanged(); startInventorySync(providerId, result.refresh); } finally { setProviderSaving(providerId, false); @@ -222,6 +224,7 @@ export function useCredentials(): UseCredentialsReturn { try { const result = await deleteProviderConfig(providerId); updateProviderStatus(result.status); + notifyVoiceDictationConfigChanged(); startInventorySync(providerId, result.refresh); } finally { setProviderSaving(providerId, false); @@ -241,6 +244,7 @@ export function useCredentials(): UseCredentialsReturn { setProviderInventoryWarning(providerId, errorMessage(error)); } await refreshStatuses(); + notifyVoiceDictationConfigChanged(); startInventorySync(providerId, initialRefresh); }, [refreshStatuses, setProviderInventoryWarning, startInventorySync], diff --git a/ui/goose2/src/features/settings/ui/SettingsModal.tsx b/ui/goose2/src/features/settings/ui/SettingsModal.tsx index 494417ef00..c2f57be606 100644 --- a/ui/goose2/src/features/settings/ui/SettingsModal.tsx +++ b/ui/goose2/src/features/settings/ui/SettingsModal.tsx @@ -58,6 +58,10 @@ export function SettingsModal({ return () => clearTimeout(timer); }, []); + useEffect(() => { + setActiveSection(initialSection); + }, [initialSection]); + useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if ( diff --git a/ui/goose2/src/features/settings/ui/VoiceInputSettings.tsx b/ui/goose2/src/features/settings/ui/VoiceInputSettings.tsx index 86be45d5d1..4357b3c9af 100644 --- a/ui/goose2/src/features/settings/ui/VoiceInputSettings.tsx +++ b/ui/goose2/src/features/settings/ui/VoiceInputSettings.tsx @@ -11,6 +11,7 @@ import { getDefaultDictationProvider, } from "@/features/chat/lib/voiceInput"; import { useVoiceInputPreferences } from "@/features/chat/hooks/useVoiceInputPreferences"; +import { requestOpenSettings } from "@/features/settings/lib/settingsEvents"; import type { DictationProvider, DictationProviderStatus, @@ -145,11 +146,7 @@ export function VoiceInputSettings() { setError(null); try { - await saveDictationProviderSecret( - selectedProvider, - apiKeyInput, - selectedStatus?.configKey ?? undefined, - ); + await saveDictationProviderSecret(selectedProvider, apiKeyInput); setApiKeyInput(""); setIsEditingApiKey(false); await refreshConfig(); @@ -161,7 +158,7 @@ export function VoiceInputSettings() { : t("general.voiceInput.saveError"), ); } - }, [apiKeyInput, refreshConfig, selectedProvider, selectedStatus, t]); + }, [apiKeyInput, refreshConfig, selectedProvider, t]); const removeApiKey = useCallback(async () => { if (!selectedProvider) { @@ -170,10 +167,7 @@ export function VoiceInputSettings() { setError(null); try { - await deleteDictationProviderSecret( - selectedProvider, - selectedStatus?.configKey ?? undefined, - ); + await deleteDictationProviderSecret(selectedProvider); setApiKeyInput(""); setIsEditingApiKey(false); await refreshConfig(); @@ -185,7 +179,7 @@ export function VoiceInputSettings() { : t("general.voiceInput.deleteError"), ); } - }, [refreshConfig, selectedProvider, selectedStatus, t]); + }, [refreshConfig, selectedProvider, t]); const handleModelChange = useCallback( async (modelId: string) => { @@ -333,6 +327,27 @@ export function VoiceInputSettings() { {selectedStatus ? ( <> + {selectedStatus.usesProviderConfig ? ( +
+
+

+ {t("general.voiceInput.providerConfigLabel")} +

+

+ {t("general.voiceInput.providerConfigDescription")} +

+
+ +
+ ) : null} + {!selectedStatus.usesProviderConfig && selectedProvider !== "local" ? (
diff --git a/ui/goose2/src/features/settings/ui/__tests__/VoiceInputSettings.test.tsx b/ui/goose2/src/features/settings/ui/__tests__/VoiceInputSettings.test.tsx new file mode 100644 index 0000000000..45c2f19b2e --- /dev/null +++ b/ui/goose2/src/features/settings/ui/__tests__/VoiceInputSettings.test.tsx @@ -0,0 +1,98 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { VoiceInputSettings } from "../VoiceInputSettings"; + +const mockGetDictationConfig = vi.fn(); +const mockUseVoiceInputPreferences = vi.fn(); + +vi.mock("@/shared/api/dictation", () => ({ + getDictationConfig: () => mockGetDictationConfig(), + saveDictationModelSelection: vi.fn(), + saveDictationProviderSecret: vi.fn(), + deleteDictationProviderSecret: vi.fn(), +})); + +vi.mock("@/features/chat/hooks/useVoiceInputPreferences", () => ({ + useVoiceInputPreferences: () => mockUseVoiceInputPreferences(), +})); + +vi.mock("@/shared/ui/ai-elements/mic-selector", () => ({ + useAudioDevices: () => ({ + devices: [], + error: null, + hasPermission: false, + loadDevices: vi.fn(), + loading: false, + }), +})); + +vi.mock("../LocalWhisperModels", () => ({ + LocalWhisperModels: () =>
, +})); + +describe("VoiceInputSettings", () => { + beforeEach(() => { + mockGetDictationConfig.mockReset(); + mockUseVoiceInputPreferences.mockReset(); + mockUseVoiceInputPreferences.mockReturnValue({ + clearSelectedProvider: vi.fn(), + hasStoredProviderPreference: true, + isHydrated: true, + preferredMicrophoneId: null, + rawAutoSubmitPhrases: "submit", + selectedProvider: "openai", + setPreferredMicrophoneId: vi.fn(), + setRawAutoSubmitPhrases: vi.fn(), + setSelectedProvider: vi.fn(), + }); + mockGetDictationConfig.mockResolvedValue({ + openai: { + configured: false, + description: "Uses OpenAI Whisper API for high-quality transcription.", + usesProviderConfig: true, + settingsPath: "Settings > Models", + configKey: null, + modelConfigKey: "OPENAI_TRANSCRIPTION_MODEL", + defaultModel: "whisper-1", + selectedModel: null, + availableModels: [ + { + id: "whisper-1", + label: "Whisper-1", + description: "OpenAI's hosted Whisper transcription model.", + }, + ], + }, + }); + }); + + it("points OpenAI Whisper setup to provider settings", async () => { + const user = userEvent.setup(); + const openSettingsListener = vi.fn(); + window.addEventListener("goose:open-settings", openSettingsListener); + + render(); + + await waitFor(() => + expect(screen.getByText("Provider credentials")).toBeInTheDocument(), + ); + expect( + screen.getByText( + "This transcription provider uses the credentials from its model provider setup.", + ), + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Add API key" }), + ).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Open Providers" })); + + expect(openSettingsListener).toHaveBeenCalledTimes(1); + expect(openSettingsListener.mock.calls[0][0]).toMatchObject({ + detail: { section: "providers" }, + }); + + window.removeEventListener("goose:open-settings", openSettingsListener); + }); +}); diff --git a/ui/goose2/src/shared/api/__tests__/dictation.test.ts b/ui/goose2/src/shared/api/__tests__/dictation.test.ts deleted file mode 100644 index 340edad8ba..0000000000 --- a/ui/goose2/src/shared/api/__tests__/dictation.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { - cancelDictationLocalModelDownload, - deleteDictationLocalModel, - deleteDictationProviderSecret, - downloadDictationLocalModel, - getDictationConfig, - getDictationLocalModelDownloadProgress, - listDictationLocalModels, - saveDictationModelSelection, - saveDictationProviderSecret, - transcribeDictation, -} from "../dictation"; -import { getClient } from "../acpConnection"; - -vi.mock("../acpConnection", () => ({ - getClient: vi.fn(), -})); - -describe("dictation SDK wiring", () => { - let client: { goose: Record> }; - beforeEach(() => { - client = { - goose: { - GooseDictationConfig: vi.fn().mockResolvedValue({ - providers: { - openai: { - configured: true, - description: "OpenAI transcription", - usesProviderConfig: true, - availableModels: [], - }, - }, - }), - GooseDictationTranscribe: vi.fn().mockResolvedValue({ text: "hello" }), - }, - }; - vi.mocked(getClient).mockResolvedValue( - client as unknown as Awaited>, - ); - }); - - it("getDictationConfig calls GooseDictationConfig and returns providers map", async () => { - const result = await getDictationConfig(); - expect(client.goose.GooseDictationConfig).toHaveBeenCalledWith({}); - expect(result.openai.configured).toBe(true); - }); - - it("transcribeDictation forwards audio + mimeType + provider", async () => { - const result = await transcribeDictation({ - audio: "base64==", - mimeType: "audio/webm", - provider: "openai", - }); - expect(client.goose.GooseDictationTranscribe).toHaveBeenCalledWith({ - audio: "base64==", - mimeType: "audio/webm", - provider: "openai", - }); - expect(result.text).toBe("hello"); - }); - - it("saveDictationModelSelection calls GooseDictationModelSelect", async () => { - client.goose.GooseDictationModelSelect = vi.fn().mockResolvedValue({}); - await saveDictationModelSelection("local", "tiny"); - expect(client.goose.GooseDictationModelSelect).toHaveBeenCalledWith({ - provider: "local", - modelId: "tiny", - }); - }); - - it("saveDictationProviderSecret calls GooseSecretUpsert", async () => { - client.goose.GooseSecretUpsert = vi.fn().mockResolvedValue({}); - await saveDictationProviderSecret("groq", "gsk-test", "GROQ_API_KEY"); - expect(client.goose.GooseSecretUpsert).toHaveBeenCalledWith({ - key: "GROQ_API_KEY", - value: "gsk-test", - }); - }); - - it("deleteDictationProviderSecret calls GooseSecretRemove", async () => { - client.goose.GooseSecretRemove = vi.fn().mockResolvedValue({}); - await deleteDictationProviderSecret("groq", "GROQ_API_KEY"); - expect(client.goose.GooseSecretRemove).toHaveBeenCalledWith({ - key: "GROQ_API_KEY", - }); - }); - - it("listDictationLocalModels returns the models array", async () => { - client.goose.GooseDictationModelsList = vi.fn().mockResolvedValue({ - models: [ - { - id: "tiny", - description: "Tiny", - sizeMb: 75, - downloaded: true, - downloadInProgress: false, - }, - ], - }); - const result = await listDictationLocalModels(); - expect(client.goose.GooseDictationModelsList).toHaveBeenCalledWith({}); - expect(result).toHaveLength(1); - expect(result[0].id).toBe("tiny"); - }); - - it("downloadDictationLocalModel forwards modelId", async () => { - client.goose.GooseDictationModelsDownload = vi.fn().mockResolvedValue({}); - await downloadDictationLocalModel("tiny"); - expect(client.goose.GooseDictationModelsDownload).toHaveBeenCalledWith({ - modelId: "tiny", - }); - }); - - it("getDictationLocalModelDownloadProgress returns progress or null", async () => { - client.goose.GooseDictationModelsDownloadProgress = vi - .fn() - .mockResolvedValue({ - progress: { - bytesDownloaded: 100, - totalBytes: 1000, - progressPercent: 10, - status: "downloading", - error: null, - }, - }); - const result = await getDictationLocalModelDownloadProgress("tiny"); - expect(result?.bytesDownloaded).toBe(100); - expect( - client.goose.GooseDictationModelsDownloadProgress, - ).toHaveBeenCalledWith({ - modelId: "tiny", - }); - }); - - it("getDictationLocalModelDownloadProgress returns null when no download", async () => { - client.goose.GooseDictationModelsDownloadProgress = vi - .fn() - .mockResolvedValue({ - progress: undefined, - }); - const result = await getDictationLocalModelDownloadProgress("tiny"); - expect(result).toBeNull(); - }); - - it("cancelDictationLocalModelDownload forwards modelId", async () => { - client.goose.GooseDictationModelsCancel = vi.fn().mockResolvedValue({}); - await cancelDictationLocalModelDownload("tiny"); - expect(client.goose.GooseDictationModelsCancel).toHaveBeenCalledWith({ - modelId: "tiny", - }); - }); - - it("deleteDictationLocalModel forwards modelId", async () => { - client.goose.GooseDictationModelsDelete = vi.fn().mockResolvedValue({}); - await deleteDictationLocalModel("tiny"); - expect(client.goose.GooseDictationModelsDelete).toHaveBeenCalledWith({ - modelId: "tiny", - }); - }); -}); diff --git a/ui/goose2/src/shared/api/dictation.ts b/ui/goose2/src/shared/api/dictation.ts index b15628d067..883fb4d6af 100644 --- a/ui/goose2/src/shared/api/dictation.ts +++ b/ui/goose2/src/shared/api/dictation.ts @@ -40,26 +40,18 @@ export async function saveDictationModelSelection( } export async function saveDictationProviderSecret( - _provider: DictationProvider, + provider: DictationProvider, value: string, - configKey?: string, ): Promise { - if (!configKey) { - throw new Error("No config key for this provider"); - } const client = await getClient(); - await client.goose.GooseSecretUpsert({ key: configKey, value }); + await client.goose.GooseDictationSecretSave({ provider, value }); } export async function deleteDictationProviderSecret( - _provider: DictationProvider, - configKey?: string, + provider: DictationProvider, ): Promise { - if (!configKey) { - throw new Error("Cannot delete secrets for this provider"); - } const client = await getClient(); - await client.goose.GooseSecretRemove({ key: configKey }); + await client.goose.GooseDictationSecretDelete({ provider }); } export async function listDictationLocalModels(): Promise< diff --git a/ui/goose2/src/shared/i18n/locales/en/settings.json b/ui/goose2/src/shared/i18n/locales/en/settings.json index 86ae746d03..d0b911f36d 100644 --- a/ui/goose2/src/shared/i18n/locales/en/settings.json +++ b/ui/goose2/src/shared/i18n/locales/en/settings.json @@ -176,6 +176,9 @@ "addApiKey": "Add API key", "updateApiKey": "Update API key", "removeApiKey": "Remove API key", + "providerConfigLabel": "Provider credentials", + "providerConfigDescription": "This transcription provider uses the credentials from its model provider setup.", + "openProviders": "Open Providers", "localModelLabel": "Local Whisper Model", "localModelDescription": "Download a Whisper model to run transcription locally. Selecting a model sets it as your active local transcription model.", "noLocalModels": "No local Whisper models available.", diff --git a/ui/goose2/src/shared/i18n/locales/es/settings.json b/ui/goose2/src/shared/i18n/locales/es/settings.json index a4df6ceca8..07ad53ffa8 100644 --- a/ui/goose2/src/shared/i18n/locales/es/settings.json +++ b/ui/goose2/src/shared/i18n/locales/es/settings.json @@ -176,6 +176,9 @@ "addApiKey": "Agregar clave API", "updateApiKey": "Actualizar clave API", "removeApiKey": "Eliminar clave API", + "providerConfigLabel": "Credenciales del proveedor", + "providerConfigDescription": "Este proveedor de transcripción usa las credenciales de la configuración de su proveedor de modelos.", + "openProviders": "Abrir proveedores", "localModelLabel": "Modelo Whisper local", "localModelDescription": "Descarga un modelo Whisper para transcribir localmente. Seleccionar un modelo lo establece como tu modelo de transcripción local activo.", "noLocalModels": "No hay modelos Whisper locales disponibles.", diff --git a/ui/sdk/src/generated/client.gen.ts b/ui/sdk/src/generated/client.gen.ts index 089a7458d9..0cf5d6997c 100644 --- a/ui/sdk/src/generated/client.gen.ts +++ b/ui/sdk/src/generated/client.gen.ts @@ -11,8 +11,6 @@ import type { AddConfigExtensionRequest, AddExtensionRequest, ArchiveSessionRequest, - CheckSecretRequest, - CheckSecretResponse, CreateSourceRequest, CreateSourceResponse, CustomProviderCreateRequest, @@ -23,6 +21,8 @@ import type { CustomProviderReadResponse, CustomProviderUpdateRequest, CustomProviderUpdateResponse, + DefaultsReadRequest, + DefaultsReadResponse, DeleteSessionRequest, DeleteSourceRequest, DictationConfigRequest, @@ -35,6 +35,8 @@ import type { DictationModelSelectRequest, DictationModelsListRequest, DictationModelsListResponse, + DictationSecretDeleteRequest, + DictationSecretSaveRequest, DictationTranscribeRequest, DictationTranscribeResponse, ExportSessionRequest, @@ -57,6 +59,10 @@ import type { ListProvidersResponse, ListSourcesRequest, ListSourcesResponse, + PreferencesReadRequest, + PreferencesReadResponse, + PreferencesRemoveRequest, + PreferencesSaveRequest, ProviderCatalogListRequest, ProviderCatalogListResponse, ProviderCatalogTemplateRequest, @@ -68,16 +74,12 @@ import type { ProviderConfigSaveRequest, ProviderConfigStatusRequest, ProviderConfigStatusResponse, - ReadConfigRequest, - ReadConfigResponse, ReadResourceRequest, ReadResourceResponse, RefreshProviderInventoryRequest, RefreshProviderInventoryResponse, RemoveConfigExtensionRequest, - RemoveConfigRequest, RemoveExtensionRequest, - RemoveSecretRequest, RenameSessionRequest, ToggleConfigExtensionRequest, UnarchiveSessionRequest, @@ -85,16 +87,14 @@ import type { UpdateSourceRequest, UpdateSourceResponse, UpdateWorkingDirRequest, - UpsertConfigRequest, - UpsertSecretRequest, } from './types.gen.js'; import { - zCheckSecretResponse, zCreateSourceResponse, zCustomProviderCreateResponse, zCustomProviderDeleteResponse, zCustomProviderReadResponse, zCustomProviderUpdateResponse, + zDefaultsReadResponse, zDictationConfigResponse, zDictationModelDownloadProgressResponse, zDictationModelsListResponse, @@ -109,12 +109,12 @@ import { zImportSourcesResponse, zListProvidersResponse, zListSourcesResponse, + zPreferencesReadResponse, zProviderCatalogListResponse, zProviderCatalogTemplateResponse, zProviderConfigChangeResponse, zProviderConfigReadResponse, zProviderConfigStatusResponse, - zReadConfigResponse, zReadResourceResponse, zRefreshProviderInventoryResponse, zUpdateSourceResponse, @@ -327,34 +327,28 @@ export class GooseExtClient { ) as ProviderConfigChangeResponse; } - async GooseConfigRead( - params: ReadConfigRequest, - ): Promise { - const raw = await this.conn.extMethod("_goose/config/read", params); - return zReadConfigResponse.parse(raw) as ReadConfigResponse; + async GoosePreferencesRead( + params: PreferencesReadRequest, + ): Promise { + const raw = await this.conn.extMethod("_goose/preferences/read", params); + return zPreferencesReadResponse.parse(raw) as PreferencesReadResponse; } - async GooseConfigUpsert(params: UpsertConfigRequest): Promise { - await this.conn.extMethod("_goose/config/upsert", params); + async GoosePreferencesSave(params: PreferencesSaveRequest): Promise { + await this.conn.extMethod("_goose/preferences/save", params); } - async GooseConfigRemove(params: RemoveConfigRequest): Promise { - await this.conn.extMethod("_goose/config/remove", params); + async GoosePreferencesRemove( + params: PreferencesRemoveRequest, + ): Promise { + await this.conn.extMethod("_goose/preferences/remove", params); } - async GooseSecretCheck( - params: CheckSecretRequest, - ): Promise { - const raw = await this.conn.extMethod("_goose/secret/check", params); - return zCheckSecretResponse.parse(raw) as CheckSecretResponse; - } - - async GooseSecretUpsert(params: UpsertSecretRequest): Promise { - await this.conn.extMethod("_goose/secret/upsert", params); - } - - async GooseSecretRemove(params: RemoveSecretRequest): Promise { - await this.conn.extMethod("_goose/secret/remove", params); + async GooseDefaultsRead( + params: DefaultsReadRequest, + ): Promise { + const raw = await this.conn.extMethod("_goose/defaults/read", params); + return zDefaultsReadResponse.parse(raw) as DefaultsReadResponse; } async GooseSessionExport( @@ -447,6 +441,18 @@ export class GooseExtClient { return zDictationConfigResponse.parse(raw) as DictationConfigResponse; } + async GooseDictationSecretSave( + params: DictationSecretSaveRequest, + ): Promise { + await this.conn.extMethod("_goose/dictation/secret/save", params); + } + + async GooseDictationSecretDelete( + params: DictationSecretDeleteRequest, + ): Promise { + await this.conn.extMethod("_goose/dictation/secret/delete", params); + } + async GooseDictationModelsList( params: DictationModelsListRequest, ): Promise { diff --git a/ui/sdk/src/generated/index.ts b/ui/sdk/src/generated/index.ts index 045118ec8e..cc4033b316 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, AddExtensionRequest, ArchiveSessionRequest, CheckSecretRequest, CheckSecretResponse, CreateSourceRequest, CreateSourceResponse, CustomProviderConfigDto, CustomProviderCreateRequest, CustomProviderCreateResponse, CustomProviderDeleteRequest, CustomProviderDeleteResponse, CustomProviderReadRequest, CustomProviderReadResponse, CustomProviderUpdateRequest, CustomProviderUpdateResponse, DeleteSessionRequest, DeleteSourceRequest, DictationConfigRequest, DictationConfigResponse, DictationDownloadProgress, DictationLocalModelStatus, DictationModelCancelRequest, DictationModelDeleteRequest, DictationModelDownloadProgressRequest, DictationModelDownloadProgressResponse, DictationModelDownloadRequest, DictationModelOption, DictationModelSelectRequest, DictationModelsListRequest, DictationModelsListResponse, DictationProviderStatusEntry, DictationTranscribeRequest, DictationTranscribeResponse, EmptyResponse, ExportSessionRequest, ExportSessionResponse, ExportSourceRequest, ExportSourceResponse, ExtRequest, ExtResponse, GetExtensionsRequest, GetExtensionsResponse, GetSessionExtensionsRequest, GetSessionExtensionsResponse, GetToolsRequest, GetToolsResponse, GooseToolCallRequest, GooseToolCallResponse, ImportSessionRequest, ImportSessionResponse, ImportSourcesRequest, ImportSourcesResponse, ListProvidersRequest, ListProvidersResponse, ListSourcesRequest, ListSourcesResponse, ProviderCatalogEntryDto, ProviderCatalogListRequest, ProviderCatalogListResponse, ProviderCatalogTemplateRequest, ProviderCatalogTemplateResponse, ProviderConfigChangeResponse, ProviderConfigDeleteRequest, ProviderConfigFieldUpdate, ProviderConfigFieldValueDto, ProviderConfigKey, ProviderConfigReadRequest, ProviderConfigReadResponse, ProviderConfigSaveRequest, ProviderConfigStatusDto, ProviderConfigStatusRequest, ProviderConfigStatusResponse, ProviderInventoryEntryDto, ProviderInventoryModelDto, ProviderTemplateCapabilitiesDto, ProviderTemplateDto, ProviderTemplateModelDto, ReadConfigRequest, ReadConfigResponse, ReadResourceRequest, ReadResourceResponse, RefreshProviderInventoryRequest, RefreshProviderInventoryResponse, RefreshProviderInventorySkipDto, RefreshProviderInventorySkipReasonDto, RemoveConfigExtensionRequest, RemoveConfigRequest, RemoveExtensionRequest, RemoveSecretRequest, RenameSessionRequest, SourceEntry, SourceType, ToggleConfigExtensionRequest, UnarchiveSessionRequest, UpdateSessionProjectRequest, UpdateSourceRequest, UpdateSourceResponse, UpdateWorkingDirRequest, UpsertConfigRequest, UpsertSecretRequest } from './types.gen.js'; +export type { AddConfigExtensionRequest, AddExtensionRequest, ArchiveSessionRequest, CreateSourceRequest, CreateSourceResponse, CustomProviderConfigDto, CustomProviderCreateRequest, CustomProviderCreateResponse, CustomProviderDeleteRequest, CustomProviderDeleteResponse, CustomProviderReadRequest, CustomProviderReadResponse, CustomProviderUpdateRequest, CustomProviderUpdateResponse, DefaultsReadRequest, DefaultsReadResponse, DeleteSessionRequest, DeleteSourceRequest, DictationConfigRequest, DictationConfigResponse, DictationDownloadProgress, DictationLocalModelStatus, DictationModelCancelRequest, DictationModelDeleteRequest, DictationModelDownloadProgressRequest, DictationModelDownloadProgressResponse, DictationModelDownloadRequest, DictationModelOption, DictationModelSelectRequest, DictationModelsListRequest, DictationModelsListResponse, DictationProviderStatusEntry, DictationSecretDeleteRequest, DictationSecretSaveRequest, DictationTranscribeRequest, DictationTranscribeResponse, EmptyResponse, ExportSessionRequest, ExportSessionResponse, ExportSourceRequest, ExportSourceResponse, ExtRequest, ExtResponse, GetExtensionsRequest, GetExtensionsResponse, GetSessionExtensionsRequest, GetSessionExtensionsResponse, GetToolsRequest, GetToolsResponse, GooseToolCallRequest, GooseToolCallResponse, ImportSessionRequest, ImportSessionResponse, ImportSourcesRequest, ImportSourcesResponse, ListProvidersRequest, ListProvidersResponse, ListSourcesRequest, ListSourcesResponse, PreferenceKey, PreferencesReadRequest, PreferencesReadResponse, PreferencesRemoveRequest, PreferencesSaveRequest, PreferenceValue, ProviderCatalogEntryDto, ProviderCatalogListRequest, ProviderCatalogListResponse, ProviderCatalogTemplateRequest, ProviderCatalogTemplateResponse, ProviderConfigChangeResponse, ProviderConfigDeleteRequest, ProviderConfigFieldUpdate, ProviderConfigFieldValueDto, ProviderConfigKey, ProviderConfigReadRequest, ProviderConfigReadResponse, ProviderConfigSaveRequest, ProviderConfigStatusDto, ProviderConfigStatusRequest, ProviderConfigStatusResponse, ProviderInventoryEntryDto, ProviderInventoryModelDto, ProviderTemplateCapabilitiesDto, ProviderTemplateDto, ProviderTemplateModelDto, ReadResourceRequest, ReadResourceResponse, RefreshProviderInventoryRequest, RefreshProviderInventoryResponse, RefreshProviderInventorySkipDto, RefreshProviderInventorySkipReasonDto, RemoveConfigExtensionRequest, RemoveExtensionRequest, RenameSessionRequest, SourceEntry, SourceType, ToggleConfigExtensionRequest, UnarchiveSessionRequest, UpdateSessionProjectRequest, UpdateSourceRequest, UpdateSourceResponse, UpdateWorkingDirRequest } from './types.gen.js'; export const GOOSE_EXT_METHODS = [ { @@ -124,34 +124,24 @@ export const GOOSE_EXT_METHODS = [ responseType: "ProviderConfigChangeResponse", }, { - method: "_goose/config/read", - requestType: "ReadConfigRequest", - responseType: "ReadConfigResponse", + method: "_goose/preferences/read", + requestType: "PreferencesReadRequest", + responseType: "PreferencesReadResponse", }, { - method: "_goose/config/upsert", - requestType: "UpsertConfigRequest", + method: "_goose/preferences/save", + requestType: "PreferencesSaveRequest", responseType: "EmptyResponse", }, { - method: "_goose/config/remove", - requestType: "RemoveConfigRequest", + method: "_goose/preferences/remove", + requestType: "PreferencesRemoveRequest", responseType: "EmptyResponse", }, { - method: "_goose/secret/check", - requestType: "CheckSecretRequest", - responseType: "CheckSecretResponse", - }, - { - method: "_goose/secret/upsert", - requestType: "UpsertSecretRequest", - responseType: "EmptyResponse", - }, - { - method: "_goose/secret/remove", - requestType: "RemoveSecretRequest", - responseType: "EmptyResponse", + method: "_goose/defaults/read", + requestType: "DefaultsReadRequest", + responseType: "DefaultsReadResponse", }, { method: "_goose/session/export", @@ -223,6 +213,16 @@ export const GOOSE_EXT_METHODS = [ requestType: "DictationConfigRequest", responseType: "DictationConfigResponse", }, + { + method: "_goose/dictation/secret/save", + requestType: "DictationSecretSaveRequest", + responseType: "EmptyResponse", + }, + { + method: "_goose/dictation/secret/delete", + requestType: "DictationSecretDeleteRequest", + responseType: "EmptyResponse", + }, { method: "_goose/dictation/models/list", requestType: "DictationModelsListRequest", diff --git a/ui/sdk/src/generated/types.gen.ts b/ui/sdk/src/generated/types.gen.ts index 8207615270..6b09eee924 100644 --- a/ui/sdk/src/generated/types.gen.ts +++ b/ui/sdk/src/generated/types.gen.ts @@ -520,61 +520,47 @@ export type ProviderConfigDeleteRequest = { }; /** - * Read a single non-secret config value. + * Read allowlisted user preferences. Empty `keys` means all supported preferences. */ -export type ReadConfigRequest = { - key: string; +export type PreferencesReadRequest = { + keys?: Array; }; -/** - * Config read response. - */ -export type ReadConfigResponse = { +export type PreferenceKey = 'autoCompactThreshold' | 'voiceAutoSubmitPhrases' | 'voiceDictationProvider' | 'voiceDictationPreferredMic'; + +export type PreferencesReadResponse = { + values: Array; +}; + +export type PreferenceValue = { + key: PreferenceKey; value?: unknown; }; /** - * Upsert a single non-secret config value. + * Save allowlisted user preferences. */ -export type UpsertConfigRequest = { - key: string; - value: unknown; +export type PreferencesSaveRequest = { + values?: Array; }; /** - * Remove a single non-secret config value. + * Remove allowlisted user preferences. */ -export type RemoveConfigRequest = { - key: string; +export type PreferencesRemoveRequest = { + keys?: Array; }; /** - * Check whether a secret exists. Never returns the actual value. + * Read Goose default provider and model configuration. */ -export type CheckSecretRequest = { - key: string; +export type DefaultsReadRequest = { + [key: string]: unknown; }; -/** - * Secret check response. - */ -export type CheckSecretResponse = { - exists: boolean; -}; - -/** - * Set a secret value (write-only). - */ -export type UpsertSecretRequest = { - key: string; - value: unknown; -}; - -/** - * Remove a secret. - */ -export type RemoveSecretRequest = { - key: string; +export type DefaultsReadResponse = { + providerId?: string | null; + modelId?: string | null; }; /** @@ -818,6 +804,21 @@ export type DictationModelOption = { description: string; }; +/** + * Set a dictation provider secret value. + */ +export type DictationSecretSaveRequest = { + provider: string; + value: string; +}; + +/** + * Remove a dictation provider secret value. + */ +export type DictationSecretDeleteRequest = { + provider: string; +}; + /** * List available local Whisper models with their download status. */ @@ -895,14 +896,14 @@ export type DictationModelSelectRequest = { export type ExtRequest = { id: string; method: string; - params?: AddExtensionRequest | RemoveExtensionRequest | GetToolsRequest | GooseToolCallRequest | ReadResourceRequest | UpdateWorkingDirRequest | DeleteSessionRequest | GetExtensionsRequest | AddConfigExtensionRequest | RemoveConfigExtensionRequest | ToggleConfigExtensionRequest | GetSessionExtensionsRequest | ListProvidersRequest | ProviderCatalogListRequest | ProviderCatalogTemplateRequest | CustomProviderCreateRequest | CustomProviderReadRequest | CustomProviderUpdateRequest | CustomProviderDeleteRequest | RefreshProviderInventoryRequest | ProviderConfigReadRequest | ProviderConfigStatusRequest | ProviderConfigSaveRequest | ProviderConfigDeleteRequest | ReadConfigRequest | UpsertConfigRequest | RemoveConfigRequest | CheckSecretRequest | UpsertSecretRequest | RemoveSecretRequest | ExportSessionRequest | ImportSessionRequest | UpdateSessionProjectRequest | RenameSessionRequest | ArchiveSessionRequest | UnarchiveSessionRequest | CreateSourceRequest | ListSourcesRequest | UpdateSourceRequest | DeleteSourceRequest | ExportSourceRequest | ImportSourcesRequest | DictationTranscribeRequest | DictationConfigRequest | DictationModelsListRequest | DictationModelDownloadRequest | DictationModelDownloadProgressRequest | DictationModelCancelRequest | DictationModelDeleteRequest | DictationModelSelectRequest | { + params?: AddExtensionRequest | RemoveExtensionRequest | GetToolsRequest | GooseToolCallRequest | ReadResourceRequest | UpdateWorkingDirRequest | DeleteSessionRequest | GetExtensionsRequest | AddConfigExtensionRequest | RemoveConfigExtensionRequest | ToggleConfigExtensionRequest | GetSessionExtensionsRequest | ListProvidersRequest | ProviderCatalogListRequest | ProviderCatalogTemplateRequest | CustomProviderCreateRequest | CustomProviderReadRequest | CustomProviderUpdateRequest | CustomProviderDeleteRequest | RefreshProviderInventoryRequest | ProviderConfigReadRequest | ProviderConfigStatusRequest | ProviderConfigSaveRequest | ProviderConfigDeleteRequest | PreferencesReadRequest | PreferencesSaveRequest | PreferencesRemoveRequest | DefaultsReadRequest | ExportSessionRequest | ImportSessionRequest | UpdateSessionProjectRequest | RenameSessionRequest | ArchiveSessionRequest | UnarchiveSessionRequest | CreateSourceRequest | ListSourcesRequest | UpdateSourceRequest | DeleteSourceRequest | ExportSourceRequest | ImportSourcesRequest | DictationTranscribeRequest | DictationConfigRequest | DictationSecretSaveRequest | DictationSecretDeleteRequest | DictationModelsListRequest | DictationModelDownloadRequest | DictationModelDownloadProgressRequest | DictationModelCancelRequest | DictationModelDeleteRequest | DictationModelSelectRequest | { [key: string]: unknown; } | null; }; export type ExtResponse = { id: string; - result?: EmptyResponse | GetToolsResponse | GooseToolCallResponse | ReadResourceResponse | GetExtensionsResponse | GetSessionExtensionsResponse | ListProvidersResponse | ProviderCatalogListResponse | ProviderCatalogTemplateResponse | CustomProviderCreateResponse | CustomProviderReadResponse | CustomProviderUpdateResponse | CustomProviderDeleteResponse | RefreshProviderInventoryResponse | ProviderConfigReadResponse | ProviderConfigStatusResponse | ProviderConfigChangeResponse | ReadConfigResponse | CheckSecretResponse | ExportSessionResponse | ImportSessionResponse | CreateSourceResponse | ListSourcesResponse | UpdateSourceResponse | ExportSourceResponse | ImportSourcesResponse | DictationTranscribeResponse | DictationConfigResponse | DictationModelsListResponse | DictationModelDownloadProgressResponse | unknown; + result?: EmptyResponse | GetToolsResponse | GooseToolCallResponse | ReadResourceResponse | GetExtensionsResponse | GetSessionExtensionsResponse | ListProvidersResponse | ProviderCatalogListResponse | ProviderCatalogTemplateResponse | CustomProviderCreateResponse | CustomProviderReadResponse | CustomProviderUpdateResponse | CustomProviderDeleteResponse | RefreshProviderInventoryResponse | ProviderConfigReadResponse | ProviderConfigStatusResponse | ProviderConfigChangeResponse | PreferencesReadResponse | DefaultsReadResponse | ExportSessionResponse | ImportSessionResponse | CreateSourceResponse | ListSourcesResponse | UpdateSourceResponse | ExportSourceResponse | ImportSourcesResponse | DictationTranscribeResponse | DictationConfigResponse | DictationModelsListResponse | DictationModelDownloadProgressResponse | unknown; } | { error: { code: number; diff --git a/ui/sdk/src/generated/zod.gen.ts b/ui/sdk/src/generated/zod.gen.ts index cf9675e2d0..c72063a1ef 100644 --- a/ui/sdk/src/generated/zod.gen.ts +++ b/ui/sdk/src/generated/zod.gen.ts @@ -486,62 +486,57 @@ export const zProviderConfigDeleteRequest = z.object({ providerId: z.string() }); -/** - * Read a single non-secret config value. - */ -export const zReadConfigRequest = z.object({ - key: z.string() -}); +export const zPreferenceKey = z.enum([ + 'autoCompactThreshold', + 'voiceAutoSubmitPhrases', + 'voiceDictationProvider', + 'voiceDictationPreferredMic' +]); /** - * Config read response. + * Read allowlisted user preferences. Empty `keys` means all supported preferences. */ -export const zReadConfigResponse = z.object({ +export const zPreferencesReadRequest = z.object({ + keys: z.array(zPreferenceKey).optional().default([]) +}); + +export const zPreferenceValue = z.object({ + key: zPreferenceKey, value: z.unknown().optional().default(null) }); -/** - * Upsert a single non-secret config value. - */ -export const zUpsertConfigRequest = z.object({ - key: z.string(), - value: z.unknown() +export const zPreferencesReadResponse = z.object({ + values: z.array(zPreferenceValue) }); /** - * Remove a single non-secret config value. + * Save allowlisted user preferences. */ -export const zRemoveConfigRequest = z.object({ - key: z.string() +export const zPreferencesSaveRequest = z.object({ + values: z.array(zPreferenceValue).optional().default([]) }); /** - * Check whether a secret exists. Never returns the actual value. + * Remove allowlisted user preferences. */ -export const zCheckSecretRequest = z.object({ - key: z.string() +export const zPreferencesRemoveRequest = z.object({ + keys: z.array(zPreferenceKey).optional().default([]) }); /** - * Secret check response. + * Read Goose default provider and model configuration. */ -export const zCheckSecretResponse = z.object({ - exists: z.boolean() -}); +export const zDefaultsReadRequest = z.record(z.unknown()); -/** - * Set a secret value (write-only). - */ -export const zUpsertSecretRequest = z.object({ - key: z.string(), - value: z.unknown() -}); - -/** - * Remove a secret. - */ -export const zRemoveSecretRequest = z.object({ - key: z.string() +export const zDefaultsReadResponse = z.object({ + providerId: z.union([ + z.string(), + z.null() + ]).optional(), + modelId: z.union([ + z.string(), + z.null() + ]).optional() }); /** @@ -802,6 +797,21 @@ export const zDictationConfigResponse = z.object({ providers: z.record(zDictationProviderStatusEntry) }); +/** + * Set a dictation provider secret value. + */ +export const zDictationSecretSaveRequest = z.object({ + provider: z.string(), + value: z.string() +}); + +/** + * Remove a dictation provider secret value. + */ +export const zDictationSecretDeleteRequest = z.object({ + provider: z.string() +}); + /** * List available local Whisper models with their download status. */ @@ -903,12 +913,10 @@ export const zExtRequest = z.object({ zProviderConfigStatusRequest, zProviderConfigSaveRequest, zProviderConfigDeleteRequest, - zReadConfigRequest, - zUpsertConfigRequest, - zRemoveConfigRequest, - zCheckSecretRequest, - zUpsertSecretRequest, - zRemoveSecretRequest, + zPreferencesReadRequest, + zPreferencesSaveRequest, + zPreferencesRemoveRequest, + zDefaultsReadRequest, zExportSessionRequest, zImportSessionRequest, zUpdateSessionProjectRequest, @@ -923,6 +931,8 @@ export const zExtRequest = z.object({ zImportSourcesRequest, zDictationTranscribeRequest, zDictationConfigRequest, + zDictationSecretSaveRequest, + zDictationSecretDeleteRequest, zDictationModelsListRequest, zDictationModelDownloadRequest, zDictationModelDownloadProgressRequest, @@ -959,8 +969,8 @@ export const zExtResponse = z.union([ zProviderConfigReadResponse, zProviderConfigStatusResponse, zProviderConfigChangeResponse, - zReadConfigResponse, - zCheckSecretResponse, + zPreferencesReadResponse, + zDefaultsReadResponse, zExportSessionResponse, zImportSessionResponse, zCreateSourceResponse, diff --git a/ui/text/src/configure.tsx b/ui/text/src/configure.tsx index 07a4bd8536..4d9a861119 100644 --- a/ui/text/src/configure.tsx +++ b/ui/text/src/configure.tsx @@ -359,9 +359,9 @@ export default function ConfigureScreen({ if (initialIntent === "model") { try { - const cfg = await client.goose.GooseConfigRead({ key: "GOOSE_PROVIDER" }); + const cfg = await client.goose.GooseDefaultsRead({}); if (cancelled) return; - const current = sorted.find((p) => p.providerId === cfg.value); + const current = sorted.find((p) => p.providerId === cfg.providerId); if (current) { setSelectedProvider(current); setPendingConfigValues({}); @@ -395,19 +395,13 @@ export default function ConfigureScreen({ ) => { setPhase("saving"); try { - for (const [key, value] of Object.entries(configValues)) { - const configKey = provider.configKeys.find((k) => k.name === key); - if (configKey?.secret) { - await client.goose.GooseSecretUpsert({ key, value }); - } else { - await client.goose.GooseConfigUpsert({ key, value }); - } - } - await client.goose.GooseConfigUpsert({ - key: "GOOSE_PROVIDER", - value: provider.providerId, + await client.goose.GooseProvidersConfigSave({ + providerId: provider.providerId, + fields: Object.entries(configValues).map(([key, value]) => ({ + key, + value, + })), }); - await client.goose.GooseConfigUpsert({ key: "GOOSE_MODEL", value: model }); await client.setSessionConfigOption({ sessionId, configId: "provider", diff --git a/ui/text/src/extensions.tsx b/ui/text/src/extensions.tsx index 230996184b..f698bdedf0 100644 --- a/ui/text/src/extensions.tsx +++ b/ui/text/src/extensions.tsx @@ -32,10 +32,6 @@ function deriveNameFromValue(addType: AddType, value: string): string { try { return new URL(value.trim()).hostname; } catch { return value.trim(); } } -function keyFromName(name: string): string { - return name.replace(/[^A-Za-z0-9_-]/g, "_").toLowerCase(); -} - function buildConfig(addType: AddType, value: string, name: string, description: string): ExtEntry { if (addType === "stdio") { const parts = value.trim().split(/\s+/); @@ -125,15 +121,12 @@ export default function ExtensionsManager({ const saveNewExtension = useCallback((description: string) => { const config = buildConfig(addType, addValue, addName, description); - const key = keyFromName(config.name); withSaving(async () => { - let extMap: Record = {}; - try { - const raw = await client.goose.GooseConfigRead({key: "extensions"}); - if (raw.value && typeof raw.value === "object") extMap = raw.value as Record; - } catch { } - extMap[key] = config; - await client.goose.GooseConfigUpsert({key: "extensions", value: extMap as any}); + await client.goose.GooseConfigExtensionsAdd({ + name: config.name, + extensionConfig: config as any, + enabled: true, + }); await client.goose.GooseExtensionsAdd({sessionId, config: config as any}); }); }, [addType, addValue, addName, client, sessionId, withSaving]); diff --git a/ui/text/src/onboarding.tsx b/ui/text/src/onboarding.tsx index 8c6c6093f8..8df1ba84d6 100644 --- a/ui/text/src/onboarding.tsx +++ b/ui/text/src/onboarding.tsx @@ -559,21 +559,12 @@ export default function Onboarding({ async (provider: ProviderInventoryEntryDto, values: Record) => { setPhase("saving"); try { - for (const [key, value] of Object.entries(values)) { - const configKey = provider.configKeys.find((k) => k.name === key); - if (configKey?.secret) { - await client.goose.GooseSecretUpsert({ key, value }); - } else { - await client.goose.GooseConfigUpsert({ key, value }); - } - } - await client.goose.GooseConfigUpsert({ - key: "GOOSE_PROVIDER", - value: provider.providerId, - }); - await client.goose.GooseConfigUpsert({ - key: "GOOSE_MODEL", - value: provider.defaultModel, + await client.goose.GooseProvidersConfigSave({ + providerId: provider.providerId, + fields: Object.entries(values).map(([key, value]) => ({ + key, + value, + })), }); setPhase("success"); setTimeout(onComplete, 1000); diff --git a/ui/text/src/tui.tsx b/ui/text/src/tui.tsx index 52975a1cb0..91c6ca2abd 100644 --- a/ui/text/src/tui.tsx +++ b/ui/text/src/tui.tsx @@ -785,11 +785,11 @@ function App({ setStatus("checking provider…"); let hasProvider = false; try { - const resp = await client.goose.GooseConfigRead({ - key: "GOOSE_PROVIDER", - }); + const resp = await client.goose.GooseDefaultsRead({}); hasProvider = - resp.value != null && resp.value !== "" && resp.value !== "null"; + resp.providerId != null && + resp.providerId !== "" && + resp.providerId !== "null"; } catch { hasProvider = false; }