fix: revert the change for unexpected change from ui/goose2 work (#9098)
This commit is contained in:
@@ -811,6 +811,13 @@ impl Agent {
|
||||
|
||||
let results = futures::future::join_all(extension_futures).await;
|
||||
|
||||
// Persist once after all extensions are loaded
|
||||
if results.iter().any(|r| r.success) {
|
||||
if let Err(e) = self.persist_extension_state(&session_id).await {
|
||||
warn!("Failed to persist extension state after bulk load: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ pub static PLATFORM_EXTENSIONS: Lazy<HashMap<&'static str, PlatformExtensionDef>
|
||||
display_name: "Analyze",
|
||||
description:
|
||||
"Analyze code structure with tree-sitter: directory overviews, file details, symbol call graphs",
|
||||
default_enabled: false,
|
||||
default_enabled: true,
|
||||
unprefixed_tools: true,
|
||||
hidden: false,
|
||||
client_factory: |ctx| Box::new(analyze::AnalyzeClient::new(ctx).unwrap()),
|
||||
@@ -64,7 +64,7 @@ pub static PLATFORM_EXTENSIONS: Lazy<HashMap<&'static str, PlatformExtensionDef>
|
||||
display_name: "Apps",
|
||||
description:
|
||||
"Create and manage custom Goose apps through chat. Apps are HTML/CSS/JavaScript and run in sandboxed windows.",
|
||||
default_enabled: false,
|
||||
default_enabled: true,
|
||||
unprefixed_tools: false,
|
||||
hidden: false,
|
||||
client_factory: |ctx| Box::new(apps::AppsManagerClient::new(ctx).unwrap()),
|
||||
@@ -105,7 +105,7 @@ pub static PLATFORM_EXTENSIONS: Lazy<HashMap<&'static str, PlatformExtensionDef>
|
||||
name: summon::EXTENSION_NAME,
|
||||
display_name: "Summon",
|
||||
description: "Load knowledge and delegate tasks to subagents",
|
||||
default_enabled: false,
|
||||
default_enabled: true,
|
||||
unprefixed_tools: true,
|
||||
hidden: false,
|
||||
client_factory: |ctx| Box::new(summon::SummonClient::new(ctx).unwrap()),
|
||||
@@ -182,7 +182,7 @@ pub static PLATFORM_EXTENSIONS: Lazy<HashMap<&'static str, PlatformExtensionDef>
|
||||
display_name: "Top Of Mind",
|
||||
description:
|
||||
"Inject custom context into every turn via GOOSE_MOIM_MESSAGE_TEXT and GOOSE_MOIM_MESSAGE_FILE environment variables",
|
||||
default_enabled: false,
|
||||
default_enabled: true,
|
||||
unprefixed_tools: false,
|
||||
hidden: false,
|
||||
client_factory: |ctx| Box::new(tom::TomClient::new(ctx).unwrap()),
|
||||
|
||||
@@ -1994,12 +1994,10 @@ extensions:
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// User config (higher priority / write target) has already migrated, then disables
|
||||
// developer and adds a new extension.
|
||||
// User config (higher priority / write target) disables developer and adds a new extension
|
||||
std::fs::write(
|
||||
local_file.path(),
|
||||
r#"
|
||||
extensions_on_demand_migration: true
|
||||
extensions:
|
||||
developer:
|
||||
enabled: false
|
||||
@@ -2024,7 +2022,7 @@ extensions:
|
||||
let values = config.load()?;
|
||||
let extensions = values.get("extensions").unwrap().as_mapping().unwrap();
|
||||
|
||||
// developer should be disabled (user config overrides system after migration)
|
||||
// developer should be disabled (user config overrides system)
|
||||
let dev = extensions.get("developer").unwrap().as_mapping().unwrap();
|
||||
assert!(!dev.get("enabled").unwrap().as_bool().unwrap());
|
||||
// Fields from the system config should be preserved via merge
|
||||
|
||||
@@ -13,13 +13,8 @@ pub const DEFAULT_EXTENSION_DESCRIPTION: &str = "";
|
||||
pub const DEFAULT_DISPLAY_NAME: &str = "Developer";
|
||||
const EXTENSIONS_CONFIG_KEY: &str = "extensions";
|
||||
|
||||
fn default_extension_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone, ToSchema)]
|
||||
pub struct ExtensionEntry {
|
||||
#[serde(default = "default_extension_enabled")]
|
||||
pub enabled: bool,
|
||||
#[serde(flatten)]
|
||||
pub config: ExtensionConfig,
|
||||
@@ -215,22 +210,4 @@ mod tests {
|
||||
assert!(!is_extension_available(&unknown_platform));
|
||||
assert!(is_extension_available(&builtin));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extension_entry_defaults_missing_enabled_to_true() {
|
||||
let value = serde_yaml::from_str(
|
||||
r#"
|
||||
type: stdio
|
||||
name: github
|
||||
description: GitHub tools
|
||||
cmd: npx
|
||||
args: []
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let entry: ExtensionEntry = serde_yaml::from_value(value).unwrap();
|
||||
|
||||
assert!(entry.enabled);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,18 +114,10 @@ mod tests {
|
||||
assert!(changed);
|
||||
let extensions_key = serde_yaml::Value::String(EXTENSIONS_CONFIG_KEY.to_string());
|
||||
assert!(config.contains_key(&extensions_key));
|
||||
|
||||
let extensions = config.get(&extensions_key).unwrap().as_mapping().unwrap();
|
||||
for (key, value) in extensions {
|
||||
let key = key.as_str().unwrap();
|
||||
let def = PLATFORM_EXTENSIONS.get(key).unwrap();
|
||||
let entry: ExtensionEntry = serde_yaml::from_value(value.clone()).unwrap();
|
||||
assert_eq!(entry.enabled, def.default_enabled);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migrate_platform_extensions_refreshes_metadata_without_changing_enabled() {
|
||||
fn test_migrate_platform_extensions_preserves_enabled_state() {
|
||||
let mut config = Mapping::new();
|
||||
let mut extensions = Mapping::new();
|
||||
let todo_entry = ExtensionEntry {
|
||||
@@ -157,105 +149,6 @@ mod tests {
|
||||
let todo_entry: ExtensionEntry = serde_yaml::from_value(todo_value.clone()).unwrap();
|
||||
|
||||
assert!(!todo_entry.enabled);
|
||||
match todo_entry.config {
|
||||
ExtensionConfig::Platform {
|
||||
description,
|
||||
display_name,
|
||||
..
|
||||
} => {
|
||||
assert_ne!(description, "old description");
|
||||
assert_ne!(display_name.as_deref(), Some("Old Name"));
|
||||
}
|
||||
other => panic!("expected platform extension, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migrate_platform_extensions_preserves_existing_enabled_values() {
|
||||
let mut config = Mapping::new();
|
||||
let mut extensions = Mapping::new();
|
||||
|
||||
let analyze_entry = ExtensionEntry {
|
||||
config: ExtensionConfig::Platform {
|
||||
name: "analyze".to_string(),
|
||||
description: "Analyze code structure".to_string(),
|
||||
display_name: Some("Analyze".to_string()),
|
||||
bundled: Some(true),
|
||||
available_tools: Vec::new(),
|
||||
},
|
||||
enabled: true,
|
||||
};
|
||||
let developer_entry = ExtensionEntry {
|
||||
config: ExtensionConfig::Platform {
|
||||
name: "developer".to_string(),
|
||||
description: "Write and edit files, and execute shell commands".to_string(),
|
||||
display_name: Some("Developer".to_string()),
|
||||
bundled: Some(true),
|
||||
available_tools: Vec::new(),
|
||||
},
|
||||
enabled: false,
|
||||
};
|
||||
let custom_developer_entry = ExtensionEntry {
|
||||
config: ExtensionConfig::Stdio {
|
||||
name: "developer".to_string(),
|
||||
description: "Custom user developer tools".to_string(),
|
||||
cmd: "custom-developer".to_string(),
|
||||
args: Vec::new(),
|
||||
envs: Default::default(),
|
||||
env_keys: Vec::new(),
|
||||
timeout: Some(300),
|
||||
bundled: None,
|
||||
available_tools: Vec::new(),
|
||||
},
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
extensions.insert(
|
||||
serde_yaml::Value::String("analyze".to_string()),
|
||||
serde_yaml::to_value(&analyze_entry).unwrap(),
|
||||
);
|
||||
extensions.insert(
|
||||
serde_yaml::Value::String("developer".to_string()),
|
||||
serde_yaml::to_value(&developer_entry).unwrap(),
|
||||
);
|
||||
extensions.insert(
|
||||
serde_yaml::Value::String("custom-developer".to_string()),
|
||||
serde_yaml::to_value(&custom_developer_entry).unwrap(),
|
||||
);
|
||||
config.insert(
|
||||
serde_yaml::Value::String(EXTENSIONS_CONFIG_KEY.to_string()),
|
||||
serde_yaml::Value::Mapping(extensions),
|
||||
);
|
||||
|
||||
assert!(run_migrations(&mut config));
|
||||
|
||||
let extensions_key = serde_yaml::Value::String(EXTENSIONS_CONFIG_KEY.to_string());
|
||||
let extensions = config.get(&extensions_key).unwrap().as_mapping().unwrap();
|
||||
let analyze: ExtensionEntry = serde_yaml::from_value(
|
||||
extensions
|
||||
.get(serde_yaml::Value::String("analyze".to_string()))
|
||||
.unwrap()
|
||||
.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
let developer: ExtensionEntry = serde_yaml::from_value(
|
||||
extensions
|
||||
.get(serde_yaml::Value::String("developer".to_string()))
|
||||
.unwrap()
|
||||
.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
let custom_developer: ExtensionEntry = serde_yaml::from_value(
|
||||
extensions
|
||||
.get(serde_yaml::Value::String("custom-developer".to_string()))
|
||||
.unwrap()
|
||||
.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(analyze.enabled);
|
||||
assert!(!developer.enabled);
|
||||
assert!(custom_developer.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -244,7 +244,7 @@ pub async fn run_config_mcp<C: Connection>() {
|
||||
let mcp = McpFixture::new(expected_session_id.clone()).await;
|
||||
|
||||
let config_yaml = format!(
|
||||
"GOOSE_MODEL: {TEST_MODEL}\nGOOSE_PROVIDER: openai\nextensions_on_demand_migration: true\nextensions:\n mcp-fixture:\n enabled: true\n type: streamable_http\n name: mcp-fixture\n description: MCP fixture\n uri: \"{}\"\n",
|
||||
"GOOSE_MODEL: {TEST_MODEL}\nGOOSE_PROVIDER: openai\nextensions:\n mcp-fixture:\n enabled: true\n type: streamable_http\n name: mcp-fixture\n description: MCP fixture\n uri: \"{}\"\n",
|
||||
mcp.url
|
||||
);
|
||||
fs::write(temp_dir.path().join(CONFIG_YAML_NAME), config_yaml).unwrap();
|
||||
@@ -294,7 +294,7 @@ pub async fn run_config_mcp<C: Connection>() {
|
||||
pub async fn run_fs_read_text_file_true<C: Connection>() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let config_yaml = format!(
|
||||
"GOOSE_MODEL: {TEST_MODEL}\nGOOSE_PROVIDER: openai\nextensions_on_demand_migration: true\nextensions:\n developer:\n enabled: true\n type: platform\n name: developer\n description: Developer\n display_name: Developer\n bundled: true\n available_tools: []\n"
|
||||
"GOOSE_MODEL: {TEST_MODEL}\nGOOSE_PROVIDER: openai\nextensions:\n developer:\n enabled: true\n type: platform\n name: developer\n description: Developer\n display_name: Developer\n bundled: true\n available_tools: []\n"
|
||||
);
|
||||
fs::write(temp_dir.path().join(CONFIG_YAML_NAME), config_yaml).unwrap();
|
||||
|
||||
@@ -463,7 +463,7 @@ pub async fn run_load_mode<C: Connection>() {
|
||||
let mcp = McpFixture::new(expected_session_id.clone()).await;
|
||||
|
||||
let config_yaml = format!(
|
||||
"GOOSE_MODEL: {TEST_MODEL}\nGOOSE_PROVIDER: openai\nextensions_on_demand_migration: true\nextensions:\n mcp-fixture:\n enabled: true\n type: streamable_http\n name: mcp-fixture\n description: MCP fixture\n uri: \"{}\"\n",
|
||||
"GOOSE_MODEL: {TEST_MODEL}\nGOOSE_PROVIDER: openai\nextensions:\n mcp-fixture:\n enabled: true\n type: streamable_http\n name: mcp-fixture\n description: MCP fixture\n uri: \"{}\"\n",
|
||||
mcp.url
|
||||
);
|
||||
fs::write(temp_dir.path().join(CONFIG_YAML_NAME), config_yaml).unwrap();
|
||||
@@ -703,7 +703,7 @@ async fn run_mode_set_impl<C: Connection>(via: SetModeVia) {
|
||||
let mcp = McpFixture::new(expected_session_id.clone()).await;
|
||||
|
||||
let config_yaml = format!(
|
||||
"GOOSE_MODEL: {TEST_MODEL}\nGOOSE_PROVIDER: openai\nextensions_on_demand_migration: true\nextensions:\n mcp-fixture:\n enabled: true\n type: streamable_http\n name: mcp-fixture\n description: MCP fixture\n uri: \"{}\"\n",
|
||||
"GOOSE_MODEL: {TEST_MODEL}\nGOOSE_PROVIDER: openai\nextensions:\n mcp-fixture:\n enabled: true\n type: streamable_http\n name: mcp-fixture\n description: MCP fixture\n uri: \"{}\"\n",
|
||||
mcp.url
|
||||
);
|
||||
fs::write(temp_dir.path().join(CONFIG_YAML_NAME), config_yaml).unwrap();
|
||||
@@ -1305,7 +1305,7 @@ pub async fn run_prompt_skill<C: Connection>() {
|
||||
.await;
|
||||
|
||||
let config = TestConnectionConfig {
|
||||
builtins: vec!["summon".to_string(), "skills".to_string()],
|
||||
builtins: vec!["summon".to_string()],
|
||||
cwd: Some(cwd),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -523,7 +523,6 @@ fn test_developer_fs_requests_use_acp_session_id() {
|
||||
// gpt-5-nano routes to the Responses API; use a Chat Completions
|
||||
// model so the canned SSE fixtures are parsed correctly.
|
||||
current_model: "gpt-4.1".to_string(),
|
||||
builtins: vec!["developer".to_string()],
|
||||
read_text_file: Some(Arc::new(move |req| {
|
||||
*seen_session_id_clone.lock().unwrap() = Some(req.session_id.0.to_string());
|
||||
Ok(agent_client_protocol::schema::ReadTextFileResponse::new(
|
||||
|
||||
@@ -5211,6 +5211,9 @@
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"enabled"
|
||||
],
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
|
||||
@@ -465,7 +465,7 @@ export type ExtensionData = {
|
||||
};
|
||||
|
||||
export type ExtensionEntry = ExtensionConfig & {
|
||||
enabled?: boolean;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export type ExtensionLoadResult = {
|
||||
|
||||
@@ -18,7 +18,6 @@ import type {
|
||||
ProviderDetails,
|
||||
ExtensionQuery,
|
||||
ExtensionConfig,
|
||||
ExtensionEntry,
|
||||
} from '../api';
|
||||
|
||||
export type { ExtensionConfig } from '../api/types.gen';
|
||||
@@ -28,12 +27,6 @@ export type FixedExtensionEntry = ExtensionConfig & {
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
const normalizeExtensions = (extensions: ExtensionEntry[]): FixedExtensionEntry[] =>
|
||||
extensions.map((extension) => ({
|
||||
...extension,
|
||||
enabled: extension.enabled ?? true,
|
||||
}));
|
||||
|
||||
interface ConfigContextType {
|
||||
config: ConfigResponse['config'];
|
||||
providersList: ProviderDetails[];
|
||||
@@ -133,10 +126,9 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
|
||||
}
|
||||
|
||||
const extensionResponse: ExtensionResponse = result.data!;
|
||||
const extensions = normalizeExtensions(extensionResponse.extensions);
|
||||
setExtensionsList(extensions);
|
||||
setExtensionsList(extensionResponse.extensions);
|
||||
setExtensionWarnings(extensionResponse.warnings || []);
|
||||
return extensions;
|
||||
return extensionResponse.extensions;
|
||||
}, [extensionsList]);
|
||||
|
||||
const addExtension = useCallback(
|
||||
@@ -221,7 +213,7 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
|
||||
// Load extensions
|
||||
try {
|
||||
const extensionsResponse = await apiGetExtensions();
|
||||
let extensions = normalizeExtensions(extensionsResponse.data?.extensions || []);
|
||||
let extensions = extensionsResponse.data?.extensions || [];
|
||||
|
||||
// Always sync bundled extensions from bundled-extensions.json
|
||||
// This ensures:
|
||||
@@ -244,7 +236,7 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
|
||||
await syncBundledExtensions(extensions, addExtensionForSync);
|
||||
// Reload extensions after sync
|
||||
const refreshedResponse = await apiGetExtensions();
|
||||
extensions = normalizeExtensions(refreshedResponse.data?.extensions || []);
|
||||
extensions = refreshedResponse.data?.extensions || [];
|
||||
|
||||
setExtensionsList(extensions);
|
||||
setExtensionWarnings(extensionsResponse.data?.warnings || []);
|
||||
|
||||
@@ -32,7 +32,7 @@ const i18n = defineMessages({
|
||||
defaultNote: {
|
||||
id: 'extensionsView.defaultNote',
|
||||
defaultMessage:
|
||||
'Extensions stay available here, and Goose can load them on demand during a chat.',
|
||||
'Extensions enabled here are used as the default for new chats. You can also toggle active extensions during chat.',
|
||||
},
|
||||
addCustomExtension: {
|
||||
id: 'extensionsView.addCustomExtension',
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
getDefaultFormData,
|
||||
} from './utils';
|
||||
|
||||
import { activateExtensionDefault, deleteExtension } from './index';
|
||||
import { activateExtensionDefault, deleteExtension, toggleExtensionDefault } from './index';
|
||||
import { ExtensionConfig } from '../../../api/types.gen';
|
||||
|
||||
const i18n = defineMessages({
|
||||
@@ -44,6 +44,8 @@ interface ExtensionSectionProps {
|
||||
showEnvVars?: boolean;
|
||||
hideButtons?: boolean;
|
||||
disableConfiguration?: boolean;
|
||||
customToggle?: (extension: FixedExtensionEntry) => Promise<boolean | void>;
|
||||
selectedExtensions?: string[]; // Add controlled state
|
||||
onModalClose?: (extensionName: string) => void;
|
||||
searchTerm?: string;
|
||||
}
|
||||
@@ -53,6 +55,8 @@ export default function ExtensionsSection({
|
||||
showEnvVars,
|
||||
hideButtons,
|
||||
disableConfiguration,
|
||||
customToggle,
|
||||
selectedExtensions = [],
|
||||
onModalClose,
|
||||
searchTerm = '',
|
||||
}: ExtensionSectionProps) {
|
||||
@@ -76,26 +80,50 @@ export default function ExtensionsSection({
|
||||
const extensions = useMemo(() => {
|
||||
if (extensionsList.length === 0) return [];
|
||||
|
||||
return [...extensionsList].sort((a, b) => {
|
||||
// First sort by builtin
|
||||
if (a.type === 'builtin' && b.type !== 'builtin') return -1;
|
||||
if (a.type !== 'builtin' && b.type === 'builtin') return 1;
|
||||
return [...extensionsList]
|
||||
.sort((a, b) => {
|
||||
// First sort by builtin
|
||||
if (a.type === 'builtin' && b.type !== 'builtin') return -1;
|
||||
if (a.type !== 'builtin' && b.type === 'builtin') return 1;
|
||||
|
||||
// Then sort by bundled (handle null/undefined cases)
|
||||
const aBundled = 'bundled' in a && a.bundled === true;
|
||||
const bBundled = 'bundled' in b && b.bundled === true;
|
||||
if (aBundled && !bBundled) return -1;
|
||||
if (!aBundled && bBundled) return 1;
|
||||
// Then sort by bundled (handle null/undefined cases)
|
||||
const aBundled = 'bundled' in a && a.bundled === true;
|
||||
const bBundled = 'bundled' in b && b.bundled === true;
|
||||
if (aBundled && !bBundled) return -1;
|
||||
if (!aBundled && bBundled) return 1;
|
||||
|
||||
// Finally sort alphabetically within each group
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}, [extensionsList]);
|
||||
// Finally sort alphabetically within each group
|
||||
return a.name.localeCompare(b.name);
|
||||
})
|
||||
.map((ext) => ({
|
||||
...ext,
|
||||
// Use selectedExtensions to determine enabled state in recipe editor
|
||||
enabled: disableConfiguration ? selectedExtensions.includes(ext.name) : ext.enabled,
|
||||
}));
|
||||
}, [extensionsList, disableConfiguration, selectedExtensions]);
|
||||
|
||||
const fetchExtensions = useCallback(async () => {
|
||||
await getExtensions(true); // Force refresh - this will update the context
|
||||
}, [getExtensions]);
|
||||
|
||||
const handleExtensionToggle = async (extensionConfig: FixedExtensionEntry) => {
|
||||
if (customToggle) {
|
||||
await customToggle(extensionConfig);
|
||||
return true;
|
||||
}
|
||||
|
||||
const toggleDirection = extensionConfig.enabled ? 'toggleOff' : 'toggleOn';
|
||||
|
||||
await toggleExtensionDefault({
|
||||
toggle: toggleDirection,
|
||||
extensionConfig: extensionConfig,
|
||||
addToConfig: addExtension,
|
||||
});
|
||||
|
||||
await fetchExtensions();
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleConfigureClick = (extension: FixedExtensionEntry) => {
|
||||
setSelectedExtension(extension);
|
||||
setIsModalOpen(true);
|
||||
@@ -181,6 +209,7 @@ export default function ExtensionsSection({
|
||||
<div className="">
|
||||
<ExtensionList
|
||||
extensions={extensions}
|
||||
onToggle={handleExtensionToggle}
|
||||
onConfigure={handleConfigureClick}
|
||||
disableConfiguration={disableConfiguration}
|
||||
searchTerm={searchTerm}
|
||||
@@ -238,7 +267,7 @@ export default function ExtensionsSection({
|
||||
title={intl.formatMessage(i18n.addCustomExtension)}
|
||||
initialData={extensionToFormData({
|
||||
...deepLinkConfig,
|
||||
enabled: false,
|
||||
enabled: true,
|
||||
} as FixedExtensionEntry)}
|
||||
onClose={handleModalClose}
|
||||
onSubmit={handleAddExtension}
|
||||
|
||||
@@ -9,7 +9,7 @@ vi.mock('./bundled-extensions.json', () => ({
|
||||
name: 'developer',
|
||||
display_name: 'Developer',
|
||||
description: 'General development tools.',
|
||||
enabled: false,
|
||||
enabled: true,
|
||||
type: 'builtin',
|
||||
timeout: 300,
|
||||
},
|
||||
@@ -18,7 +18,7 @@ vi.mock('./bundled-extensions.json', () => ({
|
||||
name: 'googledrive',
|
||||
display_name: 'Google Drive',
|
||||
description: 'Google Drive integration.',
|
||||
enabled: false,
|
||||
enabled: true,
|
||||
type: 'stdio',
|
||||
cmd: 'googledrive-mcp',
|
||||
args: [],
|
||||
@@ -130,7 +130,7 @@ describe('pruneDeprecatedBundledExtensions', () => {
|
||||
name: 'googledrive',
|
||||
bundled: true,
|
||||
}),
|
||||
false
|
||||
true
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -178,14 +178,15 @@ export async function addExtensionFromDeepLink(
|
||||
return;
|
||||
}
|
||||
|
||||
// Note: deeplink activation doesn't have access to sessionId, so the extension
|
||||
// is saved for on-demand use instead of being globally enabled.
|
||||
await addExtensionFn(config.name, config, false);
|
||||
// Note: deeplink activation doesn't have access to sessionId
|
||||
// The extension will be added to config but not activated in the current session
|
||||
// It will be activated when the next session starts
|
||||
await addExtensionFn(config.name, config, true);
|
||||
|
||||
// Show success toast and navigate to extensions page
|
||||
toastService.success({
|
||||
title: 'Extension Installed',
|
||||
msg: `${config.name} extension has been installed successfully and is available on demand.`,
|
||||
msg: `${config.name} extension has been installed successfully. Start a new chat session to use it.`,
|
||||
});
|
||||
|
||||
// Navigate to extensions page to show the newly installed extension
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import type { ExtensionConfig } from '../../../api/types.gen';
|
||||
import { toastService } from '../../../toasts';
|
||||
import { trackExtensionAdded, trackExtensionDeleted, getErrorType } from '../../../utils/analytics';
|
||||
import {
|
||||
trackExtensionAdded,
|
||||
trackExtensionEnabled,
|
||||
trackExtensionDisabled,
|
||||
trackExtensionDeleted,
|
||||
getErrorType,
|
||||
} from '../../../utils/analytics';
|
||||
|
||||
function isBuiltinExtension(config: ExtensionConfig): boolean {
|
||||
return config.type === 'builtin';
|
||||
@@ -32,6 +38,46 @@ export async function deleteExtension({
|
||||
}
|
||||
}
|
||||
|
||||
interface ToggleExtensionDefaultProps {
|
||||
toggle: 'toggleOn' | 'toggleOff';
|
||||
extensionConfig: ExtensionConfig;
|
||||
addToConfig: (name: string, extensionConfig: ExtensionConfig, enabled: boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
export async function toggleExtensionDefault({
|
||||
toggle,
|
||||
extensionConfig,
|
||||
addToConfig,
|
||||
}: ToggleExtensionDefaultProps) {
|
||||
const isBuiltin = isBuiltinExtension(extensionConfig);
|
||||
const enabled = toggle === 'toggleOn';
|
||||
|
||||
try {
|
||||
await addToConfig(extensionConfig.name, extensionConfig, enabled);
|
||||
if (enabled) {
|
||||
trackExtensionEnabled(extensionConfig.name, true, undefined, isBuiltin);
|
||||
} else {
|
||||
trackExtensionDisabled(extensionConfig.name, true, undefined, isBuiltin);
|
||||
}
|
||||
toastService.success({
|
||||
title: extensionConfig.name,
|
||||
msg: enabled ? 'Extension enabled in defaults' : 'Extension removed from defaults',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to update extension default in config:', error);
|
||||
if (enabled) {
|
||||
trackExtensionEnabled(extensionConfig.name, false, getErrorType(error), isBuiltin);
|
||||
} else {
|
||||
trackExtensionDisabled(extensionConfig.name, false, getErrorType(error), isBuiltin);
|
||||
}
|
||||
toastService.error({
|
||||
title: extensionConfig.name,
|
||||
msg: 'Failed to update extension default',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
interface ActivateExtensionDefaultProps {
|
||||
addToConfig: (name: string, extensionConfig: ExtensionConfig, enabled: boolean) => Promise<void>;
|
||||
extensionConfig: ExtensionConfig;
|
||||
@@ -44,11 +90,11 @@ export async function activateExtensionDefault({
|
||||
const isBuiltin = isBuiltinExtension(extensionConfig);
|
||||
|
||||
try {
|
||||
await addToConfig(extensionConfig.name, extensionConfig, false);
|
||||
await addToConfig(extensionConfig.name, extensionConfig, true);
|
||||
trackExtensionAdded(extensionConfig.name, true, undefined, isBuiltin);
|
||||
toastService.success({
|
||||
title: extensionConfig.name,
|
||||
msg: 'Extension added',
|
||||
msg: 'Extension added as default',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to add extension to config:', error);
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
export { DEFAULT_EXTENSION_TIMEOUT, nameToKey } from './utils';
|
||||
|
||||
export { activateExtensionDefault, deleteExtension } from './extension-manager';
|
||||
export {
|
||||
activateExtensionDefault,
|
||||
toggleExtensionDefault,
|
||||
deleteExtension,
|
||||
} from './extension-manager';
|
||||
|
||||
export { pruneDeprecatedBundledExtensions, syncBundledExtensions } from './bundled-extensions';
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import kebabCase from 'lodash/kebabCase';
|
||||
import { Switch } from '../../../ui/switch';
|
||||
import { Gear } from '../../../icons';
|
||||
import { FixedExtensionEntry } from '../../../ConfigContext';
|
||||
import { getSubtitle, getFriendlyTitle } from './ExtensionList';
|
||||
@@ -10,16 +12,59 @@ const i18n = defineMessages({
|
||||
id: 'extensionItem.configureExtension',
|
||||
defaultMessage: 'Configure {name} Extension',
|
||||
},
|
||||
toggleExtension: {
|
||||
id: 'extensionItem.toggleExtension',
|
||||
defaultMessage: 'Toggle {name} extension On or Off',
|
||||
},
|
||||
});
|
||||
|
||||
interface ExtensionItemProps {
|
||||
extension: FixedExtensionEntry;
|
||||
onToggle: (extension: FixedExtensionEntry) => Promise<boolean | void> | void;
|
||||
onConfigure?: (extension: FixedExtensionEntry) => void;
|
||||
isStatic?: boolean; // to not allow users to edit configuration
|
||||
}
|
||||
|
||||
export default function ExtensionItem({ extension, onConfigure, isStatic }: ExtensionItemProps) {
|
||||
export default function ExtensionItem({
|
||||
extension,
|
||||
onToggle,
|
||||
onConfigure,
|
||||
isStatic,
|
||||
}: ExtensionItemProps) {
|
||||
const intl = useIntl();
|
||||
// Add local state to track the visual toggle state
|
||||
const [visuallyEnabled, setVisuallyEnabled] = useState(extension.enabled);
|
||||
// Track if we're in the process of toggling
|
||||
const [isToggling, setIsToggling] = useState(false);
|
||||
|
||||
const handleToggle = async (ext: FixedExtensionEntry) => {
|
||||
// Prevent multiple toggles while one is in progress
|
||||
if (isToggling) return;
|
||||
|
||||
setIsToggling(true);
|
||||
|
||||
// Immediately update visual state
|
||||
const newState = !ext.enabled;
|
||||
setVisuallyEnabled(newState);
|
||||
|
||||
try {
|
||||
// Call the actual toggle function that performs the async operation
|
||||
await onToggle(ext);
|
||||
// Success case is handled by the useEffect below when extension.enabled changes
|
||||
} catch {
|
||||
// If there was an error, revert the visual state
|
||||
setVisuallyEnabled(!newState);
|
||||
} finally {
|
||||
setIsToggling(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Update visual state when the actual extension state changes
|
||||
useEffect(() => {
|
||||
if (!isToggling) {
|
||||
setVisuallyEnabled(extension.enabled);
|
||||
}
|
||||
}, [extension.enabled, isToggling]);
|
||||
|
||||
const renderSubtitle = () => {
|
||||
const { description, command } = getSubtitle(extension);
|
||||
@@ -51,7 +96,6 @@ export default function ExtensionItem({ extension, onConfigure, isStatic }: Exte
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{editable && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-text-secondary hover:text-text-primary"
|
||||
aria-label={intl.formatMessage(i18n.configureExtension, {
|
||||
name: getFriendlyTitle(extension),
|
||||
@@ -61,6 +105,15 @@ export default function ExtensionItem({ extension, onConfigure, isStatic }: Exte
|
||||
<Gear className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<Switch
|
||||
checked={(isToggling && visuallyEnabled) || extension.enabled}
|
||||
onCheckedChange={() => handleToggle(extension)}
|
||||
disabled={isToggling}
|
||||
variant="mono"
|
||||
aria-label={intl.formatMessage(i18n.toggleExtension, {
|
||||
name: getFriendlyTitle(extension),
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
|
||||
@@ -8,11 +8,11 @@ import { defineMessages, useIntl } from '../../../../i18n';
|
||||
const i18n = defineMessages({
|
||||
defaultExtensions: {
|
||||
id: 'extensionList.defaultExtensions',
|
||||
defaultMessage: 'Active by Default ({count})',
|
||||
defaultMessage: 'Default Extensions ({count})',
|
||||
},
|
||||
availableExtensions: {
|
||||
id: 'extensionList.availableExtensions',
|
||||
defaultMessage: 'Available On Demand ({count})',
|
||||
defaultMessage: 'Available Extensions ({count})',
|
||||
},
|
||||
noExtensions: {
|
||||
id: 'extensionList.noExtensions',
|
||||
@@ -26,6 +26,7 @@ const i18n = defineMessages({
|
||||
|
||||
interface ExtensionListProps {
|
||||
extensions: FixedExtensionEntry[];
|
||||
onToggle: (extension: FixedExtensionEntry) => Promise<boolean | void> | void;
|
||||
onConfigure?: (extension: FixedExtensionEntry) => void;
|
||||
isStatic?: boolean;
|
||||
disableConfiguration?: boolean;
|
||||
@@ -34,9 +35,10 @@ interface ExtensionListProps {
|
||||
|
||||
export default function ExtensionList({
|
||||
extensions,
|
||||
onToggle,
|
||||
onConfigure,
|
||||
isStatic,
|
||||
disableConfiguration,
|
||||
disableConfiguration: _disableConfiguration,
|
||||
searchTerm = '',
|
||||
}: ExtensionListProps) {
|
||||
const matchesSearch = (extension: FixedExtensionEntry): boolean => {
|
||||
@@ -66,9 +68,6 @@ export default function ExtensionList({
|
||||
const sortedDisabledExtensions = [...disabledExtensions].sort((a, b) =>
|
||||
getFriendlyTitle(a).localeCompare(getFriendlyTitle(b))
|
||||
);
|
||||
const configureHandler = disableConfiguration ? undefined : onConfigure;
|
||||
const hasVisibleExtensions =
|
||||
sortedEnabledExtensions.length > 0 || sortedDisabledExtensions.length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
@@ -83,7 +82,8 @@ export default function ExtensionList({
|
||||
<ExtensionItem
|
||||
key={extension.name}
|
||||
extension={extension}
|
||||
onConfigure={configureHandler}
|
||||
onToggle={onToggle}
|
||||
onConfigure={onConfigure}
|
||||
isStatic={isStatic}
|
||||
/>
|
||||
))}
|
||||
@@ -104,7 +104,8 @@ export default function ExtensionList({
|
||||
<ExtensionItem
|
||||
key={extension.name}
|
||||
extension={extension}
|
||||
onConfigure={configureHandler}
|
||||
onToggle={onToggle}
|
||||
onConfigure={onConfigure}
|
||||
isStatic={isStatic}
|
||||
/>
|
||||
))}
|
||||
@@ -112,7 +113,7 @@ export default function ExtensionList({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasVisibleExtensions && (
|
||||
{extensions.length === 0 && (
|
||||
<div className="text-center text-text-secondary py-8">
|
||||
{intl.formatMessage(i18n.noExtensions)}
|
||||
</div>
|
||||
|
||||
@@ -39,7 +39,7 @@ describe('Extension Utils', () => {
|
||||
type: 'stdio',
|
||||
cmd: '',
|
||||
endpoint: '',
|
||||
enabled: false,
|
||||
enabled: true,
|
||||
timeout: 300,
|
||||
envVars: [],
|
||||
headers: [],
|
||||
|
||||
@@ -47,7 +47,7 @@ export function getDefaultFormData(): ExtensionFormData {
|
||||
type: 'stdio',
|
||||
cmd: '',
|
||||
endpoint: '',
|
||||
enabled: false,
|
||||
enabled: true,
|
||||
timeout: 300,
|
||||
envVars: [],
|
||||
headers: [],
|
||||
|
||||
@@ -1061,14 +1061,17 @@
|
||||
"extensionItem.configureExtension": {
|
||||
"defaultMessage": "Configure {name} Extension"
|
||||
},
|
||||
"extensionItem.toggleExtension": {
|
||||
"defaultMessage": "Toggle {name} extension On or Off"
|
||||
},
|
||||
"extensionList.availableExtensions": {
|
||||
"defaultMessage": "Available On Demand ({count})"
|
||||
"defaultMessage": "Available Extensions ({count})"
|
||||
},
|
||||
"extensionList.builtInExtension": {
|
||||
"defaultMessage": "Built-in extension"
|
||||
},
|
||||
"extensionList.defaultExtensions": {
|
||||
"defaultMessage": "Active by Default ({count})"
|
||||
"defaultMessage": "Default Extensions ({count})"
|
||||
},
|
||||
"extensionList.noExtensions": {
|
||||
"defaultMessage": "No extensions available"
|
||||
@@ -1128,7 +1131,7 @@
|
||||
"defaultMessage": "Browse extensions"
|
||||
},
|
||||
"extensionsView.defaultNote": {
|
||||
"defaultMessage": "Extensions stay available here, and Goose can load them on demand during a chat."
|
||||
"defaultMessage": "Extensions enabled here are used as the default for new chats. You can also toggle active extensions during chat."
|
||||
},
|
||||
"extensionsView.description": {
|
||||
"defaultMessage": "These extensions use the Model Context Protocol (MCP). They can expand Goose's capabilities using three main components: Prompts, Resources, and Tools. {searchShortcut} to search."
|
||||
|
||||
Reference in New Issue
Block a user