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::resume_agent,
|
||||||
super::routes::agent::get_tools,
|
super::routes::agent::get_tools,
|
||||||
super::routes::agent::update_from_session,
|
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_agent_provider,
|
||||||
super::routes::agent::update_router_tool_selector,
|
super::routes::agent::update_router_tool_selector,
|
||||||
super::routes::reply::confirm_permission,
|
super::routes::reply::confirm_permission,
|
||||||
@@ -486,6 +488,8 @@ derive_utoipa!(Icon as IconSchema);
|
|||||||
super::routes::agent::StartAgentRequest,
|
super::routes::agent::StartAgentRequest,
|
||||||
super::routes::agent::ResumeAgentRequest,
|
super::routes::agent::ResumeAgentRequest,
|
||||||
super::routes::agent::UpdateFromSessionRequest,
|
super::routes::agent::UpdateFromSessionRequest,
|
||||||
|
super::routes::agent::AddExtensionRequest,
|
||||||
|
super::routes::agent::RemoveExtensionRequest,
|
||||||
super::routes::setup::SetupResponse,
|
super::routes::setup::SetupResponse,
|
||||||
))
|
))
|
||||||
)]
|
)]
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use goose::config::PermissionManager;
|
use goose::config::PermissionManager;
|
||||||
|
|
||||||
|
use goose::agents::ExtensionConfig;
|
||||||
use goose::config::Config;
|
use goose::config::Config;
|
||||||
use goose::model::ModelConfig;
|
use goose::model::ModelConfig;
|
||||||
use goose::prompt_template::render_global_file;
|
use goose::prompt_template::render_global_file;
|
||||||
@@ -70,6 +71,18 @@ pub struct ResumeAgentRequest {
|
|||||||
load_model_and_extensions: bool,
|
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(
|
#[utoipa::path(
|
||||||
post,
|
post,
|
||||||
path = "/agent/start",
|
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 {
|
pub fn routes(state: Arc<AppState>) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/agent/start", post(start_agent))
|
.route("/agent/start", post(start_agent))
|
||||||
@@ -462,5 +557,7 @@ pub fn routes(state: Arc<AppState>) -> Router {
|
|||||||
post(update_router_tool_selector),
|
post(update_router_tool_selector),
|
||||||
)
|
)
|
||||||
.route("/agent/update_from_session", post(update_from_session))
|
.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)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,15 @@ pub struct ErrorResponse {
|
|||||||
pub status: StatusCode,
|
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 {
|
impl IntoResponse for ErrorResponse {
|
||||||
fn into_response(self) -> Response {
|
fn into_response(self) -> Response {
|
||||||
let body = Json(serde_json::json!({
|
let body = Json(serde_json::json!({
|
||||||
@@ -22,3 +31,9 @@ impl IntoResponse for ErrorResponse {
|
|||||||
(self.status, body).into_response()
|
(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 audio;
|
||||||
pub mod config_management;
|
pub mod config_management;
|
||||||
pub mod errors;
|
pub mod errors;
|
||||||
pub mod extension;
|
|
||||||
pub mod recipe;
|
pub mod recipe;
|
||||||
pub mod recipe_utils;
|
pub mod recipe_utils;
|
||||||
pub mod reply;
|
pub mod reply;
|
||||||
@@ -11,6 +10,7 @@ pub mod session;
|
|||||||
pub mod setup;
|
pub mod setup;
|
||||||
pub mod status;
|
pub mod status;
|
||||||
pub mod utils;
|
pub mod utils;
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
@@ -22,7 +22,6 @@ pub fn configure(state: Arc<crate::state::AppState>) -> Router {
|
|||||||
.merge(reply::routes(state.clone()))
|
.merge(reply::routes(state.clone()))
|
||||||
.merge(agent::routes(state.clone()))
|
.merge(agent::routes(state.clone()))
|
||||||
.merge(audio::routes(state.clone()))
|
.merge(audio::routes(state.clone()))
|
||||||
.merge(extension::routes(state.clone()))
|
|
||||||
.merge(config_management::routes(state.clone()))
|
.merge(config_management::routes(state.clone()))
|
||||||
.merge(recipe::routes(state.clone()))
|
.merge(recipe::routes(state.clone()))
|
||||||
.merge(session::routes(state.clone()))
|
.merge(session::routes(state.clone()))
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ impl AppState {
|
|||||||
self.agent_manager.get_or_create_agent(session_id).await
|
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(
|
pub async fn get_agent_for_route(
|
||||||
&self,
|
&self,
|
||||||
session_id: String,
|
session_id: String,
|
||||||
|
|||||||
@@ -13,6 +13,84 @@
|
|||||||
"version": "1.11.0"
|
"version": "1.11.0"
|
||||||
},
|
},
|
||||||
"paths": {
|
"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": {
|
"/agent/resume": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -1989,6 +2067,21 @@
|
|||||||
},
|
},
|
||||||
"components": {
|
"components": {
|
||||||
"schemas": {
|
"schemas": {
|
||||||
|
"AddExtensionRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"session_id",
|
||||||
|
"config"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"config": {
|
||||||
|
"$ref": "#/components/schemas/ExtensionConfig"
|
||||||
|
},
|
||||||
|
"session_id": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Annotations": {
|
"Annotations": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -3812,6 +3905,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"RemoveExtensionRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"name",
|
||||||
|
"session_id"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"session_id": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"ResourceContents": {
|
"ResourceContents": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import type { Client, Options as Options2, TDataShape } from './client';
|
import type { Client, Options as Options2, TDataShape } from './client';
|
||||||
import { client } from './client.gen';
|
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> & {
|
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>;
|
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>) => {
|
export const resumeAgent = <ThrowOnError extends boolean = false>(options: Options<ResumeAgentData, ThrowOnError>) => {
|
||||||
return (options.client ?? client).post<ResumeAgentResponses, ResumeAgentErrors, ThrowOnError>({
|
return (options.client ?? client).post<ResumeAgentResponses, ResumeAgentErrors, ThrowOnError>({
|
||||||
url: '/agent/resume',
|
url: '/agent/resume',
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ export type ClientOptions = {
|
|||||||
baseUrl: `${string}://${string}` | (string & {});
|
baseUrl: `${string}://${string}` | (string & {});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AddExtensionRequest = {
|
||||||
|
config: ExtensionConfig;
|
||||||
|
session_id: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type Annotations = {
|
export type Annotations = {
|
||||||
audience?: Array<Role>;
|
audience?: Array<Role>;
|
||||||
lastModified?: string;
|
lastModified?: string;
|
||||||
@@ -578,6 +583,11 @@ export type RedactedThinkingContent = {
|
|||||||
data: string;
|
data: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type RemoveExtensionRequest = {
|
||||||
|
name: string;
|
||||||
|
session_id: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type ResourceContents = {
|
export type ResourceContents = {
|
||||||
_meta?: {
|
_meta?: {
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
@@ -896,6 +906,68 @@ export type UpsertPermissionsQuery = {
|
|||||||
tool_permissions: Array<ToolPermission>;
|
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 = {
|
export type ResumeAgentData = {
|
||||||
body: ResumeAgentRequest;
|
body: ResumeAgentRequest;
|
||||||
path?: never;
|
path?: never;
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import type {
|
|||||||
ExtensionQuery,
|
ExtensionQuery,
|
||||||
ExtensionConfig,
|
ExtensionConfig,
|
||||||
} from '../api';
|
} from '../api';
|
||||||
import { removeShims } from './settings/extensions/utils';
|
|
||||||
|
|
||||||
export type { ExtensionConfig } from '../api/types.gen';
|
export type { ExtensionConfig } from '../api/types.gen';
|
||||||
|
|
||||||
@@ -122,10 +121,6 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
|
|||||||
|
|
||||||
const addExtension = useCallback(
|
const addExtension = useCallback(
|
||||||
async (name: string, config: ExtensionConfig, enabled: boolean) => {
|
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 };
|
const query: ExtensionQuery = { name, config, enabled };
|
||||||
await apiAddExtension({
|
await apiAddExtension({
|
||||||
body: query,
|
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 { toastService } from '../../../toasts';
|
||||||
import { getApiUrl } from '../../../config';
|
import { agentAddExtension, ExtensionConfig, agentRemoveExtension } from '../../../api';
|
||||||
import { toastService, ToastServiceOptions } from '../../../toasts';
|
import { errorMessage } from '../../../utils/conversionUtils';
|
||||||
import { replaceWithShims } from './utils';
|
|
||||||
|
|
||||||
interface ApiResponse {
|
export async function addToAgent(
|
||||||
error?: boolean;
|
extensionConfig: ExtensionConfig,
|
||||||
message?: string;
|
sessionId: string,
|
||||||
}
|
showToast: boolean
|
||||||
|
) {
|
||||||
/**
|
const extensionName = extensionConfig.name;
|
||||||
* Makes an API call to the extension endpoints
|
let toastId = showToast
|
||||||
*/
|
? toastService.loading({
|
||||||
export async function extensionApiCall(
|
title: extensionName,
|
||||||
endpoint: string,
|
msg: `adding ${extensionName} extension...`,
|
||||||
payload: ExtensionConfig | string,
|
})
|
||||||
options: ToastServiceOptions & { isDelete?: boolean } = {},
|
: 0;
|
||||||
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...`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Build the request body
|
await agentAddExtension({
|
||||||
let requestBody: ExtensionConfig | { name: string; session_id: string };
|
body: { session_id: sessionId, config: extensionConfig },
|
||||||
if (typeof payload === 'object') {
|
throwOnError: true,
|
||||||
// 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),
|
|
||||||
});
|
});
|
||||||
|
if (showToast) {
|
||||||
// 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'}`;
|
|
||||||
toastService.dismiss(toastId);
|
toastService.dismiss(toastId);
|
||||||
// Rely on the global error catch to show the copyable error toast here
|
toastService.success({
|
||||||
throw new Error(errorMessage);
|
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) {
|
} catch (error) {
|
||||||
// Final catch-all error handler
|
if (showToast) {
|
||||||
toastService.dismiss(toastId);
|
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 errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
const msg =
|
const msg = errorMessage.length < 70 ? errorMessage : `Failed to remove extension`;
|
||||||
errorMessage.length < 70 ? errorMessage : `Failed to ${action.presentTense} extension`;
|
|
||||||
toastService.error({
|
toastService.error({
|
||||||
title: extensionName,
|
title: extensionName,
|
||||||
msg: msg,
|
msg: msg,
|
||||||
traceback: errorMessage,
|
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;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import {
|
import { addToAgentOnStartup, updateExtension, toggleExtension } from './extension-manager';
|
||||||
activateExtension,
|
|
||||||
addToAgentOnStartup,
|
|
||||||
updateExtension,
|
|
||||||
toggleExtension,
|
|
||||||
deleteExtension,
|
|
||||||
} from './extension-manager';
|
|
||||||
import * as agentApi from './agent-api';
|
import * as agentApi from './agent-api';
|
||||||
import * as toasts from '../../../toasts';
|
import * as toasts from '../../../toasts';
|
||||||
|
|
||||||
@@ -38,70 +32,9 @@ describe('Extension Manager', () => {
|
|||||||
mockRemoveFromConfig.mockResolvedValue(undefined);
|
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', () => {
|
describe('addToAgentOnStartup', () => {
|
||||||
it('should successfully add extension on startup', async () => {
|
it('should successfully add extension on startup', async () => {
|
||||||
mockAddToAgent.mockResolvedValue({} as Response);
|
mockAddToAgent.mockResolvedValue(undefined);
|
||||||
|
|
||||||
await addToAgentOnStartup({
|
await addToAgentOnStartup({
|
||||||
addToConfig: mockAddToConfig,
|
addToConfig: mockAddToConfig,
|
||||||
@@ -109,29 +42,20 @@ describe('Extension Manager', () => {
|
|||||||
extensionConfig: mockExtensionConfig,
|
extensionConfig: mockExtensionConfig,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockAddToAgent).toHaveBeenCalledWith(
|
expect(mockAddToAgent).toHaveBeenCalledWith(mockExtensionConfig, 'test-session', true);
|
||||||
mockExtensionConfig,
|
|
||||||
{ silent: true },
|
|
||||||
'test-session'
|
|
||||||
);
|
|
||||||
expect(mockAddToConfig).not.toHaveBeenCalled();
|
expect(mockAddToConfig).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should successfully add extension on startup with custom toast options', async () => {
|
it('should successfully add extension on startup with custom toast options', async () => {
|
||||||
mockAddToAgent.mockResolvedValue({} as Response);
|
mockAddToAgent.mockResolvedValue(undefined);
|
||||||
|
|
||||||
await addToAgentOnStartup({
|
await addToAgentOnStartup({
|
||||||
addToConfig: mockAddToConfig,
|
addToConfig: mockAddToConfig,
|
||||||
sessionId: 'test-session',
|
sessionId: 'test-session',
|
||||||
extensionConfig: mockExtensionConfig,
|
extensionConfig: mockExtensionConfig,
|
||||||
toastOptions: { silent: false },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockAddToAgent).toHaveBeenCalledWith(
|
expect(mockAddToAgent).toHaveBeenCalledWith(mockExtensionConfig, 'test-session', true);
|
||||||
mockExtensionConfig,
|
|
||||||
{ silent: false },
|
|
||||||
'test-session'
|
|
||||||
);
|
|
||||||
expect(mockAddToConfig).not.toHaveBeenCalled();
|
expect(mockAddToConfig).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -140,7 +64,7 @@ describe('Extension Manager', () => {
|
|||||||
mockAddToAgent
|
mockAddToAgent
|
||||||
.mockRejectedValueOnce(error428)
|
.mockRejectedValueOnce(error428)
|
||||||
.mockRejectedValueOnce(error428)
|
.mockRejectedValueOnce(error428)
|
||||||
.mockResolvedValue({} as Response);
|
.mockResolvedValue(undefined);
|
||||||
|
|
||||||
await addToAgentOnStartup({
|
await addToAgentOnStartup({
|
||||||
addToConfig: mockAddToConfig,
|
addToConfig: mockAddToConfig,
|
||||||
@@ -174,7 +98,7 @@ describe('Extension Manager', () => {
|
|||||||
|
|
||||||
describe('updateExtension', () => {
|
describe('updateExtension', () => {
|
||||||
it('should update extension without name change', async () => {
|
it('should update extension without name change', async () => {
|
||||||
mockAddToAgent.mockResolvedValue({} as Response);
|
mockAddToAgent.mockResolvedValue(undefined);
|
||||||
mockAddToConfig.mockResolvedValue(undefined);
|
mockAddToConfig.mockResolvedValue(undefined);
|
||||||
mockToastService.success = vi.fn();
|
mockToastService.success = vi.fn();
|
||||||
|
|
||||||
@@ -187,11 +111,6 @@ describe('Extension Manager', () => {
|
|||||||
originalName: 'test-extension',
|
originalName: 'test-extension',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockAddToAgent).toHaveBeenCalledWith(
|
|
||||||
{ ...mockExtensionConfig, name: 'test-extension' },
|
|
||||||
{ silent: true },
|
|
||||||
'test-session'
|
|
||||||
);
|
|
||||||
expect(mockAddToConfig).toHaveBeenCalledWith(
|
expect(mockAddToConfig).toHaveBeenCalledWith(
|
||||||
'test-extension',
|
'test-extension',
|
||||||
{ ...mockExtensionConfig, name: '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 () => {
|
it('should handle name change by removing old and adding new', async () => {
|
||||||
mockAddToAgent.mockResolvedValue({} as Response);
|
mockAddToAgent.mockResolvedValue(undefined);
|
||||||
mockRemoveFromAgent.mockResolvedValue({} as Response);
|
mockRemoveFromAgent.mockResolvedValue(undefined);
|
||||||
mockRemoveFromConfig.mockResolvedValue(undefined);
|
mockRemoveFromConfig.mockResolvedValue(undefined);
|
||||||
mockAddToConfig.mockResolvedValue(undefined);
|
mockAddToConfig.mockResolvedValue(undefined);
|
||||||
mockToastService.success = vi.fn();
|
mockToastService.success = vi.fn();
|
||||||
@@ -219,16 +138,11 @@ describe('Extension Manager', () => {
|
|||||||
originalName: 'old-extension',
|
originalName: 'old-extension',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockRemoveFromAgent).toHaveBeenCalledWith(
|
|
||||||
'old-extension',
|
|
||||||
{ silent: true },
|
|
||||||
'test-session'
|
|
||||||
);
|
|
||||||
expect(mockRemoveFromConfig).toHaveBeenCalledWith('old-extension');
|
expect(mockRemoveFromConfig).toHaveBeenCalledWith('old-extension');
|
||||||
expect(mockAddToAgent).toHaveBeenCalledWith(
|
expect(mockAddToAgent).toHaveBeenCalledWith(
|
||||||
{ ...mockExtensionConfig, name: 'new-extension' },
|
{ ...mockExtensionConfig, name: 'new-extension' },
|
||||||
{ silent: true },
|
'test-session',
|
||||||
'test-session'
|
false
|
||||||
);
|
);
|
||||||
expect(mockAddToConfig).toHaveBeenCalledWith(
|
expect(mockAddToConfig).toHaveBeenCalledWith(
|
||||||
'new-extension',
|
'new-extension',
|
||||||
@@ -265,7 +179,7 @@ describe('Extension Manager', () => {
|
|||||||
|
|
||||||
describe('toggleExtension', () => {
|
describe('toggleExtension', () => {
|
||||||
it('should toggle extension on successfully', async () => {
|
it('should toggle extension on successfully', async () => {
|
||||||
mockAddToAgent.mockResolvedValue({} as Response);
|
mockAddToAgent.mockResolvedValue(undefined);
|
||||||
mockAddToConfig.mockResolvedValue(undefined);
|
mockAddToConfig.mockResolvedValue(undefined);
|
||||||
|
|
||||||
await toggleExtension({
|
await toggleExtension({
|
||||||
@@ -275,12 +189,12 @@ describe('Extension Manager', () => {
|
|||||||
sessionId: 'test-session',
|
sessionId: 'test-session',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockAddToAgent).toHaveBeenCalledWith(mockExtensionConfig, {}, 'test-session');
|
expect(mockAddToAgent).toHaveBeenCalledWith(mockExtensionConfig, 'test-session', true);
|
||||||
expect(mockAddToConfig).toHaveBeenCalledWith('test-extension', mockExtensionConfig, true);
|
expect(mockAddToConfig).toHaveBeenCalledWith('test-extension', mockExtensionConfig, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should toggle extension off successfully', async () => {
|
it('should toggle extension off successfully', async () => {
|
||||||
mockRemoveFromAgent.mockResolvedValue({} as Response);
|
mockRemoveFromAgent.mockResolvedValue(undefined);
|
||||||
mockAddToConfig.mockResolvedValue(undefined);
|
mockAddToConfig.mockResolvedValue(undefined);
|
||||||
|
|
||||||
await toggleExtension({
|
await toggleExtension({
|
||||||
@@ -290,7 +204,7 @@ describe('Extension Manager', () => {
|
|||||||
sessionId: 'test-session',
|
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);
|
expect(mockAddToConfig).toHaveBeenCalledWith('test-extension', mockExtensionConfig, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -308,14 +222,14 @@ describe('Extension Manager', () => {
|
|||||||
})
|
})
|
||||||
).rejects.toThrow('Agent failed');
|
).rejects.toThrow('Agent failed');
|
||||||
|
|
||||||
expect(mockAddToAgent).toHaveBeenCalledWith(mockExtensionConfig, {}, 'test-session');
|
expect(mockAddToAgent).toHaveBeenCalledWith(mockExtensionConfig, 'test-session', true);
|
||||||
// addToConfig is called during the rollback (toggleOff)
|
// addToConfig is called during the rollback (toggleOff)
|
||||||
expect(mockAddToConfig).toHaveBeenCalledWith('test-extension', mockExtensionConfig, false);
|
expect(mockAddToConfig).toHaveBeenCalledWith('test-extension', mockExtensionConfig, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should remove from agent if config update fails when toggling on', async () => {
|
it('should remove from agent if config update fails when toggling on', async () => {
|
||||||
const configError = new Error('Config failed');
|
const configError = new Error('Config failed');
|
||||||
mockAddToAgent.mockResolvedValue({} as Response);
|
mockAddToAgent.mockResolvedValue(undefined);
|
||||||
mockAddToConfig.mockRejectedValue(configError);
|
mockAddToConfig.mockRejectedValue(configError);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -327,9 +241,9 @@ describe('Extension Manager', () => {
|
|||||||
})
|
})
|
||||||
).rejects.toThrow('Config failed');
|
).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(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 () => {
|
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');
|
).rejects.toThrow('Agent removal failed');
|
||||||
|
|
||||||
expect(mockRemoveFromAgent).toHaveBeenCalledWith('test-extension', {}, 'test-session');
|
|
||||||
expect(mockAddToConfig).toHaveBeenCalledWith('test-extension', mockExtensionConfig, false);
|
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> {
|
}: ActivateExtensionProps): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// AddToAgent
|
// AddToAgent
|
||||||
await addToAgent(extensionConfig, { silent: false }, sessionId);
|
await addToAgent(extensionConfig, sessionId, true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to add extension to agent:', error);
|
console.error('Failed to add extension to agent:', error);
|
||||||
// add to config with enabled = false
|
|
||||||
await addToConfig(extensionConfig.name, extensionConfig, false);
|
await addToConfig(extensionConfig.name, extensionConfig, false);
|
||||||
// Rethrow the error to inform the caller
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then add to config
|
|
||||||
try {
|
try {
|
||||||
await addToConfig(extensionConfig.name, extensionConfig, true);
|
await addToConfig(extensionConfig.name, extensionConfig, true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to add extension to config:', error);
|
console.error('Failed to add extension to config:', error);
|
||||||
// remove from Agent
|
// remove from Agent
|
||||||
try {
|
try {
|
||||||
await removeFromAgent(extensionConfig.name, {}, sessionId);
|
await removeFromAgent(extensionConfig.name, sessionId, true);
|
||||||
} catch (removeError) {
|
} catch (removeError) {
|
||||||
console.error('Failed to remove extension from agent after config failure:', 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
|
* Adds an extension to the agent during application startup with retry logic
|
||||||
|
*
|
||||||
|
* TODO(Douwe): Delete this after basecamp lands
|
||||||
*/
|
*/
|
||||||
export async function addToAgentOnStartup({
|
export async function addToAgentOnStartup({
|
||||||
addToConfig,
|
addToConfig,
|
||||||
extensionConfig,
|
extensionConfig,
|
||||||
toastOptions = { silent: true },
|
|
||||||
sessionId,
|
sessionId,
|
||||||
}: AddToAgentOnStartupProps): Promise<void> {
|
}: AddToAgentOnStartupProps): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await retryWithBackoff(() => addToAgent(extensionConfig, toastOptions, sessionId), {
|
await retryWithBackoff(() => addToAgent(extensionConfig, sessionId, true), {
|
||||||
retries: 3,
|
retries: 3,
|
||||||
delayMs: 1000,
|
delayMs: 1000,
|
||||||
shouldRetry: (error: ExtensionError) =>
|
shouldRetry: (error: ExtensionError) =>
|
||||||
@@ -165,7 +163,7 @@ export async function updateExtension({
|
|||||||
|
|
||||||
// First remove the old extension from agent (using original name)
|
// First remove the old extension from agent (using original name)
|
||||||
try {
|
try {
|
||||||
await removeFromAgent(originalName!, { silent: true }, sessionId); // Suppress removal toast since we'll show update toast
|
await removeFromAgent(originalName!, sessionId, false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to remove old extension from agent during rename:', error);
|
console.error('Failed to remove old extension from agent during rename:', error);
|
||||||
// Continue with the process even if agent removal fails
|
// Continue with the process even if agent removal fails
|
||||||
@@ -188,8 +186,7 @@ export async function updateExtension({
|
|||||||
// Add new extension with sanitized name
|
// Add new extension with sanitized name
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
try {
|
try {
|
||||||
// AddToAgent with silent option to avoid duplicate toasts
|
await addToAgent(sanitizedExtensionConfig, sessionId, false);
|
||||||
await addToAgent(sanitizedExtensionConfig, { silent: true }, sessionId);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[updateExtension]: Failed to add renamed extension to agent:', error);
|
console.error('[updateExtension]: Failed to add renamed extension to agent:', error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -218,8 +215,7 @@ export async function updateExtension({
|
|||||||
|
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
try {
|
try {
|
||||||
// AddToAgent with silent option to avoid duplicate toasts
|
await addToAgent(sanitizedExtensionConfig, sessionId, false);
|
||||||
await addToAgent(sanitizedExtensionConfig, { silent: true }, sessionId);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[updateExtension]: Failed to add extension to agent during update:', 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
|
// 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') {
|
if (toggle == 'toggleOn') {
|
||||||
try {
|
try {
|
||||||
// add to agent with toast options
|
// add to agent with toast options
|
||||||
await addToAgent(
|
await addToAgent(extensionConfig, sessionId, !toastOptions?.silent);
|
||||||
extensionConfig,
|
|
||||||
{
|
|
||||||
...toastOptions,
|
|
||||||
},
|
|
||||||
sessionId
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error adding extension to agent. Will try to toggle back off.');
|
console.error('Error adding extension to agent. Will try to toggle back off.');
|
||||||
try {
|
try {
|
||||||
@@ -308,7 +298,7 @@ export async function toggleExtension({
|
|||||||
console.error('Failed to update config after enabling extension:', error);
|
console.error('Failed to update config after enabling extension:', error);
|
||||||
// remove from agent
|
// remove from agent
|
||||||
try {
|
try {
|
||||||
await removeFromAgent(extensionConfig.name, toastOptions, sessionId);
|
await removeFromAgent(extensionConfig.name, sessionId, !toastOptions?.silent);
|
||||||
} catch (removeError) {
|
} catch (removeError) {
|
||||||
console.error('Failed to remove extension from agent after config failure:', removeError);
|
console.error('Failed to remove extension from agent after config failure:', removeError);
|
||||||
}
|
}
|
||||||
@@ -318,7 +308,7 @@ export async function toggleExtension({
|
|||||||
// enabled to disabled
|
// enabled to disabled
|
||||||
let agentRemoveError = null;
|
let agentRemoveError = null;
|
||||||
try {
|
try {
|
||||||
await removeFromAgent(extensionConfig.name, toastOptions, sessionId);
|
await removeFromAgent(extensionConfig.name, sessionId, !toastOptions?.silent);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// note there was an error, but attempt to remove from config anyway
|
// note there was an error, but attempt to remove from config anyway
|
||||||
console.error('Error removing extension from agent', extensionConfig.name, error);
|
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
|
// remove from agent
|
||||||
let agentRemoveError = null;
|
let agentRemoveError = null;
|
||||||
try {
|
try {
|
||||||
await removeFromAgent(name, { isDelete: true }, sessionId);
|
await removeFromAgent(name, sessionId, true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to remove extension from agent during deletion:', error);
|
console.error('Failed to remove extension from agent during deletion:', error);
|
||||||
agentRemoveError = error;
|
agentRemoveError = error;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { FixedExtensionEntry } from '../../../ConfigContext';
|
|
||||||
import { ExtensionConfig } from '../../../../api/types.gen';
|
|
||||||
import ExtensionItem from './ExtensionItem';
|
import ExtensionItem from './ExtensionItem';
|
||||||
import builtInExtensionsData from '../../../../built-in-extensions.json';
|
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 {
|
interface ExtensionListProps {
|
||||||
extensions: FixedExtensionEntry[];
|
extensions: FixedExtensionEntry[];
|
||||||
@@ -132,7 +132,7 @@ export function getSubtitle(config: ExtensionConfig) {
|
|||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
description: config.description || null,
|
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,
|
createExtensionConfig,
|
||||||
splitCmdAndArgs,
|
splitCmdAndArgs,
|
||||||
combineCmdAndArgs,
|
combineCmdAndArgs,
|
||||||
replaceWithShims,
|
|
||||||
removeShims,
|
|
||||||
extractCommand,
|
extractCommand,
|
||||||
extractExtensionName,
|
extractExtensionName,
|
||||||
DEFAULT_EXTENSION_TIMEOUT,
|
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', () => {
|
describe('extractCommand', () => {
|
||||||
it('should extract command from extension link', () => {
|
it('should extract command from extension link', () => {
|
||||||
const link = 'goose://extension/add?name=Test&cmd=python&arg=script.py&arg=--flag';
|
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(' ');
|
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 {
|
export function extractCommand(link: string): string {
|
||||||
const url = new URL(link);
|
const url = new URL(link);
|
||||||
const cmd = url.searchParams.get('cmd') || 'Unknown Command';
|
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 { useConfig } from '../components/ConfigContext';
|
||||||
import { getApiUrl } from '../config';
|
import { getApiUrl } from '../config';
|
||||||
import { useDictationSettings } from './useDictationSettings';
|
import { useDictationSettings } from './useDictationSettings';
|
||||||
import { safeJsonParse } from '../utils/jsonUtils';
|
import { safeJsonParse } from '../utils/conversionUtils';
|
||||||
|
|
||||||
interface UseWhisperOptions {
|
interface UseWhisperOptions {
|
||||||
onTranscription?: (text: string) => void;
|
onTranscription?: (text: string) => void;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { safeJsonParse } from './utils/jsonUtils';
|
import { safeJsonParse } from './utils/conversionUtils';
|
||||||
import { Message } from './api';
|
import { Message } from './api';
|
||||||
|
|
||||||
export interface SharedSessionDetails {
|
export interface SharedSessionDetails {
|
||||||
|
|||||||
@@ -11,3 +11,13 @@ export async function safeJsonParse<T>(
|
|||||||
throw error;
|
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 the proper type from ConfigContext
|
||||||
import { getApiUrl } from '../config';
|
import { getApiUrl } from '../config';
|
||||||
import { safeJsonParse } from './jsonUtils';
|
import { safeJsonParse } from './conversionUtils';
|
||||||
|
|
||||||
export interface ModelCostInfo {
|
export interface ModelCostInfo {
|
||||||
input_token_cost: number; // Cost per token for input (in USD)
|
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 path from 'path';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import log from './logger';
|
import log from './logger';
|
||||||
import { safeJsonParse } from './jsonUtils';
|
import { safeJsonParse } from './conversionUtils';
|
||||||
|
|
||||||
interface GitHubRelease {
|
interface GitHubRelease {
|
||||||
tag_name: string;
|
tag_name: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user