feat (ui) - use acp to manage global config and session extensions (#9948)

This commit is contained in:
Lifei Zhou
2026-06-23 10:02:55 +10:00
committed by GitHub
parent ca8938cb43
commit d58f841342
8 changed files with 214 additions and 128 deletions
+122 -52
View File
@@ -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<GooseExtensionEntry[]> {
@@ -75,13 +73,85 @@ export async function getConfiguredGooseExtensions(): Promise<GooseExtensionEntr
return response.extensions;
}
export async function getConfiguredExtensions(): Promise<ExtensionResponse> {
export async function getConfiguredExtensions(): Promise<ConfiguredExtensionsResponse> {
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<void> {
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<void> {
const client = await getAcpClient();
await client.goose.configExtensionsRemove_unstable({ configKey });
}
export async function setConfigExtensionEnabled(
configKey: string,
enabled: boolean
): Promise<void> {
const client = await getAcpClient();
await client.goose.configExtensionsSetEnabled_unstable({ configKey, enabled });
}
+28
View File
@@ -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<ExtensionConfig[]> {
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<void> {
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<void> {
const client = await getAcpClient();
await client.goose.sessionExtensionsRemove_unstable({ sessionId, name });
}
+26 -56
View File
@@ -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<unknown>;
remove: (key: string, is_secret: boolean) => Promise<void>;
addExtension: (name: string, config: ExtensionConfig, enabled: boolean) => Promise<void>;
toggleExtension: (name: string) => Promise<void>;
setExtensionEnabled: (configKey: string, enabled: boolean) => Promise<void>;
removeExtension: (name: string) => Promise<void>;
getProviders: (b: boolean) => Promise<ProviderDetails[]>;
getExtensions: (b: boolean) => Promise<FixedExtensionEntry[]>;
disableAllExtensions: () => Promise<void>;
enableBotExtensions: (extensions: ExtensionConfig[]) => Promise<void>;
}
interface ConfigProviderProps {
@@ -112,11 +108,8 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ 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<ConfigProviderProps> = ({ 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<ConfigProviderProps> = ({ 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<ProviderDetails[]> => {
@@ -202,15 +193,14 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ 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<ConfigProviderProps> = ({ 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<ConfigProviderProps> = ({ children }) => {
remove,
addExtension,
removeExtension,
toggleExtension,
setExtensionEnabled,
getProviders,
getExtensions,
disableAllExtensions,
enableBotExtensions,
};
}, [
config,
@@ -270,10 +241,9 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
remove,
addExtension,
removeExtension,
toggleExtension,
setExtensionEnabled,
getProviders,
getExtensions,
reloadConfig,
]);
return <ConfigContext.Provider value={contextValue}>{children}</ConfigContext.Provider>;
@@ -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);
},
[]
@@ -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<FixedExtensionEntry | null>(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();
@@ -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({
@@ -41,19 +41,19 @@ export async function deleteExtension({
interface ToggleExtensionDefaultProps {
toggle: 'toggleOn' | 'toggleOff';
extensionConfig: ExtensionConfig;
addToConfig: (name: string, extensionConfig: ExtensionConfig, enabled: boolean) => Promise<void>;
setEnabled: (enabled: boolean) => Promise<void>;
}
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 {
@@ -45,7 +45,6 @@ export default function ExtensionTimeoutField({
<Input
value={timeout}
onChange={(e) => 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() && (