feat(acp): replace raw config and secret methods (#9000)

Signed-off-by: Kalvin Chau <kalvin@block.xyz>
This commit is contained in:
Kalvin C
2026-05-04 18:24:28 -07:00
committed by GitHub
parent e82c4fdcb7
commit 2fe4c3d0bb
39 changed files with 1481 additions and 855 deletions
+60 -44
View File
@@ -173,69 +173,85 @@ pub struct GetSessionExtensionsResponse {
pub extensions: Vec<serde_json::Value>,
}
/// 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<PreferenceKey>,
}
/// 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<PreferenceValue>,
}
/// 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<PreferenceKey>,
}
#[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<PreferenceValue>,
}
/// 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<String>,
pub model_id: Option<String>,
}
/// 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.
+20 -20
View File
@@ -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",
+153 -125
View File
@@ -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": [
-1
View File
@@ -74,7 +74,6 @@ mod dispatch;
mod extensions;
mod providers;
mod resources;
mod secrets;
mod sessions;
mod sources;
mod tools;
+153 -16
View File
@@ -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<ReadConfigResponse, sacp::Error> {
req: PreferencesReadRequest,
) -> Result<PreferencesReadResponse, sacp::Error> {
let config = self.config()?;
let response = match config.get_param::<serde_json::Value>(&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::<serde_json::Value>(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<EmptyResponse, sacp::Error> {
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<EmptyResponse, sacp::Error> {
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<DefaultsReadResponse, sacp::Error> {
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<Option<String>, sacp::Error> {
match config.get_param::<String>(key) {
Ok(value) => Ok(Some(value)),
Err(crate::config::ConfigError::NotFound(_)) => Ok(None),
Err(e) => Err(sacp::Error::internal_error().data(e.to_string())),
}
}
+34 -34
View File
@@ -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<ReadConfigResponse, sacp::Error> {
self.on_read_config(req).await
req: PreferencesReadRequest,
) -> Result<PreferencesReadResponse, sacp::Error> {
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<EmptyResponse, sacp::Error> {
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<EmptyResponse, sacp::Error> {
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<CheckSecretResponse, sacp::Error> {
self.on_check_secret(req).await
}
#[custom_method(UpsertSecretRequest)]
async fn dispatch_upsert_secret(
&self,
req: UpsertSecretRequest,
) -> Result<EmptyResponse, sacp::Error> {
self.on_upsert_secret(req).await
}
#[custom_method(RemoveSecretRequest)]
async fn dispatch_remove_secret(
&self,
req: RemoveSecretRequest,
) -> Result<EmptyResponse, sacp::Error> {
self.on_remove_secret(req).await
req: DefaultsReadRequest,
) -> Result<DefaultsReadResponse, sacp::Error> {
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<EmptyResponse, sacp::Error> {
self.on_dictation_secret_save(req).await
}
#[custom_method(DictationSecretDeleteRequest)]
async fn dispatch_dictation_secret_delete(
&self,
req: DictationSecretDeleteRequest,
) -> Result<EmptyResponse, sacp::Error> {
self.on_dictation_secret_delete(req).await
}
#[custom_method(DictationModelsListRequest)]
async fn dispatch_dictation_models_list(
&self,
+48 -1
View File
@@ -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<EmptyResponse, sacp::Error> {
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<EmptyResponse, sacp::Error> {
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<DictationProvider, sacp::Error> {
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<String> {
match provider {
DictationProvider::OpenAI => Some(OPENAI_TRANSCRIPTION_MODEL_CONFIG_KEY.to_string()),
-32
View File
@@ -1,32 +0,0 @@
use super::*;
impl GooseAcpAgent {
pub(super) async fn on_check_secret(
&self,
req: CheckSecretRequest,
) -> Result<CheckSecretResponse, sacp::Error> {
let config = self.config()?;
let exists = config.get_secret::<serde_json::Value>(&req.key).is_ok();
Ok(CheckSecretResponse { exists })
}
pub(super) async fn on_upsert_secret(
&self,
req: UpsertSecretRequest,
) -> Result<EmptyResponse, sacp::Error> {
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<EmptyResponse, sacp::Error> {
let config = self.config()?;
config.delete_secret(&req.key).internal_err()?;
Config::global().invalidate_secrets_cache();
Ok(EmptyResponse {})
}
}
+14
View File
@@ -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
+277 -41
View File
@@ -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");
}
});
}
@@ -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::<String>("OPENAI_API_KEY")
.get_secret::<String>("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::<String>("OPENAI_API_KEY")
.get_secret::<String>("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::<String>("OPENAI_API_KEY"),
Config::global().get_secret::<String>("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(
+8
View File
@@ -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:
@@ -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);
});
});
@@ -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",
@@ -26,7 +26,10 @@ describe("useResolvedAgentModelPicker", () => {
mockGetClient.mockResolvedValue({
goose: {
GooseConfigRead: vi.fn().mockResolvedValue({ value: null }),
GooseDefaultsRead: vi.fn().mockResolvedValue({
providerId: null,
modelId: null,
}),
},
});
@@ -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<void>();
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"],
});
});
});
});
@@ -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<ConfigReadResult> {
async function readAutoCompactThreshold(): Promise<ConfigReadResult> {
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<void> {
async function writeAutoCompactThreshold(value: number): Promise<void> {
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();
@@ -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);
@@ -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<ConfigReadResult> {
async function readPreferenceStrings(
keys: VoicePreferenceKey[],
): Promise<Record<VoicePreferenceKey, ConfigReadResult>> {
const unavailable = Object.fromEntries(
keys.map((key) => [key, { ok: false }]),
) as Record<VoicePreferenceKey, ConfigReadResult>;
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<VoicePreferenceKey, ConfigReadResult>;
} catch {
return { ok: false };
return unavailable;
}
}
async function writeConfigString(key: string, value: string): Promise<void> {
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<void> {
const client = await getClient();
await client.goose.GoosePreferencesSave({ values: [{ key, value }] });
}
async function removeConfigKey(key: string): Promise<void> {
try {
const client = await getClient();
await client.goose.GooseConfigRemove({ key });
} catch {
// goose config may be unavailable
}
async function removePreferenceKey(key: VoicePreferenceKey): Promise<void> {
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>) => {
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),
);
}
},
@@ -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 =
+12 -13
View File
@@ -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<HostContextDisplayMode, "inline">;
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<AppRendererProps["hostInfo"]>;
const DESKTOP_SAFE_AREA_INSETS = {
top: 0,
right: 0,
@@ -59,6 +53,12 @@ type CallToolResult = Awaited<
type ReadResourceResult = Awaited<
ReturnType<NonNullable<AppRendererProps["onReadResource"]>>
>;
type GooseToolCallResponse = {
content?: unknown[];
structuredContent?: unknown;
isError: boolean;
_meta?: unknown;
};
type HostContextToolInfo = NonNullable<McpUiHostContext["toolInfo"]>;
type HostContextTool = HostContextToolInfo["tool"];
@@ -272,7 +272,7 @@ export function McpAppView({
const hostContext = useMemo<McpUiHostContext>(
() => ({
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}
@@ -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(
<McpAppView
payload={createPayload()}
@@ -272,10 +272,9 @@ describe("McpAppView nested tool calls", () => {
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 () => {
@@ -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 () => {
@@ -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],
@@ -58,6 +58,10 @@ export function SettingsModal({
return () => clearTimeout(timer);
}, []);
useEffect(() => {
setActiveSection(initialSection);
}, [initialSection]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
@@ -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 ? (
<div className="space-y-3 rounded-lg border border-border px-3 py-3">
<div>
<p className="text-xs font-medium text-foreground">
{t("general.voiceInput.providerConfigLabel")}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{t("general.voiceInput.providerConfigDescription")}
</p>
</div>
<Button
type="button"
size="sm"
variant="outline-flat"
onClick={() => requestOpenSettings("providers")}
>
{t("general.voiceInput.openProviders")}
</Button>
</div>
) : null}
{!selectedStatus.usesProviderConfig &&
selectedProvider !== "local" ? (
<div className="space-y-3 rounded-lg border border-border px-3 py-3">
@@ -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: () => <div />,
}));
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(<VoiceInputSettings />);
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);
});
});
@@ -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<string, ReturnType<typeof vi.fn>> };
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<ReturnType<typeof getClient>>,
);
});
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",
});
});
});
+4 -12
View File
@@ -40,26 +40,18 @@ export async function saveDictationModelSelection(
}
export async function saveDictationProviderSecret(
_provider: DictationProvider,
provider: DictationProvider,
value: string,
configKey?: string,
): Promise<void> {
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<void> {
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<
@@ -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.",
@@ -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.",
+38 -32
View File
@@ -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<ReadConfigResponse> {
const raw = await this.conn.extMethod("_goose/config/read", params);
return zReadConfigResponse.parse(raw) as ReadConfigResponse;
async GoosePreferencesRead(
params: PreferencesReadRequest,
): Promise<PreferencesReadResponse> {
const raw = await this.conn.extMethod("_goose/preferences/read", params);
return zPreferencesReadResponse.parse(raw) as PreferencesReadResponse;
}
async GooseConfigUpsert(params: UpsertConfigRequest): Promise<void> {
await this.conn.extMethod("_goose/config/upsert", params);
async GoosePreferencesSave(params: PreferencesSaveRequest): Promise<void> {
await this.conn.extMethod("_goose/preferences/save", params);
}
async GooseConfigRemove(params: RemoveConfigRequest): Promise<void> {
await this.conn.extMethod("_goose/config/remove", params);
async GoosePreferencesRemove(
params: PreferencesRemoveRequest,
): Promise<void> {
await this.conn.extMethod("_goose/preferences/remove", params);
}
async GooseSecretCheck(
params: CheckSecretRequest,
): Promise<CheckSecretResponse> {
const raw = await this.conn.extMethod("_goose/secret/check", params);
return zCheckSecretResponse.parse(raw) as CheckSecretResponse;
}
async GooseSecretUpsert(params: UpsertSecretRequest): Promise<void> {
await this.conn.extMethod("_goose/secret/upsert", params);
}
async GooseSecretRemove(params: RemoveSecretRequest): Promise<void> {
await this.conn.extMethod("_goose/secret/remove", params);
async GooseDefaultsRead(
params: DefaultsReadRequest,
): Promise<DefaultsReadResponse> {
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<void> {
await this.conn.extMethod("_goose/dictation/secret/save", params);
}
async GooseDictationSecretDelete(
params: DictationSecretDeleteRequest,
): Promise<void> {
await this.conn.extMethod("_goose/dictation/secret/delete", params);
}
async GooseDictationModelsList(
params: DictationModelsListRequest,
): Promise<DictationModelsListResponse> {
+21 -21
View File
@@ -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",
+40 -39
View File
@@ -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<PreferenceKey>;
};
/**
* Config read response.
*/
export type ReadConfigResponse = {
export type PreferenceKey = 'autoCompactThreshold' | 'voiceAutoSubmitPhrases' | 'voiceDictationProvider' | 'voiceDictationPreferredMic';
export type PreferencesReadResponse = {
values: Array<PreferenceValue>;
};
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<PreferenceValue>;
};
/**
* Remove a single non-secret config value.
* Remove allowlisted user preferences.
*/
export type RemoveConfigRequest = {
key: string;
export type PreferencesRemoveRequest = {
keys?: Array<PreferenceKey>;
};
/**
* 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;
+55 -45
View File
@@ -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,
+8 -14
View File
@@ -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",
+5 -12
View File
@@ -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<string, unknown> = {};
try {
const raw = await client.goose.GooseConfigRead({key: "extensions"});
if (raw.value && typeof raw.value === "object") extMap = raw.value as Record<string, unknown>;
} 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]);
+6 -15
View File
@@ -559,21 +559,12 @@ export default function Onboarding({
async (provider: ProviderInventoryEntryDto, values: Record<string, string>) => {
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);
+4 -4
View File
@@ -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;
}