feat (acp): exposed available tools in acp schema (#10097)
This commit is contained in:
@@ -259,13 +259,12 @@ export default function CreateEditRecipeModal({
|
||||
: undefined;
|
||||
|
||||
const cleanedExtensions = extensions?.map(
|
||||
(
|
||||
extension: RecipeExtension & {
|
||||
enabled?: boolean;
|
||||
available_tools?: unknown;
|
||||
(extension: RecipeExtension & { enabled?: boolean }) => {
|
||||
const { enabled: _enabled, ...rest } = extension;
|
||||
if (rest.available_tools == null || rest.available_tools.length === 0) {
|
||||
const { available_tools: _availableTools, ...withoutAvailableTools } = rest;
|
||||
return withoutAvailableTools;
|
||||
}
|
||||
) => {
|
||||
const { enabled: _enabled, available_tools: _availableTools, ...rest } = extension;
|
||||
return rest;
|
||||
}
|
||||
) as RecipeExtension[] | undefined;
|
||||
|
||||
@@ -38,6 +38,12 @@ type DisplayRecipeExtension = RecipeExtension & {
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
function availableToolsProps(availableTools?: string[] | null) {
|
||||
return availableTools && availableTools.length > 0
|
||||
? { available_tools: availableTools }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function toRecipeExtension(
|
||||
extension: FixedExtensionEntry | DisplayRecipeExtension
|
||||
): DisplayRecipeExtension | null {
|
||||
@@ -45,21 +51,74 @@ function toRecipeExtension(
|
||||
|
||||
switch (extension.type) {
|
||||
case 'builtin': {
|
||||
const { name, description, display_name, timeout, bundled, type } = extension;
|
||||
return { name, description, display_name, timeout, bundled, type, enabled };
|
||||
const { name, description, display_name, timeout, bundled, available_tools, type } =
|
||||
extension;
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
display_name,
|
||||
timeout,
|
||||
bundled,
|
||||
...availableToolsProps(available_tools),
|
||||
type,
|
||||
enabled,
|
||||
};
|
||||
}
|
||||
case 'platform': {
|
||||
const { name, description, display_name, bundled, type } = extension;
|
||||
return { name, description, display_name, bundled, type, enabled };
|
||||
const { name, description, display_name, bundled, available_tools, type } = extension;
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
display_name,
|
||||
bundled,
|
||||
...availableToolsProps(available_tools),
|
||||
type,
|
||||
enabled,
|
||||
};
|
||||
}
|
||||
case 'stdio': {
|
||||
const { name, description, cmd, args, envs, env_keys, timeout, cwd, bundled, type } =
|
||||
extension;
|
||||
return { name, description, cmd, args, envs, env_keys, timeout, cwd, bundled, type, enabled };
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
cmd,
|
||||
args,
|
||||
envs,
|
||||
env_keys,
|
||||
timeout,
|
||||
cwd,
|
||||
bundled,
|
||||
available_tools,
|
||||
type,
|
||||
} = extension;
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
cmd,
|
||||
args,
|
||||
envs,
|
||||
env_keys,
|
||||
timeout,
|
||||
cwd,
|
||||
bundled,
|
||||
...availableToolsProps(available_tools),
|
||||
type,
|
||||
enabled,
|
||||
};
|
||||
}
|
||||
case 'streamable_http': {
|
||||
const { name, description, uri, envs, env_keys, headers, timeout, socket, bundled, type } =
|
||||
extension;
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
uri,
|
||||
envs,
|
||||
env_keys,
|
||||
headers,
|
||||
timeout,
|
||||
socket,
|
||||
bundled,
|
||||
available_tools,
|
||||
type,
|
||||
} = extension;
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
@@ -70,6 +129,7 @@ function toRecipeExtension(
|
||||
timeout,
|
||||
socket,
|
||||
bundled,
|
||||
...availableToolsProps(available_tools),
|
||||
type,
|
||||
enabled,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, type RenderOptions, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { RecipeExtensionSelector } from '../RecipeExtensionSelector';
|
||||
import { IntlTestWrapper } from '../../../../i18n/test-utils';
|
||||
import type { FixedExtensionEntry } from '../../../ConfigContext';
|
||||
|
||||
const configContextMock = vi.hoisted(() => ({
|
||||
extensionsList: [] as FixedExtensionEntry[],
|
||||
}));
|
||||
|
||||
vi.mock('../../../ConfigContext', () => ({
|
||||
useConfig: () => ({
|
||||
extensionsList: configContextMock.extensionsList,
|
||||
}),
|
||||
}));
|
||||
|
||||
const renderWithIntl = (ui: React.ReactElement, options?: RenderOptions) =>
|
||||
render(ui, { wrapper: IntlTestWrapper, ...options });
|
||||
|
||||
describe('RecipeExtensionSelector', () => {
|
||||
beforeEach(() => {
|
||||
configContextMock.extensionsList = [];
|
||||
});
|
||||
|
||||
it('preserves non-empty available tools when selecting a configured extension', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onExtensionsChange = vi.fn();
|
||||
configContextMock.extensionsList = [
|
||||
{
|
||||
type: 'builtin',
|
||||
name: 'developer',
|
||||
description: 'Developer tools',
|
||||
enabled: true,
|
||||
available_tools: ['shell', 'read_file'],
|
||||
},
|
||||
];
|
||||
|
||||
renderWithIntl(
|
||||
<RecipeExtensionSelector selectedExtensions={[]} onExtensionsChange={onExtensionsChange} />
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('Developer'));
|
||||
|
||||
expect(onExtensionsChange).toHaveBeenCalledWith([
|
||||
expect.objectContaining({
|
||||
type: 'builtin',
|
||||
name: 'developer',
|
||||
available_tools: ['shell', 'read_file'],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -157,6 +157,34 @@ describe('Extension Utils', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve available tools metadata', () => {
|
||||
const extension: FixedExtensionEntry = {
|
||||
type: 'builtin',
|
||||
name: 'developer',
|
||||
description: 'developer',
|
||||
enabled: true,
|
||||
available_tools: ['shell', 'read_file'],
|
||||
};
|
||||
|
||||
const formData = extensionToFormData(extension);
|
||||
|
||||
expect(formData.available_tools).toEqual(['shell', 'read_file']);
|
||||
});
|
||||
|
||||
it('should omit empty available tools metadata', () => {
|
||||
const extension: FixedExtensionEntry = {
|
||||
type: 'builtin',
|
||||
name: 'developer',
|
||||
description: 'developer',
|
||||
enabled: true,
|
||||
available_tools: [],
|
||||
};
|
||||
|
||||
const formData = extensionToFormData(extension);
|
||||
|
||||
expect(formData).not.toHaveProperty('available_tools');
|
||||
});
|
||||
|
||||
it('should not escape @ in command args', () => {
|
||||
const extension: FixedExtensionEntry = {
|
||||
type: 'stdio',
|
||||
@@ -320,6 +348,73 @@ describe('Extension Utils', () => {
|
||||
timeout: 300,
|
||||
});
|
||||
});
|
||||
|
||||
it('should create sse extension config', () => {
|
||||
const formData = {
|
||||
name: 'test-sse',
|
||||
description: 'Test SSE extension',
|
||||
type: 'sse' as const,
|
||||
cmd: '',
|
||||
endpoint: 'http://api.example.com/sse',
|
||||
enabled: true,
|
||||
timeout: 300,
|
||||
envVars: [],
|
||||
headers: [],
|
||||
};
|
||||
|
||||
const config = createExtensionConfig(formData);
|
||||
|
||||
expect(config).toEqual({
|
||||
type: 'sse',
|
||||
name: 'test-sse',
|
||||
description: 'Test SSE extension',
|
||||
uri: 'http://api.example.com/sse',
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve available tools metadata', () => {
|
||||
const formData = {
|
||||
name: 'developer',
|
||||
description: 'developer',
|
||||
type: 'builtin' as const,
|
||||
cmd: '',
|
||||
endpoint: '',
|
||||
enabled: true,
|
||||
timeout: 300,
|
||||
envVars: [],
|
||||
headers: [],
|
||||
available_tools: ['shell', 'read_file'],
|
||||
};
|
||||
|
||||
const config = createExtensionConfig(formData);
|
||||
|
||||
expect(config).toEqual({
|
||||
type: 'builtin',
|
||||
name: 'developer',
|
||||
description: 'developer',
|
||||
timeout: 300,
|
||||
available_tools: ['shell', 'read_file'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should omit empty available tools metadata', () => {
|
||||
const formData = {
|
||||
name: 'developer',
|
||||
description: 'developer',
|
||||
type: 'builtin' as const,
|
||||
cmd: '',
|
||||
endpoint: '',
|
||||
enabled: true,
|
||||
timeout: 300,
|
||||
envVars: [],
|
||||
headers: [],
|
||||
available_tools: [],
|
||||
};
|
||||
|
||||
const config = createExtensionConfig(formData);
|
||||
|
||||
expect(config).not.toHaveProperty('available_tools');
|
||||
});
|
||||
});
|
||||
|
||||
describe('splitCmdAndArgs', () => {
|
||||
|
||||
@@ -38,6 +38,7 @@ export interface ExtensionFormData {
|
||||
isEdited?: boolean;
|
||||
}[];
|
||||
installation_notes?: string;
|
||||
available_tools?: string[];
|
||||
}
|
||||
|
||||
export function getDefaultFormData(): ExtensionFormData {
|
||||
@@ -95,6 +96,11 @@ export function extensionToFormData(extension: FixedExtensionEntry): ExtensionFo
|
||||
);
|
||||
}
|
||||
|
||||
const availableTools =
|
||||
'available_tools' in extension
|
||||
? availableToolsOrUndefined(extension.available_tools)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
name: extension.name || '',
|
||||
description: extension.description || '',
|
||||
@@ -116,9 +122,19 @@ export function extensionToFormData(extension: FixedExtensionEntry): ExtensionFo
|
||||
installation_notes: (extension as Record<string, unknown>)['installation_notes'] as
|
||||
| string
|
||||
| undefined,
|
||||
...(availableTools ? { available_tools: availableTools } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function availableToolsOrUndefined(availableTools?: string[] | null): string[] | undefined {
|
||||
return availableTools && availableTools.length > 0 ? availableTools : undefined;
|
||||
}
|
||||
|
||||
function availableToolsConfig(availableTools?: string[] | null) {
|
||||
const normalized = availableToolsOrUndefined(availableTools);
|
||||
return normalized ? { available_tools: normalized } : undefined;
|
||||
}
|
||||
|
||||
export function createExtensionConfig(formData: ExtensionFormData): ExtensionConfig {
|
||||
// Extract just the keys from env vars
|
||||
const env_keys = formData.envVars.map(({ key }) => key).filter((key) => key.length > 0);
|
||||
@@ -135,6 +151,7 @@ export function createExtensionConfig(formData: ExtensionFormData): ExtensionCon
|
||||
args: args,
|
||||
timeout: formData.timeout,
|
||||
...(env_keys.length > 0 ? { env_keys } : {}),
|
||||
...availableToolsConfig(formData.available_tools),
|
||||
};
|
||||
} else if (formData.type === 'streamable_http') {
|
||||
// Extract headers
|
||||
@@ -156,14 +173,22 @@ export function createExtensionConfig(formData: ExtensionFormData): ExtensionCon
|
||||
uri: formData.endpoint || '',
|
||||
...(env_keys.length > 0 ? { env_keys } : {}),
|
||||
headers,
|
||||
...availableToolsConfig(formData.available_tools),
|
||||
};
|
||||
} else {
|
||||
// For other types
|
||||
} else if (formData.type === 'builtin') {
|
||||
return {
|
||||
type: formData.type,
|
||||
name: formData.name,
|
||||
description: formData.description,
|
||||
timeout: formData.timeout,
|
||||
...availableToolsConfig(formData.available_tools),
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
type: formData.type,
|
||||
name: formData.name,
|
||||
description: formData.description,
|
||||
uri: formData.endpoint || '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user