Agent extension api (#5281)
Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
import type { Client, Options as Options2, TDataShape } from './client';
|
||||
import { client } from './client.gen';
|
||||
import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, BackupConfigData, BackupConfigErrors, BackupConfigResponses, ConfirmPermissionData, ConfirmPermissionErrors, ConfirmPermissionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetToolsData, GetToolsErrors, GetToolsResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StatusData, StatusResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateRouterToolSelectorData, UpdateRouterToolSelectorErrors, UpdateRouterToolSelectorResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen';
|
||||
import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponses, BackupConfigData, BackupConfigErrors, BackupConfigResponses, ConfirmPermissionData, ConfirmPermissionErrors, ConfirmPermissionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetToolsData, GetToolsErrors, GetToolsResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StatusData, StatusResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateRouterToolSelectorData, UpdateRouterToolSelectorErrors, UpdateRouterToolSelectorResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen';
|
||||
|
||||
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
|
||||
/**
|
||||
@@ -18,6 +18,28 @@ export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends
|
||||
meta?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export const agentAddExtension = <ThrowOnError extends boolean = false>(options: Options<AgentAddExtensionData, ThrowOnError>) => {
|
||||
return (options.client ?? client).post<AgentAddExtensionResponses, AgentAddExtensionErrors, ThrowOnError>({
|
||||
url: '/agent/add_extension',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const agentRemoveExtension = <ThrowOnError extends boolean = false>(options: Options<AgentRemoveExtensionData, ThrowOnError>) => {
|
||||
return (options.client ?? client).post<AgentRemoveExtensionResponses, AgentRemoveExtensionErrors, ThrowOnError>({
|
||||
url: '/agent/remove_extension',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const resumeAgent = <ThrowOnError extends boolean = false>(options: Options<ResumeAgentData, ThrowOnError>) => {
|
||||
return (options.client ?? client).post<ResumeAgentResponses, ResumeAgentErrors, ThrowOnError>({
|
||||
url: '/agent/resume',
|
||||
|
||||
@@ -4,6 +4,11 @@ export type ClientOptions = {
|
||||
baseUrl: `${string}://${string}` | (string & {});
|
||||
};
|
||||
|
||||
export type AddExtensionRequest = {
|
||||
config: ExtensionConfig;
|
||||
session_id: string;
|
||||
};
|
||||
|
||||
export type Annotations = {
|
||||
audience?: Array<Role>;
|
||||
lastModified?: string;
|
||||
@@ -578,6 +583,11 @@ export type RedactedThinkingContent = {
|
||||
data: string;
|
||||
};
|
||||
|
||||
export type RemoveExtensionRequest = {
|
||||
name: string;
|
||||
session_id: string;
|
||||
};
|
||||
|
||||
export type ResourceContents = {
|
||||
_meta?: {
|
||||
[key: string]: unknown;
|
||||
@@ -896,6 +906,68 @@ export type UpsertPermissionsQuery = {
|
||||
tool_permissions: Array<ToolPermission>;
|
||||
};
|
||||
|
||||
export type AgentAddExtensionData = {
|
||||
body: AddExtensionRequest;
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/agent/add_extension';
|
||||
};
|
||||
|
||||
export type AgentAddExtensionErrors = {
|
||||
/**
|
||||
* Unauthorized - invalid secret key
|
||||
*/
|
||||
401: unknown;
|
||||
/**
|
||||
* Agent not initialized
|
||||
*/
|
||||
424: unknown;
|
||||
/**
|
||||
* Internal server error
|
||||
*/
|
||||
500: unknown;
|
||||
};
|
||||
|
||||
export type AgentAddExtensionResponses = {
|
||||
/**
|
||||
* Extension added
|
||||
*/
|
||||
200: string;
|
||||
};
|
||||
|
||||
export type AgentAddExtensionResponse = AgentAddExtensionResponses[keyof AgentAddExtensionResponses];
|
||||
|
||||
export type AgentRemoveExtensionData = {
|
||||
body: RemoveExtensionRequest;
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/agent/remove_extension';
|
||||
};
|
||||
|
||||
export type AgentRemoveExtensionErrors = {
|
||||
/**
|
||||
* Unauthorized - invalid secret key
|
||||
*/
|
||||
401: unknown;
|
||||
/**
|
||||
* Agent not initialized
|
||||
*/
|
||||
424: unknown;
|
||||
/**
|
||||
* Internal server error
|
||||
*/
|
||||
500: unknown;
|
||||
};
|
||||
|
||||
export type AgentRemoveExtensionResponses = {
|
||||
/**
|
||||
* Extension removed
|
||||
*/
|
||||
200: string;
|
||||
};
|
||||
|
||||
export type AgentRemoveExtensionResponse = AgentRemoveExtensionResponses[keyof AgentRemoveExtensionResponses];
|
||||
|
||||
export type ResumeAgentData = {
|
||||
body: ResumeAgentRequest;
|
||||
path?: never;
|
||||
|
||||
@@ -19,7 +19,6 @@ import type {
|
||||
ExtensionQuery,
|
||||
ExtensionConfig,
|
||||
} from '../api';
|
||||
import { removeShims } from './settings/extensions/utils';
|
||||
|
||||
export type { ExtensionConfig } from '../api/types.gen';
|
||||
|
||||
@@ -122,10 +121,6 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
|
||||
|
||||
const addExtension = useCallback(
|
||||
async (name: string, config: ExtensionConfig, enabled: boolean) => {
|
||||
// remove shims if present
|
||||
if (config.type === 'stdio') {
|
||||
config.cmd = removeShims(config.cmd);
|
||||
}
|
||||
const query: ExtensionQuery = { name, config, enabled };
|
||||
await apiAddExtension({
|
||||
body: query,
|
||||
|
||||
@@ -1,376 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { extensionApiCall, addToAgent, removeFromAgent, sanitizeName } from './agent-api';
|
||||
import * as config from '../../../config';
|
||||
import * as toasts from '../../../toasts';
|
||||
import { ExtensionConfig } from '../../../api/types.gen';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../../../config');
|
||||
vi.mock('../../../toasts');
|
||||
vi.mock('./utils');
|
||||
|
||||
const mockGetApiUrl = vi.mocked(config.getApiUrl);
|
||||
const mockToastService = vi.mocked(toasts.toastService);
|
||||
|
||||
// Mock window.electron
|
||||
const mockElectron = {
|
||||
getSecretKey: vi.fn(),
|
||||
};
|
||||
|
||||
Object.defineProperty(window, 'electron', {
|
||||
value: mockElectron,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
// Mock fetch
|
||||
const mockFetch = vi.fn();
|
||||
(globalThis as typeof globalThis & { fetch: typeof mockFetch }).fetch = mockFetch;
|
||||
|
||||
describe('Agent API', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGetApiUrl.mockImplementation((path: string) => `http://localhost:8080${path}`);
|
||||
mockElectron.getSecretKey.mockResolvedValue('secret-key');
|
||||
mockToastService.configure = vi.fn();
|
||||
mockToastService.loading = vi.fn().mockReturnValue('toast-id');
|
||||
mockToastService.success = vi.fn();
|
||||
mockToastService.error = vi.fn();
|
||||
mockToastService.dismiss = vi.fn();
|
||||
});
|
||||
|
||||
describe('sanitizeName', () => {
|
||||
it('should sanitize extension names correctly', () => {
|
||||
expect(sanitizeName('Test Extension')).toBe('testextension');
|
||||
expect(sanitizeName('My-Extension_Name')).toBe('myextensionname');
|
||||
expect(sanitizeName('UPPERCASE')).toBe('uppercase');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extensionApiCall', () => {
|
||||
const mockExtensionConfig: ExtensionConfig = {
|
||||
type: 'stdio',
|
||||
description: 'description',
|
||||
name: 'test-extension',
|
||||
cmd: 'python',
|
||||
args: ['script.py'],
|
||||
};
|
||||
|
||||
it('should make successful API call for adding extension', async () => {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
text: vi.fn().mockResolvedValue('{"error": false}'),
|
||||
};
|
||||
mockFetch.mockResolvedValue(mockResponse);
|
||||
|
||||
const response = await extensionApiCall(
|
||||
'/extensions/add',
|
||||
mockExtensionConfig,
|
||||
{},
|
||||
'test-session'
|
||||
);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith('http://localhost:8080/extensions/add', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Secret-Key': 'secret-key',
|
||||
},
|
||||
body: JSON.stringify({ ...mockExtensionConfig, session_id: 'test-session' }),
|
||||
});
|
||||
|
||||
expect(mockToastService.loading).toHaveBeenCalledWith({
|
||||
title: 'test-extension',
|
||||
msg: 'Activating test-extension extension...',
|
||||
});
|
||||
|
||||
expect(mockToastService.success).toHaveBeenCalledWith({
|
||||
title: 'test-extension',
|
||||
msg: 'Successfully activated extension',
|
||||
});
|
||||
|
||||
expect(response).toBe(mockResponse);
|
||||
});
|
||||
|
||||
it('should make successful API call for removing extension', async () => {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
text: vi.fn().mockResolvedValue('{"error": false}'),
|
||||
};
|
||||
mockFetch.mockResolvedValue(mockResponse);
|
||||
|
||||
const response = await extensionApiCall(
|
||||
'/extensions/remove',
|
||||
'test-extension',
|
||||
{},
|
||||
'test-session'
|
||||
);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith('http://localhost:8080/extensions/remove', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Secret-Key': 'secret-key',
|
||||
},
|
||||
body: JSON.stringify({ name: 'test-extension', session_id: 'test-session' }),
|
||||
});
|
||||
|
||||
expect(mockToastService.loading).not.toHaveBeenCalled(); // No loading toast for removal
|
||||
expect(mockToastService.success).toHaveBeenCalledWith({
|
||||
title: 'test-extension',
|
||||
msg: 'Successfully deactivated extension',
|
||||
});
|
||||
|
||||
expect(response).toBe(mockResponse);
|
||||
});
|
||||
|
||||
it('should handle HTTP error responses', async () => {
|
||||
const mockResponse = {
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
};
|
||||
mockFetch.mockResolvedValue(mockResponse);
|
||||
|
||||
await expect(
|
||||
extensionApiCall('/extensions/add', mockExtensionConfig, {}, 'test-session')
|
||||
).rejects.toThrow('Server returned 500: Internal Server Error');
|
||||
|
||||
expect(mockToastService.error).toHaveBeenCalledWith({
|
||||
title: 'test-extension',
|
||||
msg: 'Failed to add test-extension extension: Server returned 500: Internal Server Error',
|
||||
traceback: 'Server returned 500: Internal Server Error',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle 428 error specially', async () => {
|
||||
const mockResponse = {
|
||||
ok: false,
|
||||
status: 428,
|
||||
statusText: 'Precondition Required',
|
||||
};
|
||||
mockFetch.mockResolvedValue(mockResponse);
|
||||
|
||||
await expect(
|
||||
extensionApiCall('/extensions/add', mockExtensionConfig, {}, 'test-session')
|
||||
).rejects.toThrow('Agent is not initialized. Please initialize the agent first.');
|
||||
|
||||
expect(mockToastService.error).toHaveBeenCalledWith({
|
||||
title: 'test-extension',
|
||||
msg: 'Failed to add extension. Goose Agent was still starting up. Please try again.',
|
||||
traceback: 'Server returned 428: Precondition Required',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle API error responses', async () => {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
text: vi.fn().mockResolvedValue('{"error": true, "message": "Extension not found"}'),
|
||||
};
|
||||
mockFetch.mockResolvedValue(mockResponse);
|
||||
|
||||
await expect(
|
||||
extensionApiCall('/extensions/remove', 'test-extension', {}, 'test-session')
|
||||
).rejects.toThrow('Error deactivating extension: Extension not found');
|
||||
|
||||
expect(mockToastService.error).toHaveBeenCalledWith({
|
||||
title: 'test-extension',
|
||||
msg: 'Error deactivating extension: Extension not found',
|
||||
traceback: 'Error deactivating extension: Extension not found',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle JSON parse errors', async () => {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
text: vi.fn().mockResolvedValue('invalid json'),
|
||||
};
|
||||
mockFetch.mockResolvedValue(mockResponse);
|
||||
|
||||
const response = await extensionApiCall(
|
||||
'/extensions/add',
|
||||
mockExtensionConfig,
|
||||
{},
|
||||
'test-session'
|
||||
);
|
||||
|
||||
expect(mockToastService.success).toHaveBeenCalledWith({
|
||||
title: 'test-extension',
|
||||
msg: 'Successfully activated extension',
|
||||
});
|
||||
|
||||
expect(response).toBe(mockResponse);
|
||||
});
|
||||
|
||||
it('should handle network errors', async () => {
|
||||
const networkError = new Error('Network error');
|
||||
mockFetch.mockRejectedValue(networkError);
|
||||
|
||||
await expect(
|
||||
extensionApiCall('/extensions/add', mockExtensionConfig, {}, 'test-session')
|
||||
).rejects.toThrow('Network error');
|
||||
|
||||
expect(mockToastService.error).toHaveBeenCalledWith({
|
||||
title: 'test-extension',
|
||||
msg: 'Network error',
|
||||
traceback: 'Network error',
|
||||
});
|
||||
});
|
||||
|
||||
it('should configure toast service with options', async () => {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
text: vi.fn().mockResolvedValue('{"error": false}'),
|
||||
};
|
||||
mockFetch.mockResolvedValue(mockResponse);
|
||||
|
||||
await extensionApiCall(
|
||||
'/extensions/add',
|
||||
mockExtensionConfig,
|
||||
{ silent: true },
|
||||
'test-session'
|
||||
);
|
||||
|
||||
expect(mockToastService.configure).toHaveBeenCalledWith({ silent: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('addToAgent', () => {
|
||||
const mockExtensionConfig: ExtensionConfig = {
|
||||
type: 'stdio',
|
||||
name: 'Test Extension',
|
||||
description: 'Test description',
|
||||
cmd: 'python',
|
||||
args: ['script.py'],
|
||||
};
|
||||
|
||||
it('should add stdio extension to agent with shim replacement', async () => {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
text: vi.fn().mockResolvedValue('{"error": false}'),
|
||||
};
|
||||
mockFetch.mockResolvedValue(mockResponse);
|
||||
|
||||
// Mock replaceWithShims
|
||||
const { replaceWithShims } = await import('./utils');
|
||||
vi.mocked(replaceWithShims).mockResolvedValue('/path/to/python');
|
||||
|
||||
await addToAgent(mockExtensionConfig, {}, 'test-session');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith('http://localhost:8080/extensions/add', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Secret-Key': 'secret-key',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...mockExtensionConfig,
|
||||
name: 'testextension',
|
||||
cmd: '/path/to/python',
|
||||
session_id: 'test-session',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle 428 error with enhanced message', async () => {
|
||||
const mockResponse = {
|
||||
ok: false,
|
||||
status: 428,
|
||||
statusText: 'Precondition Required',
|
||||
};
|
||||
mockFetch.mockResolvedValue(mockResponse);
|
||||
|
||||
await expect(addToAgent(mockExtensionConfig, {}, 'test-session')).rejects.toThrow(
|
||||
'Agent is not initialized. Please initialize the agent first.'
|
||||
);
|
||||
});
|
||||
|
||||
it('should add non-stdio extension without shim replacement', async () => {
|
||||
const sseConfig: ExtensionConfig = {
|
||||
type: 'sse',
|
||||
name: 'SSE Extension',
|
||||
description: 'Test description',
|
||||
uri: 'http://localhost:8080/events',
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
text: vi.fn().mockResolvedValue('{"error": false}'),
|
||||
};
|
||||
mockFetch.mockResolvedValue(mockResponse);
|
||||
|
||||
await addToAgent(sseConfig, {}, 'test-session');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith('http://localhost:8080/extensions/add', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Secret-Key': 'secret-key',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...sseConfig,
|
||||
name: 'sseextension',
|
||||
session_id: 'test-session',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('should not mutate the original extension config', async () => {
|
||||
const originalConfig: ExtensionConfig = {
|
||||
type: 'stdio',
|
||||
name: 'Extension Manager',
|
||||
description: 'Test description',
|
||||
cmd: 'python',
|
||||
args: ['script.py'],
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
text: vi.fn().mockResolvedValue('{"error": false}'),
|
||||
};
|
||||
mockFetch.mockResolvedValue(mockResponse);
|
||||
|
||||
const { replaceWithShims } = await import('./utils');
|
||||
vi.mocked(replaceWithShims).mockResolvedValue('/path/to/shim');
|
||||
|
||||
await addToAgent(originalConfig, {}, 'test-session');
|
||||
|
||||
// Verify the original config was not mutated
|
||||
expect(originalConfig.name).toBe('Extension Manager');
|
||||
expect(originalConfig.cmd).toBe('python');
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeFromAgent', () => {
|
||||
it('should remove extension from agent', async () => {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
text: vi.fn().mockResolvedValue('{"error": false}'),
|
||||
};
|
||||
mockFetch.mockResolvedValue(mockResponse);
|
||||
|
||||
await removeFromAgent('Test Extension', {}, 'test-session');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith('http://localhost:8080/extensions/remove', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Secret-Key': 'secret-key',
|
||||
},
|
||||
body: JSON.stringify({ name: 'testextension', session_id: 'test-session' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle removal errors', async () => {
|
||||
const mockResponse = {
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
};
|
||||
mockFetch.mockResolvedValue(mockResponse);
|
||||
|
||||
await expect(removeFromAgent('Test Extension', {}, 'test-session')).rejects.toThrow();
|
||||
|
||||
expect(mockToastService.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,200 +1,82 @@
|
||||
import { ExtensionConfig } from '../../../api/types.gen';
|
||||
import { getApiUrl } from '../../../config';
|
||||
import { toastService, ToastServiceOptions } from '../../../toasts';
|
||||
import { replaceWithShims } from './utils';
|
||||
import { toastService } from '../../../toasts';
|
||||
import { agentAddExtension, ExtensionConfig, agentRemoveExtension } from '../../../api';
|
||||
import { errorMessage } from '../../../utils/conversionUtils';
|
||||
|
||||
interface ApiResponse {
|
||||
error?: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes an API call to the extension endpoints
|
||||
*/
|
||||
export async function extensionApiCall(
|
||||
endpoint: string,
|
||||
payload: ExtensionConfig | string,
|
||||
options: ToastServiceOptions & { isDelete?: boolean } = {},
|
||||
sessionId: string
|
||||
): Promise<Response> {
|
||||
// Configure toast notifications
|
||||
toastService.configure(options);
|
||||
|
||||
// Determine if we're activating, deactivating, or removing an extension
|
||||
const isActivating = endpoint == '/extensions/add';
|
||||
const isRemoving = options.isDelete === true;
|
||||
|
||||
const action = {
|
||||
type: isActivating ? 'activating' : isRemoving ? 'removing' : 'deactivating',
|
||||
verb: isActivating ? 'Activating' : isRemoving ? 'Removing' : 'Deactivating',
|
||||
pastTense: isActivating ? 'activated' : isRemoving ? 'removed' : 'deactivated',
|
||||
presentTense: isActivating ? 'activate' : isRemoving ? 'remove' : 'deactivate',
|
||||
};
|
||||
|
||||
// for adding the payload is an extensionConfig, for removing payload is just the name
|
||||
const extensionName = isActivating ? (payload as ExtensionConfig).name : (payload as string);
|
||||
let toastId;
|
||||
|
||||
// Step 1: Show loading toast (only for activation of stdio)
|
||||
if (isActivating && typeof payload === 'object' && payload.type === 'stdio') {
|
||||
toastId = toastService.loading({
|
||||
title: extensionName,
|
||||
msg: `${action.verb} ${extensionName} extension...`,
|
||||
});
|
||||
}
|
||||
export async function addToAgent(
|
||||
extensionConfig: ExtensionConfig,
|
||||
sessionId: string,
|
||||
showToast: boolean
|
||||
) {
|
||||
const extensionName = extensionConfig.name;
|
||||
let toastId = showToast
|
||||
? toastService.loading({
|
||||
title: extensionName,
|
||||
msg: `adding ${extensionName} extension...`,
|
||||
})
|
||||
: 0;
|
||||
|
||||
try {
|
||||
// Build the request body
|
||||
let requestBody: ExtensionConfig | { name: string; session_id: string };
|
||||
if (typeof payload === 'object') {
|
||||
// For adding extensions (ExtensionConfig)
|
||||
requestBody = { ...payload, session_id: sessionId };
|
||||
} else {
|
||||
// For removing extensions (just the name string)
|
||||
requestBody = { name: payload, session_id: sessionId };
|
||||
}
|
||||
|
||||
// Step 2: Make the API call
|
||||
const response = await fetch(getApiUrl(endpoint), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Secret-Key': await window.electron.getSecretKey(),
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
await agentAddExtension({
|
||||
body: { session_id: sessionId, config: extensionConfig },
|
||||
throwOnError: true,
|
||||
});
|
||||
|
||||
// Step 3: Handle non-successful responses
|
||||
if (!response.ok) {
|
||||
return handleErrorResponse(response, extensionName, action, toastId);
|
||||
}
|
||||
|
||||
// Step 4: Parse response data
|
||||
const data = await parseResponseData(response);
|
||||
|
||||
// Step 5: Check for errors in the response data
|
||||
if (data.error) {
|
||||
const errorMessage = `Error ${action.type} extension: ${data.message || 'Unknown error'}`;
|
||||
if (showToast) {
|
||||
toastService.dismiss(toastId);
|
||||
// Rely on the global error catch to show the copyable error toast here
|
||||
throw new Error(errorMessage);
|
||||
toastService.success({
|
||||
title: extensionName,
|
||||
msg: `Successfully added extension`,
|
||||
});
|
||||
}
|
||||
|
||||
// Step 6: Success - dismiss loading toast and return
|
||||
toastService.dismiss(toastId);
|
||||
toastService.success({
|
||||
title: extensionName,
|
||||
msg: `Successfully ${action.pastTense} extension`,
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
// Final catch-all error handler
|
||||
toastService.dismiss(toastId);
|
||||
if (showToast) {
|
||||
toastService.dismiss(toastId);
|
||||
}
|
||||
const errMsg = errorMessage(error);
|
||||
const msg = errMsg.length < 70 ? errMsg : `Failed to add extension`;
|
||||
toastService.error({
|
||||
title: extensionName,
|
||||
msg: msg,
|
||||
traceback: errMsg,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeFromAgent(
|
||||
extensionName: string,
|
||||
sessionId: string,
|
||||
showToast: boolean
|
||||
) {
|
||||
let toastId = showToast
|
||||
? toastService.loading({
|
||||
title: extensionName,
|
||||
msg: `Removing ${extensionName} extension...`,
|
||||
})
|
||||
: 0;
|
||||
|
||||
try {
|
||||
await agentRemoveExtension({
|
||||
body: { session_id: sessionId, name: extensionName },
|
||||
throwOnError: true,
|
||||
});
|
||||
if (showToast) {
|
||||
toastService.dismiss(toastId);
|
||||
toastService.success({
|
||||
title: extensionName,
|
||||
msg: `Successfully removed extension`,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (showToast) {
|
||||
toastService.dismiss(toastId);
|
||||
}
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
const msg =
|
||||
errorMessage.length < 70 ? errorMessage : `Failed to ${action.presentTense} extension`;
|
||||
const msg = errorMessage.length < 70 ? errorMessage : `Failed to remove extension`;
|
||||
toastService.error({
|
||||
title: extensionName,
|
||||
msg: msg,
|
||||
traceback: errorMessage,
|
||||
});
|
||||
console.error(`Error in extensionApiCall for ${extensionName}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions to separate concerns
|
||||
|
||||
// Handles HTTP error responses
|
||||
function handleErrorResponse(
|
||||
response: Response,
|
||||
extensionName: string,
|
||||
action: { type: string; verb: string },
|
||||
toastId: string | number | undefined
|
||||
): never {
|
||||
const errorMsg = `Server returned ${response.status}: ${response.statusText}`;
|
||||
console.error(errorMsg);
|
||||
|
||||
// Special case: Agent not initialized (status 428)
|
||||
if (response.status === 428 && action.type === 'activating') {
|
||||
toastService.dismiss(toastId);
|
||||
toastService.error({
|
||||
title: extensionName,
|
||||
msg: 'Failed to add extension. Goose Agent was still starting up. Please try again.',
|
||||
traceback: errorMsg,
|
||||
});
|
||||
throw new Error('Agent is not initialized. Please initialize the agent first.');
|
||||
}
|
||||
|
||||
// General error case
|
||||
const msg = `Failed to ${action.type === 'activating' ? 'add' : action.type === 'removing' ? 'remove' : 'deactivate'} ${extensionName} extension: ${errorMsg}`;
|
||||
toastService.dismiss(toastId);
|
||||
toastService.error({
|
||||
title: extensionName,
|
||||
msg: msg,
|
||||
traceback: errorMsg,
|
||||
});
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
// Safely parses JSON response
|
||||
async function parseResponseData(response: Response): Promise<ApiResponse> {
|
||||
try {
|
||||
const text = await response.text();
|
||||
return text ? JSON.parse(text) : { error: false };
|
||||
} catch (parseError) {
|
||||
console.warn('Could not parse response as JSON, assuming success', parseError);
|
||||
return { error: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an extension to the agent
|
||||
*/
|
||||
export async function addToAgent(
|
||||
extension: ExtensionConfig,
|
||||
options: ToastServiceOptions = {},
|
||||
sessionId: string
|
||||
): Promise<Response> {
|
||||
// Create a copy to avoid mutating the original extension object
|
||||
const extensionCopy: ExtensionConfig = { ...extension };
|
||||
|
||||
try {
|
||||
if (extensionCopy.type === 'stdio') {
|
||||
extensionCopy.cmd = await replaceWithShims(extensionCopy.cmd);
|
||||
}
|
||||
|
||||
extensionCopy.name = sanitizeName(extensionCopy.name);
|
||||
|
||||
return await extensionApiCall('/extensions/add', extensionCopy, options, sessionId);
|
||||
} catch (error) {
|
||||
// Check if this is a 428 error and make the message more descriptive
|
||||
if (error instanceof Error && error.message && error.message.includes('428')) {
|
||||
const enhancedError = new Error(
|
||||
'Failed to add extension. Goose Agent was still starting up. Please try again.'
|
||||
);
|
||||
console.error(
|
||||
`Failed to add extension ${extensionCopy.name} to agent: ${enhancedError.message}`
|
||||
);
|
||||
throw enhancedError;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an extension from the agent
|
||||
*/
|
||||
export async function removeFromAgent(
|
||||
name: string,
|
||||
options: ToastServiceOptions & { isDelete?: boolean } = {},
|
||||
sessionId: string
|
||||
): Promise<Response> {
|
||||
try {
|
||||
return await extensionApiCall('/extensions/remove', sanitizeName(name), options, sessionId);
|
||||
} catch (error) {
|
||||
const action = options.isDelete ? 'remove' : 'deactivate';
|
||||
console.error(`Failed to ${action} extension ${name} from agent:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
activateExtension,
|
||||
addToAgentOnStartup,
|
||||
updateExtension,
|
||||
toggleExtension,
|
||||
deleteExtension,
|
||||
} from './extension-manager';
|
||||
import { addToAgentOnStartup, updateExtension, toggleExtension } from './extension-manager';
|
||||
import * as agentApi from './agent-api';
|
||||
import * as toasts from '../../../toasts';
|
||||
|
||||
@@ -38,70 +32,9 @@ describe('Extension Manager', () => {
|
||||
mockRemoveFromConfig.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe('activateExtension', () => {
|
||||
it('should successfully activate extension', async () => {
|
||||
mockAddToAgent.mockResolvedValue({} as Response);
|
||||
|
||||
await activateExtension({
|
||||
addToConfig: mockAddToConfig,
|
||||
sessionId: 'test-session',
|
||||
extensionConfig: mockExtensionConfig,
|
||||
});
|
||||
|
||||
expect(mockAddToAgent).toHaveBeenCalledWith(
|
||||
mockExtensionConfig,
|
||||
{ silent: false },
|
||||
'test-session'
|
||||
);
|
||||
expect(mockAddToConfig).toHaveBeenCalledWith('test-extension', mockExtensionConfig, true);
|
||||
});
|
||||
|
||||
it('should add to config as disabled if agent fails', async () => {
|
||||
const agentError = new Error('Agent failed');
|
||||
mockAddToAgent.mockRejectedValue(agentError);
|
||||
|
||||
await expect(
|
||||
activateExtension({
|
||||
addToConfig: mockAddToConfig,
|
||||
sessionId: 'test-session',
|
||||
extensionConfig: mockExtensionConfig,
|
||||
})
|
||||
).rejects.toThrow('Agent failed');
|
||||
|
||||
expect(mockAddToAgent).toHaveBeenCalledWith(
|
||||
mockExtensionConfig,
|
||||
{ silent: false },
|
||||
'test-session'
|
||||
);
|
||||
expect(mockAddToConfig).toHaveBeenCalledWith('test-extension', mockExtensionConfig, false);
|
||||
});
|
||||
|
||||
it('should remove from agent if config fails', async () => {
|
||||
const configError = new Error('Config failed');
|
||||
mockAddToAgent.mockResolvedValue({} as Response);
|
||||
mockAddToConfig.mockRejectedValue(configError);
|
||||
|
||||
await expect(
|
||||
activateExtension({
|
||||
addToConfig: mockAddToConfig,
|
||||
sessionId: 'test-session',
|
||||
extensionConfig: mockExtensionConfig,
|
||||
})
|
||||
).rejects.toThrow('Config failed');
|
||||
|
||||
expect(mockAddToAgent).toHaveBeenCalledWith(
|
||||
mockExtensionConfig,
|
||||
{ silent: false },
|
||||
'test-session'
|
||||
);
|
||||
expect(mockAddToConfig).toHaveBeenCalledWith('test-extension', mockExtensionConfig, true);
|
||||
expect(mockRemoveFromAgent).toHaveBeenCalledWith('test-extension', {}, 'test-session');
|
||||
});
|
||||
});
|
||||
|
||||
describe('addToAgentOnStartup', () => {
|
||||
it('should successfully add extension on startup', async () => {
|
||||
mockAddToAgent.mockResolvedValue({} as Response);
|
||||
mockAddToAgent.mockResolvedValue(undefined);
|
||||
|
||||
await addToAgentOnStartup({
|
||||
addToConfig: mockAddToConfig,
|
||||
@@ -109,29 +42,20 @@ describe('Extension Manager', () => {
|
||||
extensionConfig: mockExtensionConfig,
|
||||
});
|
||||
|
||||
expect(mockAddToAgent).toHaveBeenCalledWith(
|
||||
mockExtensionConfig,
|
||||
{ silent: true },
|
||||
'test-session'
|
||||
);
|
||||
expect(mockAddToAgent).toHaveBeenCalledWith(mockExtensionConfig, 'test-session', true);
|
||||
expect(mockAddToConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should successfully add extension on startup with custom toast options', async () => {
|
||||
mockAddToAgent.mockResolvedValue({} as Response);
|
||||
mockAddToAgent.mockResolvedValue(undefined);
|
||||
|
||||
await addToAgentOnStartup({
|
||||
addToConfig: mockAddToConfig,
|
||||
sessionId: 'test-session',
|
||||
extensionConfig: mockExtensionConfig,
|
||||
toastOptions: { silent: false },
|
||||
});
|
||||
|
||||
expect(mockAddToAgent).toHaveBeenCalledWith(
|
||||
mockExtensionConfig,
|
||||
{ silent: false },
|
||||
'test-session'
|
||||
);
|
||||
expect(mockAddToAgent).toHaveBeenCalledWith(mockExtensionConfig, 'test-session', true);
|
||||
expect(mockAddToConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -140,7 +64,7 @@ describe('Extension Manager', () => {
|
||||
mockAddToAgent
|
||||
.mockRejectedValueOnce(error428)
|
||||
.mockRejectedValueOnce(error428)
|
||||
.mockResolvedValue({} as Response);
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
await addToAgentOnStartup({
|
||||
addToConfig: mockAddToConfig,
|
||||
@@ -174,7 +98,7 @@ describe('Extension Manager', () => {
|
||||
|
||||
describe('updateExtension', () => {
|
||||
it('should update extension without name change', async () => {
|
||||
mockAddToAgent.mockResolvedValue({} as Response);
|
||||
mockAddToAgent.mockResolvedValue(undefined);
|
||||
mockAddToConfig.mockResolvedValue(undefined);
|
||||
mockToastService.success = vi.fn();
|
||||
|
||||
@@ -187,11 +111,6 @@ describe('Extension Manager', () => {
|
||||
originalName: 'test-extension',
|
||||
});
|
||||
|
||||
expect(mockAddToAgent).toHaveBeenCalledWith(
|
||||
{ ...mockExtensionConfig, name: 'test-extension' },
|
||||
{ silent: true },
|
||||
'test-session'
|
||||
);
|
||||
expect(mockAddToConfig).toHaveBeenCalledWith(
|
||||
'test-extension',
|
||||
{ ...mockExtensionConfig, name: 'test-extension' },
|
||||
@@ -204,8 +123,8 @@ describe('Extension Manager', () => {
|
||||
});
|
||||
|
||||
it('should handle name change by removing old and adding new', async () => {
|
||||
mockAddToAgent.mockResolvedValue({} as Response);
|
||||
mockRemoveFromAgent.mockResolvedValue({} as Response);
|
||||
mockAddToAgent.mockResolvedValue(undefined);
|
||||
mockRemoveFromAgent.mockResolvedValue(undefined);
|
||||
mockRemoveFromConfig.mockResolvedValue(undefined);
|
||||
mockAddToConfig.mockResolvedValue(undefined);
|
||||
mockToastService.success = vi.fn();
|
||||
@@ -219,16 +138,11 @@ describe('Extension Manager', () => {
|
||||
originalName: 'old-extension',
|
||||
});
|
||||
|
||||
expect(mockRemoveFromAgent).toHaveBeenCalledWith(
|
||||
'old-extension',
|
||||
{ silent: true },
|
||||
'test-session'
|
||||
);
|
||||
expect(mockRemoveFromConfig).toHaveBeenCalledWith('old-extension');
|
||||
expect(mockAddToAgent).toHaveBeenCalledWith(
|
||||
{ ...mockExtensionConfig, name: 'new-extension' },
|
||||
{ silent: true },
|
||||
'test-session'
|
||||
'test-session',
|
||||
false
|
||||
);
|
||||
expect(mockAddToConfig).toHaveBeenCalledWith(
|
||||
'new-extension',
|
||||
@@ -265,7 +179,7 @@ describe('Extension Manager', () => {
|
||||
|
||||
describe('toggleExtension', () => {
|
||||
it('should toggle extension on successfully', async () => {
|
||||
mockAddToAgent.mockResolvedValue({} as Response);
|
||||
mockAddToAgent.mockResolvedValue(undefined);
|
||||
mockAddToConfig.mockResolvedValue(undefined);
|
||||
|
||||
await toggleExtension({
|
||||
@@ -275,12 +189,12 @@ describe('Extension Manager', () => {
|
||||
sessionId: 'test-session',
|
||||
});
|
||||
|
||||
expect(mockAddToAgent).toHaveBeenCalledWith(mockExtensionConfig, {}, 'test-session');
|
||||
expect(mockAddToAgent).toHaveBeenCalledWith(mockExtensionConfig, 'test-session', true);
|
||||
expect(mockAddToConfig).toHaveBeenCalledWith('test-extension', mockExtensionConfig, true);
|
||||
});
|
||||
|
||||
it('should toggle extension off successfully', async () => {
|
||||
mockRemoveFromAgent.mockResolvedValue({} as Response);
|
||||
mockRemoveFromAgent.mockResolvedValue(undefined);
|
||||
mockAddToConfig.mockResolvedValue(undefined);
|
||||
|
||||
await toggleExtension({
|
||||
@@ -290,7 +204,7 @@ describe('Extension Manager', () => {
|
||||
sessionId: 'test-session',
|
||||
});
|
||||
|
||||
expect(mockRemoveFromAgent).toHaveBeenCalledWith('test-extension', {}, 'test-session');
|
||||
expect(mockRemoveFromAgent).toHaveBeenCalledWith('test-extension', 'test-session', true);
|
||||
expect(mockAddToConfig).toHaveBeenCalledWith('test-extension', mockExtensionConfig, false);
|
||||
});
|
||||
|
||||
@@ -308,14 +222,14 @@ describe('Extension Manager', () => {
|
||||
})
|
||||
).rejects.toThrow('Agent failed');
|
||||
|
||||
expect(mockAddToAgent).toHaveBeenCalledWith(mockExtensionConfig, {}, 'test-session');
|
||||
expect(mockAddToAgent).toHaveBeenCalledWith(mockExtensionConfig, 'test-session', true);
|
||||
// addToConfig is called during the rollback (toggleOff)
|
||||
expect(mockAddToConfig).toHaveBeenCalledWith('test-extension', mockExtensionConfig, false);
|
||||
});
|
||||
|
||||
it('should remove from agent if config update fails when toggling on', async () => {
|
||||
const configError = new Error('Config failed');
|
||||
mockAddToAgent.mockResolvedValue({} as Response);
|
||||
mockAddToAgent.mockResolvedValue(undefined);
|
||||
mockAddToConfig.mockRejectedValue(configError);
|
||||
|
||||
await expect(
|
||||
@@ -327,9 +241,9 @@ describe('Extension Manager', () => {
|
||||
})
|
||||
).rejects.toThrow('Config failed');
|
||||
|
||||
expect(mockAddToAgent).toHaveBeenCalledWith(mockExtensionConfig, {}, 'test-session');
|
||||
expect(mockAddToAgent).toHaveBeenCalledWith(mockExtensionConfig, 'test-session', true);
|
||||
expect(mockAddToConfig).toHaveBeenCalledWith('test-extension', mockExtensionConfig, true);
|
||||
expect(mockRemoveFromAgent).toHaveBeenCalledWith('test-extension', {}, 'test-session');
|
||||
expect(mockRemoveFromAgent).toHaveBeenCalledWith('test-extension', 'test-session', true);
|
||||
});
|
||||
|
||||
it('should update config even if agent removal fails when toggling off', async () => {
|
||||
@@ -346,71 +260,7 @@ describe('Extension Manager', () => {
|
||||
})
|
||||
).rejects.toThrow('Agent removal failed');
|
||||
|
||||
expect(mockRemoveFromAgent).toHaveBeenCalledWith('test-extension', {}, 'test-session');
|
||||
expect(mockAddToConfig).toHaveBeenCalledWith('test-extension', mockExtensionConfig, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteExtension', () => {
|
||||
it('should delete extension successfully', async () => {
|
||||
mockRemoveFromAgent.mockResolvedValue({} as Response);
|
||||
mockRemoveFromConfig.mockResolvedValue(undefined);
|
||||
|
||||
await deleteExtension({
|
||||
name: 'test-extension',
|
||||
removeFromConfig: mockRemoveFromConfig,
|
||||
sessionId: 'test-session',
|
||||
});
|
||||
|
||||
expect(mockRemoveFromAgent).toHaveBeenCalledWith(
|
||||
'test-extension',
|
||||
{ isDelete: true },
|
||||
'test-session'
|
||||
);
|
||||
expect(mockRemoveFromConfig).toHaveBeenCalledWith('test-extension');
|
||||
});
|
||||
|
||||
it('should remove from config even if agent removal fails', async () => {
|
||||
const agentError = new Error('Agent removal failed');
|
||||
mockRemoveFromAgent.mockRejectedValue(agentError);
|
||||
mockRemoveFromConfig.mockResolvedValue(undefined);
|
||||
|
||||
await expect(
|
||||
deleteExtension({
|
||||
name: 'test-extension',
|
||||
removeFromConfig: mockRemoveFromConfig,
|
||||
sessionId: 'test-session',
|
||||
})
|
||||
).rejects.toThrow('Agent removal failed');
|
||||
|
||||
expect(mockRemoveFromAgent).toHaveBeenCalledWith(
|
||||
'test-extension',
|
||||
{ isDelete: true },
|
||||
'test-session'
|
||||
);
|
||||
expect(mockRemoveFromConfig).toHaveBeenCalledWith('test-extension');
|
||||
});
|
||||
|
||||
it('should throw config error if both agent and config fail', async () => {
|
||||
const agentError = new Error('Agent removal failed');
|
||||
const configError = new Error('Config removal failed');
|
||||
mockRemoveFromAgent.mockRejectedValue(agentError);
|
||||
mockRemoveFromConfig.mockRejectedValue(configError);
|
||||
|
||||
await expect(
|
||||
deleteExtension({
|
||||
name: 'test-extension',
|
||||
removeFromConfig: mockRemoveFromConfig,
|
||||
sessionId: 'test-session',
|
||||
})
|
||||
).rejects.toThrow('Config removal failed');
|
||||
|
||||
expect(mockRemoveFromAgent).toHaveBeenCalledWith(
|
||||
'test-extension',
|
||||
{ isDelete: true },
|
||||
'test-session'
|
||||
);
|
||||
expect(mockRemoveFromConfig).toHaveBeenCalledWith('test-extension');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,23 +60,20 @@ export async function activateExtension({
|
||||
}: ActivateExtensionProps): Promise<void> {
|
||||
try {
|
||||
// AddToAgent
|
||||
await addToAgent(extensionConfig, { silent: false }, sessionId);
|
||||
await addToAgent(extensionConfig, sessionId, true);
|
||||
} catch (error) {
|
||||
console.error('Failed to add extension to agent:', error);
|
||||
// add to config with enabled = false
|
||||
await addToConfig(extensionConfig.name, extensionConfig, false);
|
||||
// Rethrow the error to inform the caller
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Then add to config
|
||||
try {
|
||||
await addToConfig(extensionConfig.name, extensionConfig, true);
|
||||
} catch (error) {
|
||||
console.error('Failed to add extension to config:', error);
|
||||
// remove from Agent
|
||||
try {
|
||||
await removeFromAgent(extensionConfig.name, {}, sessionId);
|
||||
await removeFromAgent(extensionConfig.name, sessionId, true);
|
||||
} catch (removeError) {
|
||||
console.error('Failed to remove extension from agent after config failure:', removeError);
|
||||
}
|
||||
@@ -94,15 +91,16 @@ interface AddToAgentOnStartupProps {
|
||||
|
||||
/**
|
||||
* Adds an extension to the agent during application startup with retry logic
|
||||
*
|
||||
* TODO(Douwe): Delete this after basecamp lands
|
||||
*/
|
||||
export async function addToAgentOnStartup({
|
||||
addToConfig,
|
||||
extensionConfig,
|
||||
toastOptions = { silent: true },
|
||||
sessionId,
|
||||
}: AddToAgentOnStartupProps): Promise<void> {
|
||||
try {
|
||||
await retryWithBackoff(() => addToAgent(extensionConfig, toastOptions, sessionId), {
|
||||
await retryWithBackoff(() => addToAgent(extensionConfig, sessionId, true), {
|
||||
retries: 3,
|
||||
delayMs: 1000,
|
||||
shouldRetry: (error: ExtensionError) =>
|
||||
@@ -165,7 +163,7 @@ export async function updateExtension({
|
||||
|
||||
// First remove the old extension from agent (using original name)
|
||||
try {
|
||||
await removeFromAgent(originalName!, { silent: true }, sessionId); // Suppress removal toast since we'll show update toast
|
||||
await removeFromAgent(originalName!, sessionId, false);
|
||||
} catch (error) {
|
||||
console.error('Failed to remove old extension from agent during rename:', error);
|
||||
// Continue with the process even if agent removal fails
|
||||
@@ -188,8 +186,7 @@ export async function updateExtension({
|
||||
// Add new extension with sanitized name
|
||||
if (enabled) {
|
||||
try {
|
||||
// AddToAgent with silent option to avoid duplicate toasts
|
||||
await addToAgent(sanitizedExtensionConfig, { silent: true }, sessionId);
|
||||
await addToAgent(sanitizedExtensionConfig, sessionId, false);
|
||||
} catch (error) {
|
||||
console.error('[updateExtension]: Failed to add renamed extension to agent:', error);
|
||||
throw error;
|
||||
@@ -218,8 +215,7 @@ export async function updateExtension({
|
||||
|
||||
if (enabled) {
|
||||
try {
|
||||
// AddToAgent with silent option to avoid duplicate toasts
|
||||
await addToAgent(sanitizedExtensionConfig, { silent: true }, sessionId);
|
||||
await addToAgent(sanitizedExtensionConfig, sessionId, false);
|
||||
} catch (error) {
|
||||
console.error('[updateExtension]: Failed to add extension to agent during update:', error);
|
||||
// Failed to add to agent -- show that error to user and do not update the config file
|
||||
@@ -278,13 +274,7 @@ export async function toggleExtension({
|
||||
if (toggle == 'toggleOn') {
|
||||
try {
|
||||
// add to agent with toast options
|
||||
await addToAgent(
|
||||
extensionConfig,
|
||||
{
|
||||
...toastOptions,
|
||||
},
|
||||
sessionId
|
||||
);
|
||||
await addToAgent(extensionConfig, sessionId, !toastOptions?.silent);
|
||||
} catch (error) {
|
||||
console.error('Error adding extension to agent. Will try to toggle back off.');
|
||||
try {
|
||||
@@ -308,7 +298,7 @@ export async function toggleExtension({
|
||||
console.error('Failed to update config after enabling extension:', error);
|
||||
// remove from agent
|
||||
try {
|
||||
await removeFromAgent(extensionConfig.name, toastOptions, sessionId);
|
||||
await removeFromAgent(extensionConfig.name, sessionId, !toastOptions?.silent);
|
||||
} catch (removeError) {
|
||||
console.error('Failed to remove extension from agent after config failure:', removeError);
|
||||
}
|
||||
@@ -318,7 +308,7 @@ export async function toggleExtension({
|
||||
// enabled to disabled
|
||||
let agentRemoveError = null;
|
||||
try {
|
||||
await removeFromAgent(extensionConfig.name, toastOptions, sessionId);
|
||||
await removeFromAgent(extensionConfig.name, sessionId, !toastOptions?.silent);
|
||||
} catch (error) {
|
||||
// note there was an error, but attempt to remove from config anyway
|
||||
console.error('Error removing extension from agent', extensionConfig.name, error);
|
||||
@@ -353,7 +343,7 @@ export async function deleteExtension({ name, removeFromConfig, sessionId }: Del
|
||||
// remove from agent
|
||||
let agentRemoveError = null;
|
||||
try {
|
||||
await removeFromAgent(name, { isDelete: true }, sessionId);
|
||||
await removeFromAgent(name, sessionId, true);
|
||||
} catch (error) {
|
||||
console.error('Failed to remove extension from agent during deletion:', error);
|
||||
agentRemoveError = error;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { FixedExtensionEntry } from '../../../ConfigContext';
|
||||
import { ExtensionConfig } from '../../../../api/types.gen';
|
||||
import ExtensionItem from './ExtensionItem';
|
||||
import builtInExtensionsData from '../../../../built-in-extensions.json';
|
||||
import { combineCmdAndArgs, removeShims } from '../utils';
|
||||
import { combineCmdAndArgs } from '../utils';
|
||||
import { ExtensionConfig } from '../../../../api';
|
||||
import { FixedExtensionEntry } from '../../../ConfigContext';
|
||||
|
||||
interface ExtensionListProps {
|
||||
extensions: FixedExtensionEntry[];
|
||||
@@ -132,7 +132,7 @@ export function getSubtitle(config: ExtensionConfig) {
|
||||
default:
|
||||
return {
|
||||
description: config.description || null,
|
||||
command: 'cmd' in config ? combineCmdAndArgs(removeShims(config.cmd), config.args) : null,
|
||||
command: 'cmd' in config ? combineCmdAndArgs(config.cmd, config.args) : null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,6 @@ import {
|
||||
createExtensionConfig,
|
||||
splitCmdAndArgs,
|
||||
combineCmdAndArgs,
|
||||
replaceWithShims,
|
||||
removeShims,
|
||||
extractCommand,
|
||||
extractExtensionName,
|
||||
DEFAULT_EXTENSION_TIMEOUT,
|
||||
@@ -342,54 +340,6 @@ describe('Extension Utils', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('replaceWithShims', () => {
|
||||
beforeEach(() => {
|
||||
mockElectron.getBinaryPath.mockImplementation((binary: string) => {
|
||||
const paths: Record<string, string> = {
|
||||
goosed: '/path/to/goosed',
|
||||
jbang: '/path/to/jbang',
|
||||
npx: '/path/to/npx',
|
||||
uvx: '/path/to/uvx',
|
||||
};
|
||||
return Promise.resolve(paths[binary] || binary);
|
||||
});
|
||||
});
|
||||
|
||||
it('should replace known commands with shim paths', async () => {
|
||||
expect(await replaceWithShims('goosed')).toBe('/path/to/goosed');
|
||||
expect(await replaceWithShims('jbang')).toBe('/path/to/jbang');
|
||||
expect(await replaceWithShims('npx')).toBe('/path/to/npx');
|
||||
expect(await replaceWithShims('uvx')).toBe('/path/to/uvx');
|
||||
});
|
||||
|
||||
it('should leave unknown commands unchanged', async () => {
|
||||
expect(await replaceWithShims('python')).toBe('python');
|
||||
expect(await replaceWithShims('node')).toBe('node');
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeShims', () => {
|
||||
it('should remove shim paths and return command name', () => {
|
||||
expect(removeShims('/path/to/goosed')).toBe('goosed');
|
||||
expect(removeShims('/usr/local/bin/jbang')).toBe('jbang');
|
||||
expect(removeShims('/Applications/Docker.app/Contents/Resources/bin/docker')).toBe('docker');
|
||||
expect(removeShims('/path/to/npx.cmd')).toBe('npx.cmd');
|
||||
});
|
||||
|
||||
it('should handle paths with trailing slashes', () => {
|
||||
// The removeShims function only works if the path ends with the shim pattern
|
||||
// Trailing slashes prevent the pattern from matching
|
||||
expect(removeShims('/path/to/goosed/')).toBe('/path/to/goosed/');
|
||||
expect(removeShims('/path/to/uvx//')).toBe('/path/to/uvx//');
|
||||
});
|
||||
|
||||
it('should leave non-shim commands unchanged', () => {
|
||||
expect(removeShims('python')).toBe('python');
|
||||
expect(removeShims('node')).toBe('node');
|
||||
expect(removeShims('/usr/bin/python3')).toBe('/usr/bin/python3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractCommand', () => {
|
||||
it('should extract command from extension link', () => {
|
||||
const link = 'goose://extension/add?name=Test&cmd=python&arg=script.py&arg=--flag';
|
||||
|
||||
@@ -190,41 +190,6 @@ export function combineCmdAndArgs(cmd: string, args: string[]): string {
|
||||
return [cmd, ...args].join(' ');
|
||||
}
|
||||
|
||||
export async function replaceWithShims(cmd: string) {
|
||||
const binaryPathMap: Record<string, string> = {
|
||||
goosed: await window.electron.getBinaryPath('goosed'),
|
||||
jbang: await window.electron.getBinaryPath('jbang'),
|
||||
npx: await window.electron.getBinaryPath('npx'),
|
||||
uvx: await window.electron.getBinaryPath('uvx'),
|
||||
};
|
||||
|
||||
if (binaryPathMap[cmd]) {
|
||||
console.log('--------> Replacing command with shim ------>', cmd, binaryPathMap[cmd]);
|
||||
cmd = binaryPathMap[cmd];
|
||||
}
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
export function removeShims(cmd: string) {
|
||||
// Only remove shims if the path matches our known shim patterns
|
||||
const shimPatterns = [/cu$/, /goosed$/, /docker$/, /jbang$/, /npx$/, /uvx$/, /npx.cmd$/];
|
||||
|
||||
// Check if the command matches any shim pattern
|
||||
const isShim = shimPatterns.some((pattern) => pattern.test(cmd));
|
||||
|
||||
if (isShim) {
|
||||
const segments = cmd.split('/');
|
||||
// Filter out any empty segments (which can happen with trailing slashes)
|
||||
const nonEmptySegments = segments.filter((segment) => segment.length > 0);
|
||||
// Return the last segment or empty string if there are no segments
|
||||
return nonEmptySegments.length > 0 ? nonEmptySegments[nonEmptySegments.length - 1] : '';
|
||||
}
|
||||
|
||||
// If it's not a shim, return the original command
|
||||
return cmd;
|
||||
}
|
||||
|
||||
export function extractCommand(link: string): string {
|
||||
const url = new URL(link);
|
||||
const cmd = url.searchParams.get('cmd') || 'Unknown Command';
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { useConfig } from '../components/ConfigContext';
|
||||
import { getApiUrl } from '../config';
|
||||
import { useDictationSettings } from './useDictationSettings';
|
||||
import { safeJsonParse } from '../utils/jsonUtils';
|
||||
import { safeJsonParse } from '../utils/conversionUtils';
|
||||
|
||||
interface UseWhisperOptions {
|
||||
onTranscription?: (text: string) => void;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { safeJsonParse } from './utils/jsonUtils';
|
||||
import { safeJsonParse } from './utils/conversionUtils';
|
||||
import { Message } from './api';
|
||||
|
||||
export interface SharedSessionDetails {
|
||||
|
||||
@@ -11,3 +11,13 @@ export async function safeJsonParse<T>(
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function errorMessage(err: Error | unknown, default_value?: string) {
|
||||
if (err instanceof Error) {
|
||||
return err.message;
|
||||
} else if (typeof err === 'object' && err !== null && 'message' in err) {
|
||||
return String(err.message);
|
||||
} else {
|
||||
return default_value || String(err);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// Import the proper type from ConfigContext
|
||||
import { getApiUrl } from '../config';
|
||||
import { safeJsonParse } from './jsonUtils';
|
||||
import { safeJsonParse } from './conversionUtils';
|
||||
|
||||
export interface ModelCostInfo {
|
||||
input_token_cost: number; // Cost per token for input (in USD)
|
||||
|
||||
@@ -4,7 +4,7 @@ import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import log from './logger';
|
||||
import { safeJsonParse } from './jsonUtils';
|
||||
import { safeJsonParse } from './conversionUtils';
|
||||
|
||||
interface GitHubRelease {
|
||||
tag_name: string;
|
||||
|
||||
Reference in New Issue
Block a user