Agent extension api (#5281)

Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
Douwe Osinga
2025-10-29 11:16:06 -04:00
committed by GitHub
parent c45ce49430
commit e7ca2e3bf7
22 changed files with 435 additions and 992 deletions
+4
View File
@@ -348,6 +348,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,
@@ -486,6 +488,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,
))
)]
+97
View File
@@ -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<Arc<AppState>>,
Json(request): Json<AddExtensionRequest>,
) -> Result<StatusCode, ErrorResponse> {
// 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<Arc<AppState>>,
Json(request): Json<RemoveExtensionRequest>,
) -> Result<StatusCode, ErrorResponse> {
let agent = state.get_agent(request.session_id).await?;
agent.remove_extension(&request.name).await?;
Ok(StatusCode::OK)
}
pub fn routes(state: Arc<AppState>) -> Router {
Router::new()
.route("/agent/start", post(start_agent))
@@ -462,5 +557,7 @@ pub fn routes(state: Arc<AppState>) -> 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)
}
+15
View File
@@ -13,6 +13,15 @@ pub struct ErrorResponse {
pub status: StatusCode,
}
impl ErrorResponse {
pub(crate) fn internal(message: impl Into<String>) -> 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<anyhow::Error> for ErrorResponse {
fn from(err: anyhow::Error) -> Self {
Self::internal(err.to_string())
}
}
-139
View File
@@ -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<String>,
}
#[derive(Deserialize)]
struct AddExtensionRequest {
session_id: String,
#[serde(flatten)]
config: ExtensionConfig,
}
async fn add_extension(
State(state): State<Arc<AppState>>,
Json(request): Json<AddExtensionRequest>,
) -> Result<Json<ExtensionResponse>, 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<Arc<AppState>>,
Json(request): Json<RemoveExtensionRequest>,
) -> Result<Json<ExtensionResponse>, 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<AppState>) -> Router {
Router::new()
.route("/extensions/add", post(add_extension))
.route("/extensions/remove", post(remove_extension))
.with_state(state)
}
+1 -2
View File
@@ -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<crate::state::AppState>) -> 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()))
-1
View File
@@ -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,
+108
View File
@@ -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": [
@@ -1989,6 +2067,21 @@
},
"components": {
"schemas": {
"AddExtensionRequest": {
"type": "object",
"required": [
"session_id",
"config"
],
"properties": {
"config": {
"$ref": "#/components/schemas/ExtensionConfig"
},
"session_id": {
"type": "string"
}
}
},
"Annotations": {
"type": "object",
"properties": {
@@ -3812,6 +3905,21 @@
}
}
},
"RemoveExtensionRequest": {
"type": "object",
"required": [
"name",
"session_id"
],
"properties": {
"name": {
"type": "string"
},
"session_id": {
"type": "string"
}
}
},
"ResourceContents": {
"anyOf": [
{
+23 -1
View File
@@ -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',
+72
View File
@@ -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';
+1 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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)
+1 -1
View File
@@ -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;