Platform extensions sketch (#4868)

Co-authored-by: Douwe Osinga <douwe@squareup.com>
Co-authored-by: Michael Neale <michael.neale@gmail.com>
This commit is contained in:
Douwe Osinga
2025-10-03 13:45:37 -04:00
committed by GitHub
parent 5b8efb5b9a
commit 96ded37e15
36 changed files with 742 additions and 847 deletions
@@ -9,7 +9,6 @@ import {
createExtensionConfig,
ExtensionFormData,
extensionToFormData,
extractExtensionConfig,
getDefaultFormData,
} from './utils';
@@ -84,33 +83,25 @@ export default function ExtensionsSection({
await getExtensions(true); // Force refresh - this will update the context
}, [getExtensions]);
const handleExtensionToggle = async (extension: FixedExtensionEntry) => {
const handleExtensionToggle = async (extensionConfig: FixedExtensionEntry) => {
if (customToggle) {
await customToggle(extension);
await customToggle(extensionConfig);
return true;
}
// If extension is enabled, we are trying to toggle if off, otherwise on
const toggleDirection = extension.enabled ? 'toggleOff' : 'toggleOn';
const extensionConfig = extractExtensionConfig(extension);
const toggleDirection = extensionConfig.enabled ? 'toggleOff' : 'toggleOn';
// eslint-disable-next-line no-useless-catch
try {
await toggleExtension({
toggle: toggleDirection,
extensionConfig: extensionConfig,
addToConfig: addExtension,
toastOptions: { silent: false },
sessionId: sessionId,
});
await toggleExtension({
toggle: toggleDirection,
extensionConfig: extensionConfig,
addToConfig: addExtension,
toastOptions: { silent: false },
sessionId: sessionId,
});
await fetchExtensions(); // Refresh the list after successful toggle
return true; // Indicate success
} catch (error) {
// Don't refresh the extension list on failure - this allows our visual state rollback to work
// The actual state in the config hasn't changed anyway
throw error; // Re-throw to let the ExtensionItem component know it failed
}
await fetchExtensions();
return true;
};
const handleConfigureClick = (extension: FixedExtensionEntry) => {
@@ -49,6 +49,7 @@ describe('Agent API', () => {
describe('extensionApiCall', () => {
const mockExtensionConfig: ExtensionConfig = {
type: 'stdio',
description: 'description',
name: 'test-extension',
cmd: 'python',
args: ['script.py'],
@@ -237,6 +238,7 @@ describe('Agent API', () => {
const mockExtensionConfig: ExtensionConfig = {
type: 'stdio',
name: 'Test Extension',
description: 'Test description',
cmd: 'python',
args: ['script.py'],
};
@@ -286,6 +288,7 @@ describe('Agent API', () => {
const sseConfig: ExtensionConfig = {
type: 'sse',
name: 'SSE Extension',
description: 'Test description',
uri: 'http://localhost:8080/events',
};
@@ -8,7 +8,7 @@ type BundledExtension = {
id: string;
name: string;
display_name?: string;
description?: string;
description: string;
enabled: boolean;
type: 'builtin' | 'stdio' | 'sse';
cmd?: string;
@@ -59,18 +59,19 @@ export async function syncBundledExtensions(
switch (bundledExt.type) {
case 'builtin':
extConfig = {
name: bundledExt.name,
display_name: bundledExt.display_name,
type: bundledExt.type,
name: bundledExt.name,
description: bundledExt.description,
display_name: bundledExt.display_name,
timeout: bundledExt.timeout ?? 300,
bundled: true,
};
break;
case 'stdio':
extConfig = {
type: bundledExt.type,
name: bundledExt.name,
description: bundledExt.description,
type: bundledExt.type,
timeout: bundledExt.timeout,
cmd: bundledExt.cmd || '',
args: bundledExt.args || [],
@@ -81,9 +82,9 @@ export async function syncBundledExtensions(
break;
case 'sse':
extConfig = {
type: bundledExt.type,
name: bundledExt.name,
description: bundledExt.description,
type: bundledExt.type,
timeout: bundledExt.timeout,
uri: bundledExt.uri || '',
bundled: true,
@@ -25,6 +25,7 @@ describe('Extension Manager', () => {
const mockExtensionConfig = {
type: 'stdio' as const,
name: 'test-extension',
description: 'test-extension',
cmd: 'python',
args: ['script.py'],
timeout: 300,
@@ -80,79 +80,42 @@ export default function ExtensionList({
}
// Helper functions
// Helper function to get a friendly title from extension name
export function getFriendlyTitle(extension: FixedExtensionEntry): string {
let name = '';
// if it's a builtin, check if there's a display_name (old configs didn't have this field)
if (
'bundled' in extension &&
extension.bundled === true &&
'display_name' in extension &&
extension.display_name
) {
// If we have a display_name for a builtin, use it directly
return extension.display_name;
} else {
// For non-builtins or builtins without display_name
name = extension.name;
}
// Format the name to be more readable
const name = (extension.type === 'builtin' && extension.display_name) || extension.name;
return name
.split(/[-_]/) // Split on hyphens and underscores
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
export interface SubtitleParts {
description: string | null;
command: string | null;
function normalizeExtensionName(name: string): string {
return name.toLowerCase().replace(/\s+/g, '');
}
// Helper function to get a subtitle based on extension type and configuration
export function getSubtitle(config: ExtensionConfig): SubtitleParts {
if (config.type === 'builtin') {
// Find matching extension in the data
const extensionData = builtInExtensionsData.find(
(ext) =>
ext.name.toLowerCase().replace(/\s+/g, '') === config.name.toLowerCase().replace(/\s+/g, '')
);
return {
description: extensionData?.description || 'Built-in extension',
command: null,
};
}
export function getSubtitle(config: ExtensionConfig) {
switch (config.type) {
case 'builtin': {
const extensionData = builtInExtensionsData.find(
(ext) => normalizeExtensionName(ext.name) === normalizeExtensionName(config.name)
);
return {
description: extensionData?.description || config.description || 'Built-in extension',
command: null,
};
}
case 'sse':
case 'streamable_http': {
const prefix = `${config.type.toUpperCase().replace('_', ' ')} extension`;
return {
description: `${prefix}${config.description ? ': ' + config.description : ''}`,
command: config.uri || null,
};
}
if (config.type === 'stdio') {
// Only include command if it exists
const full_command = config.cmd
? combineCmdAndArgs(removeShims(config.cmd), config.args)
: null;
return {
description: config.description || null,
command: full_command,
};
default:
return {
description: config.description || null,
command: 'cmd' in config ? combineCmdAndArgs(removeShims(config.cmd), config.args) : null,
};
}
if (config.type === 'sse') {
const description = config.description
? `SSE extension: ${config.description}`
: 'SSE extension';
const command = config.uri || null;
return { description, command };
}
if (config.type === 'streamable_http') {
const description = config.description
? `Streamable HTTP extension: ${config.description}`
: 'Streamable HTTP extension';
const command = config.uri || null;
return { description, command };
}
return {
description: 'Unknown type of extension',
command: null,
};
}
@@ -6,7 +6,6 @@ import {
createExtensionConfig,
splitCmdAndArgs,
combineCmdAndArgs,
extractExtensionConfig,
replaceWithShims,
removeShims,
extractCommand,
@@ -153,6 +152,7 @@ describe('Extension Utils', () => {
const extension: FixedExtensionEntry = {
type: 'stdio',
name: 'legacy-extension',
description: 'legacy',
cmd: 'node',
args: ['app.js'],
enabled: true,
@@ -176,6 +176,7 @@ describe('Extension Utils', () => {
const extension: FixedExtensionEntry = {
type: 'builtin',
name: 'developer',
description: 'developer',
enabled: true,
};
@@ -183,7 +184,7 @@ describe('Extension Utils', () => {
expect(formData).toEqual({
name: 'developer',
description: '',
description: 'developer',
type: 'builtin',
cmd: undefined,
endpoint: undefined,
@@ -284,7 +285,7 @@ describe('Extension Utils', () => {
it('should create builtin extension config', () => {
const formData = {
name: 'developer',
description: '',
description: 'developer',
type: 'builtin' as const,
cmd: '',
endpoint: '',
@@ -299,6 +300,7 @@ describe('Extension Utils', () => {
expect(config).toEqual({
type: 'builtin',
name: 'developer',
description: 'developer',
timeout: 300,
});
});
@@ -340,30 +342,6 @@ describe('Extension Utils', () => {
});
});
describe('extractExtensionConfig', () => {
it('should extract extension config from fixed entry', () => {
const fixedEntry: FixedExtensionEntry = {
type: 'stdio',
name: 'test-extension',
cmd: 'python',
args: ['script.py'],
enabled: true,
timeout: 300,
};
const config = extractExtensionConfig(fixedEntry);
expect(config).toEqual({
type: 'stdio',
name: 'test-extension',
cmd: 'python',
args: ['script.py'],
enabled: true,
timeout: 300,
});
});
});
describe('replaceWithShims', () => {
beforeEach(() => {
mockElectron.getBinaryPath.mockImplementation((binary: string) => {
@@ -96,12 +96,11 @@ export function extensionToFormData(extension: FixedExtensionEntry): ExtensionFo
return {
name: extension.name || '',
description:
extension.type === 'stdio' || extension.type === 'sse' || extension.type === 'streamable_http'
? extension.description || ''
: '',
description: extension.description || '',
type:
extension.type === 'frontend' || extension.type === 'inline_python'
extension.type === 'frontend' ||
extension.type === 'inline_python' ||
extension.type === 'platform'
? 'stdio'
: extension.type,
cmd: extension.type === 'stdio' ? combineCmdAndArgs(extension.cmd, extension.args) : undefined,
@@ -166,6 +165,7 @@ export function createExtensionConfig(formData: ExtensionFormData): ExtensionCon
return {
type: formData.type,
name: formData.name,
description: formData.description,
timeout: formData.timeout,
};
}
@@ -186,17 +186,6 @@ export function combineCmdAndArgs(cmd: string, args: string[]): string {
return [cmd, ...args].join(' ');
}
/**
* Extracts the ExtensionConfig from a FixedExtensionEntry object
* @param fixedEntry - The FixedExtensionEntry object
* @returns The ExtensionConfig portion of the object
*/
export function extractExtensionConfig(fixedEntry: FixedExtensionEntry): ExtensionConfig {
// todo: enabled not used?
const { ...extensionConfig } = fixedEntry;
return extensionConfig;
}
export async function replaceWithShims(cmd: string) {
const binaryPathMap: Record<string, string> = {
goosed: await window.electron.getBinaryPath('goosed'),