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