From d58f8413429d711818c223458d92073b3609febf Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Tue, 23 Jun 2026 10:02:55 +1000 Subject: [PATCH] feat (ui) - use acp to manage global config and session extensions (#9948) --- ui/desktop/src/acp/extensions.ts | 174 ++++++++++++------ ui/desktop/src/acp/session-extensions.ts | 28 +++ ui/desktop/src/components/ConfigContext.tsx | 82 +++------ .../BottomMenuExtensionSelection.tsx | 18 +- .../settings/extensions/ExtensionsSection.tsx | 7 +- .../settings/extensions/agent-api.ts | 26 ++- .../settings/extensions/extension-manager.ts | 6 +- .../modal/ExtensionTimeoutField.tsx | 1 - 8 files changed, 214 insertions(+), 128 deletions(-) create mode 100644 ui/desktop/src/acp/session-extensions.ts diff --git a/ui/desktop/src/acp/extensions.ts b/ui/desktop/src/acp/extensions.ts index 2fad4536a..1c69cfe82 100644 --- a/ui/desktop/src/acp/extensions.ts +++ b/ui/desktop/src/acp/extensions.ts @@ -1,7 +1,14 @@ -import type { ExtensionResponse, ExtensionEntry } from '../api'; -import type { GooseExtension, GooseExtensionEntry, McpServer } from '@aaif/goose-sdk'; +import type { ExtensionConfig, ExtensionEntry } from '../api'; +import type { GooseExtension, GooseExtensionEntry } from '@aaif/goose-sdk'; import { getAcpClient } from './acpConnection'; +export type ConfiguredExtensionEntry = ExtensionEntry & { configKey?: string }; + +export interface ConfiguredExtensionsResponse { + extensions: ConfiguredExtensionEntry[]; + warnings: string[]; +} + export function gooseExtensionName(extension: GooseExtension): string { return extension.type === 'mcp' ? extension.server.name : extension.name; } @@ -10,63 +17,54 @@ function headersToRecord(headers: { name: string; value: string }[] = []) { return Object.fromEntries(headers.map(({ name, value }) => [name, value])); } -function mcpServerToExtension( - server: McpServer, - entry: GooseExtensionEntry -): ExtensionEntry | null { - const extension = entry.extension; - if (extension.type !== 'mcp') { - return null; - } - - if ('command' in server) { - return { - type: 'stdio', - enabled: entry.enabled, - name: server.name, - description: extension.description ?? '', - cmd: server.command, - args: server.args, - env_keys: extension.envKeys ?? [], - timeout: extension.timeout, - bundled: extension.bundled, - }; - } - - if ('url' in server) { - return { - type: 'streamable_http', - enabled: entry.enabled, - name: server.name, - description: extension.description ?? '', - uri: server.url, - headers: headersToRecord(server.headers), - env_keys: extension.envKeys ?? [], - timeout: extension.timeout, - socket: extension.socket, - bundled: extension.bundled, - }; - } - - return null; -} - -function gooseExtensionEntryToExtensionEntry(entry: GooseExtensionEntry): ExtensionEntry | null { - const extension = entry.extension; - +export function gooseExtensionToExtensionConfig(extension: GooseExtension): ExtensionConfig | null { switch (extension.type) { case 'builtin': case 'platform': return { ...extension, description: extension.description ?? '', - enabled: entry.enabled, }; - case 'mcp': - return mcpServerToExtension(extension.server, entry); + case 'mcp': { + const server = extension.server; + if ('command' in server) { + return { + type: 'stdio', + name: server.name, + description: extension.description ?? '', + cmd: server.command, + args: server.args, + env_keys: extension.envKeys ?? [], + timeout: extension.timeout, + bundled: extension.bundled, + }; + } + if ('url' in server) { + return { + type: 'streamable_http', + name: server.name, + description: extension.description ?? '', + uri: server.url, + headers: headersToRecord(server.headers), + env_keys: extension.envKeys ?? [], + timeout: extension.timeout, + socket: extension.socket, + bundled: extension.bundled, + }; + } + return null; + } } +} - return null; +function gooseExtensionEntryToExtensionEntry( + entry: GooseExtensionEntry +): ConfiguredExtensionEntry | null { + const config = gooseExtensionToExtensionConfig(entry.extension); + if (!config) { + return null; + } + return { ...config, enabled: entry.enabled, configKey: entry.configKey ?? undefined }; } export async function getConfiguredGooseExtensions(): Promise { @@ -75,13 +73,85 @@ export async function getConfiguredGooseExtensions(): Promise { +export async function getConfiguredExtensions(): Promise { const client = await getAcpClient(); const response = await client.goose.configExtensionsList_unstable({}); return { extensions: response.extensions .map(gooseExtensionEntryToExtensionEntry) - .filter((entry): entry is ExtensionEntry => entry !== null), + .filter((entry): entry is ConfiguredExtensionEntry => entry !== null), warnings: response.warnings ?? [], }; } + +export function extensionConfigToGooseExtension(config: ExtensionConfig): GooseExtension | null { + switch (config.type) { + case 'builtin': + return { + type: 'builtin', + name: config.name, + description: config.description, + display_name: config.display_name, + timeout: config.timeout, + bundled: config.bundled, + }; + case 'platform': + return { + type: 'platform', + name: config.name, + description: config.description, + display_name: config.display_name, + bundled: config.bundled, + }; + case 'stdio': + return { + type: 'mcp', + server: { name: config.name, command: config.cmd, args: config.args, env: [] }, + envKeys: config.env_keys ?? [], + description: config.description, + timeout: config.timeout, + bundled: config.bundled, + }; + case 'streamable_http': + return { + type: 'mcp', + server: { + type: 'http', + name: config.name, + url: config.uri, + headers: Object.entries(config.headers ?? {}).map(([name, value]) => ({ name, value })), + }, + envKeys: config.env_keys ?? [], + description: config.description, + timeout: config.timeout, + socket: config.socket, + bundled: config.bundled, + }; + case 'sse': + case 'frontend': + case 'inline_python': + return null; + } +} + +export async function addConfigExtension(config: ExtensionConfig, enabled: boolean): Promise { + const extension = extensionConfigToGooseExtension(config); + if (!extension) { + throw new Error(`Unsupported extension type for ACP: ${config.type}`); + } + const client = await getAcpClient(); + await client.goose.configExtensionsAdd_unstable({ extension, enabled }); +} + +export async function removeConfigExtension(configKey: string): Promise { + const client = await getAcpClient(); + await client.goose.configExtensionsRemove_unstable({ configKey }); +} + +export async function setConfigExtensionEnabled( + configKey: string, + enabled: boolean +): Promise { + const client = await getAcpClient(); + await client.goose.configExtensionsSetEnabled_unstable({ configKey, enabled }); +} diff --git a/ui/desktop/src/acp/session-extensions.ts b/ui/desktop/src/acp/session-extensions.ts new file mode 100644 index 000000000..364214db3 --- /dev/null +++ b/ui/desktop/src/acp/session-extensions.ts @@ -0,0 +1,28 @@ +import type { ExtensionConfig } from '../api'; +import { getAcpClient } from './acpConnection'; +import { extensionConfigToGooseExtension, gooseExtensionToExtensionConfig } from './extensions'; + +export async function getSessionExtensions(sessionId: string): Promise { + const client = await getAcpClient(); + const response = await client.goose.sessionExtensionsList_unstable({ sessionId }); + return response.extensions + .map(gooseExtensionToExtensionConfig) + .filter((config): config is ExtensionConfig => config !== null); +} + +export async function addSessionExtension( + sessionId: string, + config: ExtensionConfig +): Promise { + const extension = extensionConfigToGooseExtension(config); + if (!extension) { + throw new Error(`Unsupported extension type for ACP: ${config.type}`); + } + const client = await getAcpClient(); + await client.goose.sessionExtensionsAdd_unstable({ sessionId, extension }); +} + +export async function removeSessionExtension(sessionId: string, name: string): Promise { + const client = await getAcpClient(); + await client.goose.sessionExtensionsRemove_unstable({ sessionId, name }); +} diff --git a/ui/desktop/src/components/ConfigContext.tsx b/ui/desktop/src/components/ConfigContext.tsx index 018f2f872..834c3d6c0 100644 --- a/ui/desktop/src/components/ConfigContext.tsx +++ b/ui/desktop/src/components/ConfigContext.tsx @@ -1,21 +1,18 @@ import React, { createContext, useContext, useState, useEffect, useMemo, useCallback } from 'react'; +import { readAllConfig, readConfig, removeConfig, upsertConfig, providers } from '../api'; import { - readAllConfig, - readConfig, - removeConfig, - upsertConfig, - addExtension as apiAddExtension, - removeExtension as apiRemoveExtension, - providers, -} from '../api'; -import { getConfiguredExtensions } from '../acp/extensions'; + getConfiguredExtensions, + addConfigExtension, + removeConfigExtension, + setConfigExtensionEnabled, +} from '../acp/extensions'; import { pruneDeprecatedBundledExtensions, syncBundledExtensions } from './settings/extensions'; +import { nameToKey } from './settings/extensions/utils'; import type { ConfigResponse, UpsertConfigQuery, ConfigKeyQuery, ProviderDetails, - ExtensionQuery, ExtensionConfig, } from '../api'; @@ -24,6 +21,7 @@ export type { ExtensionConfig } from '../api/types.gen'; // Define a local version that matches the structure of the imported one export type FixedExtensionEntry = ExtensionConfig & { enabled: boolean; + configKey?: string; }; interface ConfigContextType { @@ -35,12 +33,10 @@ interface ConfigContextType { read: (key: string, is_secret: boolean, options?: { throwOnError?: boolean }) => Promise; remove: (key: string, is_secret: boolean) => Promise; addExtension: (name: string, config: ExtensionConfig, enabled: boolean) => Promise; - toggleExtension: (name: string) => Promise; + setExtensionEnabled: (configKey: string, enabled: boolean) => Promise; removeExtension: (name: string) => Promise; getProviders: (b: boolean) => Promise; getExtensions: (b: boolean) => Promise; - disableAllExtensions: () => Promise; - enableBotExtensions: (extensions: ExtensionConfig[]) => Promise; } interface ConfigProviderProps { @@ -112,11 +108,8 @@ export const ConfigProvider: React.FC = ({ children }) => { }, []); const addExtension = useCallback( - async (name: string, config: ExtensionConfig, enabled: boolean) => { - const query: ExtensionQuery = { name, config, enabled }; - await apiAddExtension({ - body: query, - }); + async (_name: string, config: ExtensionConfig, enabled: boolean) => { + await addConfigExtension(config, enabled); await reloadConfig(); // Refresh extensions list after successful addition await refreshExtensions(); @@ -126,12 +119,13 @@ export const ConfigProvider: React.FC = ({ children }) => { const removeExtension = useCallback( async (name: string) => { - await apiRemoveExtension({ path: { name: name } }); + const entry = extensionsList.find((ext) => ext.name === name); + await removeConfigExtension(entry?.configKey ?? nameToKey(name)); await reloadConfig(); // Refresh extensions list after successful removal await refreshExtensions(); }, - [reloadConfig, refreshExtensions] + [extensionsList, reloadConfig, refreshExtensions] ); const getExtensions = useCallback( @@ -144,16 +138,13 @@ export const ConfigProvider: React.FC = ({ children }) => { [extensionsList, refreshExtensions] ); - const toggleExtension = useCallback( - async (name: string) => { - const exts = await getExtensions(true); - const extension = exts.find((ext) => ext.name === name); - - if (extension) { - await addExtension(name, extension, !extension.enabled); - } + const setExtensionEnabled = useCallback( + async (configKey: string, enabled: boolean) => { + await setConfigExtensionEnabled(configKey, enabled); + await reloadConfig(); + await refreshExtensions(); }, - [addExtension, getExtensions] + [reloadConfig, refreshExtensions] ); const getProviders = useCallback(async (forceRefresh = false): Promise => { @@ -202,15 +193,14 @@ export const ConfigProvider: React.FC = ({ children }) => { // The syncBundledExtensions function skips extensions that already exist and are marked as bundled // Platform extensions (code_execution, todo, etc.) are handled by the backend const addExtensionForSync = async ( - name: string, + _name: string, config: ExtensionConfig, enabled: boolean ) => { - const query: ExtensionQuery = { name, config, enabled }; - await apiAddExtension({ body: query }); + await addConfigExtension(config, enabled); }; - const removeExtensionForSync = async (name: string) => { - await apiRemoveExtension({ path: { name } }); + const removeExtensionForSync = async (configKey: string) => { + await removeConfigExtension(configKey); }; extensions = await pruneDeprecatedBundledExtensions(extensions, removeExtensionForSync); await syncBundledExtensions(extensions, addExtensionForSync); @@ -227,23 +217,6 @@ export const ConfigProvider: React.FC = ({ children }) => { }, []); const contextValue = useMemo(() => { - const disableAllExtensions = async () => { - const currentExtensions = await getExtensions(true); - for (const ext of currentExtensions) { - if (ext.enabled) { - await addExtension(ext.name, ext, false); - } - } - await reloadConfig(); - }; - - const enableBotExtensions = async (extensions: ExtensionConfig[]) => { - for (const ext of extensions) { - await addExtension(ext.name, ext, true); - } - await reloadConfig(); - }; - return { config, providersList, @@ -254,11 +227,9 @@ export const ConfigProvider: React.FC = ({ children }) => { remove, addExtension, removeExtension, - toggleExtension, + setExtensionEnabled, getProviders, getExtensions, - disableAllExtensions, - enableBotExtensions, }; }, [ config, @@ -270,10 +241,9 @@ export const ConfigProvider: React.FC = ({ children }) => { remove, addExtension, removeExtension, - toggleExtension, + setExtensionEnabled, getProviders, getExtensions, - reloadConfig, ]); return {children}; diff --git a/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx b/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx index bd4127033..ee152269e 100644 --- a/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx +++ b/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx @@ -4,6 +4,8 @@ import { toastService } from '../../toasts'; import { formatExtensionName } from '../settings/extensions/subcomponents/ExtensionList'; import { nameToKey } from '../settings/extensions/utils'; import { ExtensionConfig, getSessionExtensions } from '../../api'; +import { getSessionExtensions as getAcpSessionExtensions } from '../../acp/session-extensions'; +import { USE_ACP_CHAT } from '../../acpChatFeatureFlag'; import { addToAgent, removeFromAgent } from '../settings/extensions/agent-api'; import { defineMessages, useIntl } from '../../i18n'; import { AppEvents } from '../../constants/events'; @@ -260,17 +262,21 @@ function SessionExtensionsMenu({ sessionId }: { sessionId: string }) { const loadSessionExtensions = useCallback( async (targetSessionId: string, signal?: GetSessionExtensionsSignal) => { - const response = await getSessionExtensions({ - path: { session_id: targetSessionId }, - signal, - throwOnError: true, - }); + const extensions = USE_ACP_CHAT + ? await getAcpSessionExtensions(targetSessionId) + : (( + await getSessionExtensions({ + path: { session_id: targetSessionId }, + signal, + throwOnError: true, + }) + ).data?.extensions ?? []); if (signal?.aborted || latestSessionIdRef.current !== targetSessionId) { return; } - setSessionExtensions(response.data?.extensions ?? []); + setSessionExtensions(extensions); setIsSessionExtensionsLoaded(true); }, [] diff --git a/ui/desktop/src/components/settings/extensions/ExtensionsSection.tsx b/ui/desktop/src/components/settings/extensions/ExtensionsSection.tsx index 6c5606f06..6a26391cc 100644 --- a/ui/desktop/src/components/settings/extensions/ExtensionsSection.tsx +++ b/ui/desktop/src/components/settings/extensions/ExtensionsSection.tsx @@ -11,6 +11,7 @@ import { ExtensionFormData, extensionToFormData, getDefaultFormData, + nameToKey, } from './utils'; import { activateExtensionDefault, deleteExtension, toggleExtensionDefault } from './index'; @@ -61,7 +62,8 @@ export default function ExtensionsSection({ searchTerm = '', }: ExtensionSectionProps) { const intl = useIntl(); - const { getExtensions, addExtension, removeExtension, extensionsList } = useConfig(); + const { getExtensions, addExtension, removeExtension, setExtensionEnabled, extensionsList } = + useConfig(); const [selectedExtension, setSelectedExtension] = useState(null); const [isModalOpen, setIsModalOpen] = useState(false); const [isAddModalOpen, setIsAddModalOpen] = useState(false); @@ -113,11 +115,12 @@ export default function ExtensionsSection({ } const toggleDirection = extensionConfig.enabled ? 'toggleOff' : 'toggleOn'; + const configKey = extensionConfig.configKey ?? nameToKey(extensionConfig.name); await toggleExtensionDefault({ toggle: toggleDirection, extensionConfig: extensionConfig, - addToConfig: addExtension, + setEnabled: (enabled) => setExtensionEnabled(configKey, enabled), }); await fetchExtensions(); diff --git a/ui/desktop/src/components/settings/extensions/agent-api.ts b/ui/desktop/src/components/settings/extensions/agent-api.ts index bd3828487..3a29d4346 100644 --- a/ui/desktop/src/components/settings/extensions/agent-api.ts +++ b/ui/desktop/src/components/settings/extensions/agent-api.ts @@ -1,5 +1,7 @@ import { toastService } from '../../../toasts'; import { agentAddExtension, ExtensionConfig, agentRemoveExtension } from '../../../api'; +import { USE_ACP_CHAT } from '../../../acpChatFeatureFlag'; +import { addSessionExtension, removeSessionExtension } from '../../../acp/session-extensions'; import { errorMessage } from '../../../utils/conversionUtils'; import { createExtensionRecoverHints, @@ -20,10 +22,14 @@ export async function addToAgent( : 0; try { - await agentAddExtension({ - body: { session_id: sessionId, config: extensionConfig }, - throwOnError: true, - }); + if (USE_ACP_CHAT) { + await addSessionExtension(sessionId, extensionConfig); + } else { + await agentAddExtension({ + body: { session_id: sessionId, config: extensionConfig }, + throwOnError: true, + }); + } if (showToast) { toastService.dismiss(toastId); toastService.success({ @@ -61,10 +67,14 @@ export async function removeFromAgent( : 0; try { - await agentRemoveExtension({ - body: { session_id: sessionId, name: extensionName }, - throwOnError: true, - }); + if (USE_ACP_CHAT) { + await removeSessionExtension(sessionId, extensionName); + } else { + await agentRemoveExtension({ + body: { session_id: sessionId, name: extensionName }, + throwOnError: true, + }); + } if (showToast) { toastService.dismiss(toastId); toastService.success({ diff --git a/ui/desktop/src/components/settings/extensions/extension-manager.ts b/ui/desktop/src/components/settings/extensions/extension-manager.ts index bf5c501eb..1b3c23537 100644 --- a/ui/desktop/src/components/settings/extensions/extension-manager.ts +++ b/ui/desktop/src/components/settings/extensions/extension-manager.ts @@ -41,19 +41,19 @@ export async function deleteExtension({ interface ToggleExtensionDefaultProps { toggle: 'toggleOn' | 'toggleOff'; extensionConfig: ExtensionConfig; - addToConfig: (name: string, extensionConfig: ExtensionConfig, enabled: boolean) => Promise; + setEnabled: (enabled: boolean) => Promise; } export async function toggleExtensionDefault({ toggle, extensionConfig, - addToConfig, + setEnabled, }: ToggleExtensionDefaultProps) { const isBuiltin = isBuiltinExtension(extensionConfig); const enabled = toggle === 'toggleOn'; try { - await addToConfig(extensionConfig.name, extensionConfig, enabled); + await setEnabled(enabled); if (enabled) { trackExtensionEnabled(extensionConfig.name, true, undefined, isBuiltin); } else { diff --git a/ui/desktop/src/components/settings/extensions/modal/ExtensionTimeoutField.tsx b/ui/desktop/src/components/settings/extensions/modal/ExtensionTimeoutField.tsx index bb83af46e..54e0326ef 100644 --- a/ui/desktop/src/components/settings/extensions/modal/ExtensionTimeoutField.tsx +++ b/ui/desktop/src/components/settings/extensions/modal/ExtensionTimeoutField.tsx @@ -45,7 +45,6 @@ export default function ExtensionTimeoutField({ onChange('timeout', e.target.value)} - defaultValue={300} className={`${!submitAttempted || isTimeoutValid() ? 'border-border-primary' : 'border-red-500'} text-text-primary focus:border-border-primary`} /> {submitAttempted && !isTimeoutValid() && (