diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index fab35caadd12..8151b2192919 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -347,6 +347,8 @@ derive_utoipa!(Icon as IconSchema); super::routes::agent::resume_agent, super::routes::agent::get_tools, super::routes::agent::update_from_session, + super::routes::agent::agent_add_extension, + super::routes::agent::agent_remove_extension, super::routes::agent::update_agent_provider, super::routes::agent::update_router_tool_selector, super::routes::reply::confirm_permission, @@ -484,6 +486,8 @@ derive_utoipa!(Icon as IconSchema); super::routes::agent::StartAgentRequest, super::routes::agent::ResumeAgentRequest, super::routes::agent::UpdateFromSessionRequest, + super::routes::agent::AddExtensionRequest, + super::routes::agent::RemoveExtensionRequest, super::routes::setup::SetupResponse, )) )] diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs index fb850895e97b..b16cea136058 100644 --- a/crates/goose-server/src/routes/agent.rs +++ b/crates/goose-server/src/routes/agent.rs @@ -11,6 +11,7 @@ use axum::{ }; use goose::config::PermissionManager; +use goose::agents::ExtensionConfig; use goose::config::Config; use goose::model::ModelConfig; use goose::prompt_template::render_global_file; @@ -70,6 +71,18 @@ pub struct ResumeAgentRequest { load_model_and_extensions: bool, } +#[derive(Deserialize, utoipa::ToSchema)] +pub struct AddExtensionRequest { + session_id: String, + config: ExtensionConfig, +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct RemoveExtensionRequest { + name: String, + session_id: String, +} + #[utoipa::path( post, path = "/agent/start", @@ -451,6 +464,88 @@ async fn update_router_tool_selector( )) } +#[utoipa::path( + post, + path = "/agent/add_extension", + request_body = AddExtensionRequest, + responses( + (status = 200, description = "Extension added", body = String), + (status = 401, description = "Unauthorized - invalid secret key"), + (status = 424, description = "Agent not initialized"), + (status = 500, description = "Internal server error") + ) +)] +async fn agent_add_extension( + State(state): State>, + Json(request): Json, +) -> Result { + // If this is a Stdio extension that uses npx, check for Node.js installation + #[cfg(target_os = "windows")] + if let ExtensionConfig::Stdio { cmd, .. } = &request.config { + if cmd.ends_with("npx.cmd") || cmd.ends_with("npx") { + let node_exists = std::path::Path::new(r"C:\Program Files\nodejs\node.exe").exists() + || std::path::Path::new(r"C:\Program Files (x86)\nodejs\node.exe").exists(); + + if !node_exists { + let cmd_path = std::path::Path::new(&cmd); + let script_dir = cmd_path.parent().ok_or(StatusCode::INTERNAL_SERVER_ERROR)?; + let install_script = script_dir.join("install-node.cmd"); + + if install_script.exists() { + eprintln!("Installing Node.js..."); + let output = std::process::Command::new(&install_script) + .arg("https://nodejs.org/dist/v23.10.0/node-v23.10.0-x64.msi") + .output() + .map_err(|e| { + eprintln!("Failed to run Node.js installer: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + if !output.status.success() { + return Err(ErrorResponse::internal(format!( + "Failed to install Node.js: {}", + String::from_utf8_lossy(&output.stderr) + ))); + } + eprintln!("Node.js installation completed"); + } else { + return Err(ErrorResponse::internal(format!( + "Node.js not detected and no installer script not found at: {}", + install_script.display() + ))); + } + } + } + } + + let agent = state.get_agent(request.session_id).await?; + agent + .add_extension(request.config) + .await + .map_err(|e| ErrorResponse::internal(format!("Failed to add extension: {}", e)))?; + Ok(StatusCode::OK) +} + +#[utoipa::path( + post, + path = "/agent/remove_extension", + request_body = RemoveExtensionRequest, + responses( + (status = 200, description = "Extension removed", body = String), + (status = 401, description = "Unauthorized - invalid secret key"), + (status = 424, description = "Agent not initialized"), + (status = 500, description = "Internal server error") + ) +)] +async fn agent_remove_extension( + State(state): State>, + Json(request): Json, +) -> Result { + let agent = state.get_agent(request.session_id).await?; + agent.remove_extension(&request.name).await?; + Ok(StatusCode::OK) +} + pub fn routes(state: Arc) -> Router { Router::new() .route("/agent/start", post(start_agent)) @@ -462,5 +557,7 @@ pub fn routes(state: Arc) -> Router { post(update_router_tool_selector), ) .route("/agent/update_from_session", post(update_from_session)) + .route("/agent/add_extension", post(agent_add_extension)) + .route("/agent/remove_extension", post(agent_remove_extension)) .with_state(state) } diff --git a/crates/goose-server/src/routes/errors.rs b/crates/goose-server/src/routes/errors.rs index 894f13e426e9..c265482d055e 100644 --- a/crates/goose-server/src/routes/errors.rs +++ b/crates/goose-server/src/routes/errors.rs @@ -13,6 +13,15 @@ pub struct ErrorResponse { pub status: StatusCode, } +impl ErrorResponse { + pub(crate) fn internal(message: impl Into) -> Self { + Self { + message: message.into(), + status: StatusCode::INTERNAL_SERVER_ERROR, + } + } +} + impl IntoResponse for ErrorResponse { fn into_response(self) -> Response { let body = Json(serde_json::json!({ @@ -22,3 +31,9 @@ impl IntoResponse for ErrorResponse { (self.status, body).into_response() } } + +impl From for ErrorResponse { + fn from(err: anyhow::Error) -> Self { + Self::internal(err.to_string()) + } +} diff --git a/crates/goose-server/src/routes/extension.rs b/crates/goose-server/src/routes/extension.rs deleted file mode 100644 index 5fec1a3599e7..000000000000 --- a/crates/goose-server/src/routes/extension.rs +++ /dev/null @@ -1,139 +0,0 @@ -use std::sync::Arc; - -use crate::state::AppState; -use axum::{extract::State, routing::post, Json, Router}; -use goose::agents::ExtensionConfig; -use http::StatusCode; -use serde::{Deserialize, Serialize}; -use tracing; - -#[derive(Serialize)] -struct ExtensionResponse { - error: bool, - message: Option, -} - -#[derive(Deserialize)] -struct AddExtensionRequest { - session_id: String, - #[serde(flatten)] - config: ExtensionConfig, -} - -async fn add_extension( - State(state): State>, - Json(request): Json, -) -> Result, StatusCode> { - // Log the request for debugging - tracing::info!( - "Received extension request for session: {}", - request.session_id - ); - - // If this is a Stdio extension that uses npx, check for Node.js installation - #[cfg(target_os = "windows")] - if let ExtensionConfig::Stdio { cmd, .. } = &request.config { - if cmd.ends_with("npx.cmd") || cmd.ends_with("npx") { - // Check if Node.js is installed in standard locations - let node_exists = std::path::Path::new(r"C:\Program Files\nodejs\node.exe").exists() - || std::path::Path::new(r"C:\Program Files (x86)\nodejs\node.exe").exists(); - - if !node_exists { - // Get the directory containing npx.cmd - let cmd_path = std::path::Path::new(&cmd); - let script_dir = cmd_path.parent().ok_or(StatusCode::INTERNAL_SERVER_ERROR)?; - - // Run the Node.js installer script - let install_script = script_dir.join("install-node.cmd"); - - if install_script.exists() { - eprintln!("Installing Node.js..."); - let output = std::process::Command::new(&install_script) - .arg("https://nodejs.org/dist/v23.10.0/node-v23.10.0-x64.msi") - .output() - .map_err(|e| { - eprintln!("Failed to run Node.js installer: {}", e); - StatusCode::INTERNAL_SERVER_ERROR - })?; - - if !output.status.success() { - eprintln!( - "Failed to install Node.js: {}", - String::from_utf8_lossy(&output.stderr) - ); - return Ok(Json(ExtensionResponse { - error: true, - message: Some(format!( - "Failed to install Node.js: {}", - String::from_utf8_lossy(&output.stderr) - )), - })); - } - eprintln!("Node.js installation completed"); - } else { - eprintln!( - "Node.js installer script not found at: {}", - install_script.display() - ); - return Ok(Json(ExtensionResponse { - error: true, - message: Some("Node.js installer script not found".to_string()), - })); - } - } - } - } - - let agent = state.get_agent_for_route(request.session_id).await?; - let response = agent.add_extension(request.config).await; - - // Respond with the result. - match response { - Ok(_) => Ok(Json(ExtensionResponse { - error: false, - message: None, - })), - Err(e) => { - eprintln!("Failed to add extension configuration: {:?}", e); - Ok(Json(ExtensionResponse { - error: true, - message: Some(format!( - "Failed to add extension configuration, error: {:?}", - e - )), - })) - } - } -} - -#[derive(Deserialize)] -struct RemoveExtensionRequest { - name: String, - session_id: String, -} - -/// Handler for removing an extension by name -async fn remove_extension( - State(state): State>, - Json(request): Json, -) -> Result, StatusCode> { - let agent = state.get_agent_for_route(request.session_id).await?; - - match agent.remove_extension(&request.name).await { - Ok(_) => Ok(Json(ExtensionResponse { - error: false, - message: None, - })), - Err(e) => Ok(Json(ExtensionResponse { - error: true, - message: Some(format!("Failed to remove extension: {:?}", e)), - })), - } -} - -pub fn routes(state: Arc) -> Router { - Router::new() - .route("/extensions/add", post(add_extension)) - .route("/extensions/remove", post(remove_extension)) - .with_state(state) -} diff --git a/crates/goose-server/src/routes/mod.rs b/crates/goose-server/src/routes/mod.rs index 7deafd6b7b2d..9ca3799e5d89 100644 --- a/crates/goose-server/src/routes/mod.rs +++ b/crates/goose-server/src/routes/mod.rs @@ -2,7 +2,6 @@ pub mod agent; pub mod audio; pub mod config_management; pub mod errors; -pub mod extension; pub mod recipe; pub mod recipe_utils; pub mod reply; @@ -11,6 +10,7 @@ pub mod session; pub mod setup; pub mod status; pub mod utils; + use std::sync::Arc; use axum::Router; @@ -22,7 +22,6 @@ pub fn configure(state: Arc) -> Router { .merge(reply::routes(state.clone())) .merge(agent::routes(state.clone())) .merge(audio::routes(state.clone())) - .merge(extension::routes(state.clone())) .merge(config_management::routes(state.clone())) .merge(recipe::routes(state.clone())) .merge(session::routes(state.clone())) diff --git a/crates/goose-server/src/state.rs b/crates/goose-server/src/state.rs index b8b916bf9102..274da82939b3 100644 --- a/crates/goose-server/src/state.rs +++ b/crates/goose-server/src/state.rs @@ -49,7 +49,6 @@ impl AppState { self.agent_manager.get_or_create_agent(session_id).await } - /// Get agent for route handlers - always uses Interactive mode and converts any error to 500 pub async fn get_agent_for_route( &self, session_id: String, diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 319bf98c82cf..ad9a92db869d 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -13,6 +13,84 @@ "version": "1.11.0" }, "paths": { + "/agent/add_extension": { + "post": { + "tags": [ + "super::routes::agent" + ], + "operationId": "agent_add_extension", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddExtensionRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Extension added", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "401": { + "description": "Unauthorized - invalid secret key" + }, + "424": { + "description": "Agent not initialized" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/agent/remove_extension": { + "post": { + "tags": [ + "super::routes::agent" + ], + "operationId": "agent_remove_extension", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RemoveExtensionRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Extension removed", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "401": { + "description": "Unauthorized - invalid secret key" + }, + "424": { + "description": "Agent not initialized" + }, + "500": { + "description": "Internal server error" + } + } + } + }, "/agent/resume": { "post": { "tags": [ @@ -1982,6 +2060,21 @@ }, "components": { "schemas": { + "AddExtensionRequest": { + "type": "object", + "required": [ + "session_id", + "config" + ], + "properties": { + "config": { + "$ref": "#/components/schemas/ExtensionConfig" + }, + "session_id": { + "type": "string" + } + } + }, "Annotations": { "type": "object", "properties": { @@ -3668,6 +3761,21 @@ } } }, + "RemoveExtensionRequest": { + "type": "object", + "required": [ + "name", + "session_id" + ], + "properties": { + "name": { + "type": "string" + }, + "session_id": { + "type": "string" + } + } + }, "ResourceContents": { "anyOf": [ { diff --git a/ui/desktop/src/api/sdk.gen.ts b/ui/desktop/src/api/sdk.gen.ts index 9ed3262d938a..0ed77b888681 100644 --- a/ui/desktop/src/api/sdk.gen.ts +++ b/ui/desktop/src/api/sdk.gen.ts @@ -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 = Options2 & { /** @@ -18,6 +18,28 @@ export type Options; }; +export const agentAddExtension = (options: Options) => { + return (options.client ?? client).post({ + url: '/agent/add_extension', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }); +}; + +export const agentRemoveExtension = (options: Options) => { + return (options.client ?? client).post({ + url: '/agent/remove_extension', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }); +}; + export const resumeAgent = (options: Options) => { return (options.client ?? client).post({ url: '/agent/resume', diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index a33c06ecb100..b147eaf4f190 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -4,6 +4,11 @@ export type ClientOptions = { baseUrl: `${string}://${string}` | (string & {}); }; +export type AddExtensionRequest = { + config: ExtensionConfig; + session_id: string; +}; + export type Annotations = { audience?: Array; lastModified?: string; @@ -552,6 +557,11 @@ export type RedactedThinkingContent = { data: string; }; +export type RemoveExtensionRequest = { + name: string; + session_id: string; +}; + export type ResourceContents = { _meta?: { [key: string]: unknown; @@ -870,6 +880,68 @@ export type UpsertPermissionsQuery = { tool_permissions: Array; }; +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; diff --git a/ui/desktop/src/components/ConfigContext.tsx b/ui/desktop/src/components/ConfigContext.tsx index 8bda007082de..ab0618a58e6f 100644 --- a/ui/desktop/src/components/ConfigContext.tsx +++ b/ui/desktop/src/components/ConfigContext.tsx @@ -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 = ({ 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, diff --git a/ui/desktop/src/components/settings/extensions/agent-api.test.ts b/ui/desktop/src/components/settings/extensions/agent-api.test.ts deleted file mode 100644 index b5e943b0cc82..000000000000 --- a/ui/desktop/src/components/settings/extensions/agent-api.test.ts +++ /dev/null @@ -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(); - }); - }); -}); diff --git a/ui/desktop/src/components/settings/extensions/agent-api.ts b/ui/desktop/src/components/settings/extensions/agent-api.ts index 7143779b78d0..01b6463c5a20 100644 --- a/ui/desktop/src/components/settings/extensions/agent-api.ts +++ b/ui/desktop/src/components/settings/extensions/agent-api.ts @@ -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 { - // 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); - const errorMessage = error instanceof Error ? error.message : String(error); - const msg = - errorMessage.length < 70 ? errorMessage : `Failed to ${action.presentTense} extension`; + 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: errorMessage, + traceback: errMsg, }); - console.error(`Error in extensionApiCall for ${extensionName}:`, error); throw error; } } -// Helper functions to separate concerns - -// Handles HTTP error responses -function handleErrorResponse( - response: Response, +export async function removeFromAgent( 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 { - 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 { - // Create a copy to avoid mutating the original extension object - const extensionCopy: ExtensionConfig = { ...extension }; + sessionId: string, + showToast: boolean +) { + let toastId = showToast + ? toastService.loading({ + title: extensionName, + msg: `Removing ${extensionName} extension...`, + }) + : 0; try { - if (extensionCopy.type === 'stdio') { - extensionCopy.cmd = await replaceWithShims(extensionCopy.cmd); + await agentRemoveExtension({ + body: { session_id: sessionId, name: extensionName }, + throwOnError: true, + }); + if (showToast) { + toastService.dismiss(toastId); + toastService.success({ + title: extensionName, + msg: `Successfully removed extension`, + }); } - - 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; + if (showToast) { + toastService.dismiss(toastId); } - throw error; - } -} - -/** - * Remove an extension from the agent - */ -export async function removeFromAgent( - name: string, - options: ToastServiceOptions & { isDelete?: boolean } = {}, - sessionId: string -): Promise { - 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); + const errorMessage = error instanceof Error ? error.message : String(error); + const msg = errorMessage.length < 70 ? errorMessage : `Failed to remove extension`; + toastService.error({ + title: extensionName, + msg: msg, + traceback: errorMessage, + }); throw error; } } diff --git a/ui/desktop/src/components/settings/extensions/extension-manager.test.ts b/ui/desktop/src/components/settings/extensions/extension-manager.test.ts index 7406d41485d2..db379e379b24 100644 --- a/ui/desktop/src/components/settings/extensions/extension-manager.test.ts +++ b/ui/desktop/src/components/settings/extensions/extension-manager.test.ts @@ -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'); - }); - }); }); diff --git a/ui/desktop/src/components/settings/extensions/extension-manager.ts b/ui/desktop/src/components/settings/extensions/extension-manager.ts index a27693eadf5e..c8f212a1101e 100644 --- a/ui/desktop/src/components/settings/extensions/extension-manager.ts +++ b/ui/desktop/src/components/settings/extensions/extension-manager.ts @@ -60,23 +60,20 @@ export async function activateExtension({ }: ActivateExtensionProps): Promise { 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 { 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; diff --git a/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx b/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx index 1c1b37526702..a39137076e2f 100644 --- a/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx +++ b/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx @@ -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, }; } } diff --git a/ui/desktop/src/components/settings/extensions/utils.test.ts b/ui/desktop/src/components/settings/extensions/utils.test.ts index dce8ea94ce5d..98c90963d427 100644 --- a/ui/desktop/src/components/settings/extensions/utils.test.ts +++ b/ui/desktop/src/components/settings/extensions/utils.test.ts @@ -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 = { - 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'; diff --git a/ui/desktop/src/components/settings/extensions/utils.ts b/ui/desktop/src/components/settings/extensions/utils.ts index 137853034774..b561ea1437ec 100644 --- a/ui/desktop/src/components/settings/extensions/utils.ts +++ b/ui/desktop/src/components/settings/extensions/utils.ts @@ -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 = { - 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'; diff --git a/ui/desktop/src/hooks/useWhisper.ts b/ui/desktop/src/hooks/useWhisper.ts index fe0ddb62d8c2..385f674e9423 100644 --- a/ui/desktop/src/hooks/useWhisper.ts +++ b/ui/desktop/src/hooks/useWhisper.ts @@ -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; diff --git a/ui/desktop/src/sharedSessions.ts b/ui/desktop/src/sharedSessions.ts index 4e6b7184ca1e..849f5549f86e 100644 --- a/ui/desktop/src/sharedSessions.ts +++ b/ui/desktop/src/sharedSessions.ts @@ -1,4 +1,4 @@ -import { safeJsonParse } from './utils/jsonUtils'; +import { safeJsonParse } from './utils/conversionUtils'; import { Message } from './api'; export interface SharedSessionDetails { diff --git a/ui/desktop/src/utils/jsonUtils.ts b/ui/desktop/src/utils/conversionUtils.ts similarity index 51% rename from ui/desktop/src/utils/jsonUtils.ts rename to ui/desktop/src/utils/conversionUtils.ts index 1f8f3ec01ee0..51b9cb30accf 100644 --- a/ui/desktop/src/utils/jsonUtils.ts +++ b/ui/desktop/src/utils/conversionUtils.ts @@ -11,3 +11,13 @@ export async function safeJsonParse( 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); + } +} diff --git a/ui/desktop/src/utils/costDatabase.ts b/ui/desktop/src/utils/costDatabase.ts index 04dc868700ab..a2e8e5cbd213 100644 --- a/ui/desktop/src/utils/costDatabase.ts +++ b/ui/desktop/src/utils/costDatabase.ts @@ -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) diff --git a/ui/desktop/src/utils/githubUpdater.ts b/ui/desktop/src/utils/githubUpdater.ts index 4516a2aee442..1b0140b9475a 100644 --- a/ui/desktop/src/utils/githubUpdater.ts +++ b/ui/desktop/src/utils/githubUpdater.ts @@ -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;