From 1c497f0b032317cbe0077622238561847a11ffb7 Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Tue, 2 Dec 2025 11:26:59 -0800 Subject: [PATCH 01/36] work towards changing working dir in same session --- TEST_WORKING_DIR_CHANGE.md | 92 +++++++++ WORKING_DIR_CHANGE_IMPLEMENTATION.md | 104 ++++++++++ WORKING_DIR_LIMITATION.md | 65 +++++++ .../goose-mcp/src/developer/rmcp_developer.rs | 7 +- crates/goose-mcp/src/developer/shell.rs | 7 + crates/goose-server/src/openapi.rs | 4 + crates/goose-server/src/routes/agent.rs | 180 +++++++++++++++++- crates/goose-server/src/routes/session.rs | 95 +++++++++ .../tests/test_working_dir_update.rs | 53 ++++++ crates/goose/src/agents/extension_manager.rs | 13 ++ ui/desktop/openapi.json | 106 +++++++++++ ui/desktop/src/api/sdk.gen.ts | 20 +- ui/desktop/src/api/types.gen.ts | 78 ++++++++ .../components/bottom_menu/DirSwitcher.tsx | 83 +++++++- ui/desktop/src/main.ts | 18 +- ui/desktop/src/preload.ts | 3 +- 16 files changed, 914 insertions(+), 14 deletions(-) create mode 100644 TEST_WORKING_DIR_CHANGE.md create mode 100644 WORKING_DIR_CHANGE_IMPLEMENTATION.md create mode 100644 WORKING_DIR_LIMITATION.md create mode 100644 crates/goose-server/tests/test_working_dir_update.rs diff --git a/TEST_WORKING_DIR_CHANGE.md b/TEST_WORKING_DIR_CHANGE.md new file mode 100644 index 000000000000..cab44fd0ac76 --- /dev/null +++ b/TEST_WORKING_DIR_CHANGE.md @@ -0,0 +1,92 @@ +# Testing Working Directory Change Feature + +## Setup +1. Build the backend: `cargo build -p goose-server` +2. Build the frontend: `cd ui/desktop && npm run typecheck` +3. Start the application: `just run-ui` + +## Test Steps + +### 1. Initial Setup +1. Open the Goose desktop application +2. Start a new chat session +3. Note the current working directory displayed in the bottom menu + +### 2. Verify Initial Working Directory +1. In the chat, type: `pwd` +2. Verify the output matches the displayed directory + +### 3. Change Working Directory +1. Click on the directory path in the bottom menu +2. Select a different directory from the dialog +3. Wait for the success toast: "Working directory changed to [path] and agent restarted" + +### 4. Verify Directory Change +1. In the chat, type: `pwd` again +2. **Expected Result**: The output should show the NEW directory path +3. Type: `ls` to list files +4. **Expected Result**: Files from the NEW directory should be listed + +### 5. Check Logs (for debugging) +Open the browser developer console (Cmd+Option+I on Mac) and look for: +- `[DirSwitcher] Starting directory change process` +- `[DirSwitcher] New directory selected: "/path/to/new/dir"` +- `[DirSwitcher] Restarting agent to apply new working directory...` +- `[DirSwitcher] Agent restarted successfully` +- `[DirSwitcher] Working directory updated and agent restarted` + +In the backend logs (terminal where goosed is running), look for: +- `=== UPDATE SESSION WORKING DIR START ===` +- `Session ID: [id]` +- `Requested working_dir: /path/to/new/dir` +- `Verification SUCCESS: Session [id] working_dir is now: "/path/to/new/dir"` +- `=== UPDATE SESSION WORKING DIR COMPLETE ===` +- `=== RESTART AGENT START ===` +- `Setting GOOSE_WORKING_DIR environment variable to: "/path/to/new/dir"` +- `Setting MCP process working directory from GOOSE_WORKING_DIR: "/path/to/new/dir"` +- `=== RESTART AGENT COMPLETE ===` + +## What Was Fixed + +### The Problem +- The UI could update the session's working directory +- But the agent's shell commands (`pwd`, etc.) continued to execute in the original directory +- This was because MCP extensions are long-running processes that inherit their working directory when spawned + +### The Solution +1. **Backend API** (`/agent/restart`): + - Stops the current agent (shutting down MCP extensions) + - Sets `GOOSE_WORKING_DIR` environment variable + - Creates a new agent with the updated working directory + - Restores all configurations (provider, extensions, recipes) + +2. **Frontend Integration**: + - `DirSwitcher.tsx` calls both `updateSessionWorkingDir` and `restartAgent` + - Shows success notification + - Updates UI immediately without page reload + +3. **MCP Extension Manager**: + - Checks for `GOOSE_WORKING_DIR` environment variable + - Sets it as the working directory for spawned MCP processes + - Logs the directory being used + +## Benefits +- Users can change directories mid-session without losing chat context +- No need to open new windows for different projects +- Agent's shell commands correctly reflect the new working directory +- All file operations use the updated directory + +## Troubleshooting + +If `pwd` still shows the old directory: +1. Check browser console for any error messages +2. Check backend logs for restart confirmation +3. Verify the directory exists and is accessible +4. Try changing to a simple path like `/tmp` to rule out permission issues + +## Code Changes Summary +- `crates/goose-server/src/routes/agent.rs`: Added `restart_agent` endpoint +- `crates/goose-server/src/routes/session.rs`: Enhanced logging in `update_session_working_dir` +- `crates/goose/src/agents/extension_manager.rs`: Modified `child_process_client` to use `GOOSE_WORKING_DIR` +- `ui/desktop/src/components/bottom_menu/DirSwitcher.tsx`: Added agent restart after directory change +- `crates/goose-server/src/openapi.rs`: Added new endpoint to OpenAPI spec diff --git a/WORKING_DIR_CHANGE_IMPLEMENTATION.md b/WORKING_DIR_CHANGE_IMPLEMENTATION.md new file mode 100644 index 000000000000..8d9961b1388a --- /dev/null +++ b/WORKING_DIR_CHANGE_IMPLEMENTATION.md @@ -0,0 +1,104 @@ +# Working Directory Change Implementation + +## Summary +Implemented the ability to change the working directory mid-session in the Goose desktop application without launching a new window. The solution involves restarting the agent when the working directory changes, which ensures that all MCP extensions (especially the developer extension) use the new working directory for shell commands. + +## Problem +- The UI could update the session's working directory, but the agent's shell commands (`pwd`, etc.) continued to execute in the original directory +- This was because MCP extensions are long-running processes that inherit their working directory when spawned +- The MCP protocol doesn't support passing session-specific context per tool call + +## Solution +Restart the agent when the working directory changes: +1. Update the session's working directory in the backend +2. Stop the current agent (shutting down MCP extensions) +3. Create a new agent (spawning new MCP extensions with the updated directory) +4. Restore the agent's provider, extensions, and recipe configuration + +## Implementation Details + +### Backend Changes + +#### 1. New API Endpoint (`/agent/restart`) +- **File**: `crates/goose-server/src/routes/agent.rs` +- Added `RestartAgentRequest` struct +- Added `restart_agent` handler that: + - Removes the existing agent from the session + - Creates a new agent using the session's updated working directory + - Restores provider and extensions + - Reapplies any recipe configuration +- Refactored into helper functions to avoid clippy "too_many_lines" warning: + - `restore_agent_provider`: Restores the LLM provider configuration + - `restore_agent_extensions`: Reloads all enabled extensions + +#### 2. OpenAPI Specification +- **File**: `crates/goose-server/src/openapi.rs` +- Added `restart_agent` path to the API documentation +- Added `RestartAgentRequest` schema + +#### 3. Session Working Directory Update +- **File**: `crates/goose-server/src/routes/session.rs` +- Previously implemented endpoint `/sessions/{session_id}/working_dir` (PUT) +- Updates the session's `working_dir` field persistently + +### Frontend Changes + +#### 1. Directory Switcher Component +- **File**: `ui/desktop/src/components/bottom_menu/DirSwitcher.tsx` +- Modified to call the restart agent API after updating the working directory +- Workflow: + 1. User clicks on the directory display + 2. Directory chooser dialog opens + 3. On selection: + - Calls `updateSessionWorkingDir` to update the backend session + - Calls `restartAgent` to restart the agent with the new directory + - Updates local state to reflect the change immediately + - Shows success toast notification + +#### 2. API Client +- **Files**: `ui/desktop/src/api/sdk.gen.ts`, `ui/desktop/src/api/types.gen.ts` +- Auto-generated from OpenAPI specification +- Added `restartAgent` function and `RestartAgentRequest` type + +### MCP Extension Changes + +#### 1. Shell Command Configuration +- **File**: `crates/goose-mcp/src/developer/shell.rs` +- Modified `configure_shell_command` to accept an optional `working_dir` parameter +- Sets the working directory for spawned shell processes + +#### 2. Developer Extension +- **File**: `crates/goose-mcp/src/developer/rmcp_developer.rs` +- Modified to check for `GOOSE_SESSION_WORKING_DIR` environment variable +- Passes the working directory to `configure_shell_command` if set + +## Testing + +### Backend Test +- **File**: `crates/goose-server/tests/test_working_dir_update.rs` +- Unit test verifying that `SessionManager::update_session` correctly updates the working directory + +### Manual Testing Steps +1. Open the Goose desktop application +2. Start a chat session +3. Run `pwd` command to see current directory +4. Click on the directory display in the bottom menu +5. Select a new directory +6. Run `pwd` again - it should now show the new directory + +## Benefits +- Users can change directories mid-session without losing chat context +- No need to open new windows for different projects +- Agent's shell commands correctly reflect the new working directory +- All file operations use the updated directory + +## Limitations +- The agent is briefly unavailable during the restart (typically < 1 second) +- Any in-flight operations when the directory changes will be interrupted +- The conversation history is preserved, but the agent's internal state is reset + +## Future Improvements +1. Consider implementing a more graceful handoff that doesn't require full agent restart +2. Add a loading indicator during the agent restart +3. Queue any messages sent during the restart period +4. Investigate MCP protocol enhancements to support dynamic working directory changes diff --git a/WORKING_DIR_LIMITATION.md b/WORKING_DIR_LIMITATION.md new file mode 100644 index 000000000000..a24e45aa5364 --- /dev/null +++ b/WORKING_DIR_LIMITATION.md @@ -0,0 +1,65 @@ +# Working Directory Update Limitation + +## Current Status + +The frontend successfully updates the session's working directory in the backend session manager, and the UI reflects this change. However, shell commands executed through the developer extension still use the original working directory. + +## Technical Details + +The issue stems from the architecture of the MCP (Model Context Protocol) extensions: + +1. **MCP extensions run as separate processes**: The developer extension is started once and reused across all sessions and working directory changes. + +2. **No per-request context**: The MCP protocol doesn't support passing session-specific metadata (like working directory) with each tool call. + +3. **Environment variables are process-wide**: We can't dynamically change environment variables per request in a multi-session environment. + +## Implementation Attempted + +We've implemented the following: + +### Frontend (TypeScript/React) +- `DirSwitcher.tsx`: Updates the session's working directory via the API without reloading the window +- Maintains local state to reflect the current directory in the UI +- Successfully calls the backend API to update the session + +### Backend (Rust) +- `session.rs`: Added `update_session_working_dir` endpoint that updates the session's working_dir field +- Session manager correctly stores and retrieves the updated working directory + +### Developer Extension (Rust/MCP) +- `rmcp_developer.rs`: Checks for `GOOSE_SESSION_WORKING_DIR` environment variable +- `shell.rs`: `configure_shell_command` accepts an optional working directory parameter + +## The Gap + +The missing piece is passing the session's current working directory to the developer extension when executing shell commands. Since: +- The MCP protocol doesn't support per-request metadata +- The developer extension is a long-running process serving multiple sessions +- Environment variables can't be changed dynamically per request + +## Workarounds + +Until a proper solution is implemented, users can: + +1. **Use absolute paths**: When the working directory changes, use absolute paths in shell commands +2. **Prefix commands with cd**: Start shell commands with `cd /new/working/dir && ...` +3. **Restart the session**: Create a new session with the desired working directory + +## Potential Solutions + +1. **Modify MCP protocol**: Extend the protocol to support session metadata in tool calls (requires upstream changes) +2. **Per-session MCP processes**: Spawn a new developer extension for each session (resource intensive) +3. **Proxy layer**: Add a proxy between the agent and MCP that injects session context (complex) +4. **Tool parameter**: Add an optional `working_dir` parameter to the shell tool (breaks compatibility) + +## Files Modified + +- `ui/desktop/src/components/bottom_menu/DirSwitcher.tsx` +- `ui/desktop/src/main.ts` +- `ui/desktop/src/preload.ts` +- `crates/goose-server/src/routes/session.rs` +- `crates/goose-server/src/openapi.rs` +- `crates/goose-mcp/src/developer/rmcp_developer.rs` +- `crates/goose-mcp/src/developer/shell.rs` +- `crates/goose-server/tests/test_working_dir_update.rs` diff --git a/crates/goose-mcp/src/developer/rmcp_developer.rs b/crates/goose-mcp/src/developer/rmcp_developer.rs index 6529281c0917..fad66b7e8d02 100644 --- a/crates/goose-mcp/src/developer/rmcp_developer.rs +++ b/crates/goose-mcp/src/developer/rmcp_developer.rs @@ -966,6 +966,11 @@ impl DeveloperServer { .file_name() .and_then(|s| s.to_str()) .unwrap_or("bash"); + + // Get the working directory from environment variable if set by the agent + let working_dir = std::env::var("GOOSE_WORKING_DIR") + .ok() + .map(std::path::PathBuf::from); if let Some(ref env_file) = self.bash_env_file { if shell_name == "bash" { @@ -976,7 +981,7 @@ impl DeveloperServer { } } - let mut command = configure_shell_command(&shell_config, command); + let mut command = configure_shell_command(&shell_config, command, working_dir.as_deref()); if self.extend_path_with_shell { if let Err(e) = get_shell_path_dirs() diff --git a/crates/goose-mcp/src/developer/shell.rs b/crates/goose-mcp/src/developer/shell.rs index 51f91dc4faa5..cc78c12b6fc9 100644 --- a/crates/goose-mcp/src/developer/shell.rs +++ b/crates/goose-mcp/src/developer/shell.rs @@ -109,8 +109,15 @@ pub fn normalize_line_endings(text: &str) -> String { pub fn configure_shell_command( shell_config: &ShellConfig, command: &str, + working_dir: Option<&std::path::Path>, ) -> tokio::process::Command { let mut command_builder = tokio::process::Command::new(&shell_config.executable); + + // Set the working directory if provided + if let Some(dir) = working_dir { + command_builder.current_dir(dir); + } + command_builder .stdout(Stdio::piped()) .stderr(Stdio::piped()) diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index 8909234e7f4e..e721e0f238ea 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -352,6 +352,7 @@ derive_utoipa!(Icon as IconSchema); super::routes::config_management::set_config_provider, super::routes::agent::start_agent, super::routes::agent::resume_agent, + super::routes::agent::restart_agent, super::routes::agent::get_tools, super::routes::agent::update_from_session, super::routes::agent::agent_add_extension, @@ -364,6 +365,7 @@ derive_utoipa!(Icon as IconSchema); super::routes::session::get_session, super::routes::session::get_session_insights, super::routes::session::update_session_name, + super::routes::session::update_session_working_dir, super::routes::session::delete_session, super::routes::session::export_session, super::routes::session::import_session, @@ -416,6 +418,7 @@ derive_utoipa!(Icon as IconSchema); super::routes::session::ImportSessionRequest, super::routes::session::SessionListResponse, super::routes::session::UpdateSessionNameRequest, + super::routes::session::UpdateSessionWorkingDirRequest, super::routes::session::UpdateSessionUserRecipeValuesRequest, super::routes::session::UpdateSessionUserRecipeValuesResponse, super::routes::session::EditType, @@ -514,6 +517,7 @@ derive_utoipa!(Icon as IconSchema); super::routes::agent::UpdateRouterToolSelectorRequest, super::routes::agent::StartAgentRequest, super::routes::agent::ResumeAgentRequest, + super::routes::agent::RestartAgentRequest, super::routes::agent::UpdateFromSessionRequest, super::routes::agent::AddExtensionRequest, super::routes::agent::RemoveExtensionRequest, diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs index d3bed2420545..f0d3e589df9c 100644 --- a/crates/goose-server/src/routes/agent.rs +++ b/crates/goose-server/src/routes/agent.rs @@ -12,7 +12,7 @@ use axum::{ }; use goose::config::PermissionManager; -use goose::agents::ExtensionConfig; +use goose::agents::{Agent, ExtensionConfig}; use goose::config::{Config, GooseMode}; use goose::model::ModelConfig; use goose::prompt_template::render_global_file; @@ -72,6 +72,11 @@ pub struct StopAgentRequest { session_id: String, } +#[derive(Deserialize, utoipa::ToSchema)] +pub struct RestartAgentRequest { + session_id: String, +} + #[derive(Deserialize, utoipa::ToSchema)] pub struct ResumeAgentRequest { session_id: String, @@ -632,10 +637,183 @@ async fn stop_agent( Ok(StatusCode::OK) } +async fn restore_agent_provider( + agent: &Arc, + session: &Session, + session_id: &str, +) -> Result<(), ErrorResponse> { + let config = Config::global(); + let provider_name = session + .provider_name + .clone() + .or_else(|| config.get_goose_provider().ok()) + .ok_or_else(|| ErrorResponse { + message: "Could not configure agent: missing provider".into(), + status: StatusCode::INTERNAL_SERVER_ERROR, + })?; + + let model_config = match session.model_config.clone() { + Some(saved_config) => saved_config, + None => { + let model_name = config.get_goose_model().map_err(|_| ErrorResponse { + message: "Could not configure agent: missing model".into(), + status: StatusCode::INTERNAL_SERVER_ERROR, + })?; + ModelConfig::new(&model_name).map_err(|e| ErrorResponse { + message: format!("Could not configure agent: invalid model {}", e), + status: StatusCode::INTERNAL_SERVER_ERROR, + })? + } + }; + + let provider = create(&provider_name, model_config) + .await + .map_err(|e| ErrorResponse { + message: format!("Could not create provider: {}", e), + status: StatusCode::INTERNAL_SERVER_ERROR, + })?; + + agent + .update_provider(provider, session_id) + .await + .map_err(|e| ErrorResponse { + message: format!("Could not configure agent: {}", e), + status: StatusCode::INTERNAL_SERVER_ERROR, + }) +} + +async fn restore_agent_extensions(agent: Arc, working_dir: &std::path::Path) -> Result<(), ErrorResponse> { + // Set the working directory as an environment variable that MCP extensions can use + tracing::info!("Setting GOOSE_WORKING_DIR environment variable to: {:?}", working_dir); + std::env::set_var("GOOSE_WORKING_DIR", working_dir); + + let enabled_configs = goose::config::get_enabled_extensions(); + let extension_futures = enabled_configs + .into_iter() + .map(|config| { + let config_clone = config.clone(); + let agent_ref = agent.clone(); + + async move { + if let Err(e) = agent_ref.add_extension(config_clone.clone()).await { + warn!("Failed to load extension {}: {}", config_clone.name(), e); + } + Ok::<_, ErrorResponse>(()) + } + }) + .collect::>(); + + futures::future::join_all(extension_futures).await; + Ok(()) +} + +#[utoipa::path( + post, + path = "/agent/restart", + request_body = RestartAgentRequest, + responses( + (status = 200, description = "Agent restarted successfully"), + (status = 401, description = "Unauthorized - invalid secret key"), + (status = 404, description = "Session not found"), + (status = 500, description = "Internal server error") + ) +)] +async fn restart_agent( + State(state): State>, + Json(payload): Json, +) -> Result { + let session_id = payload.session_id.clone(); + + tracing::info!("=== RESTART AGENT START ==="); + tracing::info!("Session ID: {}", session_id); + + // First, remove the existing agent (this will shut down MCP extensions) + tracing::info!("Attempting to remove existing agent..."); + if let Err(e) = state.agent_manager.remove_session(&session_id).await { + tracing::warn!("Agent not found for removal during restart: {}", e); + // Continue anyway - the agent might not exist yet + } else { + tracing::info!("Successfully removed existing agent"); + } + + // Get the session to retrieve configuration + tracing::info!("Fetching session to get configuration..."); + let session = SessionManager::get_session(&session_id, false) + .await + .map_err(|err| { + error!("Failed to get session during restart: {}", err); + ErrorResponse { + message: format!("Failed to get session: {}", err), + status: StatusCode::NOT_FOUND, + } + })?; + + tracing::info!("Session retrieved successfully"); + tracing::info!("Session working_dir: {:?}", session.working_dir); + tracing::info!("Session working_dir as string: {}", session.working_dir.display()); + + // Create a new agent (this will use the updated working_dir from the session) + tracing::info!("Creating new agent..."); + let agent = state + .get_agent_for_route(session_id.clone()) + .await + .map_err(|code| ErrorResponse { + message: "Failed to create new agent during restart".into(), + status: code, + })?; + tracing::info!("New agent created successfully"); + + // Restore the provider and extensions + let provider_result = restore_agent_provider(&agent, &session, &session_id); + let extensions_result = restore_agent_extensions(agent.clone(), &session.working_dir); + + let (provider_result, extensions_result) = tokio::join!(provider_result, extensions_result); + provider_result?; + extensions_result?; + + // Apply recipe if present + let context: HashMap<&str, Value> = HashMap::new(); + let desktop_prompt = + render_global_file("desktop_prompt.md", &context).expect("Prompt should render"); + let mut update_prompt = desktop_prompt; + + if let Some(recipe) = session.recipe { + match build_recipe_with_parameter_values( + &recipe, + session.user_recipe_values.unwrap_or_default(), + ) + .await + { + Ok(Some(recipe)) => { + if let Some(prompt) = apply_recipe_to_agent(&agent, &recipe, true).await { + update_prompt = prompt; + } + } + Ok(None) => { + // Recipe has missing parameters - use default prompt + } + Err(e) => { + return Err(ErrorResponse { + message: e.to_string(), + status: StatusCode::INTERNAL_SERVER_ERROR, + }); + } + } + } + agent.extend_system_prompt(update_prompt).await; + + tracing::info!("=== RESTART AGENT COMPLETE ==="); + tracing::info!("Final session_id: {}", session_id); + tracing::info!("Agent should now be using working_dir: {}", session.working_dir.display()); + + Ok(StatusCode::OK) +} + pub fn routes(state: Arc) -> Router { Router::new() .route("/agent/start", post(start_agent)) .route("/agent/resume", post(resume_agent)) + .route("/agent/restart", post(restart_agent)) .route("/agent/tools", get(get_tools)) .route("/agent/update_provider", post(update_agent_provider)) .route( diff --git a/crates/goose-server/src/routes/session.rs b/crates/goose-server/src/routes/session.rs index 39cc203970e3..aa8c17795954 100644 --- a/crates/goose-server/src/routes/session.rs +++ b/crates/goose-server/src/routes/session.rs @@ -14,6 +14,7 @@ use goose::session::session_manager::SessionInsights; use goose::session::{Session, SessionManager}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use std::path::PathBuf; use std::sync::Arc; use utoipa::ToSchema; @@ -38,6 +39,13 @@ pub struct UpdateSessionUserRecipeValuesRequest { user_recipe_values: HashMap, } +#[derive(Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct UpdateSessionWorkingDirRequest { + /// New working directory path + working_dir: String, +} + #[derive(Debug, Serialize, ToSchema)] pub struct UpdateSessionUserRecipeValuesResponse { recipe: Recipe, @@ -181,6 +189,89 @@ async fn update_session_name( Ok(StatusCode::OK) } +#[utoipa::path( + put, + path = "/sessions/{session_id}/working_dir", + request_body = UpdateSessionWorkingDirRequest, + params( + ("session_id" = String, Path, description = "Unique identifier for the session") + ), + responses( + (status = 200, description = "Session working directory updated successfully"), + (status = 400, description = "Bad request - Invalid directory path"), + (status = 401, description = "Unauthorized - Invalid or missing API key"), + (status = 404, description = "Session not found"), + (status = 500, description = "Internal server error") + ), + security( + ("api_key" = []) + ), + tag = "Session Management" +)] +async fn update_session_working_dir( + Path(session_id): Path, + Json(request): Json, +) -> Result { + tracing::info!("=== UPDATE SESSION WORKING DIR START ==="); + tracing::info!("Session ID: {}", session_id); + tracing::info!("Requested working_dir: {}", request.working_dir); + + let working_dir = request.working_dir.trim(); + if working_dir.is_empty() { + tracing::error!("Working directory is empty"); + return Err(StatusCode::BAD_REQUEST); + } + + // Verify the directory exists + let path = PathBuf::from(working_dir); + tracing::info!("Checking if path exists: {:?}", path); + if !path.exists() { + tracing::error!("Directory does not exist: {:?}", path); + return Err(StatusCode::BAD_REQUEST); + } + + tracing::info!("Checking if path is directory: {:?}", path); + if !path.is_dir() { + tracing::error!("Path is not a directory: {:?}", path); + return Err(StatusCode::BAD_REQUEST); + } + + tracing::info!("Directory validation passed, updating session"); + tracing::info!("About to update session {} with path: {:?}", session_id, path); + + let result = SessionManager::update_session(&session_id) + .working_dir(path.clone()) + .apply() + .await; + + match result { + Ok(_) => { + tracing::info!("SessionManager::update_session succeeded"); + tracing::info!("Successfully updated working directory to: {:?}", path); + + // Let's also verify the update by reading back the session + tracing::info!("Verifying update by reading session back..."); + match SessionManager::get_session(&session_id, false).await { + Ok(session) => { + tracing::info!("Verification SUCCESS: Session {} working_dir is now: {:?}", session_id, session.working_dir); + tracing::info!("Verification: working_dir as string: {}", session.working_dir.display()); + } + Err(e) => { + tracing::error!("Failed to verify session update: {}", e); + } + } + + tracing::info!("=== UPDATE SESSION WORKING DIR COMPLETE ==="); + Ok(StatusCode::OK) + } + Err(e) => { + tracing::error!("Failed to update session working directory: {}", e); + tracing::error!("=== UPDATE SESSION WORKING DIR FAILED ==="); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } +} + #[utoipa::path( put, path = "/sessions/{session_id}/user_recipe_values", @@ -399,6 +490,10 @@ pub fn routes(state: Arc) -> Router { .route("/sessions/import", post(import_session)) .route("/sessions/insights", get(get_session_insights)) .route("/sessions/{session_id}/name", put(update_session_name)) + .route( + "/sessions/{session_id}/working_dir", + put(update_session_working_dir), + ) .route( "/sessions/{session_id}/user_recipe_values", put(update_session_user_recipe_values), diff --git a/crates/goose-server/tests/test_working_dir_update.rs b/crates/goose-server/tests/test_working_dir_update.rs new file mode 100644 index 000000000000..df0d48d08a9f --- /dev/null +++ b/crates/goose-server/tests/test_working_dir_update.rs @@ -0,0 +1,53 @@ +#[cfg(test)] +mod test_working_dir_update { + use goose::session::SessionManager; + use tempfile::TempDir; + + #[tokio::test] + async fn test_update_session_working_dir() { + // Create a temporary directory for testing + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let initial_dir = temp_dir.path().to_path_buf(); + + // Create another temp directory to change to + let new_temp_dir = TempDir::new().expect("Failed to create second temp dir"); + let new_dir = new_temp_dir.path().to_path_buf(); + + // Create a session with the initial directory + let session = SessionManager::create_session( + initial_dir.clone(), + "Test Session".to_string(), + goose::session::SessionType::User, + ) + .await + .expect("Failed to create session"); + + println!("Created session with ID: {}", session.id); + println!("Initial working_dir: {:?}", session.working_dir); + + // Verify initial directory + assert_eq!(session.working_dir, initial_dir); + + // Update the working directory + SessionManager::update_session(&session.id) + .working_dir(new_dir.clone()) + .apply() + .await + .expect("Failed to update session working directory"); + + // Fetch the updated session + let updated_session = SessionManager::get_session(&session.id, false) + .await + .expect("Failed to get updated session"); + + println!("Updated working_dir: {:?}", updated_session.working_dir); + + // Verify the directory was updated + assert_eq!(updated_session.working_dir, new_dir); + + // Clean up + SessionManager::delete_session(&session.id) + .await + .expect("Failed to delete session"); + } +} diff --git a/crates/goose/src/agents/extension_manager.rs b/crates/goose/src/agents/extension_manager.rs index dd8f356ded2f..951c2c7177f2 100644 --- a/crates/goose/src/agents/extension_manager.rs +++ b/crates/goose/src/agents/extension_manager.rs @@ -188,6 +188,19 @@ async fn child_process_client( if let Ok(path) = SearchPaths::builder().path() { command.env("PATH", path); } + + // Check if GOOSE_WORKING_DIR is set and use it as the working directory + if let Ok(working_dir) = std::env::var("GOOSE_WORKING_DIR") { + let working_path = std::path::Path::new(&working_dir); + if working_path.exists() && working_path.is_dir() { + tracing::info!("Setting MCP process working directory from GOOSE_WORKING_DIR: {:?}", working_path); + command.current_dir(working_path); + } else { + tracing::warn!("GOOSE_WORKING_DIR is set but path doesn't exist or isn't a directory: {:?}", working_dir); + } + } else { + tracing::info!("GOOSE_WORKING_DIR not set, using default working directory"); + } let (transport, mut stderr) = TokioChildProcess::builder(command) .stderr(Stdio::piped()) diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index f8acee9d6302..0df47e1edab7 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -125,6 +125,38 @@ } } }, + "/agent/restart": { + "post": { + "tags": [ + "super::routes::agent" + ], + "operationId": "restart_agent", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestartAgentRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Agent restarted successfully" + }, + "401": { + "description": "Unauthorized - invalid secret key" + }, + "404": { + "description": "Session not found" + }, + "500": { + "description": "Internal server error" + } + } + } + }, "/agent/resume": { "post": { "tags": [ @@ -2244,6 +2276,57 @@ ] } }, + "/sessions/{session_id}/working_dir": { + "put": { + "tags": [ + "Session Management" + ], + "operationId": "update_session_working_dir", + "parameters": [ + { + "name": "session_id", + "in": "path", + "description": "Unique identifier for the session", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateSessionWorkingDirRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Session working directory updated successfully" + }, + "400": { + "description": "Bad request - Invalid directory path" + }, + "401": { + "description": "Unauthorized - Invalid or missing API key" + }, + "404": { + "description": "Session not found" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "api_key": [] + } + ] + } + }, "/status": { "get": { "tags": [ @@ -4432,6 +4515,17 @@ } } }, + "RestartAgentRequest": { + "type": "object", + "required": [ + "session_id" + ], + "properties": { + "session_id": { + "type": "string" + } + } + }, "ResumeAgentRequest": { "type": "object", "required": [ @@ -5453,6 +5547,18 @@ } } }, + "UpdateSessionWorkingDirRequest": { + "type": "object", + "required": [ + "workingDir" + ], + "properties": { + "workingDir": { + "type": "string", + "description": "New working directory path" + } + } + }, "UpsertConfigQuery": { "type": "object", "required": [ diff --git a/ui/desktop/src/api/sdk.gen.ts b/ui/desktop/src/api/sdk.gen.ts index 2e188b181997..890ded7ec756 100644 --- a/ui/desktop/src/api/sdk.gen.ts +++ b/ui/desktop/src/api/sdk.gen.ts @@ -2,7 +2,7 @@ import type { Client, Options as Options2, TDataShape } from './client'; import { client } from './client.gen'; -import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponses, BackupConfigData, BackupConfigErrors, BackupConfigResponses, CheckProviderData, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, EditMessageData, EditMessageErrors, EditMessageResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsResponses, GetTunnelStatusData, GetTunnelStatusResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, 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, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, SetConfigProviderData, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StartTunnelData, StartTunnelErrors, StartTunnelResponses, StatusData, StatusResponses, StopTunnelData, StopTunnelErrors, StopTunnelResponses, 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, CheckProviderData, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, EditMessageData, EditMessageErrors, EditMessageResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsResponses, GetTunnelStatusData, GetTunnelStatusResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, 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, RestartAgentData, RestartAgentErrors, RestartAgentResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, SetConfigProviderData, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StartTunnelData, StartTunnelErrors, StartTunnelResponses, StatusData, StatusResponses, StopTunnelData, StopTunnelErrors, StopTunnelResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateRouterToolSelectorData, UpdateRouterToolSelectorErrors, UpdateRouterToolSelectorResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpdateSessionWorkingDirData, UpdateSessionWorkingDirErrors, UpdateSessionWorkingDirResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen'; export type Options = Options2 & { /** @@ -45,6 +45,15 @@ export const agentRemoveExtension = (optio } }); +export const restartAgent = (options: Options) => (options.client ?? client).post({ + url: '/agent/restart', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + export const resumeAgent = (options: Options) => (options.client ?? client).post({ url: '/agent/resume', ...options, @@ -377,6 +386,15 @@ export const updateSessionUserRecipeValues = (options: Options) => (options.client ?? client).put({ + url: '/sessions/{session_id}/working_dir', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + export const status = (options?: Options) => (options?.client ?? client).get({ url: '/status', ...options }); /** diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index e082260e9b77..d65103a1007d 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -652,6 +652,10 @@ export type Response = { json_schema?: unknown; }; +export type RestartAgentRequest = { + session_id: string; +}; + export type ResumeAgentRequest = { load_model_and_extensions: boolean; session_id: string; @@ -994,6 +998,13 @@ export type UpdateSessionUserRecipeValuesResponse = { recipe: Recipe; }; +export type UpdateSessionWorkingDirRequest = { + /** + * New working directory path + */ + workingDir: string; +}; + export type UpsertConfigQuery = { is_secret: boolean; key: string; @@ -1091,6 +1102,35 @@ export type AgentRemoveExtensionResponses = { export type AgentRemoveExtensionResponse = AgentRemoveExtensionResponses[keyof AgentRemoveExtensionResponses]; +export type RestartAgentData = { + body: RestartAgentRequest; + path?: never; + query?: never; + url: '/agent/restart'; +}; + +export type RestartAgentErrors = { + /** + * Unauthorized - invalid secret key + */ + 401: unknown; + /** + * Session not found + */ + 404: unknown; + /** + * Internal server error + */ + 500: unknown; +}; + +export type RestartAgentResponses = { + /** + * Agent restarted successfully + */ + 200: unknown; +}; + export type ResumeAgentData = { body: ResumeAgentRequest; path?: never; @@ -2739,6 +2779,44 @@ export type UpdateSessionUserRecipeValuesResponses = { export type UpdateSessionUserRecipeValuesResponse2 = UpdateSessionUserRecipeValuesResponses[keyof UpdateSessionUserRecipeValuesResponses]; +export type UpdateSessionWorkingDirData = { + body: UpdateSessionWorkingDirRequest; + path: { + /** + * Unique identifier for the session + */ + session_id: string; + }; + query?: never; + url: '/sessions/{session_id}/working_dir'; +}; + +export type UpdateSessionWorkingDirErrors = { + /** + * Bad request - Invalid directory path + */ + 400: unknown; + /** + * Unauthorized - Invalid or missing API key + */ + 401: unknown; + /** + * Session not found + */ + 404: unknown; + /** + * Internal server error + */ + 500: unknown; +}; + +export type UpdateSessionWorkingDirResponses = { + /** + * Session working directory updated successfully + */ + 200: unknown; +}; + export type StatusData = { body?: never; path?: never; diff --git a/ui/desktop/src/components/bottom_menu/DirSwitcher.tsx b/ui/desktop/src/components/bottom_menu/DirSwitcher.tsx index d5d0c824289b..150caa92303f 100644 --- a/ui/desktop/src/components/bottom_menu/DirSwitcher.tsx +++ b/ui/desktop/src/components/bottom_menu/DirSwitcher.tsx @@ -1,6 +1,9 @@ import React, { useState } from 'react'; import { FolderDot } from 'lucide-react'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/Tooltip'; +import { useChatContext } from '../../contexts/ChatContext'; +import { updateSessionWorkingDir, restartAgent } from '../../api'; +import { toast } from 'react-toastify'; interface DirSwitcherProps { className?: string; @@ -8,9 +11,76 @@ interface DirSwitcherProps { export const DirSwitcher: React.FC = ({ className = '' }) => { const [isTooltipOpen, setIsTooltipOpen] = useState(false); + const chatContext = useChatContext(); + const sessionId = chatContext?.chat?.sessionId; + + const [currentDir, setCurrentDir] = useState(String(window.appConfig.get('GOOSE_WORKING_DIR'))); const handleDirectoryChange = async () => { - window.electron.directoryChooser(true); + console.log('[DirSwitcher] Starting directory change process'); + console.log('[DirSwitcher] Current sessionId:', JSON.stringify(sessionId)); + console.log('[DirSwitcher] Current working dir:', JSON.stringify(currentDir)); + + // Open directory chooser dialog for in-place change + const result = await window.electron.directoryChooser(true); + console.log('[DirSwitcher] Directory chooser result:', JSON.stringify(result)); + + if (!result.canceled && result.filePaths.length > 0 && sessionId) { + const newDir = result.filePaths[0]; + console.log('[DirSwitcher] New directory selected:', JSON.stringify(newDir)); + + try { + // Update the working directory on the backend + const updateRequest = { + path: { + session_id: sessionId, + }, + body: { + workingDir: newDir, + }, + }; + console.log('[DirSwitcher] Sending update request:', JSON.stringify(updateRequest)); + + const response = await updateSessionWorkingDir(updateRequest); + console.log('[DirSwitcher] Update response:', JSON.stringify(response)); + + // Restart the agent to pick up the new working directory + console.log('[DirSwitcher] Restarting agent to apply new working directory...'); + const restartRequest = { + body: { + session_id: sessionId, + }, + }; + + try { + await restartAgent(restartRequest); + console.log('[DirSwitcher] Agent restarted successfully'); + } catch (restartError) { + console.error('[DirSwitcher] Failed to restart agent:', JSON.stringify(restartError)); + // Continue anyway - the working directory is still updated in the session + } + + // Update the local state and config + setCurrentDir(newDir); + + // Send an IPC message to update the config in the main process + window.electron.emit('update-working-dir', sessionId, newDir); + + // Show success message + toast.success(`Working directory changed to ${newDir} and agent restarted`); + + console.log('[DirSwitcher] Working directory updated and agent restarted'); + console.log('[DirSwitcher] Agent will now use:', newDir); + } catch (error) { + console.error('[DirSwitcher] Failed to update working directory:', JSON.stringify(error)); + console.error('[DirSwitcher] Error details:', error); + toast.error('Failed to update working directory'); + } + } else { + console.log('[DirSwitcher] Directory change canceled or no sessionId'); + console.log('[DirSwitcher] Canceled:', result.canceled); + console.log('[DirSwitcher] SessionId:', JSON.stringify(sessionId)); + } }; const handleDirectoryClick = async (event: React.MouseEvent) => { @@ -19,8 +89,7 @@ export const DirSwitcher: React.FC = ({ className = '' }) => { if (isCmdOrCtrlClick) { event.preventDefault(); event.stopPropagation(); - const workingDir = window.appConfig.get('GOOSE_WORKING_DIR') as string; - await window.electron.openDirectoryInExplorer(workingDir); + await window.electron.openDirectoryInExplorer(currentDir); } else { await handleDirectoryChange(); } @@ -35,14 +104,10 @@ export const DirSwitcher: React.FC = ({ className = '' }) => { onClick={handleDirectoryClick} > -
- {String(window.appConfig.get('GOOSE_WORKING_DIR'))} -
+
{currentDir}
- - {window.appConfig.get('GOOSE_WORKING_DIR') as string} - + {currentDir} ); diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index f994e817cabd..8d72b8d5edac 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -1116,7 +1116,23 @@ ipcMain.handle('open-external', async (_event, url: string) => { }); // Handle directory chooser -ipcMain.handle('directory-chooser', (_event) => { +ipcMain.handle('directory-chooser', async (_event, replaceInCurrentWindow?: boolean) => { + console.log( + '[Main] directory-chooser called with replaceInCurrentWindow:', + replaceInCurrentWindow + ); + + if (replaceInCurrentWindow) { + // Just return the dialog result for in-place directory change + const result = await dialog.showOpenDialog({ + properties: ['openFile', 'openDirectory', 'createDirectory'], + defaultPath: os.homedir(), + }); + console.log('[Main] Dialog result for in-place change:', JSON.stringify(result)); + return result; + } + // Original behavior - open in new window + console.log('[Main] Using original behavior - opening new window'); return openDirectoryDialog(); }); diff --git a/ui/desktop/src/preload.ts b/ui/desktop/src/preload.ts index 69696ae86619..3b41301352b5 100644 --- a/ui/desktop/src/preload.ts +++ b/ui/desktop/src/preload.ts @@ -136,7 +136,8 @@ const electronAPI: ElectronAPI = { return config; }, hideWindow: () => ipcRenderer.send('hide-window'), - directoryChooser: () => ipcRenderer.invoke('directory-chooser'), + directoryChooser: (replaceInCurrentWindow?: boolean) => + ipcRenderer.invoke('directory-chooser', replaceInCurrentWindow), createChatWindow: ( query?: string, dir?: string, From 8b56483640d21e5e7f5fb12d7cf2facb35da1fb2 Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Mon, 8 Dec 2025 15:28:17 -0800 Subject: [PATCH 02/36] remove replaceInCurrentWindow param not needed --- TEST_WORKING_DIR_CHANGE.md | 92 ---------------- WORKING_DIR_CHANGE_IMPLEMENTATION.md | 104 ------------------ WORKING_DIR_LIMITATION.md | 65 ----------- .../components/bottom_menu/DirSwitcher.tsx | 2 +- ui/desktop/src/main.ts | 24 +--- ui/desktop/src/preload.ts | 5 +- 6 files changed, 9 insertions(+), 283 deletions(-) delete mode 100644 TEST_WORKING_DIR_CHANGE.md delete mode 100644 WORKING_DIR_CHANGE_IMPLEMENTATION.md delete mode 100644 WORKING_DIR_LIMITATION.md diff --git a/TEST_WORKING_DIR_CHANGE.md b/TEST_WORKING_DIR_CHANGE.md deleted file mode 100644 index cab44fd0ac76..000000000000 --- a/TEST_WORKING_DIR_CHANGE.md +++ /dev/null @@ -1,92 +0,0 @@ -# Testing Working Directory Change Feature - -## Setup -1. Build the backend: `cargo build -p goose-server` -2. Build the frontend: `cd ui/desktop && npm run typecheck` -3. Start the application: `just run-ui` - -## Test Steps - -### 1. Initial Setup -1. Open the Goose desktop application -2. Start a new chat session -3. Note the current working directory displayed in the bottom menu - -### 2. Verify Initial Working Directory -1. In the chat, type: `pwd` -2. Verify the output matches the displayed directory - -### 3. Change Working Directory -1. Click on the directory path in the bottom menu -2. Select a different directory from the dialog -3. Wait for the success toast: "Working directory changed to [path] and agent restarted" - -### 4. Verify Directory Change -1. In the chat, type: `pwd` again -2. **Expected Result**: The output should show the NEW directory path -3. Type: `ls` to list files -4. **Expected Result**: Files from the NEW directory should be listed - -### 5. Check Logs (for debugging) -Open the browser developer console (Cmd+Option+I on Mac) and look for: -- `[DirSwitcher] Starting directory change process` -- `[DirSwitcher] New directory selected: "/path/to/new/dir"` -- `[DirSwitcher] Restarting agent to apply new working directory...` -- `[DirSwitcher] Agent restarted successfully` -- `[DirSwitcher] Working directory updated and agent restarted` - -In the backend logs (terminal where goosed is running), look for: -- `=== UPDATE SESSION WORKING DIR START ===` -- `Session ID: [id]` -- `Requested working_dir: /path/to/new/dir` -- `Verification SUCCESS: Session [id] working_dir is now: "/path/to/new/dir"` -- `=== UPDATE SESSION WORKING DIR COMPLETE ===` -- `=== RESTART AGENT START ===` -- `Setting GOOSE_WORKING_DIR environment variable to: "/path/to/new/dir"` -- `Setting MCP process working directory from GOOSE_WORKING_DIR: "/path/to/new/dir"` -- `=== RESTART AGENT COMPLETE ===` - -## What Was Fixed - -### The Problem -- The UI could update the session's working directory -- But the agent's shell commands (`pwd`, etc.) continued to execute in the original directory -- This was because MCP extensions are long-running processes that inherit their working directory when spawned - -### The Solution -1. **Backend API** (`/agent/restart`): - - Stops the current agent (shutting down MCP extensions) - - Sets `GOOSE_WORKING_DIR` environment variable - - Creates a new agent with the updated working directory - - Restores all configurations (provider, extensions, recipes) - -2. **Frontend Integration**: - - `DirSwitcher.tsx` calls both `updateSessionWorkingDir` and `restartAgent` - - Shows success notification - - Updates UI immediately without page reload - -3. **MCP Extension Manager**: - - Checks for `GOOSE_WORKING_DIR` environment variable - - Sets it as the working directory for spawned MCP processes - - Logs the directory being used - -## Benefits -- Users can change directories mid-session without losing chat context -- No need to open new windows for different projects -- Agent's shell commands correctly reflect the new working directory -- All file operations use the updated directory - -## Troubleshooting - -If `pwd` still shows the old directory: -1. Check browser console for any error messages -2. Check backend logs for restart confirmation -3. Verify the directory exists and is accessible -4. Try changing to a simple path like `/tmp` to rule out permission issues - -## Code Changes Summary -- `crates/goose-server/src/routes/agent.rs`: Added `restart_agent` endpoint -- `crates/goose-server/src/routes/session.rs`: Enhanced logging in `update_session_working_dir` -- `crates/goose/src/agents/extension_manager.rs`: Modified `child_process_client` to use `GOOSE_WORKING_DIR` -- `ui/desktop/src/components/bottom_menu/DirSwitcher.tsx`: Added agent restart after directory change -- `crates/goose-server/src/openapi.rs`: Added new endpoint to OpenAPI spec diff --git a/WORKING_DIR_CHANGE_IMPLEMENTATION.md b/WORKING_DIR_CHANGE_IMPLEMENTATION.md deleted file mode 100644 index 8d9961b1388a..000000000000 --- a/WORKING_DIR_CHANGE_IMPLEMENTATION.md +++ /dev/null @@ -1,104 +0,0 @@ -# Working Directory Change Implementation - -## Summary -Implemented the ability to change the working directory mid-session in the Goose desktop application without launching a new window. The solution involves restarting the agent when the working directory changes, which ensures that all MCP extensions (especially the developer extension) use the new working directory for shell commands. - -## Problem -- The UI could update the session's working directory, but the agent's shell commands (`pwd`, etc.) continued to execute in the original directory -- This was because MCP extensions are long-running processes that inherit their working directory when spawned -- The MCP protocol doesn't support passing session-specific context per tool call - -## Solution -Restart the agent when the working directory changes: -1. Update the session's working directory in the backend -2. Stop the current agent (shutting down MCP extensions) -3. Create a new agent (spawning new MCP extensions with the updated directory) -4. Restore the agent's provider, extensions, and recipe configuration - -## Implementation Details - -### Backend Changes - -#### 1. New API Endpoint (`/agent/restart`) -- **File**: `crates/goose-server/src/routes/agent.rs` -- Added `RestartAgentRequest` struct -- Added `restart_agent` handler that: - - Removes the existing agent from the session - - Creates a new agent using the session's updated working directory - - Restores provider and extensions - - Reapplies any recipe configuration -- Refactored into helper functions to avoid clippy "too_many_lines" warning: - - `restore_agent_provider`: Restores the LLM provider configuration - - `restore_agent_extensions`: Reloads all enabled extensions - -#### 2. OpenAPI Specification -- **File**: `crates/goose-server/src/openapi.rs` -- Added `restart_agent` path to the API documentation -- Added `RestartAgentRequest` schema - -#### 3. Session Working Directory Update -- **File**: `crates/goose-server/src/routes/session.rs` -- Previously implemented endpoint `/sessions/{session_id}/working_dir` (PUT) -- Updates the session's `working_dir` field persistently - -### Frontend Changes - -#### 1. Directory Switcher Component -- **File**: `ui/desktop/src/components/bottom_menu/DirSwitcher.tsx` -- Modified to call the restart agent API after updating the working directory -- Workflow: - 1. User clicks on the directory display - 2. Directory chooser dialog opens - 3. On selection: - - Calls `updateSessionWorkingDir` to update the backend session - - Calls `restartAgent` to restart the agent with the new directory - - Updates local state to reflect the change immediately - - Shows success toast notification - -#### 2. API Client -- **Files**: `ui/desktop/src/api/sdk.gen.ts`, `ui/desktop/src/api/types.gen.ts` -- Auto-generated from OpenAPI specification -- Added `restartAgent` function and `RestartAgentRequest` type - -### MCP Extension Changes - -#### 1. Shell Command Configuration -- **File**: `crates/goose-mcp/src/developer/shell.rs` -- Modified `configure_shell_command` to accept an optional `working_dir` parameter -- Sets the working directory for spawned shell processes - -#### 2. Developer Extension -- **File**: `crates/goose-mcp/src/developer/rmcp_developer.rs` -- Modified to check for `GOOSE_SESSION_WORKING_DIR` environment variable -- Passes the working directory to `configure_shell_command` if set - -## Testing - -### Backend Test -- **File**: `crates/goose-server/tests/test_working_dir_update.rs` -- Unit test verifying that `SessionManager::update_session` correctly updates the working directory - -### Manual Testing Steps -1. Open the Goose desktop application -2. Start a chat session -3. Run `pwd` command to see current directory -4. Click on the directory display in the bottom menu -5. Select a new directory -6. Run `pwd` again - it should now show the new directory - -## Benefits -- Users can change directories mid-session without losing chat context -- No need to open new windows for different projects -- Agent's shell commands correctly reflect the new working directory -- All file operations use the updated directory - -## Limitations -- The agent is briefly unavailable during the restart (typically < 1 second) -- Any in-flight operations when the directory changes will be interrupted -- The conversation history is preserved, but the agent's internal state is reset - -## Future Improvements -1. Consider implementing a more graceful handoff that doesn't require full agent restart -2. Add a loading indicator during the agent restart -3. Queue any messages sent during the restart period -4. Investigate MCP protocol enhancements to support dynamic working directory changes diff --git a/WORKING_DIR_LIMITATION.md b/WORKING_DIR_LIMITATION.md deleted file mode 100644 index a24e45aa5364..000000000000 --- a/WORKING_DIR_LIMITATION.md +++ /dev/null @@ -1,65 +0,0 @@ -# Working Directory Update Limitation - -## Current Status - -The frontend successfully updates the session's working directory in the backend session manager, and the UI reflects this change. However, shell commands executed through the developer extension still use the original working directory. - -## Technical Details - -The issue stems from the architecture of the MCP (Model Context Protocol) extensions: - -1. **MCP extensions run as separate processes**: The developer extension is started once and reused across all sessions and working directory changes. - -2. **No per-request context**: The MCP protocol doesn't support passing session-specific metadata (like working directory) with each tool call. - -3. **Environment variables are process-wide**: We can't dynamically change environment variables per request in a multi-session environment. - -## Implementation Attempted - -We've implemented the following: - -### Frontend (TypeScript/React) -- `DirSwitcher.tsx`: Updates the session's working directory via the API without reloading the window -- Maintains local state to reflect the current directory in the UI -- Successfully calls the backend API to update the session - -### Backend (Rust) -- `session.rs`: Added `update_session_working_dir` endpoint that updates the session's working_dir field -- Session manager correctly stores and retrieves the updated working directory - -### Developer Extension (Rust/MCP) -- `rmcp_developer.rs`: Checks for `GOOSE_SESSION_WORKING_DIR` environment variable -- `shell.rs`: `configure_shell_command` accepts an optional working directory parameter - -## The Gap - -The missing piece is passing the session's current working directory to the developer extension when executing shell commands. Since: -- The MCP protocol doesn't support per-request metadata -- The developer extension is a long-running process serving multiple sessions -- Environment variables can't be changed dynamically per request - -## Workarounds - -Until a proper solution is implemented, users can: - -1. **Use absolute paths**: When the working directory changes, use absolute paths in shell commands -2. **Prefix commands with cd**: Start shell commands with `cd /new/working/dir && ...` -3. **Restart the session**: Create a new session with the desired working directory - -## Potential Solutions - -1. **Modify MCP protocol**: Extend the protocol to support session metadata in tool calls (requires upstream changes) -2. **Per-session MCP processes**: Spawn a new developer extension for each session (resource intensive) -3. **Proxy layer**: Add a proxy between the agent and MCP that injects session context (complex) -4. **Tool parameter**: Add an optional `working_dir` parameter to the shell tool (breaks compatibility) - -## Files Modified - -- `ui/desktop/src/components/bottom_menu/DirSwitcher.tsx` -- `ui/desktop/src/main.ts` -- `ui/desktop/src/preload.ts` -- `crates/goose-server/src/routes/session.rs` -- `crates/goose-server/src/openapi.rs` -- `crates/goose-mcp/src/developer/rmcp_developer.rs` -- `crates/goose-mcp/src/developer/shell.rs` -- `crates/goose-server/tests/test_working_dir_update.rs` diff --git a/ui/desktop/src/components/bottom_menu/DirSwitcher.tsx b/ui/desktop/src/components/bottom_menu/DirSwitcher.tsx index fde528323050..bba42d0e707e 100644 --- a/ui/desktop/src/components/bottom_menu/DirSwitcher.tsx +++ b/ui/desktop/src/components/bottom_menu/DirSwitcher.tsx @@ -28,7 +28,7 @@ export const DirSwitcher: React.FC = ({ className = '' }) => { let result; try { - result = await window.electron.directoryChooser(true); + result = await window.electron.directoryChooser(); } finally { setIsDirectoryChooserOpen(false); } diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index 9c61b508728a..ecf872bc70f8 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -1152,24 +1152,12 @@ ipcMain.handle('open-external', async (_event, url: string) => { }); // Handle directory chooser -ipcMain.handle('directory-chooser', async (_event, replaceInCurrentWindow?: boolean) => { - console.log( - '[Main] directory-chooser called with replaceInCurrentWindow:', - replaceInCurrentWindow - ); - - if (replaceInCurrentWindow) { - // Just return the dialog result for in-place directory change - const result = await dialog.showOpenDialog({ - properties: ['openFile', 'openDirectory', 'createDirectory'], - defaultPath: os.homedir(), - }); - console.log('[Main] Dialog result for in-place change:', JSON.stringify(result)); - return result; - } - // Original behavior - open in new window - console.log('[Main] Using original behavior - opening new window'); - return openDirectoryDialog(); +ipcMain.handle('directory-chooser', async () => { + const result = await dialog.showOpenDialog({ + properties: ['openFile', 'openDirectory', 'createDirectory'], + defaultPath: os.homedir(), + }); + return result; }); // Handle scheduling engine settings diff --git a/ui/desktop/src/preload.ts b/ui/desktop/src/preload.ts index 3b41301352b5..0cf86968f1fd 100644 --- a/ui/desktop/src/preload.ts +++ b/ui/desktop/src/preload.ts @@ -46,7 +46,7 @@ type ElectronAPI = { reactReady: () => void; getConfig: () => Record; hideWindow: () => void; - directoryChooser: (replace?: boolean) => Promise; + directoryChooser: () => Promise; createChatWindow: ( query?: string, dir?: string, @@ -136,8 +136,7 @@ const electronAPI: ElectronAPI = { return config; }, hideWindow: () => ipcRenderer.send('hide-window'), - directoryChooser: (replaceInCurrentWindow?: boolean) => - ipcRenderer.invoke('directory-chooser', replaceInCurrentWindow), + directoryChooser: () => ipcRenderer.invoke('directory-chooser'), createChatWindow: ( query?: string, dir?: string, From a87d57c09663543e5b23bf0479f12761fcc743db Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Mon, 8 Dec 2025 15:31:35 -0800 Subject: [PATCH 03/36] clean up --- .../goose-mcp/src/developer/rmcp_developer.rs | 3 +- crates/goose-mcp/src/developer/shell.rs | 5 +- crates/goose-server/src/routes/agent.rs | 51 ++++++++++--------- 3 files changed, 31 insertions(+), 28 deletions(-) diff --git a/crates/goose-mcp/src/developer/rmcp_developer.rs b/crates/goose-mcp/src/developer/rmcp_developer.rs index fad66b7e8d02..0402a74260dd 100644 --- a/crates/goose-mcp/src/developer/rmcp_developer.rs +++ b/crates/goose-mcp/src/developer/rmcp_developer.rs @@ -966,8 +966,7 @@ impl DeveloperServer { .file_name() .and_then(|s| s.to_str()) .unwrap_or("bash"); - - // Get the working directory from environment variable if set by the agent + let working_dir = std::env::var("GOOSE_WORKING_DIR") .ok() .map(std::path::PathBuf::from); diff --git a/crates/goose-mcp/src/developer/shell.rs b/crates/goose-mcp/src/developer/shell.rs index cc78c12b6fc9..a05242833d7b 100644 --- a/crates/goose-mcp/src/developer/shell.rs +++ b/crates/goose-mcp/src/developer/shell.rs @@ -112,12 +112,11 @@ pub fn configure_shell_command( working_dir: Option<&std::path::Path>, ) -> tokio::process::Command { let mut command_builder = tokio::process::Command::new(&shell_config.executable); - - // Set the working directory if provided + if let Some(dir) = working_dir { command_builder.current_dir(dir); } - + command_builder .stdout(Stdio::piped()) .stderr(Stdio::piped()) diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs index 0c2741b30fbc..d57830edbd2b 100644 --- a/crates/goose-server/src/routes/agent.rs +++ b/crates/goose-server/src/routes/agent.rs @@ -610,11 +610,16 @@ async fn restore_agent_provider( }) } -async fn restore_agent_extensions(agent: Arc, working_dir: &std::path::Path) -> Result<(), ErrorResponse> { - // Set the working directory as an environment variable that MCP extensions can use - tracing::info!("Setting GOOSE_WORKING_DIR environment variable to: {:?}", working_dir); +async fn restore_agent_extensions( + agent: Arc, + working_dir: &std::path::Path, +) -> Result<(), ErrorResponse> { + tracing::info!( + "Setting GOOSE_WORKING_DIR environment variable to: {:?}", + working_dir + ); std::env::set_var("GOOSE_WORKING_DIR", working_dir); - + let enabled_configs = goose::config::get_enabled_extensions(); let extension_futures = enabled_configs .into_iter() @@ -651,20 +656,17 @@ async fn restart_agent( Json(payload): Json, ) -> Result { let session_id = payload.session_id.clone(); - + tracing::info!("=== RESTART AGENT START ==="); tracing::info!("Session ID: {}", session_id); - - // First, remove the existing agent (this will shut down MCP extensions) + tracing::info!("Attempting to remove existing agent..."); if let Err(e) = state.agent_manager.remove_session(&session_id).await { tracing::warn!("Agent not found for removal during restart: {}", e); - // Continue anyway - the agent might not exist yet } else { tracing::info!("Successfully removed existing agent"); } - - // Get the session to retrieve configuration + tracing::info!("Fetching session to get configuration..."); let session = SessionManager::get_session(&session_id, false) .await @@ -675,12 +677,14 @@ async fn restart_agent( status: StatusCode::NOT_FOUND, } })?; - + tracing::info!("Session retrieved successfully"); tracing::info!("Session working_dir: {:?}", session.working_dir); - tracing::info!("Session working_dir as string: {}", session.working_dir.display()); - - // Create a new agent (this will use the updated working_dir from the session) + tracing::info!( + "Session working_dir as string: {}", + session.working_dir.display() + ); + tracing::info!("Creating new agent..."); let agent = state .get_agent_for_route(session_id.clone()) @@ -690,21 +694,19 @@ async fn restart_agent( status: code, })?; tracing::info!("New agent created successfully"); - - // Restore the provider and extensions + let provider_result = restore_agent_provider(&agent, &session, &session_id); let extensions_result = restore_agent_extensions(agent.clone(), &session.working_dir); - + let (provider_result, extensions_result) = tokio::join!(provider_result, extensions_result); provider_result?; extensions_result?; - - // Apply recipe if present + let context: HashMap<&str, Value> = HashMap::new(); let desktop_prompt = render_global_file("desktop_prompt.md", &context).expect("Prompt should render"); let mut update_prompt = desktop_prompt; - + if let Some(recipe) = session.recipe { match build_recipe_with_parameter_values( &recipe, @@ -729,11 +731,14 @@ async fn restart_agent( } } agent.extend_system_prompt(update_prompt).await; - + tracing::info!("=== RESTART AGENT COMPLETE ==="); tracing::info!("Final session_id: {}", session_id); - tracing::info!("Agent should now be using working_dir: {}", session.working_dir.display()); - + tracing::info!( + "Agent should now be using working_dir: {}", + session.working_dir.display() + ); + Ok(StatusCode::OK) } From b0824f3bd333ed258acbd4bbc326d35fa175fd82 Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Wed, 10 Dec 2025 13:05:38 -0800 Subject: [PATCH 04/36] change working dir from hub --- crates/goose-server/src/routes/reply.rs | 7 ++ ui/desktop/src/components/ChatInput.tsx | 2 +- .../components/bottom_menu/DirSwitcher.tsx | 107 ++++++++---------- ui/desktop/src/sessions.ts | 6 +- ui/desktop/src/store/newChatState.ts | 37 ++++++ 5 files changed, 97 insertions(+), 62 deletions(-) create mode 100644 ui/desktop/src/store/newChatState.ts diff --git a/crates/goose-server/src/routes/reply.rs b/crates/goose-server/src/routes/reply.rs index 5b98f9c70126..ede7c715192d 100644 --- a/crates/goose-server/src/routes/reply.rs +++ b/crates/goose-server/src/routes/reply.rs @@ -271,6 +271,13 @@ pub async fn reply( } }; + tracing::info!( + "Setting GOOSE_WORKING_DIR for session {}: {:?}", + session_id, + session.working_dir + ); + std::env::set_var("GOOSE_WORKING_DIR", &session.working_dir); + let session_config = SessionConfig { id: session_id.clone(), schedule_id: session.schedule_id.clone(), diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index 3b972dc92ef5..8a0003b2986a 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -1457,7 +1457,7 @@ export default function ChatInput({ {/* Secondary actions and controls row below input */}
{/* Directory path */} - +
diff --git a/ui/desktop/src/components/bottom_menu/DirSwitcher.tsx b/ui/desktop/src/components/bottom_menu/DirSwitcher.tsx index bba42d0e707e..6bd5021391bc 100644 --- a/ui/desktop/src/components/bottom_menu/DirSwitcher.tsx +++ b/ui/desktop/src/components/bottom_menu/DirSwitcher.tsx @@ -1,28 +1,44 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { FolderDot } from 'lucide-react'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/Tooltip'; -import { useChatContext } from '../../contexts/ChatContext'; -import { updateSessionWorkingDir, restartAgent } from '../../api'; +import { updateSessionWorkingDir, restartAgent, getSession } from '../../api'; import { toast } from 'react-toastify'; +import { setWorkingDir, getWorkingDir } from '../../store/newChatState'; interface DirSwitcherProps { - className?: string; + className: string; + sessionId: string | undefined; } -export const DirSwitcher: React.FC = ({ className = '' }) => { +export const DirSwitcher: React.FC = ({ className, sessionId }) => { const [isTooltipOpen, setIsTooltipOpen] = useState(false); const [isDirectoryChooserOpen, setIsDirectoryChooserOpen] = useState(false); - const chatContext = useChatContext(); - const sessionId = chatContext?.chat?.sessionId; - const [currentDir, setCurrentDir] = useState(String(window.appConfig.get('GOOSE_WORKING_DIR'))); + const [currentDir, setCurrentDir] = useState(getWorkingDir()); - const handleDirectoryChange = async () => { - console.log('[DirSwitcher] Starting directory change process'); - console.log('[DirSwitcher] Current sessionId:', JSON.stringify(sessionId)); - console.log('[DirSwitcher] Current working dir:', JSON.stringify(currentDir)); + // Fetch the working directory from the session when sessionId changes + useEffect(() => { + const fetchSessionWorkingDir = async () => { + if (!sessionId) { + // No session - use the pending/default working dir + setCurrentDir(getWorkingDir()); + return; + } - // Open directory chooser dialog for in-place change + try { + const response = await getSession({ path: { session_id: sessionId } }); + if (response.data?.working_dir) { + setCurrentDir(response.data.working_dir); + } + } catch (error) { + console.error('[DirSwitcher] Failed to fetch session working dir:', error); + } + }; + + fetchSessionWorkingDir(); + }, [sessionId]); + + const handleDirectoryChange = async () => { if (isDirectoryChooserOpen) return; setIsDirectoryChooserOpen(true); @@ -33,63 +49,34 @@ export const DirSwitcher: React.FC = ({ className = '' }) => { setIsDirectoryChooserOpen(false); } - console.log('[DirSwitcher] Directory chooser result:', JSON.stringify(result)); + if (result.canceled || result.filePaths.length === 0) { + return; + } - if (!result.canceled && result.filePaths.length > 0 && sessionId) { - const newDir = result.filePaths[0]; - console.log('[DirSwitcher] New directory selected:', JSON.stringify(newDir)); + const newDir = result.filePaths[0]; + // Always update the new chat state so new sessions use it + setWorkingDir(newDir); + setCurrentDir(newDir); + + if (sessionId) { + // Update existing session try { - // Update the working directory on the backend - const updateRequest = { - path: { - session_id: sessionId, - }, - body: { - workingDir: newDir, - }, - }; - console.log('[DirSwitcher] Sending update request:', JSON.stringify(updateRequest)); - - const response = await updateSessionWorkingDir(updateRequest); - console.log('[DirSwitcher] Update response:', JSON.stringify(response)); - - // Restart the agent to pick up the new working directory - console.log('[DirSwitcher] Restarting agent to apply new working directory...'); - const restartRequest = { - body: { - session_id: sessionId, - }, - }; + await updateSessionWorkingDir({ + path: { session_id: sessionId }, + body: { workingDir: newDir }, + }); try { - await restartAgent(restartRequest); - console.log('[DirSwitcher] Agent restarted successfully'); + await restartAgent({ body: { session_id: sessionId } }); } catch (restartError) { - console.error('[DirSwitcher] Failed to restart agent:', JSON.stringify(restartError)); - // Continue anyway - the working directory is still updated in the session + console.error('[DirSwitcher] Failed to restart agent:', restartError); + toast.error('Failed to update working directory'); } - - // Update the local state and config - setCurrentDir(newDir); - - // Send an IPC message to update the config in the main process - window.electron.emit('update-working-dir', sessionId, newDir); - - // Show success message - toast.success(`Working directory changed to ${newDir} and agent restarted`); - - console.log('[DirSwitcher] Working directory updated and agent restarted'); - console.log('[DirSwitcher] Agent will now use:', newDir); } catch (error) { - console.error('[DirSwitcher] Failed to update working directory:', JSON.stringify(error)); - console.error('[DirSwitcher] Error details:', error); + console.error('[DirSwitcher] Failed to update working directory:', error); toast.error('Failed to update working directory'); } - } else { - console.log('[DirSwitcher] Directory change canceled or no sessionId'); - console.log('[DirSwitcher] Canceled:', result.canceled); - console.log('[DirSwitcher] SessionId:', JSON.stringify(sessionId)); } }; diff --git a/ui/desktop/src/sessions.ts b/ui/desktop/src/sessions.ts index 5217dd0f42b0..730917e2a294 100644 --- a/ui/desktop/src/sessions.ts +++ b/ui/desktop/src/sessions.ts @@ -1,5 +1,6 @@ import { Session, startAgent } from './api'; import type { setViewType } from './hooks/useNavigation'; +import { getWorkingDir } from './store/newChatState'; export function resumeSession(session: Session, setView: setViewType) { setView('pair', { @@ -17,9 +18,12 @@ export async function createSession(options?: { recipe_id?: string; recipe_deeplink?: string; } = { - working_dir: window.appConfig.get('GOOSE_WORKING_DIR') as string, + working_dir: getWorkingDir(), }; + // Note: We intentionally don't clear newChatState here + // so that new sessions in the same window continue to use the last selected directory + if (options?.recipeId) { body.recipe_id = options.recipeId; } else if (options?.recipeDeeplink) { diff --git a/ui/desktop/src/store/newChatState.ts b/ui/desktop/src/store/newChatState.ts new file mode 100644 index 000000000000..a6222b83350b --- /dev/null +++ b/ui/desktop/src/store/newChatState.ts @@ -0,0 +1,37 @@ +// Store for pending new chat configuration +// Holds state that will be applied when creating a new session +// This allows changing settings from Hub before any session exists + +interface NewChatState { + workingDir: string | null; + // Future additions: + // extensions?: string[]; + // provider?: string; + // model?: string; +} + +const state: NewChatState = { + workingDir: null, +}; + +export function setWorkingDir(dir: string): void { + state.workingDir = dir; +} + +export function getWorkingDir(): string { + return state.workingDir ?? (window.appConfig.get('GOOSE_WORKING_DIR') as string); +} + +export function clearWorkingDir(): void { + state.workingDir = null; +} + +// Generic getters/setters for future extensibility +export function getNewChatState(): Readonly { + return { ...state }; +} + +export function resetNewChatState(): void { + state.workingDir = null; + // Reset future fields here +} From b379ef4a4f7217380c9af37be34eb2c85475a37a Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Wed, 10 Dec 2025 14:15:16 -0800 Subject: [PATCH 05/36] fix agent not picking up working dir --- .../components/bottom_menu/DirSwitcher.tsx | 23 +++++++++---------- ui/desktop/src/hooks/useAgent.ts | 5 ++-- ui/desktop/src/sessions.ts | 12 ++++++++-- ui/desktop/src/store/newChatState.ts | 6 ++--- 4 files changed, 27 insertions(+), 19 deletions(-) diff --git a/ui/desktop/src/components/bottom_menu/DirSwitcher.tsx b/ui/desktop/src/components/bottom_menu/DirSwitcher.tsx index 6bd5021391bc..357a6b428584 100644 --- a/ui/desktop/src/components/bottom_menu/DirSwitcher.tsx +++ b/ui/desktop/src/components/bottom_menu/DirSwitcher.tsx @@ -14,21 +14,20 @@ export const DirSwitcher: React.FC = ({ className, sessionId } const [isTooltipOpen, setIsTooltipOpen] = useState(false); const [isDirectoryChooserOpen, setIsDirectoryChooserOpen] = useState(false); - const [currentDir, setCurrentDir] = useState(getWorkingDir()); + const [sessionWorkingDir, setSessionWorkingDir] = useState(null); // Fetch the working directory from the session when sessionId changes useEffect(() => { - const fetchSessionWorkingDir = async () => { - if (!sessionId) { - // No session - use the pending/default working dir - setCurrentDir(getWorkingDir()); - return; - } + if (!sessionId) { + setSessionWorkingDir(null); + return; + } + const fetchSessionWorkingDir = async () => { try { const response = await getSession({ path: { session_id: sessionId } }); if (response.data?.working_dir) { - setCurrentDir(response.data.working_dir); + setSessionWorkingDir(response.data.working_dir); } } catch (error) { console.error('[DirSwitcher] Failed to fetch session working dir:', error); @@ -38,6 +37,8 @@ export const DirSwitcher: React.FC = ({ className, sessionId } fetchSessionWorkingDir(); }, [sessionId]); + const currentDir = sessionWorkingDir ?? getWorkingDir(); + const handleDirectoryChange = async () => { if (isDirectoryChooserOpen) return; setIsDirectoryChooserOpen(true); @@ -54,13 +55,9 @@ export const DirSwitcher: React.FC = ({ className, sessionId } } const newDir = result.filePaths[0]; - - // Always update the new chat state so new sessions use it setWorkingDir(newDir); - setCurrentDir(newDir); if (sessionId) { - // Update existing session try { await updateSessionWorkingDir({ path: { session_id: sessionId }, @@ -73,6 +70,8 @@ export const DirSwitcher: React.FC = ({ className, sessionId } console.error('[DirSwitcher] Failed to restart agent:', restartError); toast.error('Failed to update working directory'); } + + setSessionWorkingDir(newDir); } catch (error) { console.error('[DirSwitcher] Failed to update working directory:', error); toast.error('Failed to update working directory'); diff --git a/ui/desktop/src/hooks/useAgent.ts b/ui/desktop/src/hooks/useAgent.ts index 37fea45d973c..1dc6cf62c6fc 100644 --- a/ui/desktop/src/hooks/useAgent.ts +++ b/ui/desktop/src/hooks/useAgent.ts @@ -14,6 +14,7 @@ import { validateConfig, } from '../api'; import { COST_TRACKING_ENABLED } from '../updates'; +import { getWorkingDir } from '../store/newChatState'; export enum AgentState { UNINITIALIZED = 'uninitialized', @@ -157,7 +158,7 @@ export function useAgent(): UseAgentReturn { }) : await startAgent({ body: { - working_dir: window.appConfig.get('GOOSE_WORKING_DIR') as string, + working_dir: getWorkingDir(), ...buildRecipeInput( initContext.recipe, recipeIdFromConfig.current, @@ -178,7 +179,7 @@ export function useAgent(): UseAgentReturn { agentResponse = await startAgent({ body: { - working_dir: window.appConfig.get('GOOSE_WORKING_DIR') as string, + working_dir: getWorkingDir(), ...buildRecipeInput( initContext.recipe, recipeIdFromConfig.current, diff --git a/ui/desktop/src/sessions.ts b/ui/desktop/src/sessions.ts index 730917e2a294..f2c1943ae495 100644 --- a/ui/desktop/src/sessions.ts +++ b/ui/desktop/src/sessions.ts @@ -1,4 +1,4 @@ -import { Session, startAgent } from './api'; +import { Session, startAgent, restartAgent } from './api'; import type { setViewType } from './hooks/useNavigation'; import { getWorkingDir } from './store/newChatState'; @@ -34,7 +34,15 @@ export async function createSession(options?: { body, throwOnError: true, }); - return newAgent.data; + + const session = newAgent.data; + + // Restart agent to ensure it picks up the session's working dir + await restartAgent({ + body: { session_id: session.id }, + }); + + return session; } export async function startNewSession( diff --git a/ui/desktop/src/store/newChatState.ts b/ui/desktop/src/store/newChatState.ts index a6222b83350b..dc7c461e7f0e 100644 --- a/ui/desktop/src/store/newChatState.ts +++ b/ui/desktop/src/store/newChatState.ts @@ -1,6 +1,6 @@ -// Store for pending new chat configuration -// Holds state that will be applied when creating a new session -// This allows changing settings from Hub before any session exists +// Store for new chat configuration +// Acts as a cache that can be updated from UI or synced from session +// Resets on page refresh - defaults to window.appConfig.get('GOOSE_WORKING_DIR') interface NewChatState { workingDir: string | null; From a24a105f5dab9d39a6cdd124bea2c351bb8a4a1c Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Wed, 10 Dec 2025 14:28:44 -0800 Subject: [PATCH 06/36] cargo fmt --- crates/goose-server/src/routes/session.rs | 29 +++++++++++++------ .../tests/test_working_dir_update.rs | 4 +-- crates/goose/src/agents/extension_manager.rs | 12 ++++++-- 3 files changed, 31 insertions(+), 14 deletions(-) diff --git a/crates/goose-server/src/routes/session.rs b/crates/goose-server/src/routes/session.rs index aa8c17795954..4a850b896ff9 100644 --- a/crates/goose-server/src/routes/session.rs +++ b/crates/goose-server/src/routes/session.rs @@ -215,7 +215,7 @@ async fn update_session_working_dir( tracing::info!("=== UPDATE SESSION WORKING DIR START ==="); tracing::info!("Session ID: {}", session_id); tracing::info!("Requested working_dir: {}", request.working_dir); - + let working_dir = request.working_dir.trim(); if working_dir.is_empty() { tracing::error!("Working directory is empty"); @@ -229,7 +229,7 @@ async fn update_session_working_dir( tracing::error!("Directory does not exist: {:?}", path); return Err(StatusCode::BAD_REQUEST); } - + tracing::info!("Checking if path is directory: {:?}", path); if !path.is_dir() { tracing::error!("Path is not a directory: {:?}", path); @@ -237,30 +237,41 @@ async fn update_session_working_dir( } tracing::info!("Directory validation passed, updating session"); - tracing::info!("About to update session {} with path: {:?}", session_id, path); - + tracing::info!( + "About to update session {} with path: {:?}", + session_id, + path + ); + let result = SessionManager::update_session(&session_id) .working_dir(path.clone()) .apply() .await; - + match result { Ok(_) => { tracing::info!("SessionManager::update_session succeeded"); tracing::info!("Successfully updated working directory to: {:?}", path); - + // Let's also verify the update by reading back the session tracing::info!("Verifying update by reading session back..."); match SessionManager::get_session(&session_id, false).await { Ok(session) => { - tracing::info!("Verification SUCCESS: Session {} working_dir is now: {:?}", session_id, session.working_dir); - tracing::info!("Verification: working_dir as string: {}", session.working_dir.display()); + tracing::info!( + "Verification SUCCESS: Session {} working_dir is now: {:?}", + session_id, + session.working_dir + ); + tracing::info!( + "Verification: working_dir as string: {}", + session.working_dir.display() + ); } Err(e) => { tracing::error!("Failed to verify session update: {}", e); } } - + tracing::info!("=== UPDATE SESSION WORKING DIR COMPLETE ==="); Ok(StatusCode::OK) } diff --git a/crates/goose-server/tests/test_working_dir_update.rs b/crates/goose-server/tests/test_working_dir_update.rs index df0d48d08a9f..8637dabb5187 100644 --- a/crates/goose-server/tests/test_working_dir_update.rs +++ b/crates/goose-server/tests/test_working_dir_update.rs @@ -8,7 +8,7 @@ mod test_working_dir_update { // Create a temporary directory for testing let temp_dir = TempDir::new().expect("Failed to create temp dir"); let initial_dir = temp_dir.path().to_path_buf(); - + // Create another temp directory to change to let new_temp_dir = TempDir::new().expect("Failed to create second temp dir"); let new_dir = new_temp_dir.path().to_path_buf(); @@ -44,7 +44,7 @@ mod test_working_dir_update { // Verify the directory was updated assert_eq!(updated_session.working_dir, new_dir); - + // Clean up SessionManager::delete_session(&session.id) .await diff --git a/crates/goose/src/agents/extension_manager.rs b/crates/goose/src/agents/extension_manager.rs index 39c85ad52c87..49f5aab2ebaa 100644 --- a/crates/goose/src/agents/extension_manager.rs +++ b/crates/goose/src/agents/extension_manager.rs @@ -199,15 +199,21 @@ async fn child_process_client( if let Ok(path) = SearchPaths::builder().path() { command.env("PATH", path); } - + // Check if GOOSE_WORKING_DIR is set and use it as the working directory if let Ok(working_dir) = std::env::var("GOOSE_WORKING_DIR") { let working_path = std::path::Path::new(&working_dir); if working_path.exists() && working_path.is_dir() { - tracing::info!("Setting MCP process working directory from GOOSE_WORKING_DIR: {:?}", working_path); + tracing::info!( + "Setting MCP process working directory from GOOSE_WORKING_DIR: {:?}", + working_path + ); command.current_dir(working_path); } else { - tracing::warn!("GOOSE_WORKING_DIR is set but path doesn't exist or isn't a directory: {:?}", working_dir); + tracing::warn!( + "GOOSE_WORKING_DIR is set but path doesn't exist or isn't a directory: {:?}", + working_dir + ); } } else { tracing::info!("GOOSE_WORKING_DIR not set, using default working directory"); From 82dbd94141b519ea19c08e99cd2d8ffe0ee60049 Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Thu, 11 Dec 2025 10:43:25 -0800 Subject: [PATCH 07/36] remove verbose logging --- crates/goose-server/src/routes/agent.rs | 32 +---------- crates/goose-server/src/routes/reply.rs | 5 -- crates/goose-server/src/routes/session.rs | 67 +++-------------------- 3 files changed, 11 insertions(+), 93 deletions(-) diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs index ae4b5321c785..0623c237b6ef 100644 --- a/crates/goose-server/src/routes/agent.rs +++ b/crates/goose-server/src/routes/agent.rs @@ -618,10 +618,6 @@ async fn restore_agent_extensions( agent: Arc, working_dir: &std::path::Path, ) -> Result<(), ErrorResponse> { - tracing::info!( - "Setting GOOSE_WORKING_DIR environment variable to: {:?}", - working_dir - ); std::env::set_var("GOOSE_WORKING_DIR", working_dir); let enabled_configs = goose::config::get_enabled_extensions(); @@ -661,17 +657,9 @@ async fn restart_agent( ) -> Result { let session_id = payload.session_id.clone(); - tracing::info!("=== RESTART AGENT START ==="); - tracing::info!("Session ID: {}", session_id); + // Remove existing agent (ignore error if not found) + let _ = state.agent_manager.remove_session(&session_id).await; - tracing::info!("Attempting to remove existing agent..."); - if let Err(e) = state.agent_manager.remove_session(&session_id).await { - tracing::warn!("Agent not found for removal during restart: {}", e); - } else { - tracing::info!("Successfully removed existing agent"); - } - - tracing::info!("Fetching session to get configuration..."); let session = SessionManager::get_session(&session_id, false) .await .map_err(|err| { @@ -682,14 +670,6 @@ async fn restart_agent( } })?; - tracing::info!("Session retrieved successfully"); - tracing::info!("Session working_dir: {:?}", session.working_dir); - tracing::info!( - "Session working_dir as string: {}", - session.working_dir.display() - ); - - tracing::info!("Creating new agent..."); let agent = state .get_agent_for_route(session_id.clone()) .await @@ -697,7 +677,6 @@ async fn restart_agent( message: "Failed to create new agent during restart".into(), status: code, })?; - tracing::info!("New agent created successfully"); let provider_result = restore_agent_provider(&agent, &session, &session_id); let extensions_result = restore_agent_extensions(agent.clone(), &session.working_dir); @@ -736,13 +715,6 @@ async fn restart_agent( } agent.extend_system_prompt(update_prompt).await; - tracing::info!("=== RESTART AGENT COMPLETE ==="); - tracing::info!("Final session_id: {}", session_id); - tracing::info!( - "Agent should now be using working_dir: {}", - session.working_dir.display() - ); - Ok(StatusCode::OK) } diff --git a/crates/goose-server/src/routes/reply.rs b/crates/goose-server/src/routes/reply.rs index ede7c715192d..4c90b66da650 100644 --- a/crates/goose-server/src/routes/reply.rs +++ b/crates/goose-server/src/routes/reply.rs @@ -271,11 +271,6 @@ pub async fn reply( } }; - tracing::info!( - "Setting GOOSE_WORKING_DIR for session {}: {:?}", - session_id, - session.working_dir - ); std::env::set_var("GOOSE_WORKING_DIR", &session.working_dir); let session_config = SessionConfig { diff --git a/crates/goose-server/src/routes/session.rs b/crates/goose-server/src/routes/session.rs index 4a850b896ff9..4df382a9c7ff 100644 --- a/crates/goose-server/src/routes/session.rs +++ b/crates/goose-server/src/routes/session.rs @@ -212,75 +212,26 @@ async fn update_session_working_dir( Path(session_id): Path, Json(request): Json, ) -> Result { - tracing::info!("=== UPDATE SESSION WORKING DIR START ==="); - tracing::info!("Session ID: {}", session_id); - tracing::info!("Requested working_dir: {}", request.working_dir); - let working_dir = request.working_dir.trim(); if working_dir.is_empty() { - tracing::error!("Working directory is empty"); return Err(StatusCode::BAD_REQUEST); } - // Verify the directory exists let path = PathBuf::from(working_dir); - tracing::info!("Checking if path exists: {:?}", path); - if !path.exists() { - tracing::error!("Directory does not exist: {:?}", path); - return Err(StatusCode::BAD_REQUEST); - } - - tracing::info!("Checking if path is directory: {:?}", path); - if !path.is_dir() { - tracing::error!("Path is not a directory: {:?}", path); + if !path.exists() || !path.is_dir() { return Err(StatusCode::BAD_REQUEST); } - tracing::info!("Directory validation passed, updating session"); - tracing::info!( - "About to update session {} with path: {:?}", - session_id, - path - ); - - let result = SessionManager::update_session(&session_id) - .working_dir(path.clone()) + SessionManager::update_session(&session_id) + .working_dir(path) .apply() - .await; - - match result { - Ok(_) => { - tracing::info!("SessionManager::update_session succeeded"); - tracing::info!("Successfully updated working directory to: {:?}", path); - - // Let's also verify the update by reading back the session - tracing::info!("Verifying update by reading session back..."); - match SessionManager::get_session(&session_id, false).await { - Ok(session) => { - tracing::info!( - "Verification SUCCESS: Session {} working_dir is now: {:?}", - session_id, - session.working_dir - ); - tracing::info!( - "Verification: working_dir as string: {}", - session.working_dir.display() - ); - } - Err(e) => { - tracing::error!("Failed to verify session update: {}", e); - } - } - - tracing::info!("=== UPDATE SESSION WORKING DIR COMPLETE ==="); - Ok(StatusCode::OK) - } - Err(e) => { + .await + .map_err(|e| { tracing::error!("Failed to update session working directory: {}", e); - tracing::error!("=== UPDATE SESSION WORKING DIR FAILED ==="); - Err(StatusCode::INTERNAL_SERVER_ERROR) - } - } + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(StatusCode::OK) } #[utoipa::path( From 01917bcca8ac4ed2a6aca0d480d53b974717029b Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Thu, 11 Dec 2025 10:49:27 -0800 Subject: [PATCH 08/36] change mention popover to use new working dir --- ui/desktop/src/components/ChatInput.tsx | 25 +++++++++++++++++++- ui/desktop/src/components/MentionPopover.tsx | 6 +++-- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index 8a0003b2986a..239a6ed71b94 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -27,9 +27,10 @@ import { Recipe } from '../recipe'; import MessageQueue from './MessageQueue'; import { detectInterruption } from '../utils/interruptionDetector'; import { DiagnosticsModal } from './ui/DownloadDiagnostics'; -import { Message } from '../api'; +import { getSession, Message } from '../api'; import CreateRecipeFromSessionModal from './recipes/CreateRecipeFromSessionModal'; import CreateEditRecipeModal from './recipes/CreateEditRecipeModal'; +import { getWorkingDir } from '../store/newChatState'; interface QueuedMessage { id: string; @@ -143,6 +144,27 @@ export default function ChatInput({ const [showCreateRecipeModal, setShowCreateRecipeModal] = useState(false); const [showEditRecipeModal, setShowEditRecipeModal] = useState(false); const [isFilePickerOpen, setIsFilePickerOpen] = useState(false); + const [sessionWorkingDir, setSessionWorkingDir] = useState(null); + + useEffect(() => { + if (!sessionId) { + setSessionWorkingDir(null); + return; + } + + const fetchSessionWorkingDir = async () => { + try { + const response = await getSession({ path: { session_id: sessionId } }); + if (response.data?.working_dir) { + setSessionWorkingDir(response.data.working_dir); + } + } catch (error) { + console.error('[ChatInput] Failed to fetch session working dir:', error); + } + }; + + fetchSessionWorkingDir(); + }, [sessionId]); // Save queue state (paused/interrupted) to storage useEffect(() => { @@ -1571,6 +1593,7 @@ export default function ChatInput({ onSelectedIndexChange={(index) => setMentionPopover((prev) => ({ ...prev, selectedIndex: index })) } + workingDir={sessionWorkingDir ?? getWorkingDir()} /> {sessionId && showCreateRecipeModal && ( diff --git a/ui/desktop/src/components/MentionPopover.tsx b/ui/desktop/src/components/MentionPopover.tsx index 0220ab617dd8..44d4d01164b5 100644 --- a/ui/desktop/src/components/MentionPopover.tsx +++ b/ui/desktop/src/components/MentionPopover.tsx @@ -9,6 +9,7 @@ import { } from 'react'; import { ItemIcon } from './ItemIcon'; import { CommandType, getSlashCommands } from '../api'; +import { getWorkingDir } from '../store/newChatState'; type DisplayItemType = CommandType | 'Directory' | 'File'; @@ -41,6 +42,7 @@ interface MentionPopoverProps { isSlashCommand: boolean; selectedIndex: number; onSelectedIndexChange: (index: number) => void; + workingDir?: string; } // Enhanced fuzzy matching algorithm @@ -121,6 +123,7 @@ const MentionPopover = forwardRef< isSlashCommand, selectedIndex, onSelectedIndexChange, + workingDir, }, ref ) => { @@ -128,8 +131,7 @@ const MentionPopover = forwardRef< const [isLoading, setIsLoading] = useState(false); const popoverRef = useRef(null); const listRef = useRef(null); - - const currentWorkingDir = window.appConfig.get('GOOSE_WORKING_DIR') as string; + const currentWorkingDir = workingDir ?? getWorkingDir(); const scanDirectoryFromRoot = useCallback( async (dirPath: string, relativePath = '', depth = 0): Promise => { From c4c2c9cd2580618a02835b9db481909c088ea0ed Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Thu, 11 Dec 2025 11:33:40 -0800 Subject: [PATCH 09/36] set as recent dir and use for other new window launches --- ui/desktop/src/components/LauncherView.tsx | 6 ++---- ui/desktop/src/components/Layout/AppLayout.tsx | 6 ++---- ui/desktop/src/components/ParameterInputModal.tsx | 9 +++------ .../src/components/bottom_menu/DirSwitcher.tsx | 2 ++ ui/desktop/src/components/recipes/RecipesView.tsx | 5 +++-- ui/desktop/src/main.ts | 14 +++++++++++++- ui/desktop/src/preload.ts | 2 ++ 7 files changed, 27 insertions(+), 17 deletions(-) diff --git a/ui/desktop/src/components/LauncherView.tsx b/ui/desktop/src/components/LauncherView.tsx index 60b0ed3f7ebd..802d584867fc 100644 --- a/ui/desktop/src/components/LauncherView.tsx +++ b/ui/desktop/src/components/LauncherView.tsx @@ -1,4 +1,5 @@ import { useRef, useState } from 'react'; +import { getWorkingDir } from '../store/newChatState'; export default function LauncherView() { const [query, setQuery] = useState(''); @@ -7,11 +8,8 @@ export default function LauncherView() { const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (query.trim()) { - // Create a new chat window with the query - const workingDir = window.appConfig?.get('GOOSE_WORKING_DIR') as string; - window.electron.createChatWindow(query, workingDir); + window.electron.createChatWindow(query, getWorkingDir()); setQuery(''); - // Don't manually close - the blur handler will close the launcher when the new window takes focus } }; diff --git a/ui/desktop/src/components/Layout/AppLayout.tsx b/ui/desktop/src/components/Layout/AppLayout.tsx index 26029c48f63e..cdcf030558ea 100644 --- a/ui/desktop/src/components/Layout/AppLayout.tsx +++ b/ui/desktop/src/components/Layout/AppLayout.tsx @@ -5,6 +5,7 @@ import { View, ViewOptions } from '../../utils/navigationUtils'; import { AppWindowMac, AppWindow } from 'lucide-react'; import { Button } from '../ui/button'; import { Sidebar, SidebarInset, SidebarProvider, SidebarTrigger, useSidebar } from '../ui/sidebar'; +import { getWorkingDir } from '../../store/newChatState'; const AppLayoutContent: React.FC = () => { const navigate = useNavigate(); @@ -66,10 +67,7 @@ const AppLayoutContent: React.FC = () => { }; const handleNewWindow = () => { - window.electron.createChatWindow( - undefined, - window.appConfig.get('GOOSE_WORKING_DIR') as string | undefined - ); + window.electron.createChatWindow(undefined, getWorkingDir()); }; return ( diff --git a/ui/desktop/src/components/ParameterInputModal.tsx b/ui/desktop/src/components/ParameterInputModal.tsx index fec16b6d1deb..e49469d96c8a 100644 --- a/ui/desktop/src/components/ParameterInputModal.tsx +++ b/ui/desktop/src/components/ParameterInputModal.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect } from 'react'; import { Parameter } from '../recipe'; import { Button } from './ui/button'; +import { getWorkingDir } from '../store/newChatState'; interface ParameterInputModalProps { parameters: Parameter[]; @@ -72,16 +73,12 @@ const ParameterInputModal: React.FC = ({ const handleCancelOption = (option: 'new-chat' | 'back-to-form'): void => { if (option === 'new-chat') { - // Create a new chat window without recipe config try { - const workingDir = window.appConfig.get('GOOSE_WORKING_DIR'); - console.log(`Creating new chat window without recipe, working dir: ${workingDir}`); - window.electron.createChatWindow(undefined, workingDir as string); - // Close the current window after creating the new one + const workingDir = getWorkingDir(); + window.electron.createChatWindow(undefined, workingDir); window.electron.hideWindow(); } catch (error) { console.error('Error creating new window:', error); - // Fallback: just close the modal onClose(); } } else { diff --git a/ui/desktop/src/components/bottom_menu/DirSwitcher.tsx b/ui/desktop/src/components/bottom_menu/DirSwitcher.tsx index 357a6b428584..a48394e3f529 100644 --- a/ui/desktop/src/components/bottom_menu/DirSwitcher.tsx +++ b/ui/desktop/src/components/bottom_menu/DirSwitcher.tsx @@ -57,6 +57,8 @@ export const DirSwitcher: React.FC = ({ className, sessionId } const newDir = result.filePaths[0]; setWorkingDir(newDir); + await window.electron.addRecentDir(newDir); + if (sessionId) { try { await updateSessionWorkingDir({ diff --git a/ui/desktop/src/components/recipes/RecipesView.tsx b/ui/desktop/src/components/recipes/RecipesView.tsx index 3c278451df1b..bda9f86299a5 100644 --- a/ui/desktop/src/components/recipes/RecipesView.tsx +++ b/ui/desktop/src/components/recipes/RecipesView.tsx @@ -34,6 +34,7 @@ import { CronPicker } from '../schedule/CronPicker'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../ui/dialog'; import { SearchView } from '../conversation/SearchView'; import cronstrue from 'cronstrue'; +import { getWorkingDir } from '../../store/newChatState'; export default function RecipesView() { const setView = useNavigation(); @@ -118,7 +119,7 @@ export default function RecipesView() { try { const newAgent = await startAgent({ body: { - working_dir: window.appConfig.get('GOOSE_WORKING_DIR') as string, + working_dir: getWorkingDir(), recipe, }, throwOnError: true, @@ -137,7 +138,7 @@ export default function RecipesView() { const handleStartRecipeChatInNewWindow = (recipeId: string) => { window.electron.createChatWindow( undefined, - window.appConfig.get('GOOSE_WORKING_DIR') as string, + getWorkingDir(), undefined, undefined, 'pair', diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index ecf872bc70f8..7d91a1418c47 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -1151,7 +1151,6 @@ ipcMain.handle('open-external', async (_event, url: string) => { } }); -// Handle directory chooser ipcMain.handle('directory-chooser', async () => { const result = await dialog.showOpenDialog({ properties: ['openFile', 'openDirectory', 'createDirectory'], @@ -1160,6 +1159,19 @@ ipcMain.handle('directory-chooser', async () => { return result; }); +ipcMain.handle('add-recent-dir', async (_event, dir: string) => { + try { + if (!dir) { + return false; + } + addRecentDir(dir); + return true; + } catch (error) { + console.error('Error adding recent dir:', error); + return false; + } +}); + // Handle scheduling engine settings ipcMain.handle('get-settings', () => { try { diff --git a/ui/desktop/src/preload.ts b/ui/desktop/src/preload.ts index 0cf86968f1fd..1b705021b45a 100644 --- a/ui/desktop/src/preload.ts +++ b/ui/desktop/src/preload.ts @@ -117,6 +117,7 @@ type ElectronAPI = { hasAcceptedRecipeBefore: (recipe: Recipe) => Promise; recordRecipeHash: (recipe: Recipe) => Promise; openDirectoryInExplorer: (directoryPath: string) => Promise; + addRecentDir: (dir: string) => Promise; }; type AppConfigAPI = { @@ -251,6 +252,7 @@ const electronAPI: ElectronAPI = { recordRecipeHash: (recipe: Recipe) => ipcRenderer.invoke('record-recipe-hash', recipe), openDirectoryInExplorer: (directoryPath: string) => ipcRenderer.invoke('open-directory-in-explorer', directoryPath), + addRecentDir: (dir: string) => ipcRenderer.invoke('add-recent-dir', dir), }; const appConfigAPI: AppConfigAPI = { From 646fe880fcf4dc66bf8829bd9f7cc92796fb0c2a Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Thu, 11 Dec 2025 13:49:38 -0800 Subject: [PATCH 10/36] each extension gets the correct working directory for its session, even when multiple sessions are active concurrently --- crates/goose-cli/src/commands/acp.rs | 2 +- crates/goose-cli/src/commands/configure.rs | 2 +- crates/goose-cli/src/commands/web.rs | 2 +- crates/goose-cli/src/session/builder.rs | 4 +- crates/goose-cli/src/session/mod.rs | 8 +-- crates/goose-server/src/routes/agent.rs | 47 +++++++-------- crates/goose/examples/agent.rs | 2 +- crates/goose/src/agents/agent.rs | 8 ++- crates/goose/src/agents/extension_manager.rs | 57 +++++++++++++------ .../src/agents/extension_manager_extension.rs | 2 +- crates/goose/src/agents/reply_parts.rs | 19 ++++--- crates/goose/src/agents/subagent_handler.rs | 2 +- crates/goose/src/scheduler.rs | 2 +- crates/goose/tests/agent.rs | 2 +- crates/goose/tests/mcp_integration_test.rs | 4 +- ui/desktop/src/App.tsx | 6 +- 16 files changed, 98 insertions(+), 71 deletions(-) diff --git a/crates/goose-cli/src/commands/acp.rs b/crates/goose-cli/src/commands/acp.rs index 8c9684a037c8..842ee59e605a 100644 --- a/crates/goose-cli/src/commands/acp.rs +++ b/crates/goose-cli/src/commands/acp.rs @@ -256,7 +256,7 @@ impl GooseAcpAgent { set.spawn(async move { ( extension.name(), - agent_ptr_clone.add_extension(extension.clone()).await, + agent_ptr_clone.add_extension(extension.clone(), None).await, ) }); } diff --git a/crates/goose-cli/src/commands/configure.rs b/crates/goose-cli/src/commands/configure.rs index 407d755d748c..6dc6c8d81320 100644 --- a/crates/goose-cli/src/commands/configure.rs +++ b/crates/goose-cli/src/commands/configure.rs @@ -1464,7 +1464,7 @@ pub async fn configure_tool_permissions_dialog() -> anyhow::Result<()> { agent.update_provider(new_provider, &session.id).await?; if let Some(config) = get_extension_by_name(&selected_extension_name) { agent - .add_extension(config.clone()) + .add_extension(config.clone(), None) .await .unwrap_or_else(|_| { println!( diff --git a/crates/goose-cli/src/commands/web.rs b/crates/goose-cli/src/commands/web.rs index c6aa47f6b82e..7c28854df416 100644 --- a/crates/goose-cli/src/commands/web.rs +++ b/crates/goose-cli/src/commands/web.rs @@ -166,7 +166,7 @@ pub async fn handle_web( let enabled_configs = goose::config::get_enabled_extensions(); for config in enabled_configs { - if let Err(e) = agent.add_extension(config.clone()).await { + if let Err(e) = agent.add_extension(config.clone(), None).await { eprintln!("Warning: Failed to load extension {}: {}", config.name(), e); } } diff --git a/crates/goose-cli/src/session/builder.rs b/crates/goose-cli/src/session/builder.rs index 947e2d334950..2a938d2d8942 100644 --- a/crates/goose-cli/src/session/builder.rs +++ b/crates/goose-cli/src/session/builder.rs @@ -163,7 +163,7 @@ async fn offer_extension_debugging_help( let extensions = get_all_extensions(); for ext_wrapper in extensions { if ext_wrapper.enabled && ext_wrapper.config.name() == "developer" { - if let Err(e) = debug_agent.add_extension(ext_wrapper.config).await { + if let Err(e) = debug_agent.add_extension(ext_wrapper.config, None).await { // If we can't add developer extension, continue without it eprintln!( "Note: Could not load developer extension for debugging: {}", @@ -462,7 +462,7 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession { set.spawn(async move { ( extension.name(), - agent_ptr.add_extension(extension.clone()).await, + agent_ptr.add_extension(extension.clone(), None).await, ) }); } diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index 6c206a9a74b4..9867d0249777 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -237,7 +237,7 @@ impl CliSession { }; self.agent - .add_extension(config) + .add_extension(config, None) .await .map_err(|e| anyhow::anyhow!("Failed to start extension: {}", e))?; @@ -267,7 +267,7 @@ impl CliSession { }; self.agent - .add_extension(config) + .add_extension(config, None) .await .map_err(|e| anyhow::anyhow!("Failed to start extension: {}", e))?; @@ -298,7 +298,7 @@ impl CliSession { }; self.agent - .add_extension(config) + .add_extension(config, None) .await .map_err(|e| anyhow::anyhow!("Failed to start extension: {}", e))?; @@ -334,7 +334,7 @@ impl CliSession { } }; self.agent - .add_extension(config) + .add_extension(config, None) .await .map_err(|e| anyhow::anyhow!("Failed to start builtin extension: {}", e))?; } diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs index 0623c237b6ef..643ab10e383d 100644 --- a/crates/goose-server/src/routes/agent.rs +++ b/crates/goose-server/src/routes/agent.rs @@ -268,31 +268,11 @@ async fn resume_agent( }) }; - let extensions_result = async { - let enabled_configs = goose::config::get_enabled_extensions(); - let agent_clone = agent.clone(); - - let extension_futures = enabled_configs - .into_iter() - .map(|config| { - let config_clone = config.clone(); - let agent_ref = agent_clone.clone(); - - async move { - if let Err(e) = agent_ref.add_extension(config_clone.clone()).await { - warn!("Failed to load extension {}: {}", config_clone.name(), e); - } - Ok::<_, ErrorResponse>(()) - } - }) - .collect::>(); - - futures::future::join_all(extension_futures).await; - Ok::<(), ErrorResponse>(()) // Fixed type annotation - }; + let extensions_result = restore_agent_extensions(agent.clone(), &session.working_dir); - let (provider_result, _) = tokio::join!(provider_result, extensions_result); + let (provider_result, extensions_result) = tokio::join!(provider_result, extensions_result); provider_result?; + extensions_result?; } Ok(Json(session)) @@ -513,9 +493,19 @@ async fn agent_add_extension( State(state): State>, Json(request): Json, ) -> Result { + let session = SessionManager::get_session(&request.session_id, false) + .await + .map_err(|err| { + error!("Failed to get session for add_extension: {}", err); + ErrorResponse { + message: format!("Failed to get session: {}", err), + status: StatusCode::NOT_FOUND, + } + })?; + let agent = state.get_agent(request.session_id).await?; agent - .add_extension(request.config) + .add_extension(request.config, Some(session.working_dir)) .await .map_err(|e| ErrorResponse::internal(format!("Failed to add extension: {}", e)))?; Ok(StatusCode::OK) @@ -618,17 +608,20 @@ async fn restore_agent_extensions( agent: Arc, working_dir: &std::path::Path, ) -> Result<(), ErrorResponse> { - std::env::set_var("GOOSE_WORKING_DIR", working_dir); - + let working_dir_buf = working_dir.to_path_buf(); let enabled_configs = goose::config::get_enabled_extensions(); let extension_futures = enabled_configs .into_iter() .map(|config| { let config_clone = config.clone(); let agent_ref = agent.clone(); + let wd = working_dir_buf.clone(); async move { - if let Err(e) = agent_ref.add_extension(config_clone.clone()).await { + if let Err(e) = agent_ref + .add_extension(config_clone.clone(), Some(wd)) + .await + { warn!("Failed to load extension {}: {}", config_clone.name(), e); } Ok::<_, ErrorResponse>(()) diff --git a/crates/goose/examples/agent.rs b/crates/goose/examples/agent.rs index 4e4bb5795903..59d83197519e 100644 --- a/crates/goose/examples/agent.rs +++ b/crates/goose/examples/agent.rs @@ -33,7 +33,7 @@ async fn main() -> anyhow::Result<()> { DEFAULT_EXTENSION_TIMEOUT, ) .with_args(vec!["mcp", "developer"]); - agent.add_extension(config).await?; + agent.add_extension(config, None).await?; println!("Extensions:"); for extension in agent.list_extensions().await { diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index ef4d5f69197b..a1edb82d27f2 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -655,7 +655,11 @@ impl Agent { Ok(()) } - pub async fn add_extension(&self, extension: ExtensionConfig) -> ExtensionResult<()> { + pub async fn add_extension( + &self, + extension: ExtensionConfig, + working_dir: Option, + ) -> ExtensionResult<()> { match &extension { ExtensionConfig::Frontend { tools, @@ -684,7 +688,7 @@ impl Agent { } _ => { self.extension_manager - .add_extension(extension.clone()) + .add_extension(extension.clone(), working_dir) .await?; } } diff --git a/crates/goose/src/agents/extension_manager.rs b/crates/goose/src/agents/extension_manager.rs index 49f5aab2ebaa..d8d7341eee19 100644 --- a/crates/goose/src/agents/extension_manager.rs +++ b/crates/goose/src/agents/extension_manager.rs @@ -191,6 +191,7 @@ async fn child_process_client( mut command: Command, timeout: &Option, provider: SharedProvider, + working_dir: Option<&PathBuf>, ) -> ExtensionResult { #[cfg(unix)] command.process_group(0); @@ -200,23 +201,25 @@ async fn child_process_client( command.env("PATH", path); } - // Check if GOOSE_WORKING_DIR is set and use it as the working directory - if let Ok(working_dir) = std::env::var("GOOSE_WORKING_DIR") { - let working_path = std::path::Path::new(&working_dir); - if working_path.exists() && working_path.is_dir() { - tracing::info!( - "Setting MCP process working directory from GOOSE_WORKING_DIR: {:?}", - working_path - ); - command.current_dir(working_path); + // Use explicitly passed working_dir, falling back to GOOSE_WORKING_DIR env var + let effective_working_dir = working_dir + .map(|p| p.to_path_buf()) + .or_else(|| std::env::var("GOOSE_WORKING_DIR").ok().map(PathBuf::from)); + + if let Some(ref dir) = effective_working_dir { + if dir.exists() && dir.is_dir() { + tracing::info!("Setting MCP process working directory: {:?}", dir); + command.current_dir(dir); + // Also set GOOSE_WORKING_DIR env var for the child process + command.env("GOOSE_WORKING_DIR", dir); } else { tracing::warn!( - "GOOSE_WORKING_DIR is set but path doesn't exist or isn't a directory: {:?}", - working_dir + "Working directory doesn't exist or isn't a directory: {:?}", + dir ); } } else { - tracing::info!("GOOSE_WORKING_DIR not set, using default working directory"); + tracing::info!("No working directory specified, using default"); } let (transport, mut stderr) = TokioChildProcess::builder(command) @@ -307,7 +310,11 @@ impl ExtensionManager { .any(|ext| ext.supports_resources()) } - pub async fn add_extension(&self, config: ExtensionConfig) -> ExtensionResult<()> { + pub async fn add_extension( + &self, + config: ExtensionConfig, + working_dir: Option, + ) -> ExtensionResult<()> { let config_name = config.key().to_string(); let sanitized_name = normalize(config_name.clone()); let mut temp_dir = None; @@ -514,7 +521,13 @@ impl ExtensionManager { command.args(args).envs(all_envs); }); - let client = child_process_client(command, timeout, self.provider.clone()).await?; + let client = child_process_client( + command, + timeout, + self.provider.clone(), + working_dir.as_ref(), + ) + .await?; Box::new(client) } ExtensionConfig::Builtin { @@ -543,7 +556,13 @@ impl ExtensionManager { let command = Command::new(cmd).configure(|command| { command.arg("mcp").arg(name); }); - let client = child_process_client(command, timeout, self.provider.clone()).await?; + let client = child_process_client( + command, + timeout, + self.provider.clone(), + working_dir.as_ref(), + ) + .await?; Box::new(client) } ExtensionConfig::Platform { name, .. } => { @@ -579,7 +598,13 @@ impl ExtensionManager { command.arg("python").arg(file_path.to_str().unwrap()); }); - let client = child_process_client(command, timeout, self.provider.clone()).await?; + let client = child_process_client( + command, + timeout, + self.provider.clone(), + working_dir.as_ref(), + ) + .await?; Box::new(client) } diff --git a/crates/goose/src/agents/extension_manager_extension.rs b/crates/goose/src/agents/extension_manager_extension.rs index 099ec70b6f57..aa501326e57e 100644 --- a/crates/goose/src/agents/extension_manager_extension.rs +++ b/crates/goose/src/agents/extension_manager_extension.rs @@ -246,7 +246,7 @@ impl ExtensionManagerClient { }; let result = extension_manager - .add_extension(config) + .add_extension(config, None) .await .map(|_| { vec![Content::text(format!( diff --git a/crates/goose/src/agents/reply_parts.rs b/crates/goose/src/agents/reply_parts.rs index 40b1a99d6a8a..14abe766a627 100644 --- a/crates/goose/src/agents/reply_parts.rs +++ b/crates/goose/src/agents/reply_parts.rs @@ -470,14 +470,17 @@ mod tests { ]; agent - .add_extension(crate::agents::extension::ExtensionConfig::Frontend { - name: "frontend".to_string(), - description: "desc".to_string(), - tools: frontend_tools, - instructions: None, - bundled: None, - available_tools: vec![], - }) + .add_extension( + crate::agents::extension::ExtensionConfig::Frontend { + name: "frontend".to_string(), + description: "desc".to_string(), + tools: frontend_tools, + instructions: None, + bundled: None, + available_tools: vec![], + }, + None, + ) .await .unwrap(); diff --git a/crates/goose/src/agents/subagent_handler.rs b/crates/goose/src/agents/subagent_handler.rs index 2d6f7f8167a9..a58bae498bfd 100644 --- a/crates/goose/src/agents/subagent_handler.rs +++ b/crates/goose/src/agents/subagent_handler.rs @@ -122,7 +122,7 @@ fn get_agent_messages( .map_err(|e| anyhow!("Failed to set provider on sub agent: {}", e))?; for extension in task_config.extensions { - if let Err(e) = agent.add_extension(extension.clone()).await { + if let Err(e) = agent.add_extension(extension.clone(), None).await { debug!( "Failed to add extension '{}' to subagent: {}", extension.name(), diff --git a/crates/goose/src/scheduler.rs b/crates/goose/src/scheduler.rs index d9c01b9700e2..453c11dd3f7d 100644 --- a/crates/goose/src/scheduler.rs +++ b/crates/goose/src/scheduler.rs @@ -731,7 +731,7 @@ async fn execute_job( if let Some(ref extensions) = recipe.extensions { for ext in extensions { - agent.add_extension(ext.clone()).await?; + agent.add_extension(ext.clone(), None).await?; } } diff --git a/crates/goose/tests/agent.rs b/crates/goose/tests/agent.rs index 85485b5c9848..92d1dbf2937b 100644 --- a/crates/goose/tests/agent.rs +++ b/crates/goose/tests/agent.rs @@ -486,7 +486,7 @@ mod tests { }; agent - .add_extension(ext_config) + .add_extension(ext_config, None) .await .expect("Failed to add extension manager"); agent diff --git a/crates/goose/tests/mcp_integration_test.rs b/crates/goose/tests/mcp_integration_test.rs index df1b70d36fa5..c5d3cd27181a 100644 --- a/crates/goose/tests/mcp_integration_test.rs +++ b/crates/goose/tests/mcp_integration_test.rs @@ -260,7 +260,9 @@ async fn test_replayed_session( #[allow(clippy::redundant_closure_call)] let result = (async || -> Result<(), Box> { - extension_manager.add_extension(extension_config).await?; + extension_manager + .add_extension(extension_config, None) + .await?; let mut results = Vec::new(); for tool_call in tool_calls { let tool_call = CallToolRequestParam { diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index 13ca9aaef7c1..f87d59c5715b 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -17,6 +17,7 @@ import AnnouncementModal from './components/AnnouncementModal'; import TelemetryOptOutModal from './components/TelemetryOptOutModal'; import ProviderGuard from './components/ProviderGuard'; import { createSession } from './sessions'; +import { getWorkingDir } from './store/newChatState'; import { ChatType } from './types/chat'; import Hub from './components/Hub'; @@ -444,9 +445,8 @@ export function AppInner() { if ((isMac ? event.metaKey : event.ctrlKey) && event.key === 'n') { event.preventDefault(); try { - const workingDir = window.appConfig?.get('GOOSE_WORKING_DIR'); - console.log(`Creating new chat window with working dir: ${workingDir}`); - window.electron.createChatWindow(undefined, workingDir as string); + const workingDir = getWorkingDir(); + window.electron.createChatWindow(undefined, workingDir); } catch (error) { console.error('Error creating new window:', error); } From 399277a9e64829d51f0e98b6e7b5abff4ff0f7ca Mon Sep 17 00:00:00 2001 From: Zane <75694352+zanesq@users.noreply.github.com> Date: Fri, 12 Dec 2025 16:18:22 -0800 Subject: [PATCH 11/36] Add working dir to MOIM (#6081) --- crates/goose/src/agents/agent.rs | 1 + crates/goose/src/agents/extension_manager.rs | 8 ++++++-- crates/goose/src/agents/moim.rs | 16 ++++++++++++---- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index a1edb82d27f2..248c4d21d59e 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -999,6 +999,7 @@ impl Agent { let conversation_with_moim = super::moim::inject_moim( conversation.clone(), &self.extension_manager, + &working_dir, ).await; let mut stream = Self::stream_response_from_provider( diff --git a/crates/goose/src/agents/extension_manager.rs b/crates/goose/src/agents/extension_manager.rs index d8d7341eee19..7dfc7b749ae9 100644 --- a/crates/goose/src/agents/extension_manager.rs +++ b/crates/goose/src/agents/extension_manager.rs @@ -1223,9 +1223,13 @@ impl ExtensionManager { .map(|ext| ext.get_client()) } - pub async fn collect_moim(&self) -> Option { + pub async fn collect_moim(&self, working_dir: &std::path::Path) -> Option { let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(); - let mut content = format!("\nDatetime: {}\n", timestamp); + let mut content = format!( + "\nDatetime: {}\nWorking directory: {}\n", + timestamp, + working_dir.display() + ); let extensions = self.extensions.lock().await; for (name, extension) in extensions.iter() { diff --git a/crates/goose/src/agents/moim.rs b/crates/goose/src/agents/moim.rs index d920f3b7dc74..886087cb25e8 100644 --- a/crates/goose/src/agents/moim.rs +++ b/crates/goose/src/agents/moim.rs @@ -2,6 +2,7 @@ use crate::agents::extension_manager::ExtensionManager; use crate::conversation::message::Message; use crate::conversation::{fix_conversation, Conversation}; use rmcp::model::Role; +use std::path::Path; // Test-only utility. Do not use in production code. No `test` directive due to call outside crate. thread_local! { @@ -11,12 +12,13 @@ thread_local! { pub async fn inject_moim( conversation: Conversation, extension_manager: &ExtensionManager, + working_dir: &Path, ) -> Conversation { if SKIP.with(|f| f.get()) { return conversation; } - if let Some(moim) = extension_manager.collect_moim().await { + if let Some(moim) = extension_manager.collect_moim(working_dir).await { let mut messages = conversation.messages().clone(); let idx = messages .iter() @@ -45,17 +47,19 @@ pub async fn inject_moim( mod tests { use super::*; use rmcp::model::CallToolRequestParam; + use std::path::PathBuf; #[tokio::test] async fn test_moim_injection_before_assistant() { let em = ExtensionManager::new_without_provider(); + let working_dir = PathBuf::from("/test/dir"); let conv = Conversation::new_unvalidated(vec![ Message::user().with_text("Hello"), Message::assistant().with_text("Hi"), Message::user().with_text("Bye"), ]); - let result = inject_moim(conv, &em).await; + let result = inject_moim(conv, &em, &working_dir).await; let msgs = result.messages(); assert_eq!(msgs.len(), 3); @@ -70,14 +74,16 @@ mod tests { .join(""); assert!(merged_content.contains("Hello")); assert!(merged_content.contains("")); + assert!(merged_content.contains("Working directory: /test/dir")); } #[tokio::test] async fn test_moim_injection_no_assistant() { let em = ExtensionManager::new_without_provider(); + let working_dir = PathBuf::from("/test/dir"); let conv = Conversation::new_unvalidated(vec![Message::user().with_text("Hello")]); - let result = inject_moim(conv, &em).await; + let result = inject_moim(conv, &em, &working_dir).await; assert_eq!(result.messages().len(), 1); @@ -89,11 +95,13 @@ mod tests { .join(""); assert!(merged_content.contains("Hello")); assert!(merged_content.contains("")); + assert!(merged_content.contains("Working directory: /test/dir")); } #[tokio::test] async fn test_moim_with_tool_calls() { let em = ExtensionManager::new_without_provider(); + let working_dir = PathBuf::from("/test/dir"); let conv = Conversation::new_unvalidated(vec![ Message::user().with_text("Search for something"), @@ -119,7 +127,7 @@ mod tests { Message::user().with_tool_response("search_2", Ok(vec![])), ]); - let result = inject_moim(conv, &em).await; + let result = inject_moim(conv, &em, &working_dir).await; let msgs = result.messages(); assert_eq!(msgs.len(), 6); From 71d8349c36cc7535213caf6315b97d99ee0b95e5 Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Mon, 15 Dec 2025 16:15:42 -0800 Subject: [PATCH 12/36] refactor from feedback --- crates/goose-server/src/openapi.rs | 2 + crates/goose-server/src/routes/agent.rs | 146 ++++++++++++++---- .../tests/test_working_dir_update.rs | 53 ------- ui/desktop/openapi.json | 50 ++++++ ui/desktop/src/api/sdk.gen.ts | 11 +- ui/desktop/src/api/types.gen.ts | 38 +++++ ui/desktop/src/components/ChatInput.tsx | 9 +- .../components/bottom_menu/DirSwitcher.tsx | 67 +++----- ui/desktop/src/main.ts | 16 +- 9 files changed, 244 insertions(+), 148 deletions(-) delete mode 100644 crates/goose-server/tests/test_working_dir_update.rs diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index b645f74070a2..89d7170f6af7 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -354,6 +354,7 @@ derive_utoipa!(Icon as IconSchema); super::routes::agent::start_agent, super::routes::agent::resume_agent, super::routes::agent::restart_agent, + super::routes::agent::update_working_dir, super::routes::agent::get_tools, super::routes::agent::read_resource, super::routes::agent::call_tool, @@ -527,6 +528,7 @@ derive_utoipa!(Icon as IconSchema); super::routes::agent::StartAgentRequest, super::routes::agent::ResumeAgentRequest, super::routes::agent::RestartAgentRequest, + super::routes::agent::UpdateWorkingDirRequest, super::routes::agent::UpdateFromSessionRequest, super::routes::agent::AddExtensionRequest, super::routes::agent::RemoveExtensionRequest, diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs index b2c06b4cb6d7..faf2b6812ee1 100644 --- a/crates/goose-server/src/routes/agent.rs +++ b/crates/goose-server/src/routes/agent.rs @@ -79,6 +79,12 @@ pub struct RestartAgentRequest { session_id: String, } +#[derive(Deserialize, utoipa::ToSchema)] +pub struct UpdateWorkingDirRequest { + session_id: String, + working_dir: String, +} + #[derive(Deserialize, utoipa::ToSchema)] pub struct ResumeAgentRequest { session_id: String, @@ -661,45 +667,23 @@ async fn restore_agent_extensions( Ok(()) } -#[utoipa::path( - post, - path = "/agent/restart", - request_body = RestartAgentRequest, - responses( - (status = 200, description = "Agent restarted successfully"), - (status = 401, description = "Unauthorized - invalid secret key"), - (status = 404, description = "Session not found"), - (status = 500, description = "Internal server error") - ) -)] -async fn restart_agent( - State(state): State>, - Json(payload): Json, -) -> Result { - let session_id = payload.session_id.clone(); - +async fn restart_agent_internal( + state: &Arc, + session_id: &str, + session: &Session, +) -> Result<(), ErrorResponse> { // Remove existing agent (ignore error if not found) - let _ = state.agent_manager.remove_session(&session_id).await; - - let session = SessionManager::get_session(&session_id, false) - .await - .map_err(|err| { - error!("Failed to get session during restart: {}", err); - ErrorResponse { - message: format!("Failed to get session: {}", err), - status: StatusCode::NOT_FOUND, - } - })?; + let _ = state.agent_manager.remove_session(session_id).await; let agent = state - .get_agent_for_route(session_id.clone()) + .get_agent_for_route(session_id.to_string()) .await .map_err(|code| ErrorResponse { message: "Failed to create new agent during restart".into(), status: code, })?; - let provider_result = restore_agent_provider(&agent, &session, &session_id); + let provider_result = restore_agent_provider(&agent, session, session_id); let extensions_result = restore_agent_extensions(agent.clone(), &session.working_dir); let (provider_result, extensions_result) = tokio::join!(provider_result, extensions_result); @@ -711,10 +695,10 @@ async fn restart_agent( render_global_file("desktop_prompt.md", &context).expect("Prompt should render"); let mut update_prompt = desktop_prompt; - if let Some(recipe) = session.recipe { + if let Some(ref recipe) = session.recipe { match build_recipe_with_parameter_values( - &recipe, - session.user_recipe_values.unwrap_or_default(), + recipe, + session.user_recipe_values.clone().unwrap_or_default(), ) .await { @@ -736,6 +720,101 @@ async fn restart_agent( } agent.extend_system_prompt(update_prompt).await; + Ok(()) +} + +#[utoipa::path( + post, + path = "/agent/restart", + request_body = RestartAgentRequest, + responses( + (status = 200, description = "Agent restarted successfully"), + (status = 401, description = "Unauthorized - invalid secret key"), + (status = 404, description = "Session not found"), + (status = 500, description = "Internal server error") + ) +)] +async fn restart_agent( + State(state): State>, + Json(payload): Json, +) -> Result { + let session_id = payload.session_id.clone(); + + let session = SessionManager::get_session(&session_id, false) + .await + .map_err(|err| { + error!("Failed to get session during restart: {}", err); + ErrorResponse { + message: format!("Failed to get session: {}", err), + status: StatusCode::NOT_FOUND, + } + })?; + + restart_agent_internal(&state, &session_id, &session).await?; + + Ok(StatusCode::OK) +} + +#[utoipa::path( + post, + path = "/agent/update_working_dir", + request_body = UpdateWorkingDirRequest, + responses( + (status = 200, description = "Working directory updated and agent restarted successfully"), + (status = 400, description = "Bad request - invalid directory path"), + (status = 401, description = "Unauthorized - invalid secret key"), + (status = 404, description = "Session not found"), + (status = 500, description = "Internal server error") + ) +)] +async fn update_working_dir( + State(state): State>, + Json(payload): Json, +) -> Result { + let session_id = payload.session_id.clone(); + let working_dir = payload.working_dir.trim(); + + if working_dir.is_empty() { + return Err(ErrorResponse { + message: "Working directory cannot be empty".into(), + status: StatusCode::BAD_REQUEST, + }); + } + + let path = PathBuf::from(working_dir); + if !path.exists() || !path.is_dir() { + return Err(ErrorResponse { + message: "Invalid directory path".into(), + status: StatusCode::BAD_REQUEST, + }); + } + + // Update the session's working directory + SessionManager::update_session(&session_id) + .working_dir(path) + .apply() + .await + .map_err(|e| { + error!("Failed to update session working directory: {}", e); + ErrorResponse { + message: format!("Failed to update working directory: {}", e), + status: StatusCode::INTERNAL_SERVER_ERROR, + } + })?; + + // Get the updated session and restart the agent + let session = SessionManager::get_session(&session_id, false) + .await + .map_err(|err| { + error!("Failed to get session after working dir update: {}", err); + ErrorResponse { + message: format!("Failed to get session: {}", err), + status: StatusCode::NOT_FOUND, + } + })?; + + restart_agent_internal(&state, &session_id, &session).await?; + Ok(StatusCode::OK) } @@ -825,6 +904,7 @@ pub fn routes(state: Arc) -> Router { .route("/agent/start", post(start_agent)) .route("/agent/resume", post(resume_agent)) .route("/agent/restart", post(restart_agent)) + .route("/agent/update_working_dir", post(update_working_dir)) .route("/agent/tools", get(get_tools)) .route("/agent/read_resource", post(read_resource)) .route("/agent/call_tool", post(call_tool)) diff --git a/crates/goose-server/tests/test_working_dir_update.rs b/crates/goose-server/tests/test_working_dir_update.rs deleted file mode 100644 index 8637dabb5187..000000000000 --- a/crates/goose-server/tests/test_working_dir_update.rs +++ /dev/null @@ -1,53 +0,0 @@ -#[cfg(test)] -mod test_working_dir_update { - use goose::session::SessionManager; - use tempfile::TempDir; - - #[tokio::test] - async fn test_update_session_working_dir() { - // Create a temporary directory for testing - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let initial_dir = temp_dir.path().to_path_buf(); - - // Create another temp directory to change to - let new_temp_dir = TempDir::new().expect("Failed to create second temp dir"); - let new_dir = new_temp_dir.path().to_path_buf(); - - // Create a session with the initial directory - let session = SessionManager::create_session( - initial_dir.clone(), - "Test Session".to_string(), - goose::session::SessionType::User, - ) - .await - .expect("Failed to create session"); - - println!("Created session with ID: {}", session.id); - println!("Initial working_dir: {:?}", session.working_dir); - - // Verify initial directory - assert_eq!(session.working_dir, initial_dir); - - // Update the working directory - SessionManager::update_session(&session.id) - .working_dir(new_dir.clone()) - .apply() - .await - .expect("Failed to update session working directory"); - - // Fetch the updated session - let updated_session = SessionManager::get_session(&session.id, false) - .await - .expect("Failed to get updated session"); - - println!("Updated working_dir: {:?}", updated_session.working_dir); - - // Verify the directory was updated - assert_eq!(updated_session.working_dir, new_dir); - - // Clean up - SessionManager::delete_session(&session.id) - .await - .expect("Failed to delete session"); - } -} diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 6629a8fa64c3..0d3e53a56512 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -489,6 +489,41 @@ } } }, + "/agent/update_working_dir": { + "post": { + "tags": [ + "super::routes::agent" + ], + "operationId": "update_working_dir", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateWorkingDirRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Working directory updated and agent restarted successfully" + }, + "400": { + "description": "Bad request - invalid directory path" + }, + "401": { + "description": "Unauthorized - invalid secret key" + }, + "404": { + "description": "Session not found" + }, + "500": { + "description": "Internal server error" + } + } + } + }, "/config": { "get": { "tags": [ @@ -5817,6 +5852,21 @@ } } }, + "UpdateWorkingDirRequest": { + "type": "object", + "required": [ + "session_id", + "working_dir" + ], + "properties": { + "session_id": { + "type": "string" + }, + "working_dir": { + "type": "string" + } + } + }, "UpsertConfigQuery": { "type": "object", "required": [ diff --git a/ui/desktop/src/api/sdk.gen.ts b/ui/desktop/src/api/sdk.gen.ts index 83739cc61ab6..bfa492102386 100644 --- a/ui/desktop/src/api/sdk.gen.ts +++ b/ui/desktop/src/api/sdk.gen.ts @@ -2,7 +2,7 @@ import type { Client, Options as Options2, TDataShape } from './client'; import { client } from './client.gen'; -import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponses, BackupConfigData, BackupConfigErrors, BackupConfigResponses, CallToolData, CallToolErrors, CallToolResponses, CheckProviderData, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DetectProviderData, DetectProviderErrors, DetectProviderResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, EditMessageData, EditMessageErrors, EditMessageResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsResponses, GetTunnelStatusData, GetTunnelStatusResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, ReadResourceData, ReadResourceErrors, ReadResourceResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, RestartAgentData, RestartAgentErrors, RestartAgentResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, SetConfigProviderData, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StartTunnelData, StartTunnelErrors, StartTunnelResponses, StatusData, StatusResponses, StopTunnelData, StopTunnelErrors, StopTunnelResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateRouterToolSelectorData, UpdateRouterToolSelectorErrors, UpdateRouterToolSelectorResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpdateSessionWorkingDirData, UpdateSessionWorkingDirErrors, UpdateSessionWorkingDirResponses, 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, CallToolData, CallToolErrors, CallToolResponses, CheckProviderData, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DetectProviderData, DetectProviderErrors, DetectProviderResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, EditMessageData, EditMessageErrors, EditMessageResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsResponses, GetTunnelStatusData, GetTunnelStatusResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, ReadResourceData, ReadResourceErrors, ReadResourceResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, RestartAgentData, RestartAgentErrors, RestartAgentResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, SetConfigProviderData, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StartTunnelData, StartTunnelErrors, StartTunnelResponses, StatusData, StatusResponses, StopTunnelData, StopTunnelErrors, StopTunnelResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateRouterToolSelectorData, UpdateRouterToolSelectorErrors, UpdateRouterToolSelectorResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpdateSessionWorkingDirData, UpdateSessionWorkingDirErrors, UpdateSessionWorkingDirResponses, UpdateWorkingDirData, UpdateWorkingDirErrors, UpdateWorkingDirResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen'; export type Options = Options2 & { /** @@ -119,6 +119,15 @@ export const updateRouterToolSelector = (o } }); +export const updateWorkingDir = (options: Options) => (options.client ?? client).post({ + url: '/agent/update_working_dir', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + export const readAllConfig = (options?: Options) => (options?.client ?? client).get({ url: '/config', ...options }); export const backupConfig = (options?: Options) => (options?.client ?? client).post({ url: '/config/backup', ...options }); diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 52f9bfaa3884..8dd9b5923602 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -1045,6 +1045,11 @@ export type UpdateSessionWorkingDirRequest = { workingDir: string; }; +export type UpdateWorkingDirRequest = { + session_id: string; + working_dir: string; +}; + export type UpsertConfigQuery = { is_secret: boolean; key: string; @@ -1434,6 +1439,39 @@ export type UpdateRouterToolSelectorResponses = { export type UpdateRouterToolSelectorResponse = UpdateRouterToolSelectorResponses[keyof UpdateRouterToolSelectorResponses]; +export type UpdateWorkingDirData = { + body: UpdateWorkingDirRequest; + path?: never; + query?: never; + url: '/agent/update_working_dir'; +}; + +export type UpdateWorkingDirErrors = { + /** + * Bad request - invalid directory path + */ + 400: unknown; + /** + * Unauthorized - invalid secret key + */ + 401: unknown; + /** + * Session not found + */ + 404: unknown; + /** + * Internal server error + */ + 500: unknown; +}; + +export type UpdateWorkingDirResponses = { + /** + * Working directory updated and agent restarted successfully + */ + 200: unknown; +}; + export type ReadAllConfigData = { body?: never; path?: never; diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index a741019bb849..b1e03b57fca5 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -148,7 +148,6 @@ export default function ChatInput({ useEffect(() => { if (!sessionId) { - setSessionWorkingDir(null); return; } @@ -1510,8 +1509,12 @@ export default function ChatInput({ {/* Secondary actions and controls row below input */}
- {/* Directory path */} - + setSessionWorkingDir(newDir)} + />
diff --git a/ui/desktop/src/components/bottom_menu/DirSwitcher.tsx b/ui/desktop/src/components/bottom_menu/DirSwitcher.tsx index a48394e3f529..4c69055dc9f4 100644 --- a/ui/desktop/src/components/bottom_menu/DirSwitcher.tsx +++ b/ui/desktop/src/components/bottom_menu/DirSwitcher.tsx @@ -1,44 +1,26 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState } from 'react'; import { FolderDot } from 'lucide-react'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/Tooltip'; -import { updateSessionWorkingDir, restartAgent, getSession } from '../../api'; +import { updateWorkingDir } from '../../api'; +import { setWorkingDir } from '../../store/newChatState'; import { toast } from 'react-toastify'; -import { setWorkingDir, getWorkingDir } from '../../store/newChatState'; interface DirSwitcherProps { className: string; sessionId: string | undefined; + workingDir: string; + onWorkingDirChange?: (newDir: string) => void; } -export const DirSwitcher: React.FC = ({ className, sessionId }) => { +export const DirSwitcher: React.FC = ({ + className, + sessionId, + workingDir, + onWorkingDirChange, +}) => { const [isTooltipOpen, setIsTooltipOpen] = useState(false); const [isDirectoryChooserOpen, setIsDirectoryChooserOpen] = useState(false); - const [sessionWorkingDir, setSessionWorkingDir] = useState(null); - - // Fetch the working directory from the session when sessionId changes - useEffect(() => { - if (!sessionId) { - setSessionWorkingDir(null); - return; - } - - const fetchSessionWorkingDir = async () => { - try { - const response = await getSession({ path: { session_id: sessionId } }); - if (response.data?.working_dir) { - setSessionWorkingDir(response.data.working_dir); - } - } catch (error) { - console.error('[DirSwitcher] Failed to fetch session working dir:', error); - } - }; - - fetchSessionWorkingDir(); - }, [sessionId]); - - const currentDir = sessionWorkingDir ?? getWorkingDir(); - const handleDirectoryChange = async () => { if (isDirectoryChooserOpen) return; setIsDirectoryChooserOpen(true); @@ -55,29 +37,22 @@ export const DirSwitcher: React.FC = ({ className, sessionId } } const newDir = result.filePaths[0]; - setWorkingDir(newDir); - await window.electron.addRecentDir(newDir); + window.electron.addRecentDir(newDir); if (sessionId) { try { - await updateSessionWorkingDir({ - path: { session_id: sessionId }, - body: { workingDir: newDir }, + await updateWorkingDir({ + body: { session_id: sessionId, working_dir: newDir }, }); - - try { - await restartAgent({ body: { session_id: sessionId } }); - } catch (restartError) { - console.error('[DirSwitcher] Failed to restart agent:', restartError); - toast.error('Failed to update working directory'); - } - - setSessionWorkingDir(newDir); + onWorkingDirChange?.(newDir); } catch (error) { console.error('[DirSwitcher] Failed to update working directory:', error); toast.error('Failed to update working directory'); } + } else { + setWorkingDir(newDir); + onWorkingDirChange?.(newDir); } }; @@ -92,7 +67,7 @@ export const DirSwitcher: React.FC = ({ className, sessionId } if (isCmdOrCtrlClick) { event.preventDefault(); event.stopPropagation(); - await window.electron.openDirectoryInExplorer(currentDir); + await window.electron.openDirectoryInExplorer(workingDir); } else { await handleDirectoryChange(); } @@ -113,10 +88,10 @@ export const DirSwitcher: React.FC = ({ className, sessionId } disabled={isDirectoryChooserOpen} > -
{currentDir}
+
{workingDir}
- {currentDir} + {workingDir}
); diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index fc25c51d3291..77a88a90cd50 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -1189,23 +1189,15 @@ ipcMain.handle('open-external', async (_event, url: string) => { }); ipcMain.handle('directory-chooser', async () => { - const result = await dialog.showOpenDialog({ - properties: ['openFile', 'openDirectory', 'createDirectory'], + return dialog.showOpenDialog({ + properties: ['openDirectory', 'createDirectory'], defaultPath: os.homedir(), }); - return result; }); -ipcMain.handle('add-recent-dir', async (_event, dir: string) => { - try { - if (!dir) { - return false; - } +ipcMain.handle('add-recent-dir', (_event, dir: string) => { + if (dir) { addRecentDir(dir); - return true; - } catch (error) { - console.error('Error adding recent dir:', error); - return false; } }); From d8f27f304d3cf3367f4f7af67cddea8e86e7e9f2 Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Mon, 15 Dec 2025 16:34:34 -0800 Subject: [PATCH 13/36] move to agent helper utils --- crates/goose-server/src/routes/agent.rs | 79 +--------------- crates/goose-server/src/routes/agent_utils.rs | 93 +++++++++++++++++++ crates/goose-server/src/routes/mod.rs | 1 + 3 files changed, 97 insertions(+), 76 deletions(-) create mode 100644 crates/goose-server/src/routes/agent_utils.rs diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs index faf2b6812ee1..53cc5ae2419e 100644 --- a/crates/goose-server/src/routes/agent.rs +++ b/crates/goose-server/src/routes/agent.rs @@ -1,3 +1,4 @@ +use crate::routes::agent_utils::{restore_agent_extensions, restore_agent_provider}; use crate::routes::errors::ErrorResponse; use crate::routes::recipe_utils::{ apply_recipe_to_agent, build_recipe_with_parameter_values, load_recipe_by_id, validate_recipe, @@ -12,7 +13,7 @@ use axum::{ }; use goose::config::PermissionManager; -use goose::agents::{Agent, ExtensionConfig}; +use goose::agents::ExtensionConfig; use goose::config::{Config, GooseMode}; use goose::model::ModelConfig; use goose::prompt_template::render_global_file; @@ -33,7 +34,7 @@ use std::path::PathBuf; use std::sync::atomic::Ordering; use std::sync::Arc; use tokio_util::sync::CancellationToken; -use tracing::{error, warn}; +use tracing::error; #[derive(Deserialize, utoipa::ToSchema)] pub struct UpdateFromSessionRequest { @@ -593,80 +594,6 @@ async fn stop_agent( Ok(StatusCode::OK) } -async fn restore_agent_provider( - agent: &Arc, - session: &Session, - session_id: &str, -) -> Result<(), ErrorResponse> { - let config = Config::global(); - let provider_name = session - .provider_name - .clone() - .or_else(|| config.get_goose_provider().ok()) - .ok_or_else(|| ErrorResponse { - message: "Could not configure agent: missing provider".into(), - status: StatusCode::INTERNAL_SERVER_ERROR, - })?; - - let model_config = match session.model_config.clone() { - Some(saved_config) => saved_config, - None => { - let model_name = config.get_goose_model().map_err(|_| ErrorResponse { - message: "Could not configure agent: missing model".into(), - status: StatusCode::INTERNAL_SERVER_ERROR, - })?; - ModelConfig::new(&model_name).map_err(|e| ErrorResponse { - message: format!("Could not configure agent: invalid model {}", e), - status: StatusCode::INTERNAL_SERVER_ERROR, - })? - } - }; - - let provider = create(&provider_name, model_config) - .await - .map_err(|e| ErrorResponse { - message: format!("Could not create provider: {}", e), - status: StatusCode::INTERNAL_SERVER_ERROR, - })?; - - agent - .update_provider(provider, session_id) - .await - .map_err(|e| ErrorResponse { - message: format!("Could not configure agent: {}", e), - status: StatusCode::INTERNAL_SERVER_ERROR, - }) -} - -async fn restore_agent_extensions( - agent: Arc, - working_dir: &std::path::Path, -) -> Result<(), ErrorResponse> { - let working_dir_buf = working_dir.to_path_buf(); - let enabled_configs = goose::config::get_enabled_extensions(); - let extension_futures = enabled_configs - .into_iter() - .map(|config| { - let config_clone = config.clone(); - let agent_ref = agent.clone(); - let wd = working_dir_buf.clone(); - - async move { - if let Err(e) = agent_ref - .add_extension(config_clone.clone(), Some(wd)) - .await - { - warn!("Failed to load extension {}: {}", config_clone.name(), e); - } - Ok::<_, ErrorResponse>(()) - } - }) - .collect::>(); - - futures::future::join_all(extension_futures).await; - Ok(()) -} - async fn restart_agent_internal( state: &Arc, session_id: &str, diff --git a/crates/goose-server/src/routes/agent_utils.rs b/crates/goose-server/src/routes/agent_utils.rs new file mode 100644 index 000000000000..650272358e03 --- /dev/null +++ b/crates/goose-server/src/routes/agent_utils.rs @@ -0,0 +1,93 @@ +//! Utility functions for agent lifecycle management. +//! +//! These functions handle restoring agent state (provider, extensions) and are used +//! by various route handlers that need to initialize or restart agents. + +use crate::routes::errors::ErrorResponse; +use axum::http::StatusCode; +use goose::agents::Agent; +use goose::config::Config; +use goose::model::ModelConfig; +use goose::providers::create; +use goose::session::Session; +use std::sync::Arc; +use tracing::warn; + +/// Restore the provider for an agent from session or global config. +pub async fn restore_agent_provider( + agent: &Arc, + session: &Session, + session_id: &str, +) -> Result<(), ErrorResponse> { + let config = Config::global(); + let provider_name = session + .provider_name + .clone() + .or_else(|| config.get_goose_provider().ok()) + .ok_or_else(|| ErrorResponse { + message: "Could not configure agent: missing provider".into(), + status: StatusCode::INTERNAL_SERVER_ERROR, + })?; + + let model_config = match session.model_config.clone() { + Some(saved_config) => saved_config, + None => { + let model_name = config.get_goose_model().map_err(|_| ErrorResponse { + message: "Could not configure agent: missing model".into(), + status: StatusCode::INTERNAL_SERVER_ERROR, + })?; + ModelConfig::new(&model_name).map_err(|e| ErrorResponse { + message: format!("Could not configure agent: invalid model {}", e), + status: StatusCode::INTERNAL_SERVER_ERROR, + })? + } + }; + + let provider = create(&provider_name, model_config) + .await + .map_err(|e| ErrorResponse { + message: format!("Could not create provider: {}", e), + status: StatusCode::INTERNAL_SERVER_ERROR, + })?; + + agent + .update_provider(provider, session_id) + .await + .map_err(|e| ErrorResponse { + message: format!("Could not configure agent: {}", e), + status: StatusCode::INTERNAL_SERVER_ERROR, + }) +} + +/// Restore extensions for an agent from the global configuration. +/// +/// Note: This currently loads extensions from the global config, not from the session. +/// Extensions are loaded in parallel for better performance. +pub async fn restore_agent_extensions( + agent: Arc, + working_dir: &std::path::Path, +) -> Result<(), ErrorResponse> { + let working_dir_buf = working_dir.to_path_buf(); + let enabled_configs = goose::config::get_enabled_extensions(); + let extension_futures = enabled_configs + .into_iter() + .map(|config| { + let config_clone = config.clone(); + let agent_ref = agent.clone(); + let wd = working_dir_buf.clone(); + + async move { + if let Err(e) = agent_ref + .add_extension(config_clone.clone(), Some(wd)) + .await + { + warn!("Failed to load extension {}: {}", config_clone.name(), e); + } + Ok::<_, ErrorResponse>(()) + } + }) + .collect::>(); + + futures::future::join_all(extension_futures).await; + Ok(()) +} diff --git a/crates/goose-server/src/routes/mod.rs b/crates/goose-server/src/routes/mod.rs index a79a8b9bf597..af8efeb0e8a9 100644 --- a/crates/goose-server/src/routes/mod.rs +++ b/crates/goose-server/src/routes/mod.rs @@ -1,5 +1,6 @@ pub mod action_required; pub mod agent; +pub mod agent_utils; pub mod audio; pub mod config_management; pub mod errors; From 77abd7ca4c2150b1771acd33dd65e1463e01ba24 Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Mon, 15 Dec 2025 16:34:59 -0800 Subject: [PATCH 14/36] cleanup --- crates/goose-server/src/routes/agent_utils.rs | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/crates/goose-server/src/routes/agent_utils.rs b/crates/goose-server/src/routes/agent_utils.rs index 650272358e03..ed6cd9023e41 100644 --- a/crates/goose-server/src/routes/agent_utils.rs +++ b/crates/goose-server/src/routes/agent_utils.rs @@ -1,8 +1,3 @@ -//! Utility functions for agent lifecycle management. -//! -//! These functions handle restoring agent state (provider, extensions) and are used -//! by various route handlers that need to initialize or restart agents. - use crate::routes::errors::ErrorResponse; use axum::http::StatusCode; use goose::agents::Agent; @@ -13,7 +8,6 @@ use goose::session::Session; use std::sync::Arc; use tracing::warn; -/// Restore the provider for an agent from session or global config. pub async fn restore_agent_provider( agent: &Arc, session: &Session, @@ -59,10 +53,6 @@ pub async fn restore_agent_provider( }) } -/// Restore extensions for an agent from the global configuration. -/// -/// Note: This currently loads extensions from the global config, not from the session. -/// Extensions are loaded in parallel for better performance. pub async fn restore_agent_extensions( agent: Arc, working_dir: &std::path::Path, From 960285bfae03a0628d6f53f43148e43e0bbeb064 Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Tue, 16 Dec 2025 09:54:29 -0800 Subject: [PATCH 15/36] refactor to remove newChatState frontend cache --- ui/desktop/src/App.tsx | 7 ++-- ui/desktop/src/components/ChatInput.tsx | 6 +-- .../GroupedExtensionLoadingToast.tsx | 3 +- ui/desktop/src/components/Hub.tsx | 3 +- ui/desktop/src/components/LauncherView.tsx | 4 +- .../src/components/Layout/AppLayout.tsx | 4 +- ui/desktop/src/components/MentionPopover.tsx | 4 +- .../src/components/ParameterInputModal.tsx | 4 +- .../components/bottom_menu/DirSwitcher.tsx | 2 - .../src/components/recipes/RecipesView.tsx | 6 +-- ui/desktop/src/hooks/useAgent.ts | 6 +-- ui/desktop/src/sessions.ts | 20 +++++----- ui/desktop/src/store/newChatState.ts | 37 ------------------- ui/desktop/src/toasts.tsx | 5 ++- ui/desktop/src/utils/workingDir.ts | 3 ++ 15 files changed, 41 insertions(+), 73 deletions(-) delete mode 100644 ui/desktop/src/store/newChatState.ts create mode 100644 ui/desktop/src/utils/workingDir.ts diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index f87d59c5715b..5b41aebecd39 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -17,7 +17,6 @@ import AnnouncementModal from './components/AnnouncementModal'; import TelemetryOptOutModal from './components/TelemetryOptOutModal'; import ProviderGuard from './components/ProviderGuard'; import { createSession } from './sessions'; -import { getWorkingDir } from './store/newChatState'; import { ChatType } from './types/chat'; import Hub from './components/Hub'; @@ -42,6 +41,7 @@ import { View, ViewOptions } from './utils/navigationUtils'; import { NoProviderOrModelError, useAgent } from './hooks/useAgent'; import { useNavigation } from './hooks/useNavigation'; import { errorMessage } from './utils/conversionUtils'; +import { getInitialWorkingDir } from './utils/workingDir'; // Route Components const HubRouteWrapper = ({ isExtensionsLoading }: { isExtensionsLoading: boolean }) => { @@ -108,7 +108,7 @@ const PairRouteWrapper = ({ (async () => { try { - const newSession = await createSession({ + const newSession = await createSession(getInitialWorkingDir(), { recipeId, recipeDeeplink: recipeDeeplinkFromConfig, }); @@ -445,8 +445,7 @@ export function AppInner() { if ((isMac ? event.metaKey : event.ctrlKey) && event.key === 'n') { event.preventDefault(); try { - const workingDir = getWorkingDir(); - window.electron.createChatWindow(undefined, workingDir); + window.electron.createChatWindow(undefined, getInitialWorkingDir()); } catch (error) { console.error('Error creating new window:', error); } diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index b1e03b57fca5..489aca06bcea 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -30,7 +30,7 @@ import { DiagnosticsModal } from './ui/DownloadDiagnostics'; import { getSession, Message } from '../api'; import CreateRecipeFromSessionModal from './recipes/CreateRecipeFromSessionModal'; import CreateEditRecipeModal from './recipes/CreateEditRecipeModal'; -import { getWorkingDir } from '../store/newChatState'; +import { getInitialWorkingDir } from '../utils/workingDir'; interface QueuedMessage { id: string; @@ -1512,7 +1512,7 @@ export default function ChatInput({ setSessionWorkingDir(newDir)} />
@@ -1628,7 +1628,7 @@ export default function ChatInput({ onSelectedIndexChange={(index) => setMentionPopover((prev) => ({ ...prev, selectedIndex: index })) } - workingDir={sessionWorkingDir ?? getWorkingDir()} + workingDir={sessionWorkingDir ?? getInitialWorkingDir()} /> {sessionId && showCreateRecipeModal && ( diff --git a/ui/desktop/src/components/GroupedExtensionLoadingToast.tsx b/ui/desktop/src/components/GroupedExtensionLoadingToast.tsx index 3eee2d6a9dcf..91cabb33fc83 100644 --- a/ui/desktop/src/components/GroupedExtensionLoadingToast.tsx +++ b/ui/desktop/src/components/GroupedExtensionLoadingToast.tsx @@ -5,6 +5,7 @@ import { Button } from './ui/button'; import { startNewSession } from '../sessions'; import { useNavigation } from '../hooks/useNavigation'; import { formatExtensionErrorMessage } from '../utils/extensionErrorUtils'; +import { getInitialWorkingDir } from '../utils/workingDir'; export interface ExtensionLoadingStatus { name: string; @@ -107,7 +108,7 @@ export function GroupedExtensionLoadingToast({ size="sm" onClick={(e) => { e.stopPropagation(); - startNewSession(ext.recoverHints, setView); + startNewSession(getInitialWorkingDir(), ext.recoverHints, setView); }} className="self-start" > diff --git a/ui/desktop/src/components/Hub.tsx b/ui/desktop/src/components/Hub.tsx index 0f354ddd6801..6038e1c53079 100644 --- a/ui/desktop/src/components/Hub.tsx +++ b/ui/desktop/src/components/Hub.tsx @@ -20,6 +20,7 @@ import { ChatState } from '../types/chatState'; import 'react-toastify/dist/ReactToastify.css'; import { View, ViewOptions } from '../utils/navigationUtils'; import { startNewSession } from '../sessions'; +import { getInitialWorkingDir } from '../utils/workingDir'; export default function Hub({ setView, @@ -33,7 +34,7 @@ export default function Hub({ const combinedTextFromInput = customEvent.detail?.value || ''; if (combinedTextFromInput.trim()) { - await startNewSession(combinedTextFromInput, setView); + await startNewSession(getInitialWorkingDir(), combinedTextFromInput, setView); e.preventDefault(); } }; diff --git a/ui/desktop/src/components/LauncherView.tsx b/ui/desktop/src/components/LauncherView.tsx index 802d584867fc..c601b560e189 100644 --- a/ui/desktop/src/components/LauncherView.tsx +++ b/ui/desktop/src/components/LauncherView.tsx @@ -1,5 +1,5 @@ import { useRef, useState } from 'react'; -import { getWorkingDir } from '../store/newChatState'; +import { getInitialWorkingDir } from '../utils/workingDir'; export default function LauncherView() { const [query, setQuery] = useState(''); @@ -8,7 +8,7 @@ export default function LauncherView() { const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (query.trim()) { - window.electron.createChatWindow(query, getWorkingDir()); + window.electron.createChatWindow(query, getInitialWorkingDir()); setQuery(''); } }; diff --git a/ui/desktop/src/components/Layout/AppLayout.tsx b/ui/desktop/src/components/Layout/AppLayout.tsx index cdcf030558ea..d801fa86ce6a 100644 --- a/ui/desktop/src/components/Layout/AppLayout.tsx +++ b/ui/desktop/src/components/Layout/AppLayout.tsx @@ -5,7 +5,7 @@ import { View, ViewOptions } from '../../utils/navigationUtils'; import { AppWindowMac, AppWindow } from 'lucide-react'; import { Button } from '../ui/button'; import { Sidebar, SidebarInset, SidebarProvider, SidebarTrigger, useSidebar } from '../ui/sidebar'; -import { getWorkingDir } from '../../store/newChatState'; +import { getInitialWorkingDir } from '../../utils/workingDir'; const AppLayoutContent: React.FC = () => { const navigate = useNavigate(); @@ -67,7 +67,7 @@ const AppLayoutContent: React.FC = () => { }; const handleNewWindow = () => { - window.electron.createChatWindow(undefined, getWorkingDir()); + window.electron.createChatWindow(undefined, getInitialWorkingDir()); }; return ( diff --git a/ui/desktop/src/components/MentionPopover.tsx b/ui/desktop/src/components/MentionPopover.tsx index 44d4d01164b5..a115ccfbb3e8 100644 --- a/ui/desktop/src/components/MentionPopover.tsx +++ b/ui/desktop/src/components/MentionPopover.tsx @@ -9,7 +9,7 @@ import { } from 'react'; import { ItemIcon } from './ItemIcon'; import { CommandType, getSlashCommands } from '../api'; -import { getWorkingDir } from '../store/newChatState'; +import { getInitialWorkingDir } from '../utils/workingDir'; type DisplayItemType = CommandType | 'Directory' | 'File'; @@ -131,7 +131,7 @@ const MentionPopover = forwardRef< const [isLoading, setIsLoading] = useState(false); const popoverRef = useRef(null); const listRef = useRef(null); - const currentWorkingDir = workingDir ?? getWorkingDir(); + const currentWorkingDir = workingDir ?? getInitialWorkingDir(); const scanDirectoryFromRoot = useCallback( async (dirPath: string, relativePath = '', depth = 0): Promise => { diff --git a/ui/desktop/src/components/ParameterInputModal.tsx b/ui/desktop/src/components/ParameterInputModal.tsx index e49469d96c8a..414835bb8e5a 100644 --- a/ui/desktop/src/components/ParameterInputModal.tsx +++ b/ui/desktop/src/components/ParameterInputModal.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from 'react'; import { Parameter } from '../recipe'; import { Button } from './ui/button'; -import { getWorkingDir } from '../store/newChatState'; +import { getInitialWorkingDir } from '../utils/workingDir'; interface ParameterInputModalProps { parameters: Parameter[]; @@ -74,7 +74,7 @@ const ParameterInputModal: React.FC = ({ const handleCancelOption = (option: 'new-chat' | 'back-to-form'): void => { if (option === 'new-chat') { try { - const workingDir = getWorkingDir(); + const workingDir = getInitialWorkingDir(); window.electron.createChatWindow(undefined, workingDir); window.electron.hideWindow(); } catch (error) { diff --git a/ui/desktop/src/components/bottom_menu/DirSwitcher.tsx b/ui/desktop/src/components/bottom_menu/DirSwitcher.tsx index 4c69055dc9f4..be505bcb2dfa 100644 --- a/ui/desktop/src/components/bottom_menu/DirSwitcher.tsx +++ b/ui/desktop/src/components/bottom_menu/DirSwitcher.tsx @@ -2,7 +2,6 @@ import React, { useState } from 'react'; import { FolderDot } from 'lucide-react'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/Tooltip'; import { updateWorkingDir } from '../../api'; -import { setWorkingDir } from '../../store/newChatState'; import { toast } from 'react-toastify'; interface DirSwitcherProps { @@ -51,7 +50,6 @@ export const DirSwitcher: React.FC = ({ toast.error('Failed to update working directory'); } } else { - setWorkingDir(newDir); onWorkingDirChange?.(newDir); } }; diff --git a/ui/desktop/src/components/recipes/RecipesView.tsx b/ui/desktop/src/components/recipes/RecipesView.tsx index bda9f86299a5..af728dab9797 100644 --- a/ui/desktop/src/components/recipes/RecipesView.tsx +++ b/ui/desktop/src/components/recipes/RecipesView.tsx @@ -34,7 +34,7 @@ import { CronPicker } from '../schedule/CronPicker'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../ui/dialog'; import { SearchView } from '../conversation/SearchView'; import cronstrue from 'cronstrue'; -import { getWorkingDir } from '../../store/newChatState'; +import { getInitialWorkingDir } from '../../utils/workingDir'; export default function RecipesView() { const setView = useNavigation(); @@ -119,7 +119,7 @@ export default function RecipesView() { try { const newAgent = await startAgent({ body: { - working_dir: getWorkingDir(), + working_dir: getInitialWorkingDir(), recipe, }, throwOnError: true, @@ -138,7 +138,7 @@ export default function RecipesView() { const handleStartRecipeChatInNewWindow = (recipeId: string) => { window.electron.createChatWindow( undefined, - getWorkingDir(), + getInitialWorkingDir(), undefined, undefined, 'pair', diff --git a/ui/desktop/src/hooks/useAgent.ts b/ui/desktop/src/hooks/useAgent.ts index 1dc6cf62c6fc..50c7bd72af7c 100644 --- a/ui/desktop/src/hooks/useAgent.ts +++ b/ui/desktop/src/hooks/useAgent.ts @@ -14,7 +14,7 @@ import { validateConfig, } from '../api'; import { COST_TRACKING_ENABLED } from '../updates'; -import { getWorkingDir } from '../store/newChatState'; +import { getInitialWorkingDir } from '../utils/workingDir'; export enum AgentState { UNINITIALIZED = 'uninitialized', @@ -158,7 +158,7 @@ export function useAgent(): UseAgentReturn { }) : await startAgent({ body: { - working_dir: getWorkingDir(), + working_dir: getInitialWorkingDir(), ...buildRecipeInput( initContext.recipe, recipeIdFromConfig.current, @@ -179,7 +179,7 @@ export function useAgent(): UseAgentReturn { agentResponse = await startAgent({ body: { - working_dir: getWorkingDir(), + working_dir: getInitialWorkingDir(), ...buildRecipeInput( initContext.recipe, recipeIdFromConfig.current, diff --git a/ui/desktop/src/sessions.ts b/ui/desktop/src/sessions.ts index f2c1943ae495..e3e564009481 100644 --- a/ui/desktop/src/sessions.ts +++ b/ui/desktop/src/sessions.ts @@ -1,6 +1,5 @@ import { Session, startAgent, restartAgent } from './api'; import type { setViewType } from './hooks/useNavigation'; -import { getWorkingDir } from './store/newChatState'; export function resumeSession(session: Session, setView: setViewType) { setView('pair', { @@ -9,21 +8,21 @@ export function resumeSession(session: Session, setView: setViewType) { }); } -export async function createSession(options?: { - recipeId?: string; - recipeDeeplink?: string; -}): Promise { +export async function createSession( + workingDir: string, + options?: { + recipeId?: string; + recipeDeeplink?: string; + } +): Promise { const body: { working_dir: string; recipe_id?: string; recipe_deeplink?: string; } = { - working_dir: getWorkingDir(), + working_dir: workingDir, }; - // Note: We intentionally don't clear newChatState here - // so that new sessions in the same window continue to use the last selected directory - if (options?.recipeId) { body.recipe_id = options.recipeId; } else if (options?.recipeDeeplink) { @@ -46,6 +45,7 @@ export async function createSession(options?: { } export async function startNewSession( + workingDir: string, initialText: string | undefined, setView: setViewType, options?: { @@ -53,7 +53,7 @@ export async function startNewSession( recipeDeeplink?: string; } ): Promise { - const session = await createSession(options); + const session = await createSession(workingDir, options); setView('pair', { disableAnimation: true, diff --git a/ui/desktop/src/store/newChatState.ts b/ui/desktop/src/store/newChatState.ts deleted file mode 100644 index dc7c461e7f0e..000000000000 --- a/ui/desktop/src/store/newChatState.ts +++ /dev/null @@ -1,37 +0,0 @@ -// Store for new chat configuration -// Acts as a cache that can be updated from UI or synced from session -// Resets on page refresh - defaults to window.appConfig.get('GOOSE_WORKING_DIR') - -interface NewChatState { - workingDir: string | null; - // Future additions: - // extensions?: string[]; - // provider?: string; - // model?: string; -} - -const state: NewChatState = { - workingDir: null, -}; - -export function setWorkingDir(dir: string): void { - state.workingDir = dir; -} - -export function getWorkingDir(): string { - return state.workingDir ?? (window.appConfig.get('GOOSE_WORKING_DIR') as string); -} - -export function clearWorkingDir(): void { - state.workingDir = null; -} - -// Generic getters/setters for future extensibility -export function getNewChatState(): Readonly { - return { ...state }; -} - -export function resetNewChatState(): void { - state.workingDir = null; - // Reset future fields here -} diff --git a/ui/desktop/src/toasts.tsx b/ui/desktop/src/toasts.tsx index 6e3754e26579..b0e6dbaef695 100644 --- a/ui/desktop/src/toasts.tsx +++ b/ui/desktop/src/toasts.tsx @@ -8,6 +8,7 @@ import { GroupedExtensionLoadingToast, ExtensionLoadingStatus, } from './components/GroupedExtensionLoadingToast'; +import { getInitialWorkingDir } from './utils/workingDir'; export interface ToastServiceOptions { silent?: boolean; @@ -195,7 +196,9 @@ function ToastErrorContent({
{showRecovery && ( - + )} {hasBoth && ( diff --git a/ui/desktop/src/utils/workingDir.ts b/ui/desktop/src/utils/workingDir.ts new file mode 100644 index 000000000000..413e38f6cc87 --- /dev/null +++ b/ui/desktop/src/utils/workingDir.ts @@ -0,0 +1,3 @@ +export const getInitialWorkingDir = (): string => { + return (window.appConfig?.get('GOOSE_WORKING_DIR') as string) || ''; +}; From 6b4d5709a8c65342f4eb89b02ef3b4121db6ad31 Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Tue, 16 Dec 2025 10:31:23 -0800 Subject: [PATCH 16/36] get working_dir from agent synced with session --- crates/goose-cli/src/commands/acp.rs | 2 +- crates/goose-cli/src/commands/configure.rs | 2 +- crates/goose-cli/src/commands/web.rs | 2 +- crates/goose-cli/src/session/builder.rs | 8 ++++++-- crates/goose-cli/src/session/mod.rs | 8 ++++---- crates/goose-server/src/routes/agent.rs | 6 +++++- crates/goose-server/src/routes/agent_utils.rs | 10 ++++------ crates/goose/examples/agent.rs | 2 +- crates/goose/src/agents/agent.rs | 19 ++++++++++++++----- crates/goose/src/agents/reply_parts.rs | 19 ++++++++----------- crates/goose/src/agents/subagent_handler.rs | 2 +- crates/goose/src/scheduler.rs | 2 +- crates/goose/tests/agent.rs | 2 +- 13 files changed, 48 insertions(+), 36 deletions(-) diff --git a/crates/goose-cli/src/commands/acp.rs b/crates/goose-cli/src/commands/acp.rs index 4f3043370be0..89242bc6560b 100644 --- a/crates/goose-cli/src/commands/acp.rs +++ b/crates/goose-cli/src/commands/acp.rs @@ -256,7 +256,7 @@ impl GooseAcpAgent { set.spawn(async move { ( extension.name(), - agent_ptr_clone.add_extension(extension.clone(), None).await, + agent_ptr_clone.add_extension(extension.clone()).await, ) }); } diff --git a/crates/goose-cli/src/commands/configure.rs b/crates/goose-cli/src/commands/configure.rs index 068b2fdc762a..b6ae23df2674 100644 --- a/crates/goose-cli/src/commands/configure.rs +++ b/crates/goose-cli/src/commands/configure.rs @@ -1464,7 +1464,7 @@ pub async fn configure_tool_permissions_dialog() -> anyhow::Result<()> { agent.update_provider(new_provider, &session.id).await?; if let Some(config) = get_extension_by_name(&selected_extension_name) { agent - .add_extension(config.clone(), None) + .add_extension(config.clone()) .await .unwrap_or_else(|_| { println!( diff --git a/crates/goose-cli/src/commands/web.rs b/crates/goose-cli/src/commands/web.rs index 7c28854df416..c6aa47f6b82e 100644 --- a/crates/goose-cli/src/commands/web.rs +++ b/crates/goose-cli/src/commands/web.rs @@ -166,7 +166,7 @@ pub async fn handle_web( let enabled_configs = goose::config::get_enabled_extensions(); for config in enabled_configs { - if let Err(e) = agent.add_extension(config.clone(), None).await { + if let Err(e) = agent.add_extension(config.clone()).await { eprintln!("Warning: Failed to load extension {}: {}", config.name(), e); } } diff --git a/crates/goose-cli/src/session/builder.rs b/crates/goose-cli/src/session/builder.rs index 2a938d2d8942..be1597133525 100644 --- a/crates/goose-cli/src/session/builder.rs +++ b/crates/goose-cli/src/session/builder.rs @@ -163,7 +163,7 @@ async fn offer_extension_debugging_help( let extensions = get_all_extensions(); for ext_wrapper in extensions { if ext_wrapper.enabled && ext_wrapper.config.name() == "developer" { - if let Err(e) = debug_agent.add_extension(ext_wrapper.config, None).await { + if let Err(e) = debug_agent.add_extension(ext_wrapper.config).await { // If we can't add developer extension, continue without it eprintln!( "Note: Could not load developer extension for debugging: {}", @@ -430,6 +430,10 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession { // Setup extensions for the agent // Extensions need to be added after the session is created because we change directory when resuming a session + // Set the agent's working directory before adding extensions + let working_dir = std::env::current_dir().expect("Could not get working directory"); + agent.set_working_dir(working_dir).await; + // If we get extensions_override, only run those extensions and none other let extensions_to_run: Vec<_> = if let Some(extensions) = session_config.extensions_override { agent.disable_router_for_recipe().await; @@ -462,7 +466,7 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession { set.spawn(async move { ( extension.name(), - agent_ptr.add_extension(extension.clone(), None).await, + agent_ptr.add_extension(extension.clone()).await, ) }); } diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index 9867d0249777..6c206a9a74b4 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -237,7 +237,7 @@ impl CliSession { }; self.agent - .add_extension(config, None) + .add_extension(config) .await .map_err(|e| anyhow::anyhow!("Failed to start extension: {}", e))?; @@ -267,7 +267,7 @@ impl CliSession { }; self.agent - .add_extension(config, None) + .add_extension(config) .await .map_err(|e| anyhow::anyhow!("Failed to start extension: {}", e))?; @@ -298,7 +298,7 @@ impl CliSession { }; self.agent - .add_extension(config, None) + .add_extension(config) .await .map_err(|e| anyhow::anyhow!("Failed to start extension: {}", e))?; @@ -334,7 +334,7 @@ impl CliSession { } }; self.agent - .add_extension(config, None) + .add_extension(config) .await .map_err(|e| anyhow::anyhow!("Failed to start builtin extension: {}", e))?; } diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs index 53cc5ae2419e..d027c8104753 100644 --- a/crates/goose-server/src/routes/agent.rs +++ b/crates/goose-server/src/routes/agent.rs @@ -539,8 +539,12 @@ async fn agent_add_extension( })?; let agent = state.get_agent(request.session_id).await?; + + // Set the agent's working directory from the session before adding the extension + agent.set_working_dir(session.working_dir).await; + agent - .add_extension(request.config, Some(session.working_dir)) + .add_extension(request.config) .await .map_err(|e| ErrorResponse::internal(format!("Failed to add extension: {}", e)))?; Ok(StatusCode::OK) diff --git a/crates/goose-server/src/routes/agent_utils.rs b/crates/goose-server/src/routes/agent_utils.rs index ed6cd9023e41..ca989a7e3f2b 100644 --- a/crates/goose-server/src/routes/agent_utils.rs +++ b/crates/goose-server/src/routes/agent_utils.rs @@ -57,20 +57,18 @@ pub async fn restore_agent_extensions( agent: Arc, working_dir: &std::path::Path, ) -> Result<(), ErrorResponse> { - let working_dir_buf = working_dir.to_path_buf(); + // Set the agent's working directory before adding extensions + agent.set_working_dir(working_dir.to_path_buf()).await; + let enabled_configs = goose::config::get_enabled_extensions(); let extension_futures = enabled_configs .into_iter() .map(|config| { let config_clone = config.clone(); let agent_ref = agent.clone(); - let wd = working_dir_buf.clone(); async move { - if let Err(e) = agent_ref - .add_extension(config_clone.clone(), Some(wd)) - .await - { + if let Err(e) = agent_ref.add_extension(config_clone.clone()).await { warn!("Failed to load extension {}: {}", config_clone.name(), e); } Ok::<_, ErrorResponse>(()) diff --git a/crates/goose/examples/agent.rs b/crates/goose/examples/agent.rs index 59d83197519e..4e4bb5795903 100644 --- a/crates/goose/examples/agent.rs +++ b/crates/goose/examples/agent.rs @@ -33,7 +33,7 @@ async fn main() -> anyhow::Result<()> { DEFAULT_EXTENSION_TIMEOUT, ) .with_args(vec!["mcp", "developer"]); - agent.add_extension(config, None).await?; + agent.add_extension(config).await?; println!("Extensions:"); for extension in agent.list_extensions().await { diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 0c2fbcc03ed6..910e0fdf897b 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -100,6 +100,7 @@ pub struct Agent { pub(super) scheduler_service: Mutex>>, pub(super) retry_manager: RetryManager, pub(super) tool_inspection_manager: ToolInspectionManager, + pub(super) working_dir: Mutex>, } #[derive(Clone, Debug)] @@ -174,9 +175,19 @@ impl Agent { scheduler_service: Mutex::new(None), retry_manager: RetryManager::new(), tool_inspection_manager: Self::create_default_tool_inspection_manager(), + working_dir: Mutex::new(None), } } + pub async fn set_working_dir(&self, working_dir: std::path::PathBuf) { + let mut wd = self.working_dir.lock().await; + *wd = Some(working_dir); + } + + pub async fn get_working_dir(&self) -> Option { + self.working_dir.lock().await.clone() + } + /// Create a tool inspection manager with default inspectors fn create_default_tool_inspection_manager() -> ToolInspectionManager { let mut tool_inspection_manager = ToolInspectionManager::new(); @@ -581,11 +592,9 @@ impl Agent { Ok(()) } - pub async fn add_extension( - &self, - extension: ExtensionConfig, - working_dir: Option, - ) -> ExtensionResult<()> { + pub async fn add_extension(&self, extension: ExtensionConfig) -> ExtensionResult<()> { + let working_dir = self.get_working_dir().await; + match &extension { ExtensionConfig::Frontend { tools, diff --git a/crates/goose/src/agents/reply_parts.rs b/crates/goose/src/agents/reply_parts.rs index 06dde75ae01c..2fb26da650d2 100644 --- a/crates/goose/src/agents/reply_parts.rs +++ b/crates/goose/src/agents/reply_parts.rs @@ -460,17 +460,14 @@ mod tests { ]; agent - .add_extension( - crate::agents::extension::ExtensionConfig::Frontend { - name: "frontend".to_string(), - description: "desc".to_string(), - tools: frontend_tools, - instructions: None, - bundled: None, - available_tools: vec![], - }, - None, - ) + .add_extension(crate::agents::extension::ExtensionConfig::Frontend { + name: "frontend".to_string(), + description: "desc".to_string(), + tools: frontend_tools, + instructions: None, + bundled: None, + available_tools: vec![], + }) .await .unwrap(); diff --git a/crates/goose/src/agents/subagent_handler.rs b/crates/goose/src/agents/subagent_handler.rs index 5368abb6bda3..98e87a524f58 100644 --- a/crates/goose/src/agents/subagent_handler.rs +++ b/crates/goose/src/agents/subagent_handler.rs @@ -138,7 +138,7 @@ fn get_agent_messages( .map_err(|e| anyhow!("Failed to set provider on sub agent: {}", e))?; for extension in task_config.extensions { - if let Err(e) = agent.add_extension(extension.clone(), None).await { + if let Err(e) = agent.add_extension(extension.clone()).await { debug!( "Failed to add extension '{}' to subagent: {}", extension.name(), diff --git a/crates/goose/src/scheduler.rs b/crates/goose/src/scheduler.rs index 453c11dd3f7d..d9c01b9700e2 100644 --- a/crates/goose/src/scheduler.rs +++ b/crates/goose/src/scheduler.rs @@ -731,7 +731,7 @@ async fn execute_job( if let Some(ref extensions) = recipe.extensions { for ext in extensions { - agent.add_extension(ext.clone(), None).await?; + agent.add_extension(ext.clone()).await?; } } diff --git a/crates/goose/tests/agent.rs b/crates/goose/tests/agent.rs index 92d1dbf2937b..85485b5c9848 100644 --- a/crates/goose/tests/agent.rs +++ b/crates/goose/tests/agent.rs @@ -486,7 +486,7 @@ mod tests { }; agent - .add_extension(ext_config, None) + .add_extension(ext_config) .await .expect("Failed to add extension manager"); agent From f6672b9464e5b96aa5cce0c046cabd96b9fb745d Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Thu, 18 Dec 2025 13:45:23 -0800 Subject: [PATCH 17/36] upstream merge fix --- crates/goose-server/src/routes/agent_utils.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/goose-server/src/routes/agent_utils.rs b/crates/goose-server/src/routes/agent_utils.rs index ca989a7e3f2b..acfd45d312cb 100644 --- a/crates/goose-server/src/routes/agent_utils.rs +++ b/crates/goose-server/src/routes/agent_utils.rs @@ -70,6 +70,10 @@ pub async fn restore_agent_extensions( async move { if let Err(e) = agent_ref.add_extension(config_clone.clone()).await { warn!("Failed to load extension {}: {}", config_clone.name(), e); + goose::posthog::emit_error( + "extension_load_failed", + &format!("{}: {}", config_clone.name(), e), + ); } Ok::<_, ErrorResponse>(()) } From 616ab9e4d4ee2a6319dfa46db9b1520e58853d8e Mon Sep 17 00:00:00 2001 From: Zane <75694352+zanesq@users.noreply.github.com> Date: Mon, 5 Jan 2026 09:40:04 -0800 Subject: [PATCH 18/36] Session Specific Extensions (#6179) --- crates/goose-server/src/openapi.rs | 5 + crates/goose-server/src/routes/agent.rs | 201 +++++---- crates/goose-server/src/routes/agent_utils.rs | 115 ++++- crates/goose-server/src/routes/session.rs | 47 ++- crates/goose-server/src/state.rs | 44 ++ crates/goose/src/agents/extension_manager.rs | 10 +- crates/goose/src/agents/mod.rs | 2 +- ui/desktop/openapi.json | 131 +++++- ui/desktop/src/App.tsx | 149 ++----- ui/desktop/src/api/sdk.gen.ts | 4 +- ui/desktop/src/api/types.gen.ts | 67 ++- ui/desktop/src/components/BaseChat.tsx | 14 +- ui/desktop/src/components/ChatInput.tsx | 49 ++- .../GroupedExtensionLoadingToast.tsx | 86 ++-- ui/desktop/src/components/Hub.tsx | 55 ++- ui/desktop/src/components/LoadingGoose.tsx | 2 + .../BottomMenuExtensionSelection.tsx | 253 ++++++++--- .../components/bottom_menu/DirSwitcher.tsx | 10 +- .../components/extensions/ExtensionsView.tsx | 26 +- .../components/sessions/SessionListView.tsx | 49 ++- .../components/sessions/SessionsInsights.tsx | 1 - .../components/settings/app/UpdateSection.tsx | 1 - .../settings/extensions/ExtensionsSection.tsx | 69 +-- .../extensions/extension-manager.test.ts | 255 ----------- .../settings/extensions/extension-manager.ts | 396 +++--------------- .../components/settings/extensions/index.ts | 13 +- .../subcomponents/ExtensionItem.tsx | 14 +- .../subcomponents/ExtensionList.tsx | 13 +- ui/desktop/src/hooks/useAgent.ts | 19 +- ui/desktop/src/hooks/useChatStream.ts | 26 +- ui/desktop/src/sessions.ts | 33 +- ui/desktop/src/store/extensionOverrides.ts | 59 +++ ui/desktop/src/toasts.tsx | 2 +- ui/desktop/src/types/chatState.ts | 1 + ui/desktop/src/utils/extensionErrorUtils.ts | 43 ++ ui/desktop/src/utils/navigationUtils.ts | 3 - ui/desktop/src/utils/providerUtils.ts | 76 ---- 37 files changed, 1184 insertions(+), 1159 deletions(-) delete mode 100644 ui/desktop/src/components/settings/extensions/extension-manager.test.ts create mode 100644 ui/desktop/src/store/extensionOverrides.ts diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index 0036ac8e32ad..887255ede198 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -376,6 +376,7 @@ derive_utoipa!(Icon as IconSchema); super::routes::session::import_session, super::routes::session::update_session_user_recipe_values, super::routes::session::edit_message, + super::routes::session::get_session_extensions, super::routes::schedule::create_schedule, super::routes::schedule::list_schedules, super::routes::schedule::delete_schedule, @@ -435,6 +436,7 @@ derive_utoipa!(Icon as IconSchema); super::routes::session::EditType, super::routes::session::EditMessageRequest, super::routes::session::EditMessageResponse, + super::routes::session::SessionExtensionsResponse, Message, MessageContent, MessageMetadata, @@ -537,6 +539,9 @@ derive_utoipa!(Icon as IconSchema); super::routes::agent::UpdateFromSessionRequest, super::routes::agent::AddExtensionRequest, super::routes::agent::RemoveExtensionRequest, + super::routes::agent::ResumeAgentResponse, + super::routes::agent::RestartAgentResponse, + super::routes::agent_utils::ExtensionLoadResult, super::routes::setup::SetupResponse, super::tunnel::TunnelInfo, super::tunnel::TunnelState, diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs index b40d8cfde168..aad1533a5e5b 100644 --- a/crates/goose-server/src/routes/agent.rs +++ b/crates/goose-server/src/routes/agent.rs @@ -1,4 +1,7 @@ -use crate::routes::agent_utils::{restore_agent_extensions, restore_agent_provider}; +use crate::routes::agent_utils::{ + persist_session_extensions, restore_agent_extensions, restore_agent_provider, + ExtensionLoadResult, +}; use crate::routes::errors::ErrorResponse; use crate::routes::recipe_utils::{ apply_recipe_to_agent, build_recipe_with_parameter_values, load_recipe_by_id, validate_recipe, @@ -20,8 +23,9 @@ use goose::prompt_template::render_global_file; use goose::providers::create; use goose::recipe::Recipe; use goose::recipe_deeplink; +use goose::session::extension_data::ExtensionState; use goose::session::session_manager::SessionType; -use goose::session::{Session, SessionManager}; +use goose::session::{EnabledExtensionsState, Session, SessionManager}; use goose::{ agents::{extension::ToolInfo, extension_manager::get_parameter_names}, config::permission::PermissionLevel, @@ -68,6 +72,8 @@ pub struct StartAgentRequest { recipe_id: Option, #[serde(default)] recipe_deeplink: Option, + #[serde(default)] + extension_overrides: Option>, } #[derive(Deserialize, utoipa::ToSchema)] @@ -130,6 +136,18 @@ pub struct CallToolResponse { is_error: bool, } +#[derive(Serialize, utoipa::ToSchema)] +pub struct ResumeAgentResponse { + pub session: Session, + #[serde(skip_serializing_if = "Option::is_none")] + pub extension_results: Option>, +} + +#[derive(Serialize, utoipa::ToSchema)] +pub struct RestartAgentResponse { + pub extension_results: Vec, +} + #[utoipa::path( post, path = "/agent/start", @@ -141,6 +159,7 @@ pub struct CallToolResponse { (status = 500, description = "Internal server error", body = ErrorResponse) ) )] +#[allow(clippy::too_many_lines)] async fn start_agent( State(state): State>, Json(payload): Json, @@ -152,6 +171,7 @@ async fn start_agent( recipe, recipe_id, recipe_deeplink, + extension_overrides, } = payload; let original_recipe = if let Some(deeplink) = recipe_deeplink { @@ -199,30 +219,87 @@ async fn start_agent( } })?; - if let Some(recipe) = original_recipe { + // Initialize session with extensions (either overrides from hub or global defaults) + let extensions_to_use = + extension_overrides.unwrap_or_else(goose::config::get_enabled_extensions); + let mut extension_data = session.extension_data.clone(); + let extensions_state = EnabledExtensionsState::new(extensions_to_use); + if let Err(e) = extensions_state.to_extension_data(&mut extension_data) { + tracing::warn!("Failed to initialize session with extensions: {}", e); + } else { SessionManager::update_session(&session.id) - .recipe(Some(recipe)) + .extension_data(extension_data.clone()) .apply() .await .map_err(|err| { - error!("Failed to update session with recipe: {}", err); + error!("Failed to save initial extension state: {}", err); ErrorResponse { - message: format!("Failed to update session with recipe: {}", err), + message: format!("Failed to save initial extension state: {}", err), status: StatusCode::INTERNAL_SERVER_ERROR, } })?; + } - session = SessionManager::get_session(&session.id, false) + if let Some(recipe) = original_recipe { + SessionManager::update_session(&session.id) + .recipe(Some(recipe)) + .apply() .await .map_err(|err| { - error!("Failed to get updated session: {}", err); + error!("Failed to update session with recipe: {}", err); ErrorResponse { - message: format!("Failed to get updated session: {}", err), + message: format!("Failed to update session with recipe: {}", err), status: StatusCode::INTERNAL_SERVER_ERROR, } })?; } + // Refetch session to get all updates + session = SessionManager::get_session(&session.id, false) + .await + .map_err(|err| { + error!("Failed to get updated session: {}", err); + ErrorResponse { + message: format!("Failed to get updated session: {}", err), + status: StatusCode::INTERNAL_SERVER_ERROR, + } + })?; + + // Eagerly start loading extensions in the background + let session_for_spawn = session.clone(); + let state_for_spawn = state.clone(); + let session_id_for_task = session.id.clone(); + let task = tokio::spawn(async move { + match state_for_spawn + .get_agent(session_for_spawn.id.clone()) + .await + { + Ok(agent) => { + agent + .set_working_dir(session_for_spawn.working_dir.clone()) + .await; + + let results = restore_agent_extensions(agent, &session_for_spawn).await; + tracing::debug!( + "Background extension loading completed for session {}", + session_for_spawn.id + ); + results + } + Err(e) => { + tracing::warn!( + "Failed to create agent for background extension loading: {}", + e + ); + vec![] + } + } + }); + + state + .set_extension_loading_task(session_id_for_task, task) + .await; + Ok(Json(session)) } @@ -231,7 +308,7 @@ async fn start_agent( path = "/agent/resume", request_body = ResumeAgentRequest, responses( - (status = 200, description = "Agent started successfully", body = Session), + (status = 200, description = "Agent started successfully", body = ResumeAgentResponse), (status = 400, description = "Bad request - invalid working directory"), (status = 401, description = "Unauthorized - invalid secret key"), (status = 500, description = "Internal server error") @@ -240,7 +317,7 @@ async fn start_agent( async fn resume_agent( State(state): State>, Json(payload): Json, -) -> Result, ErrorResponse> { +) -> Result, ErrorResponse> { goose::posthog::set_session_context("desktop", true); let session = SessionManager::get_session(&payload.session_id, true) @@ -254,7 +331,7 @@ async fn resume_agent( } })?; - if payload.load_model_and_extensions { + let extension_results = if payload.load_model_and_extensions { let agent = state .get_agent_for_route(payload.session_id.clone()) .await @@ -263,57 +340,35 @@ async fn resume_agent( status: code, })?; - let config = Config::global(); - - let provider_result = async { - let provider_name = session - .provider_name - .clone() - .or_else(|| config.get_goose_provider().ok()) - .ok_or_else(|| ErrorResponse { - message: "Could not configure agent: missing provider".into(), - status: StatusCode::INTERNAL_SERVER_ERROR, - })?; - - let model_config = match session.model_config.clone() { - Some(saved_config) => saved_config, - None => { - let model_name = config.get_goose_model().map_err(|_| ErrorResponse { - message: "Could not configure agent: missing model".into(), - status: StatusCode::INTERNAL_SERVER_ERROR, - })?; - ModelConfig::new(&model_name).map_err(|e| ErrorResponse { - message: format!("Could not configure agent: invalid model {}", e), - status: StatusCode::INTERNAL_SERVER_ERROR, - })? - } + restore_agent_provider(&agent, &session, &payload.session_id).await?; + + let extension_results = + if let Some(results) = state.take_extension_loading_task(&payload.session_id).await { + tracing::debug!( + "Using background extension loading results for session {}", + payload.session_id + ); + state + .remove_extension_loading_task(&payload.session_id) + .await; + results + } else { + tracing::debug!( + "No background task found, loading extensions for session {}", + payload.session_id + ); + restore_agent_extensions(agent.clone(), &session).await }; - let provider = - create(&provider_name, model_config) - .await - .map_err(|e| ErrorResponse { - message: format!("Could not create provider: {}", e), - status: StatusCode::INTERNAL_SERVER_ERROR, - })?; - - agent - .update_provider(provider, &payload.session_id) - .await - .map_err(|e| ErrorResponse { - message: format!("Could not configure agent: {}", e), - status: StatusCode::INTERNAL_SERVER_ERROR, - }) - }; - - let extensions_result = restore_agent_extensions(agent.clone(), &session.working_dir); - - let (provider_result, extensions_result) = tokio::join!(provider_result, extensions_result); - provider_result?; - extensions_result?; - } + Some(extension_results) + } else { + None + }; - Ok(Json(session)) + Ok(Json(ResumeAgentResponse { + session, + extension_results, + })) } #[utoipa::path( @@ -542,7 +597,7 @@ async fn agent_add_extension( })?; let extension_name = request.config.name(); - let agent = state.get_agent(request.session_id).await?; + let agent = state.get_agent(request.session_id.clone()).await?; // Set the agent's working directory from the session before adding the extension agent.set_working_dir(session.working_dir).await; @@ -554,6 +609,8 @@ async fn agent_add_extension( ); ErrorResponse::internal(format!("Failed to add extension: {}", e)) })?; + + persist_session_extensions(&agent, &request.session_id).await?; Ok(StatusCode::OK) } @@ -572,8 +629,11 @@ async fn agent_remove_extension( State(state): State>, Json(request): Json, ) -> Result { - let agent = state.get_agent(request.session_id).await?; + let agent = state.get_agent(request.session_id.clone()).await?; agent.remove_extension(&request.name).await?; + + persist_session_extensions(&agent, &request.session_id).await?; + Ok(StatusCode::OK) } @@ -609,7 +669,7 @@ async fn restart_agent_internal( state: &Arc, session_id: &str, session: &Session, -) -> Result<(), ErrorResponse> { +) -> Result, ErrorResponse> { // Remove existing agent (ignore error if not found) let _ = state.agent_manager.remove_session(session_id).await; @@ -622,11 +682,10 @@ async fn restart_agent_internal( })?; let provider_result = restore_agent_provider(&agent, session, session_id); - let extensions_result = restore_agent_extensions(agent.clone(), &session.working_dir); + let extensions_future = restore_agent_extensions(agent.clone(), session); - let (provider_result, extensions_result) = tokio::join!(provider_result, extensions_result); + let (provider_result, extension_results) = tokio::join!(provider_result, extensions_future); provider_result?; - extensions_result?; let context: HashMap<&str, Value> = HashMap::new(); let desktop_prompt = @@ -658,7 +717,7 @@ async fn restart_agent_internal( } agent.extend_system_prompt(update_prompt).await; - Ok(()) + Ok(extension_results) } #[utoipa::path( @@ -666,7 +725,7 @@ async fn restart_agent_internal( path = "/agent/restart", request_body = RestartAgentRequest, responses( - (status = 200, description = "Agent restarted successfully"), + (status = 200, description = "Agent restarted successfully", body = RestartAgentResponse), (status = 401, description = "Unauthorized - invalid secret key"), (status = 404, description = "Session not found"), (status = 500, description = "Internal server error") @@ -675,7 +734,7 @@ async fn restart_agent_internal( async fn restart_agent( State(state): State>, Json(payload): Json, -) -> Result { +) -> Result, ErrorResponse> { let session_id = payload.session_id.clone(); let session = SessionManager::get_session(&session_id, false) @@ -688,9 +747,9 @@ async fn restart_agent( } })?; - restart_agent_internal(&state, &session_id, &session).await?; + let extension_results = restart_agent_internal(&state, &session_id, &session).await?; - Ok(StatusCode::OK) + Ok(Json(RestartAgentResponse { extension_results })) } #[utoipa::path( diff --git a/crates/goose-server/src/routes/agent_utils.rs b/crates/goose-server/src/routes/agent_utils.rs index acfd45d312cb..4ba3489bdece 100644 --- a/crates/goose-server/src/routes/agent_utils.rs +++ b/crates/goose-server/src/routes/agent_utils.rs @@ -1,12 +1,22 @@ use crate::routes::errors::ErrorResponse; use axum::http::StatusCode; -use goose::agents::Agent; +use goose::agents::{normalize, Agent}; use goose::config::Config; use goose::model::ModelConfig; use goose::providers::create; -use goose::session::Session; +use goose::session::extension_data::ExtensionState; +use goose::session::{EnabledExtensionsState, Session, SessionManager}; +use serde::Serialize; use std::sync::Arc; -use tracing::warn; +use tracing::{error, warn}; + +#[derive(Debug, Clone, Serialize, utoipa::ToSchema)] +pub struct ExtensionLoadResult { + pub name: String, + pub success: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} pub async fn restore_agent_provider( agent: &Arc, @@ -55,12 +65,20 @@ pub async fn restore_agent_provider( pub async fn restore_agent_extensions( agent: Arc, - working_dir: &std::path::Path, -) -> Result<(), ErrorResponse> { + session: &Session, +) -> Vec { // Set the agent's working directory before adding extensions - agent.set_working_dir(working_dir.to_path_buf()).await; + agent.set_working_dir(session.working_dir.clone()).await; + + // Try to load session-specific extensions first, fall back to global config + let session_extensions = EnabledExtensionsState::from_extension_data(&session.extension_data); + let enabled_configs = session_extensions + .map(|state| state.extensions) + .unwrap_or_else(|| { + tracing::info!("restore_agent_extensions: falling back to global config"); + goose::config::get_enabled_extensions() + }); - let enabled_configs = goose::config::get_enabled_extensions(); let extension_futures = enabled_configs .into_iter() .map(|config| { @@ -68,18 +86,85 @@ pub async fn restore_agent_extensions( let agent_ref = agent.clone(); async move { - if let Err(e) = agent_ref.add_extension(config_clone.clone()).await { - warn!("Failed to load extension {}: {}", config_clone.name(), e); - goose::posthog::emit_error( - "extension_load_failed", - &format!("{}: {}", config_clone.name(), e), - ); + let name = config_clone.name().to_string(); + let normalized_name = normalize(&name); + + if agent_ref + .extension_manager + .is_extension_enabled(&normalized_name) + .await + { + tracing::debug!("Extension {} already loaded, skipping", name); + return ExtensionLoadResult { + name, + success: true, + error: None, + }; + } + + match agent_ref.add_extension(config_clone).await { + Ok(_) => ExtensionLoadResult { + name, + success: true, + error: None, + }, + Err(e) => { + let error_msg = e.to_string(); + warn!("Failed to load extension {}: {}", name, error_msg); + ExtensionLoadResult { + name, + success: false, + error: Some(error_msg), + } + } } - Ok::<_, ErrorResponse>(()) } }) .collect::>(); - futures::future::join_all(extension_futures).await; + futures::future::join_all(extension_futures).await +} + +pub async fn persist_session_extensions( + agent: &Arc, + session_id: &str, +) -> Result<(), ErrorResponse> { + let current_extensions = agent.extension_manager.get_extension_configs().await; + let extensions_state = EnabledExtensionsState::new(current_extensions); + + // Get the current session to access its extension_data + let session = SessionManager::get_session(session_id, false) + .await + .map_err(|e| { + error!("Failed to get session for persisting extensions: {}", e); + ErrorResponse { + message: format!("Failed to get session: {}", e), + status: StatusCode::INTERNAL_SERVER_ERROR, + } + })?; + + let mut extension_data = session.extension_data.clone(); + extensions_state + .to_extension_data(&mut extension_data) + .map_err(|e| { + error!("Failed to serialize extension state: {}", e); + ErrorResponse { + message: format!("Failed to serialize extension state: {}", e), + status: StatusCode::INTERNAL_SERVER_ERROR, + } + })?; + + SessionManager::update_session(session_id) + .extension_data(extension_data) + .apply() + .await + .map_err(|e| { + error!("Failed to persist extension state: {}", e); + ErrorResponse { + message: format!("Failed to persist extension state: {}", e), + status: StatusCode::INTERNAL_SERVER_ERROR, + } + })?; + Ok(()) } diff --git a/crates/goose-server/src/routes/session.rs b/crates/goose-server/src/routes/session.rs index 4f05162f4614..610e7a27157a 100644 --- a/crates/goose-server/src/routes/session.rs +++ b/crates/goose-server/src/routes/session.rs @@ -9,9 +9,11 @@ use axum::{ routing::{delete, get, put}, Json, Router, }; +use goose::agents::ExtensionConfig; use goose::recipe::Recipe; +use goose::session::extension_data::ExtensionState; use goose::session::session_manager::SessionInsights; -use goose::session::{Session, SessionManager}; +use goose::session::{EnabledExtensionsState, Session, SessionManager}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::PathBuf; @@ -42,7 +44,6 @@ pub struct UpdateSessionUserRecipeValuesRequest { #[derive(Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct UpdateSessionWorkingDirRequest { - /// New working directory path working_dir: String, } @@ -446,6 +447,44 @@ async fn edit_message( } } +#[derive(Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct SessionExtensionsResponse { + extensions: Vec, +} + +#[utoipa::path( + get, + path = "/sessions/{session_id}/extensions", + params( + ("session_id" = String, Path, description = "Unique identifier for the session") + ), + responses( + (status = 200, description = "Session extensions retrieved successfully", body = SessionExtensionsResponse), + (status = 401, description = "Unauthorized - Invalid or missing API key"), + (status = 404, description = "Session not found"), + (status = 500, description = "Internal server error") + ), + security( + ("api_key" = []) + ), + tag = "Session Management" +)] +async fn get_session_extensions( + Path(session_id): Path, +) -> Result, StatusCode> { + let session = SessionManager::get_session(&session_id, false) + .await + .map_err(|_| StatusCode::NOT_FOUND)?; + + // Try to get session-specific extensions, fall back to global config + let extensions = EnabledExtensionsState::from_extension_data(&session.extension_data) + .map(|state| state.extensions) + .unwrap_or_else(goose::config::get_enabled_extensions); + + Ok(Json(SessionExtensionsResponse { extensions })) +} + pub fn routes(state: Arc) -> Router { Router::new() .route("/sessions", get(list_sessions)) @@ -464,5 +503,9 @@ pub fn routes(state: Arc) -> Router { put(update_session_user_recipe_values), ) .route("/sessions/{session_id}/edit_message", post(edit_message)) + .route( + "/sessions/{session_id}/extensions", + get(get_session_extensions), + ) .with_state(state) } diff --git a/crates/goose-server/src/state.rs b/crates/goose-server/src/state.rs index 4a9c582e39e2..2ebc5a0b53f3 100644 --- a/crates/goose-server/src/state.rs +++ b/crates/goose-server/src/state.rs @@ -6,9 +6,14 @@ use std::path::PathBuf; use std::sync::atomic::AtomicUsize; use std::sync::Arc; use tokio::sync::Mutex; +use tokio::task::JoinHandle; +use crate::routes::agent_utils::ExtensionLoadResult; use crate::tunnel::TunnelManager; +type ExtensionLoadingTasks = + Arc>>>>>>>; + #[derive(Clone)] pub struct AppState { pub(crate) agent_manager: Arc, @@ -17,6 +22,7 @@ pub struct AppState { /// Tracks sessions that have already emitted recipe telemetry to prevent double counting. recipe_session_tracker: Arc>>, pub tunnel_manager: Arc, + pub extension_loading_tasks: ExtensionLoadingTasks, } impl AppState { @@ -30,9 +36,47 @@ impl AppState { session_counter: Arc::new(AtomicUsize::new(0)), recipe_session_tracker: Arc::new(Mutex::new(HashSet::new())), tunnel_manager, + extension_loading_tasks: Arc::new(Mutex::new(HashMap::new())), })) } + pub async fn set_extension_loading_task( + &self, + session_id: String, + task: JoinHandle>, + ) { + let mut tasks = self.extension_loading_tasks.lock().await; + tasks.insert(session_id, Arc::new(Mutex::new(Some(task)))); + } + + pub async fn take_extension_loading_task( + &self, + session_id: &str, + ) -> Option> { + let task_holder = { + let tasks = self.extension_loading_tasks.lock().await; + tasks.get(session_id).cloned() + }; + + if let Some(holder) = task_holder { + let task = holder.lock().await.take(); + if let Some(handle) = task { + match handle.await { + Ok(results) => return Some(results), + Err(e) => { + tracing::warn!("Background extension loading task failed: {}", e); + } + } + } + } + None + } + + pub async fn remove_extension_loading_task(&self, session_id: &str) { + let mut tasks = self.extension_loading_tasks.lock().await; + tasks.remove(session_id); + } + pub fn scheduler(&self) -> Arc { self.agent_manager.scheduler() } diff --git a/crates/goose/src/agents/extension_manager.rs b/crates/goose/src/agents/extension_manager.rs index 16c720baf68a..13fe29c143ca 100644 --- a/crates/goose/src/agents/extension_manager.rs +++ b/crates/goose/src/agents/extension_manager.rs @@ -133,7 +133,7 @@ impl ResourceItem { /// Sanitizes a string by replacing invalid characters with underscores. /// Valid characters match [a-zA-Z0-9_-] -fn normalize(input: String) -> String { +pub fn normalize(input: &str) -> String { let mut result = String::with_capacity(input.len()); for c in input.chars() { result.push(match c { @@ -316,7 +316,7 @@ impl ExtensionManager { working_dir: Option, ) -> ExtensionResult<()> { let config_name = config.key().to_string(); - let sanitized_name = normalize(config_name.clone()); + let sanitized_name = normalize(&config_name); let mut temp_dir = None; /// Helper function to merge environment variables from direct envs and keychain-stored env_keys @@ -567,7 +567,7 @@ impl ExtensionManager { } ExtensionConfig::Platform { name, .. } => { // Normalize the name to match the key used in PLATFORM_EXTENSIONS - let normalized_key = normalize(name.clone()); + let normalized_key = normalize(name); let def = PLATFORM_EXTENSIONS .get(normalized_key.as_str()) .ok_or_else(|| { @@ -660,7 +660,7 @@ impl ExtensionManager { /// Get aggregated usage statistics pub async fn remove_extension(&self, name: &str) -> ExtensionResult<()> { - let sanitized_name = normalize(name.to_string()); + let sanitized_name = normalize(name); self.extensions.lock().await.remove(&sanitized_name); Ok(()) } @@ -1375,7 +1375,7 @@ mod tests { client: McpClientBox, available_tools: Vec, ) { - let sanitized_name = normalize(name.clone()); + let sanitized_name = normalize(&name); let config = ExtensionConfig::Builtin { name: name.clone(), display_name: Some(name.clone()), diff --git a/crates/goose/src/agents/mod.rs b/crates/goose/src/agents/mod.rs index ceddd2bd1f65..f7da7c20e70c 100644 --- a/crates/goose/src/agents/mod.rs +++ b/crates/goose/src/agents/mod.rs @@ -31,7 +31,7 @@ pub mod types; pub use agent::{Agent, AgentEvent}; pub use execute_commands::COMPACT_TRIGGERS; pub use extension::ExtensionConfig; -pub use extension_manager::ExtensionManager; +pub use extension_manager::{normalize, ExtensionManager}; pub use prompt_manager::PromptManager; pub use subagent_task_config::TaskConfig; pub use types::{FrontendTool, RetryConfig, SessionConfig, SuccessCheck}; diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 6b2a665ea937..6b1f4697ecef 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -227,7 +227,14 @@ }, "responses": { "200": { - "description": "Agent restarted successfully" + "description": "Agent restarted successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestartAgentResponse" + } + } + } }, "401": { "description": "Unauthorized - invalid secret key" @@ -263,7 +270,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Session" + "$ref": "#/components/schemas/ResumeAgentResponse" } } } @@ -2338,6 +2345,51 @@ ] } }, + "/sessions/{session_id}/extensions": { + "get": { + "tags": [ + "Session Management" + ], + "operationId": "get_session_extensions", + "parameters": [ + { + "name": "session_id", + "in": "path", + "description": "Unique identifier for the session", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Session extensions retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionExtensionsResponse" + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing API key" + }, + "404": { + "description": "Session not found" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "api_key": [] + } + ] + } + }, "/sessions/{session_id}/name": { "put": { "tags": [ @@ -3653,6 +3705,25 @@ } ] }, + "ExtensionLoadResult": { + "type": "object", + "required": [ + "name", + "success" + ], + "properties": { + "error": { + "type": "string", + "nullable": true + }, + "name": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + }, "ExtensionQuery": { "type": "object", "required": [ @@ -4944,6 +5015,20 @@ } } }, + "RestartAgentResponse": { + "type": "object", + "required": [ + "extension_results" + ], + "properties": { + "extension_results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ExtensionLoadResult" + } + } + } + }, "ResumeAgentRequest": { "type": "object", "required": [ @@ -4959,6 +5044,24 @@ } } }, + "ResumeAgentResponse": { + "type": "object", + "required": [ + "session" + ], + "properties": { + "extension_results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ExtensionLoadResult" + }, + "nullable": true + }, + "session": { + "$ref": "#/components/schemas/Session" + } + } + }, "RetryConfig": { "type": "object", "description": "Configuration for retry logic in recipe execution", @@ -5299,6 +5402,20 @@ } } }, + "SessionExtensionsResponse": { + "type": "object", + "required": [ + "extensions" + ], + "properties": { + "extensions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ExtensionConfig" + } + } + } + }, "SessionInsights": { "type": "object", "required": [ @@ -5455,6 +5572,13 @@ "working_dir" ], "properties": { + "extension_overrides": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ExtensionConfig" + }, + "nullable": true + }, "recipe": { "allOf": [ { @@ -5989,8 +6113,7 @@ ], "properties": { "workingDir": { - "type": "string", - "description": "New working directory path" + "type": "string" } } }, diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index e6e79e3ec92f..3d6fff5135ff 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -52,133 +52,75 @@ function PageViewTracker() { } // Route Components -const HubRouteWrapper = ({ isExtensionsLoading }: { isExtensionsLoading: boolean }) => { +const HubRouteWrapper = () => { const setView = useNavigation(); - - return ; + return ; }; const PairRouteWrapper = ({ chat, setChat, - activeSessionId, - setActiveSessionId, }: { chat: ChatType; setChat: (chat: ChatType) => void; - activeSessionId: string | null; - setActiveSessionId: (id: string | null) => void; }) => { + const { extensionsList } = useConfig(); const location = useLocation(); - const routeState = - (location.state as PairRouteState) || (window.history.state as PairRouteState) || {}; - const [searchParams, setSearchParams] = useSearchParams(); - - // Capture initialMessage in local state to survive route state being cleared by setSearchParams - const [capturedInitialMessage, setCapturedInitialMessage] = useState( - undefined - ); - const [lastSessionId, setLastSessionId] = useState(undefined); - const [isCreatingSession, setIsCreatingSession] = useState(false); - + const navigate = useNavigate(); + const routeState = (location.state as PairRouteState) || {}; + const [searchParams] = useSearchParams(); const resumeSessionId = searchParams.get('resumeSessionId') ?? undefined; const recipeId = searchParams.get('recipeId') ?? undefined; const recipeDeeplinkFromConfig = window.appConfig?.get('recipeDeeplink') as string | undefined; - // Determine which session ID to use: - // 1. From route state (when navigating from Hub with a new session) - // 2. From URL params (when resuming a session or after refresh) - // 3. From active session state (when navigating back from other routes) - // 4. From the existing chat state - const sessionId = - routeState.resumeSessionId || resumeSessionId || activeSessionId || chat.sessionId; - - // Use route state if available, otherwise use captured state - const initialMessage = routeState.initialMessage || capturedInitialMessage; + // Session ID and initialMessage come from route state (Hub, fork) or URL params (refresh, deeplink) + const sessionIdFromState = routeState.resumeSessionId; + const initialMessage = routeState.initialMessage; + const sessionId = sessionIdFromState || resumeSessionId || chat.sessionId || undefined; + // Handle recipe deeplinks - create session if needed useEffect(() => { - if (routeState.initialMessage) { - setCapturedInitialMessage(routeState.initialMessage); - } - }, [routeState.initialMessage]); - - useEffect(() => { - // Create a new session if we have an initialMessage, recipeId, or recipeDeeplink from config but no sessionId - if ( - (initialMessage || recipeId || recipeDeeplinkFromConfig) && - !sessionId && - !isCreatingSession - ) { - console.log( - '[PairRouteWrapper] Creating new session for initialMessage, recipeId, or recipeDeeplink from config' - ); - setIsCreatingSession(true); - + if ((recipeId || recipeDeeplinkFromConfig) && !sessionId) { (async () => { try { const newSession = await createSession(getInitialWorkingDir(), { recipeId, recipeDeeplink: recipeDeeplinkFromConfig, + allExtensions: extensionsList, }); - - setSearchParams((prev) => { - prev.set('resumeSessionId', newSession.id); - // Remove recipeId from URL after session is created - prev.delete('recipeId'); - return prev; + navigate(`/pair?resumeSessionId=${newSession.id}`, { + replace: true, + state: { resumeSessionId: newSession.id }, }); - setActiveSessionId(newSession.id); } catch (error) { - console.error('[PairRouteWrapper] Failed to create session:', error); + console.error('Failed to create session for recipe:', error); trackErrorWithContext(error, { component: 'PairRouteWrapper', action: 'create_session', recoverable: true, }); - } finally { - setIsCreatingSession(false); } })(); } - }, [ - initialMessage, - recipeId, - recipeDeeplinkFromConfig, - sessionId, - isCreatingSession, - setSearchParams, - setActiveSessionId, - ]); - - // Clear captured initialMessage when sessionId actually changes to a different session - useEffect(() => { - if (sessionId !== lastSessionId) { - setLastSessionId(sessionId); - if (!routeState.initialMessage) { - setCapturedInitialMessage(undefined); - } - } - }, [sessionId, lastSessionId, routeState.initialMessage]); + }, [recipeId, recipeDeeplinkFromConfig, sessionId, extensionsList, navigate]); - // Update URL with session ID when on /pair route (for refresh support) + // Sync URL with session ID for refresh support (only if not already in URL) useEffect(() => { if (sessionId && sessionId !== resumeSessionId) { - setSearchParams((prev) => { - prev.set('resumeSessionId', sessionId); - return prev; + navigate(`/pair?resumeSessionId=${sessionId}`, { + replace: true, + state: { resumeSessionId: sessionIdFromState, initialMessage }, }); } - }, [sessionId, resumeSessionId, setSearchParams]); - - // Update active session state when session ID changes - useEffect(() => { - if (sessionId && sessionId !== activeSessionId) { - setActiveSessionId(sessionId); - } - }, [sessionId, activeSessionId, setActiveSessionId]); + }, [sessionId, resumeSessionId, navigate, sessionIdFromState, initialMessage]); return ( - + ); }; @@ -367,7 +309,6 @@ export function AppInner() { const [agentWaitingMessage, setAgentWaitingMessage] = useState(null); const [isLoadingSharedSession, setIsLoadingSharedSession] = useState(false); const [sharedSessionError, setSharedSessionError] = useState(null); - const [isExtensionsLoading, setIsExtensionsLoading] = useState(false); const [didSelectProvider, setDidSelectProvider] = useState(false); const navigate = useNavigate(); @@ -382,9 +323,6 @@ export function AppInner() { recipe: null, }); - // Store the active session ID for navigation persistence - const [activeSessionId, setActiveSessionId] = useState(null); - const { addExtension } = useConfig(); const { loadCurrentChat } = useAgent(); @@ -408,7 +346,6 @@ export function AppInner() { try { const loadedChat = await loadCurrentChat({ setAgentWaitingMessage, - setIsExtensionsLoading, }); setChat(loadedChat); } catch (e) { @@ -567,11 +504,21 @@ export function AppInner() { // Handle initial message from launcher useEffect(() => { - const handleSetInitialMessage = (_event: IpcRendererEvent, ...args: unknown[]) => { + const handleSetInitialMessage = async (_event: IpcRendererEvent, ...args: unknown[]) => { const initialMessage = args[0] as string; if (initialMessage) { console.log('Received initial message from launcher:', initialMessage); - navigate('/pair', { state: { initialMessage } }); + try { + const session = await createSession(getInitialWorkingDir(), {}); + navigate('/pair', { + state: { + initialMessage, + resumeSessionId: session.id, + }, + }); + } catch (error) { + console.error('Failed to create session for launcher message:', error); + } } }; window.electron.on('set-initial-message', handleSetInitialMessage); @@ -627,18 +574,8 @@ export function AppInner() { } > - } /> - - } - /> + } /> + } /> } /> = Options2 & { /** @@ -413,6 +413,8 @@ export const editMessage = (options: Optio export const exportSession = (options: Options) => (options.client ?? client).get({ url: '/sessions/{session_id}/export', ...options }); +export const getSessionExtensions = (options: Options) => (options.client ?? client).get({ url: '/sessions/{session_id}/extensions', ...options }); + export const updateSessionName = (options: Options) => (options.client ?? client).put({ url: '/sessions/{session_id}/name', ...options, diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 7936888ecbf1..af0677983536 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -327,6 +327,12 @@ export type ExtensionEntry = ExtensionConfig & { enabled: boolean; }; +export type ExtensionLoadResult = { + error?: string | null; + name: string; + success: boolean; +}; + export type ExtensionQuery = { config: ExtensionConfig; enabled: boolean; @@ -716,11 +722,20 @@ export type RestartAgentRequest = { session_id: string; }; +export type RestartAgentResponse = { + extension_results: Array; +}; + export type ResumeAgentRequest = { load_model_and_extensions: boolean; session_id: string; }; +export type ResumeAgentResponse = { + extension_results?: Array | null; + session: Session; +}; + /** * Configuration for retry logic in recipe execution */ @@ -827,6 +842,10 @@ export type SessionDisplayInfo = { workingDir: string; }; +export type SessionExtensionsResponse = { + extensions: Array; +}; + export type SessionInsights = { totalSessions: number; totalTokens: number; @@ -877,6 +896,7 @@ export type SlashCommandsResponse = { }; export type StartAgentRequest = { + extension_overrides?: Array | null; recipe?: Recipe | null; recipe_deeplink?: string | null; recipe_id?: string | null; @@ -1071,9 +1091,6 @@ export type UpdateSessionUserRecipeValuesResponse = { }; export type UpdateSessionWorkingDirRequest = { - /** - * New working directory path - */ workingDir: string; }; @@ -1275,9 +1292,11 @@ export type RestartAgentResponses = { /** * Agent restarted successfully */ - 200: unknown; + 200: RestartAgentResponse; }; +export type RestartAgentResponse2 = RestartAgentResponses[keyof RestartAgentResponses]; + export type ResumeAgentData = { body: ResumeAgentRequest; path?: never; @@ -1304,10 +1323,10 @@ export type ResumeAgentResponses = { /** * Agent started successfully */ - 200: Session; + 200: ResumeAgentResponse; }; -export type ResumeAgentResponse = ResumeAgentResponses[keyof ResumeAgentResponses]; +export type ResumeAgentResponse2 = ResumeAgentResponses[keyof ResumeAgentResponses]; export type StartAgentData = { body: StartAgentRequest; @@ -2922,6 +2941,42 @@ export type ExportSessionResponses = { export type ExportSessionResponse = ExportSessionResponses[keyof ExportSessionResponses]; +export type GetSessionExtensionsData = { + body?: never; + path: { + /** + * Unique identifier for the session + */ + session_id: string; + }; + query?: never; + url: '/sessions/{session_id}/extensions'; +}; + +export type GetSessionExtensionsErrors = { + /** + * Unauthorized - Invalid or missing API key + */ + 401: unknown; + /** + * Session not found + */ + 404: unknown; + /** + * Internal server error + */ + 500: unknown; +}; + +export type GetSessionExtensionsResponses = { + /** + * Session extensions retrieved successfully + */ + 200: SessionExtensionsResponse; +}; + +export type GetSessionExtensionsResponse = GetSessionExtensionsResponses[keyof GetSessionExtensionsResponses]; + export type UpdateSessionNameData = { body: UpdateSessionNameRequest; path: { diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index 8c4ed85e6266..9327a6fbda81 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -95,6 +95,7 @@ function BaseChatContent({ session, messages, chatState, + setChatState, handleSubmit, submitElicitationResponse, stopStreaming, @@ -131,15 +132,18 @@ function BaseChatContent({ const shouldStartAgent = searchParams.get('shouldStartAgent') === 'true'; if (initialMessage) { - // Submit the initial message (e.g., from fork) hasAutoSubmittedRef.current = true; handleSubmit(initialMessage); + // Clear initialMessage from navigation state to prevent re-sending on refresh + navigate(location.pathname + location.search, { + replace: true, + state: { ...location.state, initialMessage: undefined }, + }); } else if (shouldStartAgent) { - // Trigger agent to continue with existing conversation hasAutoSubmittedRef.current = true; handleSubmit(''); } - }, [session, initialMessage, searchParams, handleSubmit]); + }, [session, initialMessage, searchParams, handleSubmit, navigate, location]); const handleFormSubmit = (e: React.FormEvent) => { const customEvent = e as unknown as CustomEvent; @@ -300,8 +304,7 @@ function BaseChatContent({ : recipe.prompt; } - const initialPrompt = - (initialMessage && !hasAutoSubmittedRef.current ? initialMessage : '') || recipePrompt; + const initialPrompt = recipePrompt; if (sessionLoadError) { return ( @@ -407,6 +410,7 @@ function BaseChatContent({ sessionId={sessionId} handleSubmit={handleFormSubmit} chatState={chatState} + setChatState={setChatState} onStop={stopStreaming} commandHistory={commandHistory} initialValue={initialPrompt} diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index ebe2cb1f4461..841b6516d8c2 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -73,6 +73,7 @@ interface ChatInputProps { sessionId: string | null; handleSubmit: (e: React.FormEvent) => void; chatState: ChatState; + setChatState?: (state: ChatState) => void; onStop?: () => void; commandHistory?: string[]; initialValue?: string; @@ -97,13 +98,14 @@ interface ChatInputProps { initialPrompt?: string; toolCount: number; append?: (message: Message) => void; - isExtensionsLoading?: boolean; + onWorkingDirChange?: (newDir: string) => void; } export default function ChatInput({ sessionId, handleSubmit, chatState = ChatState.Idle, + setChatState, onStop, commandHistory = [], initialValue = '', @@ -122,7 +124,7 @@ export default function ChatInput({ initialPrompt, toolCount, append: _append, - isExtensionsLoading = false, + onWorkingDirChange, }: ChatInputProps) { const [_value, setValue] = useState(initialValue); const [displayValue, setDisplayValue] = useState(initialValue); // For immediate visual feedback @@ -1131,7 +1133,7 @@ export default function ChatInput({ isAnyDroppedFileLoading || isRecording || isTranscribing || - isExtensionsLoading; + chatState === ChatState.RestartingAgent; // Queue management functions - no storage persistence, only in-memory const handleRemoveQueuedMessage = (messageId: string) => { @@ -1374,16 +1376,16 @@ export default function ChatInput({

- {isExtensionsLoading - ? 'Loading extensions...' - : isAnyImageLoading - ? 'Waiting for images to save...' - : isAnyDroppedFileLoading - ? 'Processing dropped files...' - : isRecording - ? 'Recording...' - : isTranscribing - ? 'Transcribing...' + {isAnyImageLoading + ? 'Waiting for images to save...' + : isAnyDroppedFileLoading + ? 'Processing dropped files...' + : isRecording + ? 'Recording...' + : isTranscribing + ? 'Transcribing...' + : chatState === ChatState.RestartingAgent + ? 'Restarting agent...' : 'Send'}

@@ -1528,7 +1530,14 @@ export default function ChatInput({ className="mr-0" sessionId={sessionId ?? undefined} workingDir={sessionWorkingDir ?? getInitialWorkingDir()} - onWorkingDirChange={(newDir) => setSessionWorkingDir(newDir)} + onWorkingDirChange={(newDir) => { + setSessionWorkingDir(newDir); + if (onWorkingDirChange) { + onWorkingDirChange(newDir); + } + }} + onRestartStart={() => setChatState?.(ChatState.RestartingAgent)} + onRestartEnd={() => setChatState?.(ChatState.Idle)} />
@@ -1573,12 +1582,12 @@ export default function ChatInput({
- {sessionId && process.env.ALPHA && ( - <> -
- - - )} +
+ setChatState?.(ChatState.RestartingAgent)} + onRestartEnd={() => setChatState?.(ChatState.Idle)} + /> {sessionId && messages.length > 0 && ( <>
diff --git a/ui/desktop/src/components/GroupedExtensionLoadingToast.tsx b/ui/desktop/src/components/GroupedExtensionLoadingToast.tsx index 91cabb33fc83..47ca1f89d48b 100644 --- a/ui/desktop/src/components/GroupedExtensionLoadingToast.tsx +++ b/ui/desktop/src/components/GroupedExtensionLoadingToast.tsx @@ -6,6 +6,7 @@ import { startNewSession } from '../sessions'; import { useNavigation } from '../hooks/useNavigation'; import { formatExtensionErrorMessage } from '../utils/extensionErrorUtils'; import { getInitialWorkingDir } from '../utils/workingDir'; +import { formatExtensionName } from './settings/extensions/subcomponents/ExtensionList'; export interface ExtensionLoadingStatus { name: string; @@ -92,46 +93,53 @@ export function GroupedExtensionLoadingToast({
- {extensions.map((ext) => ( -
-
- {getStatusIcon(ext.status)} -
{ext.name}
-
- {ext.status === 'error' && ext.error && ( -
-
- {formatExtensionErrorMessage(ext.error, 'Failed to add extension')} -
- {ext.recoverHints && setView ? ( - - ) : ( - - )} + {extensions.map((ext) => { + const friendlyName = formatExtensionName(ext.name); + + return ( +
+
+ {getStatusIcon(ext.status)} +
{friendlyName}
- )} -
- ))} + {ext.status === 'error' && ext.error && ( +
+
+ {formatExtensionErrorMessage(ext.error, 'Failed to add extension')} +
+
+ {ext.recoverHints && setView && ( + + )} + +
+
+ )} +
+ ); + })}
diff --git a/ui/desktop/src/components/Hub.tsx b/ui/desktop/src/components/Hub.tsx index 6038e1c53079..251a10bfcd64 100644 --- a/ui/desktop/src/components/Hub.tsx +++ b/ui/desktop/src/components/Hub.tsx @@ -7,48 +7,81 @@ * Key Responsibilities: * - Displays SessionInsights to show session statistics and recent chats * - Provides a ChatInput for users to start new conversations - * - Navigates to Pair with the submitted message to start a new conversation - * - Ensures each submission from Hub always starts a fresh conversation + * - Creates a new session and navigates to Pair with the session ID + * - Shows loading state while session is being created * * Navigation Flow: - * Hub (input submission) → Pair (new conversation with the submitted message) + * Hub (input submission) → Create Session → Pair (with session ID and initial message) */ +import { useState } from 'react'; import { SessionInsights } from './sessions/SessionsInsights'; import ChatInput from './ChatInput'; import { ChatState } from '../types/chatState'; import 'react-toastify/dist/ReactToastify.css'; import { View, ViewOptions } from '../utils/navigationUtils'; -import { startNewSession } from '../sessions'; +import { useConfig } from './ConfigContext'; +import { + getExtensionConfigsWithOverrides, + clearExtensionOverrides, +} from '../store/extensionOverrides'; import { getInitialWorkingDir } from '../utils/workingDir'; +import { createSession } from '../sessions'; +import LoadingGoose from './LoadingGoose'; export default function Hub({ setView, - isExtensionsLoading, }: { setView: (view: View, viewOptions?: ViewOptions) => void; - isExtensionsLoading: boolean; }) { + const { extensionsList } = useConfig(); + const [workingDir, setWorkingDir] = useState(getInitialWorkingDir()); + const [isCreatingSession, setIsCreatingSession] = useState(false); + const handleSubmit = async (e: React.FormEvent) => { const customEvent = e as unknown as CustomEvent; const combinedTextFromInput = customEvent.detail?.value || ''; - if (combinedTextFromInput.trim()) { - await startNewSession(getInitialWorkingDir(), combinedTextFromInput, setView); + if (combinedTextFromInput.trim() && !isCreatingSession) { + const extensionConfigs = getExtensionConfigsWithOverrides(extensionsList); + clearExtensionOverrides(); + setIsCreatingSession(true); + + try { + const session = await createSession(workingDir, { + extensionConfigs, + allExtensions: extensionConfigs ? undefined : extensionsList, + }); + + setView('pair', { + disableAnimation: true, + resumeSessionId: session.id, + initialMessage: combinedTextFromInput, + }); + } catch (error) { + console.error('Failed to create session:', error); + setIsCreatingSession(false); + } + e.preventDefault(); } }; return (
-
+
+ {isCreatingSession && ( +
+ +
+ )}
{}} initialValue="" setView={setView} @@ -60,8 +93,8 @@ export default function Hub({ messages={[]} disableAnimation={false} sessionCosts={undefined} - isExtensionsLoading={isExtensionsLoading} toolCount={0} + onWorkingDirChange={setWorkingDir} />
); diff --git a/ui/desktop/src/components/LoadingGoose.tsx b/ui/desktop/src/components/LoadingGoose.tsx index 56cddb27aa61..92f8b81375ea 100644 --- a/ui/desktop/src/components/LoadingGoose.tsx +++ b/ui/desktop/src/components/LoadingGoose.tsx @@ -15,6 +15,7 @@ const STATE_MESSAGES: Record = { [ChatState.WaitingForUserInput]: 'goose is waiting…', [ChatState.Compacting]: 'goose is compacting the conversation...', [ChatState.Idle]: 'goose is working on it…', + [ChatState.RestartingAgent]: 'restarting agent...', }; const STATE_ICONS: Record = { @@ -26,6 +27,7 @@ const STATE_ICONS: Record = { ), [ChatState.Compacting]: , [ChatState.Idle]: , + [ChatState.RestartingAgent]: , }; const LoadingGoose = ({ message, chatState = ChatState.Idle }: LoadingGooseProps) => { diff --git a/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx b/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx index 05b0b11b782f..6a5cfbbb2cc3 100644 --- a/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx +++ b/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx @@ -1,25 +1,108 @@ -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState, useRef } from 'react'; import { Puzzle } from 'lucide-react'; import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '../ui/dropdown-menu'; import { Input } from '../ui/input'; import { Switch } from '../ui/switch'; import { FixedExtensionEntry, useConfig } from '../ConfigContext'; -import { toggleExtension } from '../settings/extensions/extension-manager'; import { toastService } from '../../toasts'; -import { getFriendlyTitle } from '../settings/extensions/subcomponents/ExtensionList'; +import { formatExtensionName } from '../settings/extensions/subcomponents/ExtensionList'; +import { ExtensionConfig, getSessionExtensions } from '../../api'; +import { addToAgent, removeFromAgent } from '../settings/extensions/agent-api'; +import { + setExtensionOverride, + getExtensionOverride, + getExtensionOverrides, +} from '../../store/extensionOverrides'; interface BottomMenuExtensionSelectionProps { - sessionId: string; + sessionId: string | null; + onRestartStart?: () => void; + onRestartEnd?: () => void; } -export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionSelectionProps) => { +export const BottomMenuExtensionSelection = ({ + sessionId, + onRestartStart, + onRestartEnd, +}: BottomMenuExtensionSelectionProps) => { const [searchQuery, setSearchQuery] = useState(''); const [isOpen, setIsOpen] = useState(false); - const { extensionsList, addExtension } = useConfig(); + const [sessionExtensions, setSessionExtensions] = useState([]); + const [hubUpdateTrigger, setHubUpdateTrigger] = useState(0); + const [isTransitioning, setIsTransitioning] = useState(false); + const [pendingSort, setPendingSort] = useState(false); + const [togglingExtension, setTogglingExtension] = useState(null); + const sortTimeoutRef = useRef | null>(null); + const { extensionsList: allExtensions } = useConfig(); + const isHubView = !sessionId; + + useEffect(() => { + return () => { + if (sortTimeoutRef.current) { + clearTimeout(sortTimeoutRef.current); + } + }; + }, []); + + // Fetch session-specific extensions or use global defaults + useEffect(() => { + const fetchExtensions = async () => { + if (!sessionId) { + return; + } + + try { + const response = await getSessionExtensions({ + path: { session_id: sessionId }, + }); + + if (response.data?.extensions) { + setSessionExtensions(response.data.extensions); + } + } catch (error) { + console.error('Failed to fetch session extensions:', error); + } + }; + + fetchExtensions(); + }, [sessionId, isOpen]); const handleToggle = useCallback( async (extensionConfig: FixedExtensionEntry) => { + if (togglingExtension === extensionConfig.name) { + return; + } + + setIsTransitioning(true); + setTogglingExtension(extensionConfig.name); + + if (isHubView) { + const currentState = getExtensionOverride(extensionConfig.name) ?? extensionConfig.enabled; + setExtensionOverride(extensionConfig.name, !currentState); + setPendingSort(true); + + if (sortTimeoutRef.current) { + clearTimeout(sortTimeoutRef.current); + } + + // Delay the re-sort to allow animation + sortTimeoutRef.current = setTimeout(() => { + setHubUpdateTrigger((prev) => prev + 1); + setPendingSort(false); + setIsTransitioning(false); + setTogglingExtension(null); + }, 800); + + toastService.success({ + title: 'Extension Updated', + msg: `${formatExtensionName(extensionConfig.name)} will be ${!currentState ? 'enabled' : 'disabled'} in new chats`, + }); + return; + } + if (!sessionId) { + setIsTransitioning(false); + setTogglingExtension(null); toastService.error({ title: 'Extension Toggle Error', msg: 'No active session found. Please start a chat session first.', @@ -28,27 +111,70 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS return; } + onRestartStart?.(); + try { - const toggleDirection = extensionConfig.enabled ? 'toggleOff' : 'toggleOn'; - - await toggleExtension({ - toggle: toggleDirection, - extensionConfig: extensionConfig, - addToConfig: addExtension, - toastOptions: { silent: false }, - sessionId: sessionId, - }); - } catch (error) { - toastService.error({ - title: 'Extension Error', - msg: `Failed to ${extensionConfig.enabled ? 'disable' : 'enable'} ${extensionConfig.name}`, - traceback: error instanceof Error ? error.message : String(error), - }); + if (extensionConfig.enabled) { + await removeFromAgent(extensionConfig.name, sessionId, true); + } else { + await addToAgent(extensionConfig, sessionId, true); + } + + setPendingSort(true); + + if (sortTimeoutRef.current) { + clearTimeout(sortTimeoutRef.current); + } + + sortTimeoutRef.current = setTimeout(async () => { + const response = await getSessionExtensions({ + path: { session_id: sessionId }, + }); + + if (response.data?.extensions) { + setSessionExtensions(response.data.extensions); + } + setPendingSort(false); + setIsTransitioning(false); + setTogglingExtension(null); + onRestartEnd?.(); + }, 800); + } catch { + setIsTransitioning(false); + setPendingSort(false); + setTogglingExtension(null); + onRestartEnd?.(); } }, - [sessionId, addExtension] + [sessionId, isHubView, togglingExtension, onRestartStart, onRestartEnd] ); + // Merge all available extensions with session-specific or hub override state + const extensionsList = useMemo(() => { + const hubOverrides = getExtensionOverrides(); + + if (isHubView) { + return allExtensions.map( + (ext) => + ({ + ...ext, + enabled: hubOverrides.has(ext.name) ? hubOverrides.get(ext.name)! : ext.enabled, + }) as FixedExtensionEntry + ); + } + + const sessionExtensionNames = new Set(sessionExtensions.map((ext) => ext.name)); + + return allExtensions.map( + (ext) => + ({ + ...ext, + enabled: sessionExtensionNames.has(ext.name), + }) as FixedExtensionEntry + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [allExtensions, sessionExtensions, isHubView, hubUpdateTrigger]); + const filteredExtensions = useMemo(() => { return extensionsList.filter((ext) => { const query = searchQuery.toLowerCase(); @@ -60,24 +186,11 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS }, [extensionsList, searchQuery]); const sortedExtensions = useMemo(() => { - const getTypePriority = (type: string): number => { - const priorities: Record = { - builtin: 0, - platform: 1, - frontend: 2, - }; - return priorities[type] ?? Number.MAX_SAFE_INTEGER; - }; - return [...filteredExtensions].sort((a, b) => { - // First sort by priority type - const typeDiff = getTypePriority(a.type) - getTypePriority(b.type); - if (typeDiff !== 0) return typeDiff; - - // Then sort by enabled status (enabled first) + // Primary sort: enabled first if (a.enabled !== b.enabled) return a.enabled ? -1 : 1; - // Finally sort alphabetically + // Secondary sort: alphabetically by name return a.name.localeCompare(b.name); }); }, [filteredExtensions]); @@ -92,7 +205,13 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS onOpenChange={(open) => { setIsOpen(open); if (!open) { - setSearchQuery(''); // Reset search when closing + setSearchQuery(''); + if (sortTimeoutRef.current) { + clearTimeout(sortTimeoutRef.current); + } + setIsTransitioning(false); + setPendingSort(false); + setTogglingExtension(null); } }} > @@ -105,7 +224,14 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS {activeCount} - + { + e.preventDefault(); + }} + >
+

+ {isHubView ? 'Extensions for new chats' : 'Extensions for this chat session'} +

-
+
{sortedExtensions.length === 0 ? (
{searchQuery ? 'no extensions found' : 'no extensions available'}
) : ( - sortedExtensions.map((ext) => ( -
handleToggle(ext)} - title={ext.description || ext.name} - > -
{getFriendlyTitle(ext)}
-
e.stopPropagation()}> - handleToggle(ext)} - variant="mono" - /> + sortedExtensions.map((ext) => { + const isToggling = togglingExtension === ext.name; + return ( +
!isToggling && handleToggle(ext)} + title={ext.description || ext.name} + > +
+ {formatExtensionName(ext.name)} +
+
e.stopPropagation()}> + handleToggle(ext)} + variant="mono" + disabled={isToggling} + /> +
-
- )) + ); + }) )}
diff --git a/ui/desktop/src/components/bottom_menu/DirSwitcher.tsx b/ui/desktop/src/components/bottom_menu/DirSwitcher.tsx index be505bcb2dfa..26c8dc399eae 100644 --- a/ui/desktop/src/components/bottom_menu/DirSwitcher.tsx +++ b/ui/desktop/src/components/bottom_menu/DirSwitcher.tsx @@ -9,6 +9,8 @@ interface DirSwitcherProps { sessionId: string | undefined; workingDir: string; onWorkingDirChange?: (newDir: string) => void; + onRestartStart?: () => void; + onRestartEnd?: () => void; } export const DirSwitcher: React.FC = ({ @@ -16,6 +18,8 @@ export const DirSwitcher: React.FC = ({ sessionId, workingDir, onWorkingDirChange, + onRestartStart, + onRestartEnd, }) => { const [isTooltipOpen, setIsTooltipOpen] = useState(false); const [isDirectoryChooserOpen, setIsDirectoryChooserOpen] = useState(false); @@ -40,14 +44,18 @@ export const DirSwitcher: React.FC = ({ window.electron.addRecentDir(newDir); if (sessionId) { + onWorkingDirChange?.(newDir); + onRestartStart?.(); + try { await updateWorkingDir({ body: { session_id: sessionId, working_dir: newDir }, }); - onWorkingDirChange?.(newDir); } catch (error) { console.error('[DirSwitcher] Failed to update working directory:', error); toast.error('Failed to update working directory'); + } finally { + onRestartEnd?.(); } } else { onWorkingDirChange?.(newDir); diff --git a/ui/desktop/src/components/extensions/ExtensionsView.tsx b/ui/desktop/src/components/extensions/ExtensionsView.tsx index 8177441ae85e..34e243aaa004 100644 --- a/ui/desktop/src/components/extensions/ExtensionsView.tsx +++ b/ui/desktop/src/components/extensions/ExtensionsView.tsx @@ -1,5 +1,4 @@ import { View, ViewOptions } from '../../utils/navigationUtils'; -import { useChatContext } from '../../contexts/ChatContext'; import ExtensionsSection from '../settings/extensions/ExtensionsSection'; import { ExtensionConfig } from '../../api'; import { MainPanelLayout } from '../Layout/MainPanelLayout'; @@ -14,7 +13,7 @@ import { ExtensionFormData, createExtensionConfig, } from '../settings/extensions/utils'; -import { activateExtension } from '../settings/extensions'; +import { activateExtensionDefault } from '../settings/extensions'; import { useConfig } from '../ConfigContext'; import { SearchView } from '../conversation/SearchView'; @@ -34,14 +33,7 @@ export default function ExtensionsView({ const [refreshKey, setRefreshKey] = useState(0); const [searchTerm, setSearchTerm] = useState(''); const { addExtension } = useConfig(); - const chatContext = useChatContext(); - const sessionId = chatContext?.chat.sessionId || ''; - if (!sessionId) { - console.error('ExtensionsView: No session ID available'); - } - - // Only trigger refresh when deep link config changes AND we don't need to show env vars useEffect(() => { if (viewOptions.deepLinkConfig && !viewOptions.showEnvVars) { setRefreshKey((prevKey) => prevKey + 1); @@ -80,19 +72,12 @@ export default function ExtensionsView({ // Close the modal immediately handleModalClose(); - if (!sessionId) { - console.warn('Cannot activate extension without session'); - setRefreshKey((prevKey) => prevKey + 1); - return; - } - const extensionConfig = createExtensionConfig(formData); try { - await activateExtension({ + await activateExtensionDefault({ addToConfig: addExtension, extensionConfig: extensionConfig, - sessionId: sessionId, }); // Trigger a refresh of the extensions list setRefreshKey((prevKey) => prevKey + 1); @@ -113,11 +98,15 @@ export default function ExtensionsView({

Extensions

-

+

These extensions use the Model Context Protocol (MCP). They can expand Goose's capabilities using three main components: Prompts, Resources, and Tools. ⌘F/Ctrl+F to search.

+

+ Extensions enabled here are used as the default for new chats. You can also toggle + active extensions during chat. +

{/* Action Buttons */}
@@ -147,7 +136,6 @@ export default function ExtensionsView({ setSearchTerm(term)} placeholder="Search extensions..."> formatExtensionName(ext.name)); + } catch { + return []; + } +} interface EditSessionModalProps { session: Session | null; @@ -48,7 +66,6 @@ const EditSessionModal = React.memo( if (session && isOpen) { setDescription(session.name); } else if (!isOpen) { - // Reset state when modal closes setDescription(''); setIsUpdating(false); } @@ -71,8 +88,6 @@ const EditSessionModal = React.memo( throwOnError: true, }); await onSave(session.id, trimmedDescription); - - // Close modal, then show success toast on a timeout to let the UI update complete. onClose(); setTimeout(() => { toast.success('Session description updated successfully'); @@ -547,6 +562,12 @@ const SessionListView: React.FC = React.memo( [onOpenInNewWindow, session] ); + // Get extension names for this session + const extensionNames = useMemo( + () => getSessionExtensionNames(session.extension_data), + [session.extension_data] + ); + return ( = React.memo( {(session.total_tokens || 0).toLocaleString()}
)} + {extensionNames.length > 0 && ( + + + +
e.stopPropagation()}> + + {extensionNames.length} +
+
+ +
+
Extensions:
+
    + {extensionNames.map((name) => ( +
  • {name}
  • + ))} +
+
+
+
+
+ )}
diff --git a/ui/desktop/src/components/sessions/SessionsInsights.tsx b/ui/desktop/src/components/sessions/SessionsInsights.tsx index 15c2ad53578a..b9ec0e2cf7dc 100644 --- a/ui/desktop/src/components/sessions/SessionsInsights.tsx +++ b/ui/desktop/src/components/sessions/SessionsInsights.tsx @@ -78,7 +78,6 @@ export function SessionInsights() { loadInsights(); loadRecentSessions(); - // Cleanup timeout on unmount return () => { if (loadingTimeout) { window.clearTimeout(loadingTimeout); diff --git a/ui/desktop/src/components/settings/app/UpdateSection.tsx b/ui/desktop/src/components/settings/app/UpdateSection.tsx index c694980e863e..b68aea9e6cad 100644 --- a/ui/desktop/src/components/settings/app/UpdateSection.tsx +++ b/ui/desktop/src/components/settings/app/UpdateSection.tsx @@ -125,7 +125,6 @@ export default function UpdateSection() { } }); - // Cleanup timeout on unmount return () => { if (progressTimeoutRef.current) { clearTimeout(progressTimeoutRef.current); diff --git a/ui/desktop/src/components/settings/extensions/ExtensionsSection.tsx b/ui/desktop/src/components/settings/extensions/ExtensionsSection.tsx index 309dd3223f7c..ef3b577ce9a3 100644 --- a/ui/desktop/src/components/settings/extensions/ExtensionsSection.tsx +++ b/ui/desktop/src/components/settings/extensions/ExtensionsSection.tsx @@ -12,11 +12,10 @@ import { getDefaultFormData, } from './utils'; -import { activateExtension, deleteExtension, toggleExtension, updateExtension } from './index'; +import { activateExtensionDefault, deleteExtension, toggleExtensionDefault } from './index'; import { ExtensionConfig } from '../../../api/types.gen'; interface ExtensionSectionProps { - sessionId: string; // Add required sessionId prop deepLinkConfig?: ExtensionConfig; showEnvVars?: boolean; hideButtons?: boolean; @@ -28,7 +27,6 @@ interface ExtensionSectionProps { } export default function ExtensionsSection({ - sessionId, deepLinkConfig, showEnvVars, hideButtons, @@ -48,25 +46,12 @@ export default function ExtensionsSection({ const [showEnvVarsStateVar, setShowEnvVarsStateVar] = useState( showEnvVars ); - const [pendingActivationExtensions, setPendingActivationExtensions] = useState>( - new Set() - ); - // Update deep link state when props change useEffect(() => { setDeepLinkConfigStateVar(deepLinkConfig); setShowEnvVarsStateVar(showEnvVars); - - if (deepLinkConfig && !showEnvVars) { - setPendingActivationExtensions((prev) => { - const updated = new Set(prev); - updated.add(deepLinkConfig.name); - return updated; - }); - } }, [deepLinkConfig, showEnvVars]); - // Process extensions from context - this automatically updates when extensionsList changes const extensions = useMemo(() => { if (extensionsList.length === 0) return []; @@ -102,21 +87,12 @@ export default function ExtensionsSection({ return true; } - // If extension is enabled, we are trying to toggle if off, otherwise on const toggleDirection = extensionConfig.enabled ? 'toggleOff' : 'toggleOn'; - await toggleExtension({ + await toggleExtensionDefault({ toggle: toggleDirection, extensionConfig: extensionConfig, addToConfig: addExtension, - toastOptions: { silent: false }, - sessionId: sessionId, - }); - - setPendingActivationExtensions((prev) => { - const updated = new Set(prev); - updated.delete(extensionConfig.name); - return updated; }); await fetchExtensions(); @@ -134,26 +110,12 @@ export default function ExtensionsSection({ const extensionConfig = createExtensionConfig(formData); try { - await activateExtension({ + await activateExtensionDefault({ addToConfig: addExtension, extensionConfig: extensionConfig, - sessionId: sessionId, - }); - setPendingActivationExtensions((prev) => { - const updated = new Set(prev); - updated.delete(extensionConfig.name); - return updated; }); } catch (error) { - console.error('Failed to activate extension:', error); - // If activation fails, mark as pending if it's enabled in config - if (formData.enabled) { - setPendingActivationExtensions((prev) => { - const updated = new Set(prev); - updated.add(extensionConfig.name); - return updated; - }); - } + console.error('Failed to add extension:', error); } finally { await fetchExtensions(); if (onModalClose) { @@ -177,42 +139,28 @@ export default function ExtensionsSection({ const originalName = selectedExtension.name; try { - await updateExtension({ - enabled: formData.enabled, - extensionConfig: extensionConfig, - addToConfig: addExtension, - removeFromConfig: removeExtension, - originalName: originalName, - sessionId: sessionId, - }); + if (originalName !== extensionConfig.name) { + await removeExtension(originalName); + } + await addExtension(extensionConfig.name, extensionConfig, formData.enabled); } catch (error) { console.error('Failed to update extension:', error); - // We don't reopen the modal on failure } finally { - // Refresh the extensions list regardless of success or failure await fetchExtensions(); } }; const handleDeleteExtension = async (name: string) => { - // Capture the selected extension before closing the modal - const extensionToDelete = selectedExtension; - - // Close the modal immediately handleModalClose(); try { await deleteExtension({ name, removeFromConfig: removeExtension, - sessionId: sessionId, - extensionConfig: extensionToDelete ?? undefined, }); } catch (error) { console.error('Failed to delete extension:', error); - // We don't reopen the modal on failure } finally { - // Refresh the extensions list regardless of success or failure await fetchExtensions(); } }; @@ -240,7 +188,6 @@ export default function ExtensionsSection({ onConfigure={handleConfigureClick} disableConfiguration={disableConfiguration} searchTerm={searchTerm} - pendingActivationExtensions={pendingActivationExtensions} /> {!hideButtons && ( diff --git a/ui/desktop/src/components/settings/extensions/extension-manager.test.ts b/ui/desktop/src/components/settings/extensions/extension-manager.test.ts deleted file mode 100644 index 0151ebca3658..000000000000 --- a/ui/desktop/src/components/settings/extensions/extension-manager.test.ts +++ /dev/null @@ -1,255 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { addToAgentOnStartup, updateExtension, toggleExtension } from './extension-manager'; -import * as agentApi from './agent-api'; -import * as toasts from '../../../toasts'; - -// Mock dependencies -vi.mock('./agent-api'); -vi.mock('../../../toasts'); - -const mockAddToAgent = vi.mocked(agentApi.addToAgent); -const mockRemoveFromAgent = vi.mocked(agentApi.removeFromAgent); -const mockSanitizeName = vi.mocked(agentApi.sanitizeName); -const mockToastService = vi.mocked(toasts.toastService); - -describe('Extension Manager', () => { - const mockAddToConfig = vi.fn(); - const mockRemoveFromConfig = vi.fn(); - - const mockExtensionConfig = { - type: 'stdio' as const, - name: 'test-extension', - description: 'test-extension', - cmd: 'python', - args: ['script.py'], - timeout: 300, - }; - - beforeEach(() => { - vi.clearAllMocks(); - mockSanitizeName.mockImplementation((name: string) => name.toLowerCase()); - mockAddToConfig.mockResolvedValue(undefined); - mockRemoveFromConfig.mockResolvedValue(undefined); - }); - - describe('addToAgentOnStartup', () => { - it('should successfully add extension on startup', async () => { - mockAddToAgent.mockResolvedValue(undefined); - - await addToAgentOnStartup({ - sessionId: 'test-session', - extensionConfig: mockExtensionConfig, - }); - - expect(mockAddToAgent).toHaveBeenCalledWith(mockExtensionConfig, 'test-session', true); - }); - - it('should successfully add extension on startup with custom toast options', async () => { - mockAddToAgent.mockResolvedValue(undefined); - - await addToAgentOnStartup({ - sessionId: 'test-session', - extensionConfig: mockExtensionConfig, - }); - - expect(mockAddToAgent).toHaveBeenCalledWith(mockExtensionConfig, 'test-session', true); - }); - - it('should retry on 428 errors', async () => { - const error428 = new Error('428 Precondition Required'); - mockAddToAgent - .mockRejectedValueOnce(error428) - .mockRejectedValueOnce(error428) - .mockResolvedValue(undefined); - - await addToAgentOnStartup({ - sessionId: 'test-session', - extensionConfig: mockExtensionConfig, - }); - - expect(mockAddToAgent).toHaveBeenCalledTimes(3); - }); - - it('should throw error after max retries', async () => { - const error428 = new Error('428 Precondition Required'); - mockAddToAgent.mockRejectedValue(error428); - - await expect( - addToAgentOnStartup({ - sessionId: 'test-session', - extensionConfig: mockExtensionConfig, - }) - ).rejects.toThrow('428 Precondition Required'); - - expect(mockAddToAgent).toHaveBeenCalledTimes(4); // Initial + 3 retries - }); - }); - - describe('updateExtension', () => { - it('should update extension without name change', async () => { - mockAddToAgent.mockResolvedValue(undefined); - mockAddToConfig.mockResolvedValue(undefined); - mockToastService.success = vi.fn(); - - await updateExtension({ - enabled: true, - addToConfig: mockAddToConfig, - sessionId: 'test-session', - removeFromConfig: mockRemoveFromConfig, - extensionConfig: mockExtensionConfig, - originalName: 'test-extension', - }); - - expect(mockAddToConfig).toHaveBeenCalledWith( - 'test-extension', - { ...mockExtensionConfig, name: 'test-extension' }, - true - ); - expect(mockToastService.success).toHaveBeenCalledWith({ - title: 'Update extension', - msg: 'Successfully updated test-extension extension', - }); - }); - - it('should handle name change by removing old and adding new', async () => { - mockAddToAgent.mockResolvedValue(undefined); - mockRemoveFromAgent.mockResolvedValue(undefined); - mockRemoveFromConfig.mockResolvedValue(undefined); - mockAddToConfig.mockResolvedValue(undefined); - mockToastService.success = vi.fn(); - - await updateExtension({ - enabled: true, - addToConfig: mockAddToConfig, - sessionId: 'test-session', - removeFromConfig: mockRemoveFromConfig, - extensionConfig: { ...mockExtensionConfig, name: 'new-extension' }, - originalName: 'old-extension', - }); - - expect(mockRemoveFromConfig).toHaveBeenCalledWith('old-extension'); - expect(mockAddToAgent).toHaveBeenCalledWith( - { ...mockExtensionConfig, name: 'new-extension' }, - 'test-session', - false - ); - expect(mockAddToConfig).toHaveBeenCalledWith( - 'new-extension', - { ...mockExtensionConfig, name: 'new-extension' }, - true - ); - }); - - it('should update disabled extension without calling agent', async () => { - mockAddToConfig.mockResolvedValue(undefined); - mockToastService.success = vi.fn(); - - await updateExtension({ - enabled: false, - addToConfig: mockAddToConfig, - sessionId: 'test-session', - removeFromConfig: mockRemoveFromConfig, - extensionConfig: mockExtensionConfig, - originalName: 'test-extension', - }); - - expect(mockAddToAgent).not.toHaveBeenCalled(); - expect(mockAddToConfig).toHaveBeenCalledWith( - 'test-extension', - { ...mockExtensionConfig, name: 'test-extension' }, - false - ); - expect(mockToastService.success).toHaveBeenCalledWith({ - title: 'Update extension', - msg: 'Successfully updated test-extension extension', - }); - }); - }); - - describe('toggleExtension', () => { - it('should toggle extension on successfully', async () => { - mockAddToAgent.mockResolvedValue(undefined); - mockAddToConfig.mockResolvedValue(undefined); - - await toggleExtension({ - toggle: 'toggleOn', - extensionConfig: mockExtensionConfig, - addToConfig: mockAddToConfig, - sessionId: '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(undefined); - mockAddToConfig.mockResolvedValue(undefined); - - await toggleExtension({ - toggle: 'toggleOff', - extensionConfig: mockExtensionConfig, - addToConfig: mockAddToConfig, - sessionId: 'test-session', - }); - - expect(mockRemoveFromAgent).toHaveBeenCalledWith('test-extension', 'test-session', true); - expect(mockAddToConfig).toHaveBeenCalledWith('test-extension', mockExtensionConfig, false); - }); - - it('should rollback on agent failure when toggling on', async () => { - const agentError = new Error('Agent failed'); - mockAddToAgent.mockRejectedValue(agentError); - mockAddToConfig.mockResolvedValue(undefined); - - await expect( - toggleExtension({ - toggle: 'toggleOn', - extensionConfig: mockExtensionConfig, - addToConfig: mockAddToConfig, - sessionId: 'test-session', - }) - ).rejects.toThrow('Agent failed'); - - 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(undefined); - mockAddToConfig.mockRejectedValue(configError); - - await expect( - toggleExtension({ - toggle: 'toggleOn', - extensionConfig: mockExtensionConfig, - addToConfig: mockAddToConfig, - sessionId: 'test-session', - }) - ).rejects.toThrow('Config failed'); - - expect(mockAddToAgent).toHaveBeenCalledWith(mockExtensionConfig, 'test-session', true); - expect(mockAddToConfig).toHaveBeenCalledWith('test-extension', mockExtensionConfig, true); - expect(mockRemoveFromAgent).toHaveBeenCalledWith('test-extension', 'test-session', true); - }); - - it('should update config even if agent removal fails when toggling off', async () => { - const agentError = new Error('Agent removal failed'); - mockRemoveFromAgent.mockRejectedValue(agentError); - mockAddToConfig.mockResolvedValue(undefined); - - await expect( - toggleExtension({ - toggle: 'toggleOff', - extensionConfig: mockExtensionConfig, - addToConfig: mockAddToConfig, - sessionId: 'test-session', - }) - ).rejects.toThrow('Agent removal failed'); - - expect(mockAddToConfig).toHaveBeenCalledWith('test-extension', mockExtensionConfig, false); - }); - }); -}); diff --git a/ui/desktop/src/components/settings/extensions/extension-manager.ts b/ui/desktop/src/components/settings/extensions/extension-manager.ts index 37b072894846..bf5c501eb4c2 100644 --- a/ui/desktop/src/components/settings/extensions/extension-manager.ts +++ b/ui/desktop/src/components/settings/extensions/extension-manager.ts @@ -1,6 +1,5 @@ import type { ExtensionConfig } from '../../../api/types.gen'; -import { toastService, ToastServiceOptions } from '../../../toasts'; -import { addToAgent, removeFromAgent, sanitizeName } from './agent-api'; +import { toastService } from '../../../toasts'; import { trackExtensionAdded, trackExtensionEnabled, @@ -13,380 +12,97 @@ function isBuiltinExtension(config: ExtensionConfig): boolean { return config.type === 'builtin'; } -interface ActivateExtensionProps { - addToConfig: (name: string, extensionConfig: ExtensionConfig, enabled: boolean) => Promise; - extensionConfig: ExtensionConfig; - sessionId: string; -} - -type ExtensionError = { - message?: string; - code?: number; - name?: string; - stack?: string; -}; - -type RetryOptions = { - retries?: number; - delayMs?: number; - shouldRetry?: (error: ExtensionError, attempt: number) => boolean; - backoffFactor?: number; // multiplier for exponential backoff -}; - -async function retryWithBackoff(fn: () => Promise, options: RetryOptions = {}): Promise { - const { retries = 3, delayMs = 1000, backoffFactor = 1.5, shouldRetry = () => true } = options; - - let attempt = 0; - let lastError: ExtensionError = new Error('Unknown error'); - - while (attempt <= retries) { - try { - return await fn(); - } catch (err) { - lastError = err as ExtensionError; - attempt++; - - if (attempt > retries || !shouldRetry(lastError, attempt)) { - break; - } - - const waitTime = delayMs * Math.pow(backoffFactor, attempt - 1); - console.warn(`Retry attempt ${attempt} failed. Retrying in ${waitTime}ms...`, err); - await new Promise((res) => setTimeout(res, waitTime)); - } - } - - throw lastError; +interface DeleteExtensionProps { + name: string; + removeFromConfig: (name: string) => Promise; + extensionConfig?: ExtensionConfig; } /** - * Activates an extension by adding it to both the config system and the API. - * @param props The extension activation properties - * @returns Promise that resolves when activation is complete + * Deletes an extension from config (will no longer be loaded in new sessions) */ -export async function activateExtension({ - addToConfig, +export async function deleteExtension({ + name, + removeFromConfig, extensionConfig, - sessionId, -}: ActivateExtensionProps): Promise { - const isBuiltin = isBuiltinExtension(extensionConfig); - - try { - // AddToAgent - await addToAgent(extensionConfig, sessionId, true); - } catch (error) { - console.error('Failed to add extension to agent:', error); - await addToConfig(extensionConfig.name, extensionConfig, false); - trackExtensionAdded(extensionConfig.name, false, getErrorType(error), isBuiltin); - throw error; - } +}: DeleteExtensionProps) { + const isBuiltin = extensionConfig ? isBuiltinExtension(extensionConfig) : false; try { - await addToConfig(extensionConfig.name, extensionConfig, true); - trackExtensionAdded(extensionConfig.name, true, undefined, isBuiltin); + await removeFromConfig(name); + trackExtensionDeleted(name, true, undefined, isBuiltin); } catch (error) { - console.error('Failed to add extension to config:', error); - // remove from Agent - try { - await removeFromAgent(extensionConfig.name, sessionId, true); - } catch (removeError) { - console.error('Failed to remove extension from agent after config failure:', removeError); - } - trackExtensionAdded(extensionConfig.name, false, getErrorType(error), isBuiltin); - // Rethrow the error to inform the caller + console.error('Failed to remove extension from config:', error); + trackExtensionDeleted(name, false, getErrorType(error), isBuiltin); throw error; } } -interface AddToAgentOnStartupProps { +interface ToggleExtensionDefaultProps { + toggle: 'toggleOn' | 'toggleOff'; extensionConfig: ExtensionConfig; - toastOptions?: ToastServiceOptions; - sessionId: string; -} - -/** - * Adds an extension to the agent during application startup with retry logic - * - * TODO(Douwe): Delete this after basecamp lands - */ -export async function addToAgentOnStartup({ - extensionConfig, - sessionId, - toastOptions, -}: AddToAgentOnStartupProps): Promise { - const showToast = !toastOptions?.silent; - - // Errors are caught by the grouped notification in providerUtils.ts - // Individual error toasts are suppressed during startup (showToast=false) - await retryWithBackoff(() => addToAgent(extensionConfig, sessionId, showToast), { - retries: 3, - delayMs: 1000, - shouldRetry: (error: ExtensionError) => - !!error.message && - (error.message.includes('428') || - error.message.includes('Precondition Required') || - error.message.includes('Agent is not initialized')), - }); -} - -interface UpdateExtensionProps { - enabled: boolean; addToConfig: (name: string, extensionConfig: ExtensionConfig, enabled: boolean) => Promise; - removeFromConfig: (name: string) => Promise; - extensionConfig: ExtensionConfig; - originalName?: string; - sessionId: string; } -/** - * Updates an extension configuration, handling name changes - */ -export async function updateExtension({ - enabled, - addToConfig, - removeFromConfig, +export async function toggleExtensionDefault({ + toggle, extensionConfig, - originalName, - sessionId, -}: UpdateExtensionProps) { - // Sanitize the new name to match the behavior when adding extensions - const sanitizedNewName = sanitizeName(extensionConfig.name); - const sanitizedOriginalName = originalName ? sanitizeName(originalName) : undefined; - - // Check if the sanitized name has changed - const nameChanged = sanitizedOriginalName && sanitizedOriginalName !== sanitizedNewName; - - if (nameChanged) { - // Handle name change: remove old extension and add new one - - // First remove the old extension from agent (using original name) - try { - 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 - } - - // Remove old extension from config (using original name) - try { - await removeFromConfig(originalName!); // We know originalName is not undefined here because nameChanged is true - } catch (error) { - console.error('Failed to remove old extension from config during rename:', error); - throw error; // This is more critical, so we throw - } - - // Create a copy of the extension config with the sanitized name - const sanitizedExtensionConfig = { - ...extensionConfig, - name: sanitizedNewName, - }; + addToConfig, +}: ToggleExtensionDefaultProps) { + const isBuiltin = isBuiltinExtension(extensionConfig); + const enabled = toggle === 'toggleOn'; - // Add new extension with sanitized name + try { + await addToConfig(extensionConfig.name, extensionConfig, enabled); if (enabled) { - try { - await addToAgent(sanitizedExtensionConfig, sessionId, false); - } catch (error) { - console.error('[updateExtension]: Failed to add renamed extension to agent:', error); - throw error; - } - } - - // Add to config with sanitized name - try { - await addToConfig(sanitizedNewName, sanitizedExtensionConfig, enabled); - } catch (error) { - console.error('[updateExtension]: Failed to add renamed extension to config:', error); - throw error; + trackExtensionEnabled(extensionConfig.name, true, undefined, isBuiltin); + } else { + trackExtensionDisabled(extensionConfig.name, true, undefined, isBuiltin); } - - toastService.configure({ silent: false }); toastService.success({ - title: `Update extension`, - msg: `Successfully updated ${sanitizedNewName} extension`, + title: extensionConfig.name, + msg: enabled ? 'Extension enabled in defaults' : 'Extension removed from defaults', }); - } else { - // Create a copy of the extension config with the sanitized name - const sanitizedExtensionConfig = { - ...extensionConfig, - name: sanitizedNewName, - }; - + } catch (error) { + console.error('Failed to update extension default in config:', error); if (enabled) { - try { - 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 - throw error; - } - - // Then add to config - try { - await addToConfig(sanitizedNewName, sanitizedExtensionConfig, enabled); - } catch (error) { - console.error('[updateExtension]: Failed to update extension in config:', error); - throw error; - } - - // show a toast that it was successfully updated - toastService.success({ - title: `Update extension`, - msg: `Successfully updated ${sanitizedNewName} extension`, - }); + trackExtensionEnabled(extensionConfig.name, false, getErrorType(error), isBuiltin); } else { - try { - await addToConfig(sanitizedNewName, sanitizedExtensionConfig, enabled); - } catch (error) { - console.error('[updateExtension]: Failed to update disabled extension in config:', error); - throw error; - } - - // show a toast that it was successfully updated - toastService.success({ - title: `Update extension`, - msg: `Successfully updated ${sanitizedNewName} extension`, - }); + trackExtensionDisabled(extensionConfig.name, false, getErrorType(error), isBuiltin); } + toastService.error({ + title: extensionConfig.name, + msg: 'Failed to update extension default', + }); + throw error; } } -interface ToggleExtensionProps { - toggle: 'toggleOn' | 'toggleOff'; - extensionConfig: ExtensionConfig; +interface ActivateExtensionDefaultProps { addToConfig: (name: string, extensionConfig: ExtensionConfig, enabled: boolean) => Promise; - toastOptions?: ToastServiceOptions; - sessionId: string; + extensionConfig: ExtensionConfig; } -/** - * Toggles an extension between enabled and disabled states - */ -export async function toggleExtension({ - toggle, - extensionConfig, +export async function activateExtensionDefault({ addToConfig, - toastOptions = {}, - sessionId, -}: ToggleExtensionProps) { - const isBuiltin = isBuiltinExtension(extensionConfig); - - // disabled to enabled - if (toggle == 'toggleOn') { - try { - // add to agent with toast options - await addToAgent(extensionConfig, sessionId, !toastOptions?.silent); - } catch (error) { - console.error('Error adding extension to agent. Attempting to toggle back off.'); - trackExtensionEnabled(extensionConfig.name, false, getErrorType(error), isBuiltin); - try { - await toggleExtension({ - toggle: 'toggleOff', - extensionConfig, - addToConfig, - toastOptions: { silent: true }, // otherwise we will see a toast for removing something that was never added - sessionId, - }); - } catch (toggleError) { - console.error('Failed to toggle extension off after agent error:', toggleError); - } - throw error; - } - - // update the config - try { - await addToConfig(extensionConfig.name, extensionConfig, true); - trackExtensionEnabled(extensionConfig.name, true, undefined, isBuiltin); - } catch (error) { - console.error('Failed to update config after enabling extension:', error); - trackExtensionEnabled(extensionConfig.name, false, getErrorType(error), isBuiltin); - // remove from agent - try { - await removeFromAgent(extensionConfig.name, sessionId, !toastOptions?.silent); - } catch (removeError) { - console.error('Failed to remove extension from agent after config failure:', removeError); - } - throw error; - } - } else if (toggle == 'toggleOff') { - // enabled to disabled - let agentRemoveError = null; - try { - 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); - agentRemoveError = error; - } - - // update the config - try { - await addToConfig(extensionConfig.name, extensionConfig, false); - if (agentRemoveError) { - trackExtensionDisabled( - extensionConfig.name, - false, - getErrorType(agentRemoveError), - isBuiltin - ); - } else { - trackExtensionDisabled(extensionConfig.name, true, undefined, isBuiltin); - } - } catch (error) { - console.error('Error removing extension from config', extensionConfig.name, 'Error:', error); - trackExtensionDisabled(extensionConfig.name, false, getErrorType(error), isBuiltin); - throw error; - } - - // If we had an error removing from agent but succeeded updating config, still throw the original error - if (agentRemoveError) { - throw agentRemoveError; - } - } -} - -interface DeleteExtensionProps { - name: string; - removeFromConfig: (name: string) => Promise; - sessionId: string; - extensionConfig?: ExtensionConfig; -} - -/** - * Deletes an extension completely from both agent and config - */ -export async function deleteExtension({ - name, - removeFromConfig, - sessionId, extensionConfig, -}: DeleteExtensionProps) { - const isBuiltin = extensionConfig ? isBuiltinExtension(extensionConfig) : false; - - let agentRemoveError = null; - try { - await removeFromAgent(name, sessionId, true); - } catch (error) { - console.error('Failed to remove extension from agent during deletion:', error); - agentRemoveError = error; - } +}: ActivateExtensionDefaultProps): Promise { + const isBuiltin = isBuiltinExtension(extensionConfig); try { - await removeFromConfig(name); - if (agentRemoveError) { - trackExtensionDeleted(name, false, getErrorType(agentRemoveError), isBuiltin); - } else { - trackExtensionDeleted(name, true, undefined, isBuiltin); - } + await addToConfig(extensionConfig.name, extensionConfig, true); + trackExtensionAdded(extensionConfig.name, true, undefined, isBuiltin); + toastService.success({ + title: extensionConfig.name, + msg: 'Extension added as default', + }); } catch (error) { - console.error( - 'Failed to remove extension from config after removing from agent. Error:', - error - ); - trackExtensionDeleted(name, false, getErrorType(error), isBuiltin); + console.error('Failed to add extension to config:', error); + trackExtensionAdded(extensionConfig.name, false, getErrorType(error), isBuiltin); + toastService.error({ + title: extensionConfig.name, + msg: 'Failed to add extension', + }); throw error; } - - if (agentRemoveError) { - throw agentRemoveError; - } } diff --git a/ui/desktop/src/components/settings/extensions/index.ts b/ui/desktop/src/components/settings/extensions/index.ts index 5469fc52ad69..67d0dc161df0 100644 --- a/ui/desktop/src/components/settings/extensions/index.ts +++ b/ui/desktop/src/components/settings/extensions/index.ts @@ -1,20 +1,13 @@ -// Export public API export { DEFAULT_EXTENSION_TIMEOUT, nameToKey } from './utils'; -// Export extension management functions export { - activateExtension, - addToAgentOnStartup, - updateExtension, - toggleExtension, + activateExtensionDefault, + toggleExtensionDefault, deleteExtension, } from './extension-manager'; -// Export built-in extension functions export { syncBundledExtensions, initializeBundledExtensions } from './bundled-extensions'; -// Export deeplink handling export { addExtensionFromDeepLink } from './deeplink'; -// Export agent API functions -export { addToAgent as AddToAgent, removeFromAgent as RemoveFromAgent } from './agent-api'; +export { addToAgent, removeFromAgent } from './agent-api'; diff --git a/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionItem.tsx b/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionItem.tsx index f677260332eb..92663222a58a 100644 --- a/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionItem.tsx +++ b/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionItem.tsx @@ -11,7 +11,6 @@ interface ExtensionItemProps { onToggle: (extension: FixedExtensionEntry) => Promise | void; onConfigure?: (extension: FixedExtensionEntry) => void; isStatic?: boolean; // to not allow users to edit configuration - isPendingActivation?: boolean; } export default function ExtensionItem({ @@ -19,7 +18,6 @@ export default function ExtensionItem({ onToggle, onConfigure, isStatic, - isPendingActivation = false, }: ExtensionItemProps) { // Add local state to track the visual toggle state const [visuallyEnabled, setVisuallyEnabled] = useState(extension.enabled); @@ -81,17 +79,7 @@ export default function ExtensionItem({ onClick={() => handleToggle(extension)} > - - {getFriendlyTitle(extension)} - {isPendingActivation && ( - - Pending - - )} - + {getFriendlyTitle(extension)} e.stopPropagation()}>
diff --git a/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx b/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx index a2ab0b121fbf..378bc5c7fda6 100644 --- a/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx +++ b/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx @@ -11,7 +11,6 @@ interface ExtensionListProps { isStatic?: boolean; disableConfiguration?: boolean; searchTerm?: string; - pendingActivationExtensions?: Set; } export default function ExtensionList({ @@ -21,7 +20,6 @@ export default function ExtensionList({ isStatic, disableConfiguration: _disableConfiguration, searchTerm = '', - pendingActivationExtensions = new Set(), }: ExtensionListProps) { const matchesSearch = (extension: FixedExtensionEntry): boolean => { if (!searchTerm) return true; @@ -55,7 +53,7 @@ export default function ExtensionList({

- Enabled Extensions ({sortedEnabledExtensions.length}) + Default Extensions ({sortedEnabledExtensions.length})

{sortedEnabledExtensions.map((extension) => ( @@ -65,7 +63,6 @@ export default function ExtensionList({ onToggle={onToggle} onConfigure={onConfigure} isStatic={isStatic} - isPendingActivation={pendingActivationExtensions.has(extension.name)} /> ))}
@@ -100,14 +97,18 @@ export default function ExtensionList({ } // Helper functions -export function getFriendlyTitle(extension: FixedExtensionEntry): string { - const name = (extension.type === 'builtin' && extension.display_name) || extension.name; +export function formatExtensionName(name: string): string { return name .split(/[-_]/) // Split on hyphens and underscores .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join(' '); } +export function getFriendlyTitle(extension: FixedExtensionEntry): string { + const name = (extension.type === 'builtin' && extension.display_name) || extension.name; + return formatExtensionName(name); +} + function normalizeExtensionName(name: string): string { return name.toLowerCase().replace(/\s+/g, ''); } diff --git a/ui/desktop/src/hooks/useAgent.ts b/ui/desktop/src/hooks/useAgent.ts index 12f5a19fb54d..dfe591a17baa 100644 --- a/ui/desktop/src/hooks/useAgent.ts +++ b/ui/desktop/src/hooks/useAgent.ts @@ -26,7 +26,6 @@ export interface InitializationContext { recipe?: Recipe; resumeSessionId?: string; setAgentWaitingMessage: (msg: string | null) => void; - setIsExtensionsLoading?: (isLoading: boolean) => void; } interface UseAgentReturn { @@ -112,15 +111,15 @@ export function useAgent(): UseAgentReturn { // Fall through to create new session if (agentResponse?.data) { - const agentSession = agentResponse.data; - const messages = agentSession.conversation || []; + const agentSession = agentResponse.data.session; + const messages = agentSession?.conversation || []; return { - sessionId: agentSession.id, - name: agentSession.recipe?.title || agentSession.name, + sessionId: agentSession?.id || '', + name: agentSession?.recipe?.title || agentSession?.name || '', messageHistoryIndex: 0, messages, - recipe: agentSession.recipe, - recipeParameterValues: agentSession.user_recipe_values || null, + recipe: agentSession?.recipe, + recipeParameterValues: agentSession?.user_recipe_values || null, }; } } @@ -194,7 +193,10 @@ export function useAgent(): UseAgentReturn { } } - const agentSession = agentResponse.data; + // Handle different response types: resumeAgent returns { session, extension_results }, startAgent returns Session directly + const responseData = agentResponse.data; + const agentSession = + responseData && 'session' in responseData ? responseData.session : responseData; if (!agentSession) { throw Error('Failed to get session info'); } @@ -229,7 +231,6 @@ export function useAgent(): UseAgentReturn { await initializeSystem(agentSession.id, provider as string, model as string, { getExtensions, addExtension, - setIsExtensionsLoading: initContext.setIsExtensionsLoading, recipeParameters: agentSession.user_recipe_values, recipe: recipeForInit, }); diff --git a/ui/desktop/src/hooks/useChatStream.ts b/ui/desktop/src/hooks/useChatStream.ts index 41580f7609ae..70e9fcbf125e 100644 --- a/ui/desktop/src/hooks/useChatStream.ts +++ b/ui/desktop/src/hooks/useChatStream.ts @@ -20,6 +20,7 @@ import { NotificationEvent, } from '../types/message'; import { errorMessage } from '../utils/conversionUtils'; +import { showExtensionLoadResults } from '../utils/extensionErrorUtils'; const resultsCache = new Map(); @@ -33,6 +34,7 @@ interface UseChatStreamReturn { session?: Session; messages: Message[]; chatState: ChatState; + setChatState: (state: ChatState) => void; handleSubmit: (userMessage: string) => Promise; submitElicitationResponse: ( elicitationId: string, @@ -221,6 +223,7 @@ export function useChatStream({ accumulatedTotalTokens: cached.session?.accumulated_total_tokens ?? 0, }); setChatState(ChatState.Idle); + onSessionLoaded?.(); return; } @@ -246,16 +249,20 @@ export function useChatStream({ return; } - const session = response.data; - setSession(session); - updateMessages(session?.conversation || []); + const resumeData = response.data; + const loadedSession = resumeData?.session; + const extensionResults = resumeData?.extension_results; + + showExtensionLoadResults(extensionResults); + setSession(loadedSession); + updateMessages(loadedSession?.conversation || []); setTokenState({ - inputTokens: session?.input_tokens ?? 0, - outputTokens: session?.output_tokens ?? 0, - totalTokens: session?.total_tokens ?? 0, - accumulatedInputTokens: session?.accumulated_input_tokens ?? 0, - accumulatedOutputTokens: session?.accumulated_output_tokens ?? 0, - accumulatedTotalTokens: session?.accumulated_total_tokens ?? 0, + inputTokens: loadedSession?.input_tokens ?? 0, + outputTokens: loadedSession?.output_tokens ?? 0, + totalTokens: loadedSession?.total_tokens ?? 0, + accumulatedInputTokens: loadedSession?.accumulated_input_tokens ?? 0, + accumulatedOutputTokens: loadedSession?.accumulated_output_tokens ?? 0, + accumulatedTotalTokens: loadedSession?.accumulated_total_tokens ?? 0, }); setChatState(ChatState.Idle); onSessionLoaded?.(); @@ -507,6 +514,7 @@ export function useChatStream({ messages: maybe_cached_messages, session: maybe_cached_session, chatState, + setChatState, handleSubmit, submitElicitationResponse, stopStreaming, diff --git a/ui/desktop/src/sessions.ts b/ui/desktop/src/sessions.ts index e3e564009481..1efec4d44c0d 100644 --- a/ui/desktop/src/sessions.ts +++ b/ui/desktop/src/sessions.ts @@ -1,5 +1,11 @@ -import { Session, startAgent, restartAgent } from './api'; +import { Session, startAgent, ExtensionConfig } from './api'; import type { setViewType } from './hooks/useNavigation'; +import { + getExtensionConfigsWithOverrides, + clearExtensionOverrides, + hasExtensionOverrides, +} from './store/extensionOverrides'; +import type { FixedExtensionEntry } from './components/ConfigContext'; export function resumeSession(session: Session, setView: setViewType) { setView('pair', { @@ -13,12 +19,15 @@ export async function createSession( options?: { recipeId?: string; recipeDeeplink?: string; + extensionConfigs?: ExtensionConfig[]; + allExtensions?: FixedExtensionEntry[]; } ): Promise { const body: { working_dir: string; recipe_id?: string; recipe_deeplink?: string; + extension_overrides?: ExtensionConfig[]; } = { working_dir: workingDir, }; @@ -29,19 +38,24 @@ export async function createSession( body.recipe_deeplink = options.recipeDeeplink; } + if (options?.extensionConfigs && options.extensionConfigs.length > 0) { + body.extension_overrides = options.extensionConfigs; + } else if (options?.allExtensions) { + const extensionConfigs = getExtensionConfigsWithOverrides(options.allExtensions); + if (extensionConfigs.length > 0) { + body.extension_overrides = extensionConfigs; + } + if (hasExtensionOverrides()) { + clearExtensionOverrides(); + } + } + const newAgent = await startAgent({ body, throwOnError: true, }); - const session = newAgent.data; - - // Restart agent to ensure it picks up the session's working dir - await restartAgent({ - body: { session_id: session.id }, - }); - - return session; + return newAgent.data; } export async function startNewSession( @@ -51,6 +65,7 @@ export async function startNewSession( options?: { recipeId?: string; recipeDeeplink?: string; + allExtensions?: FixedExtensionEntry[]; } ): Promise { const session = await createSession(workingDir, options); diff --git a/ui/desktop/src/store/extensionOverrides.ts b/ui/desktop/src/store/extensionOverrides.ts new file mode 100644 index 000000000000..5755126961a0 --- /dev/null +++ b/ui/desktop/src/store/extensionOverrides.ts @@ -0,0 +1,59 @@ +// Store for extension overrides when starting a new session from the hub +// These overrides allow temporarily enabling/disabling extensions before creating a session +// Resets after session creation + +import type { ExtensionConfig } from '../api'; + +// Map of extension name -> enabled state (overrides from hub view) +type ExtensionOverrides = Map; + +const state: { + extensionOverrides: ExtensionOverrides; +} = { + extensionOverrides: new Map(), +}; + +export function setExtensionOverride(name: string, enabled: boolean): void { + state.extensionOverrides.set(name, enabled); +} + +export function getExtensionOverride(name: string): boolean | undefined { + return state.extensionOverrides.get(name); +} + +export function hasExtensionOverrides(): boolean { + return state.extensionOverrides.size > 0; +} + +export function getExtensionOverrides(): ExtensionOverrides { + return state.extensionOverrides; +} + +export function clearExtensionOverrides(): void { + state.extensionOverrides.clear(); +} + +export function getExtensionConfigsWithOverrides( + allExtensions: Array<{ name: string; enabled: boolean } & Omit> +): ExtensionConfig[] { + if (state.extensionOverrides.size === 0) { + return allExtensions + .filter((ext) => ext.enabled) + .map((ext) => { + const { enabled: _enabled, ...config } = ext; + return config as ExtensionConfig; + }); + } + + return allExtensions + .filter((ext) => { + if (state.extensionOverrides.has(ext.name)) { + return state.extensionOverrides.get(ext.name); + } + return ext.enabled; + }) + .map((ext) => { + const { enabled: _enabled, ...config } = ext; + return config as ExtensionConfig; + }); +} diff --git a/ui/desktop/src/toasts.tsx b/ui/desktop/src/toasts.tsx index b0e6dbaef695..c6d19cd1efbf 100644 --- a/ui/desktop/src/toasts.tsx +++ b/ui/desktop/src/toasts.tsx @@ -110,7 +110,7 @@ class ToastService { { ...commonToastOptions, toastId, - autoClose: false, + autoClose: isComplete ? 5000 : false, closeButton: true, closeOnClick: false, // Prevent closing when clicking to expand/collapse } diff --git a/ui/desktop/src/types/chatState.ts b/ui/desktop/src/types/chatState.ts index 067aee4f7b0b..46ec6c36853a 100644 --- a/ui/desktop/src/types/chatState.ts +++ b/ui/desktop/src/types/chatState.ts @@ -5,4 +5,5 @@ export enum ChatState { WaitingForUserInput = 'waitingForUserInput', Compacting = 'compacting', LoadingConversation = 'loadingConversation', + RestartingAgent = 'restartingAgent', } diff --git a/ui/desktop/src/utils/extensionErrorUtils.ts b/ui/desktop/src/utils/extensionErrorUtils.ts index e707c6c0e757..369a64c82cec 100644 --- a/ui/desktop/src/utils/extensionErrorUtils.ts +++ b/ui/desktop/src/utils/extensionErrorUtils.ts @@ -2,6 +2,9 @@ * Shared constants and utilities for extension error handling */ +import { ExtensionLoadResult } from '../api/types.gen'; +import { toastService, ExtensionLoadingStatus } from '../toasts'; + export const MAX_ERROR_MESSAGE_LENGTH = 70; /** @@ -28,3 +31,43 @@ export function formatExtensionErrorMessage( ): string { return errorMsg.length < MAX_ERROR_MESSAGE_LENGTH ? errorMsg : fallback; } + +/** + * Shows toast notifications for extension load results. + * Uses grouped toast for multiple extensions, individual error toast for single failed extension. + * @param results - Array of extension load results from the backend + */ +export function showExtensionLoadResults(results: ExtensionLoadResult[] | null | undefined): void { + if (!results || results.length === 0) { + return; + } + + const failedExtensions = results.filter((r) => !r.success); + + if (results.length === 1 && failedExtensions.length === 1) { + const failed = failedExtensions[0]; + const errorMsg = failed.error || 'Unknown error'; + const recoverHints = createExtensionRecoverHints(errorMsg); + const displayMsg = formatExtensionErrorMessage(errorMsg, 'Failed to load extension'); + + toastService.error({ + title: failed.name, + msg: displayMsg, + traceback: errorMsg, + recoverHints, + }); + return; + } + + const extensionStatuses: ExtensionLoadingStatus[] = results.map((r) => { + const errorMsg = r.error || 'Unknown error'; + return { + name: r.name, + status: r.success ? 'success' : 'error', + error: r.success ? undefined : errorMsg, + recoverHints: r.success ? undefined : createExtensionRecoverHints(errorMsg), + }; + }); + + toastService.extensionLoading(extensionStatuses, results.length, true); +} diff --git a/ui/desktop/src/utils/navigationUtils.ts b/ui/desktop/src/utils/navigationUtils.ts index d9bbe36e7263..aaa6ba67b1c6 100644 --- a/ui/desktop/src/utils/navigationUtils.ts +++ b/ui/desktop/src/utils/navigationUtils.ts @@ -19,9 +19,7 @@ export type View = | 'recipes' | 'permission'; -// TODO(Douwe): check these for usage, especially key: string for resetChat export type ViewOptions = { - extensionId?: string; showEnvVars?: boolean; deepLinkConfig?: unknown; sessionDetails?: unknown; @@ -32,7 +30,6 @@ export type ViewOptions = { parentViewOptions?: ViewOptions; disableAnimation?: boolean; initialMessage?: string; - resetChat?: boolean; shareToken?: string; resumeSessionId?: string; pendingScheduleDeepLink?: string; diff --git a/ui/desktop/src/utils/providerUtils.ts b/ui/desktop/src/utils/providerUtils.ts index 8862b5c12d69..40627920e3c7 100644 --- a/ui/desktop/src/utils/providerUtils.ts +++ b/ui/desktop/src/utils/providerUtils.ts @@ -1,13 +1,9 @@ import { initializeBundledExtensions, syncBundledExtensions, - addToAgentOnStartup, } from '../components/settings/extensions'; import type { ExtensionConfig, FixedExtensionEntry } from '../components/ConfigContext'; import { Recipe, updateAgentProvider, updateFromSession } from '../api'; -import { toastService, ExtensionLoadingStatus } from '../toasts'; -import { errorMessage } from './conversionUtils'; -import { createExtensionRecoverHints } from './extensionErrorUtils'; // Helper function to substitute parameters in text export const substituteParameters = (text: string, params: Record): string => { @@ -29,7 +25,6 @@ export const initializeSystem = async ( options?: { getExtensions?: (b: boolean) => Promise; addExtension?: (name: string, config: ExtensionConfig, enabled: boolean) => Promise; - setIsExtensionsLoading?: (loading: boolean) => void; recipeParameters?: Record | null; recipe?: Recipe; } @@ -72,82 +67,11 @@ export const initializeSystem = async ( if (refreshedExtensions.length === 0) { await initializeBundledExtensions(options.addExtension); - refreshedExtensions = await options.getExtensions(false); } else { await syncBundledExtensions(refreshedExtensions, options.addExtension); } - - // Add enabled extensions to agent in parallel - const enabledExtensions = refreshedExtensions.filter((ext) => ext.enabled); - - if (enabledExtensions.length === 0) { - return; - } - - options?.setIsExtensionsLoading?.(true); - - // Initialize extension status tracking - const extensionStatuses: Map = new Map( - enabledExtensions.map((ext) => [ext.name, { name: ext.name, status: 'loading' as const }]) - ); - - // Show initial loading toast - const updateToast = (isComplete: boolean = false) => { - toastService.extensionLoading( - Array.from(extensionStatuses.values()), - enabledExtensions.length, - isComplete - ); - }; - - updateToast(); - - // Load extensions in parallel and update status - const extensionLoadingPromises = enabledExtensions.map(async (extensionConfig) => { - const extensionName = extensionConfig.name; - - try { - await addToAgentOnStartup({ - extensionConfig, - toastOptions: { silent: true }, // Silent since we're using grouped notification - sessionId, - }); - - // Update status to success - extensionStatuses.set(extensionName, { - name: extensionName, - status: 'success', - }); - updateToast(); - } catch (error) { - console.error(`Failed to load extension ${extensionName}:`, error); - - // Extract error message using shared utility - const errMsg = errorMessage(error); - - // Create recovery hints for "Ask goose" button - const recoverHints = createExtensionRecoverHints(errMsg); - - // Update status to error - extensionStatuses.set(extensionName, { - name: extensionName, - status: 'error', - error: errMsg, - recoverHints, - }); - updateToast(); - } - }); - - await Promise.allSettled(extensionLoadingPromises); - - // Show final completion toast - updateToast(true); - - options?.setIsExtensionsLoading?.(false); } catch (error) { console.error('Failed to initialize agent:', error); - options?.setIsExtensionsLoading?.(false); throw error; } }; From 760b1d280c9e7a0e042271349910926e9cb79b48 Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Mon, 5 Jan 2026 10:54:58 -0800 Subject: [PATCH 19/36] change so working_dir is now passed explicitly when adding extensions rather than being stored on the Agent --- crates/goose-cli/src/commands/acp.rs | 10 +- crates/goose-cli/src/commands/configure.rs | 3 +- crates/goose-cli/src/commands/web.rs | 6 +- crates/goose-cli/src/session/builder.rs | 11 +- crates/goose-cli/src/session/mod.rs | 9 +- crates/goose-server/src/openapi.rs | 2 +- crates/goose-server/src/routes/agent.rs | 26 ++-- crates/goose-server/src/routes/agent_utils.rs | 111 ++--------------- crates/goose-server/src/state.rs | 2 +- crates/goose/examples/agent.rs | 3 +- crates/goose/src/agents/agent.rs | 114 +++++++++++++++--- crates/goose/src/agents/mod.rs | 2 +- crates/goose/src/agents/reply_parts.rs | 19 +-- crates/goose/src/agents/subagent_handler.rs | 6 +- crates/goose/src/scheduler.rs | 5 +- crates/goose/tests/agent.rs | 2 +- ui/desktop/openapi.json | 1 + ui/desktop/src/api/types.gen.ts | 3 + 18 files changed, 179 insertions(+), 156 deletions(-) diff --git a/crates/goose-cli/src/commands/acp.rs b/crates/goose-cli/src/commands/acp.rs index 77d9308a21e6..867dd9229aed 100644 --- a/crates/goose-cli/src/commands/acp.rs +++ b/crates/goose-cli/src/commands/acp.rs @@ -241,6 +241,7 @@ fn format_tool_name(tool_name: &str) -> String { } async fn add_builtins(agent: &Agent, builtins: Vec) { + let working_dir = std::env::current_dir().ok(); for builtin in builtins { let config = if PLATFORM_EXTENSIONS.contains_key(builtin.as_str()) { ExtensionConfig::Platform { @@ -259,7 +260,7 @@ async fn add_builtins(agent: &Agent, builtins: Vec) { available_tools: Vec::new(), } }; - match agent.add_extension(config).await { + match agent.add_extension(config, working_dir.clone()).await { Ok(_) => info!(extension = %builtin, "builtin extension loaded"), Err(e) => warn!(extension = %builtin, error = %e, "builtin extension load failed"), } @@ -318,14 +319,16 @@ impl GooseAcpAgent { let mut set = JoinSet::new(); let mut waiting_on = HashSet::new(); + let working_dir = std::env::current_dir().ok(); for extension in extensions_to_run { waiting_on.insert(extension.name()); let agent_ptr_clone = agent_ptr.clone(); + let wd = working_dir.clone(); set.spawn(async move { ( extension.name(), - agent_ptr_clone.add_extension(extension.clone()).await, + agent_ptr_clone.add_extension(extension.clone(), wd).await, ) }); } @@ -732,6 +735,7 @@ impl GooseAcpAgent { sessions.insert(goose_session.id.clone(), session); // Add MCP servers specified in the session request + let working_dir = std::env::current_dir().ok(); for mcp_server in args.mcp_servers { let config = match mcp_server_to_extension_config(mcp_server) { Ok(c) => c, @@ -740,7 +744,7 @@ impl GooseAcpAgent { } }; let name = config.name().to_string(); - if let Err(e) = self.agent.add_extension(config).await { + if let Err(e) = self.agent.add_extension(config, working_dir.clone()).await { return Err(sacp::Error::new( sacp::ErrorCode::InternalError.into(), format!("Failed to add MCP server '{}': {}", name, e), diff --git a/crates/goose-cli/src/commands/configure.rs b/crates/goose-cli/src/commands/configure.rs index 726087aefe75..62d728412a3c 100644 --- a/crates/goose-cli/src/commands/configure.rs +++ b/crates/goose-cli/src/commands/configure.rs @@ -1336,8 +1336,9 @@ pub async fn configure_tool_permissions_dialog() -> anyhow::Result<()> { let new_provider = create(&provider_name, model_config).await?; agent.update_provider(new_provider, &session.id).await?; if let Some(config) = get_extension_by_name(&selected_extension_name) { + let working_dir = std::env::current_dir().ok(); agent - .add_extension(config.clone()) + .add_extension(config.clone(), working_dir) .await .unwrap_or_else(|_| { println!( diff --git a/crates/goose-cli/src/commands/web.rs b/crates/goose-cli/src/commands/web.rs index c6aa47f6b82e..aa019c99cfff 100644 --- a/crates/goose-cli/src/commands/web.rs +++ b/crates/goose-cli/src/commands/web.rs @@ -165,8 +165,12 @@ pub async fn handle_web( agent.update_provider(provider, &init_session.id).await?; let enabled_configs = goose::config::get_enabled_extensions(); + let working_dir = std::env::current_dir().ok(); for config in enabled_configs { - if let Err(e) = agent.add_extension(config.clone()).await { + if let Err(e) = agent + .add_extension(config.clone(), working_dir.clone()) + .await + { eprintln!("Warning: Failed to load extension {}: {}", config.name(), e); } } diff --git a/crates/goose-cli/src/session/builder.rs b/crates/goose-cli/src/session/builder.rs index 3dd716133067..8baf086f888d 100644 --- a/crates/goose-cli/src/session/builder.rs +++ b/crates/goose-cli/src/session/builder.rs @@ -158,9 +158,13 @@ async fn offer_extension_debugging_help( // Add the developer extension if available to help with debugging let extensions = get_all_extensions(); + let working_dir = std::env::current_dir().ok(); for ext_wrapper in extensions { if ext_wrapper.enabled && ext_wrapper.config.name() == "developer" { - if let Err(e) = debug_agent.add_extension(ext_wrapper.config).await { + if let Err(e) = debug_agent + .add_extension(ext_wrapper.config, working_dir.clone()) + .await + { // If we can't add developer extension, continue without it eprintln!( "Note: Could not load developer extension for debugging: {}", @@ -419,9 +423,7 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession { // Setup extensions for the agent // Extensions need to be added after the session is created because we change directory when resuming a session - // Set the agent's working directory before adding extensions let working_dir = std::env::current_dir().expect("Could not get working directory"); - agent.set_working_dir(working_dir).await; for warning in goose::config::get_warnings() { eprintln!("{}", style(format!("Warning: {}", warning)).yellow()); @@ -455,10 +457,11 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession { for extension in extensions_to_run { waiting_on.insert(extension.name()); let agent_ptr = agent_ptr.clone(); + let wd = Some(working_dir.clone()); set.spawn(async move { ( extension.name(), - agent_ptr.add_extension(extension.clone()).await, + agent_ptr.add_extension(extension.clone(), wd).await, ) }); } diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index ded355467c74..71941df861ca 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -243,8 +243,9 @@ impl CliSession { available_tools: Vec::new(), }; + let working_dir = std::env::current_dir().ok(); self.agent - .add_extension(config) + .add_extension(config, working_dir) .await .map_err(|e| anyhow::anyhow!("Failed to start extension: {}", e))?; @@ -272,8 +273,9 @@ impl CliSession { available_tools: Vec::new(), }; + let working_dir = std::env::current_dir().ok(); self.agent - .add_extension(config) + .add_extension(config, working_dir) .await .map_err(|e| anyhow::anyhow!("Failed to start extension: {}", e))?; @@ -288,6 +290,7 @@ impl CliSession { /// # Arguments /// * `builtin_name` - Name of the builtin extension(s), comma separated pub async fn add_builtin(&mut self, builtin_name: String) -> Result<()> { + let working_dir = std::env::current_dir().ok(); for name in builtin_name.split(',') { let extension_name = name.trim(); @@ -309,7 +312,7 @@ impl CliSession { } }; self.agent - .add_extension(config) + .add_extension(config, working_dir.clone()) .await .map_err(|e| anyhow::anyhow!("Failed to start builtin extension: {}", e))?; } diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index 6f43332ff828..1021d8d32712 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -539,7 +539,7 @@ derive_utoipa!(Icon as IconSchema); super::routes::agent::RemoveExtensionRequest, super::routes::agent::ResumeAgentResponse, super::routes::agent::RestartAgentResponse, - super::routes::agent_utils::ExtensionLoadResult, + goose::agents::ExtensionLoadResult, super::routes::setup::SetupResponse, super::tunnel::TunnelInfo, super::tunnel::TunnelState, diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs index e3c9c457b640..37083cf7f8f0 100644 --- a/crates/goose-server/src/routes/agent.rs +++ b/crates/goose-server/src/routes/agent.rs @@ -1,6 +1,5 @@ use crate::routes::agent_utils::{ persist_session_extensions, restore_agent_extensions, restore_agent_provider, - ExtensionLoadResult, }; use crate::routes::errors::ErrorResponse; use crate::routes::recipe_utils::{ @@ -14,6 +13,7 @@ use axum::{ routing::{get, post}, Json, Router, }; +use goose::agents::ExtensionLoadResult; use goose::config::PermissionManager; use base64::Engine; @@ -279,10 +279,6 @@ async fn start_agent( .await { Ok(agent) => { - agent - .set_working_dir(session_for_spawn.working_dir.clone()) - .await; - let results = restore_agent_extensions(agent, &session_for_spawn).await; tracing::debug!( "Background extension loading completed for session {}", @@ -574,16 +570,16 @@ async fn agent_add_extension( let extension_name = request.config.name(); let agent = state.get_agent(request.session_id.clone()).await?; - // Set the agent's working directory from the session before adding the extension - agent.set_working_dir(session.working_dir).await; - - agent.add_extension(request.config).await.map_err(|e| { - goose::posthog::emit_error( - "extension_add_failed", - &format!("{}: {}", extension_name, e), - ); - ErrorResponse::internal(format!("Failed to add extension: {}", e)) - })?; + agent + .add_extension(request.config, Some(session.working_dir)) + .await + .map_err(|e| { + goose::posthog::emit_error( + "extension_add_failed", + &format!("{}: {}", extension_name, e), + ); + ErrorResponse::internal(format!("Failed to add extension: {}", e)) + })?; persist_session_extensions(&agent, &request.session_id).await?; Ok(StatusCode::OK) diff --git a/crates/goose-server/src/routes/agent_utils.rs b/crates/goose-server/src/routes/agent_utils.rs index 4ba3489bdece..f17fb6012f1c 100644 --- a/crates/goose-server/src/routes/agent_utils.rs +++ b/crates/goose-server/src/routes/agent_utils.rs @@ -1,22 +1,12 @@ use crate::routes::errors::ErrorResponse; use axum::http::StatusCode; -use goose::agents::{normalize, Agent}; +use goose::agents::{Agent, ExtensionLoadResult}; use goose::config::Config; use goose::model::ModelConfig; use goose::providers::create; -use goose::session::extension_data::ExtensionState; -use goose::session::{EnabledExtensionsState, Session, SessionManager}; -use serde::Serialize; +use goose::session::Session; use std::sync::Arc; -use tracing::{error, warn}; - -#[derive(Debug, Clone, Serialize, utoipa::ToSchema)] -pub struct ExtensionLoadResult { - pub name: String, - pub success: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, -} +use tracing::error; pub async fn restore_agent_provider( agent: &Arc, @@ -63,100 +53,23 @@ pub async fn restore_agent_provider( }) } +/// Load extensions from session into the agent +/// Delegates to Agent::load_extensions_from_session pub async fn restore_agent_extensions( agent: Arc, session: &Session, ) -> Vec { - // Set the agent's working directory before adding extensions - agent.set_working_dir(session.working_dir.clone()).await; - - // Try to load session-specific extensions first, fall back to global config - let session_extensions = EnabledExtensionsState::from_extension_data(&session.extension_data); - let enabled_configs = session_extensions - .map(|state| state.extensions) - .unwrap_or_else(|| { - tracing::info!("restore_agent_extensions: falling back to global config"); - goose::config::get_enabled_extensions() - }); - - let extension_futures = enabled_configs - .into_iter() - .map(|config| { - let config_clone = config.clone(); - let agent_ref = agent.clone(); - - async move { - let name = config_clone.name().to_string(); - let normalized_name = normalize(&name); - - if agent_ref - .extension_manager - .is_extension_enabled(&normalized_name) - .await - { - tracing::debug!("Extension {} already loaded, skipping", name); - return ExtensionLoadResult { - name, - success: true, - error: None, - }; - } - - match agent_ref.add_extension(config_clone).await { - Ok(_) => ExtensionLoadResult { - name, - success: true, - error: None, - }, - Err(e) => { - let error_msg = e.to_string(); - warn!("Failed to load extension {}: {}", name, error_msg); - ExtensionLoadResult { - name, - success: false, - error: Some(error_msg), - } - } - } - } - }) - .collect::>(); - - futures::future::join_all(extension_futures).await + agent.load_extensions_from_session(session).await } +/// Persist current extension state to session +/// Delegates to Agent::persist_extension_state pub async fn persist_session_extensions( agent: &Arc, session_id: &str, ) -> Result<(), ErrorResponse> { - let current_extensions = agent.extension_manager.get_extension_configs().await; - let extensions_state = EnabledExtensionsState::new(current_extensions); - - // Get the current session to access its extension_data - let session = SessionManager::get_session(session_id, false) - .await - .map_err(|e| { - error!("Failed to get session for persisting extensions: {}", e); - ErrorResponse { - message: format!("Failed to get session: {}", e), - status: StatusCode::INTERNAL_SERVER_ERROR, - } - })?; - - let mut extension_data = session.extension_data.clone(); - extensions_state - .to_extension_data(&mut extension_data) - .map_err(|e| { - error!("Failed to serialize extension state: {}", e); - ErrorResponse { - message: format!("Failed to serialize extension state: {}", e), - status: StatusCode::INTERNAL_SERVER_ERROR, - } - })?; - - SessionManager::update_session(session_id) - .extension_data(extension_data) - .apply() + agent + .persist_extension_state(session_id) .await .map_err(|e| { error!("Failed to persist extension state: {}", e); @@ -164,7 +77,5 @@ pub async fn persist_session_extensions( message: format!("Failed to persist extension state: {}", e), status: StatusCode::INTERNAL_SERVER_ERROR, } - })?; - - Ok(()) + }) } diff --git a/crates/goose-server/src/state.rs b/crates/goose-server/src/state.rs index 2ebc5a0b53f3..845eccda8f6e 100644 --- a/crates/goose-server/src/state.rs +++ b/crates/goose-server/src/state.rs @@ -8,8 +8,8 @@ use std::sync::Arc; use tokio::sync::Mutex; use tokio::task::JoinHandle; -use crate::routes::agent_utils::ExtensionLoadResult; use crate::tunnel::TunnelManager; +use goose::agents::ExtensionLoadResult; type ExtensionLoadingTasks = Arc>>>>>>>; diff --git a/crates/goose/examples/agent.rs b/crates/goose/examples/agent.rs index 4e4bb5795903..4816f516e724 100644 --- a/crates/goose/examples/agent.rs +++ b/crates/goose/examples/agent.rs @@ -33,7 +33,8 @@ async fn main() -> anyhow::Result<()> { DEFAULT_EXTENSION_TIMEOUT, ) .with_args(vec!["mcp", "developer"]); - agent.add_extension(config).await?; + let working_dir = std::env::current_dir().ok(); + agent.add_extension(config, working_dir).await?; println!("Extensions:"); for extension in agent.list_extensions().await { diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 29881d1c9f74..8de6936fb101 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -13,7 +13,7 @@ use super::platform_tools; use super::tool_execution::{ToolCallResult, CHAT_MODE_TOOL_SKIPPED_RESPONSE, DECLINED_RESPONSE}; use crate::action_required_manager::ActionRequiredManager; use crate::agents::extension::{ExtensionConfig, ExtensionResult, ToolInfo}; -use crate::agents::extension_manager::{get_parameter_names, ExtensionManager}; +use crate::agents::extension_manager::{get_parameter_names, normalize, ExtensionManager}; use crate::agents::extension_manager_extension::MANAGE_EXTENSIONS_TOOL_NAME_COMPLETE; use crate::agents::final_output_tool::{FINAL_OUTPUT_CONTINUATION_MESSAGE, FINAL_OUTPUT_TOOL_NAME}; use crate::agents::platform_tools::PLATFORM_MANAGE_SCHEDULE_TOOL_NAME; @@ -77,6 +77,15 @@ pub struct ToolCategorizeResult { pub filtered_response: Message, } +/// Result of loading an extension +#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)] +pub struct ExtensionLoadResult { + pub name: String, + pub success: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + /// The main goose Agent pub struct Agent { pub(super) provider: SharedProvider, @@ -95,7 +104,6 @@ pub struct Agent { pub(super) scheduler_service: Mutex>>, pub(super) retry_manager: RetryManager, pub(super) tool_inspection_manager: ToolInspectionManager, - pub(super) working_dir: Mutex>, } #[derive(Clone, Debug)] @@ -169,19 +177,9 @@ impl Agent { scheduler_service: Mutex::new(None), retry_manager: RetryManager::new(), tool_inspection_manager: Self::create_default_tool_inspection_manager(), - working_dir: Mutex::new(None), } } - pub async fn set_working_dir(&self, working_dir: std::path::PathBuf) { - let mut wd = self.working_dir.lock().await; - *wd = Some(working_dir); - } - - pub async fn get_working_dir(&self) -> Option { - self.working_dir.lock().await.clone() - } - /// Create a tool inspection manager with default inspectors fn create_default_tool_inspection_manager() -> ToolInspectionManager { let mut tool_inspection_manager = ToolInspectionManager::new(); @@ -576,9 +574,97 @@ impl Agent { Ok(()) } - pub async fn add_extension(&self, extension: ExtensionConfig) -> ExtensionResult<()> { - let working_dir = self.get_working_dir().await; + /// Save current extension state to session by session_id + /// Simpler version of save_extension_state that just takes a session_id + pub async fn persist_extension_state(&self, session_id: &str) -> Result<()> { + let extension_configs = self.extension_manager.get_extension_configs().await; + let extensions_state = EnabledExtensionsState::new(extension_configs); + + let session = SessionManager::get_session(session_id, false).await?; + let mut extension_data = session.extension_data.clone(); + + extensions_state + .to_extension_data(&mut extension_data) + .map_err(|e| anyhow!("Failed to serialize extension state: {}", e))?; + SessionManager::update_session(session_id) + .extension_data(extension_data) + .apply() + .await?; + + Ok(()) + } + + /// Load extensions from session into the agent + /// Skips extensions that are already loaded + pub async fn load_extensions_from_session( + self: &Arc, + session: &Session, + ) -> Vec { + let working_dir = session.working_dir.clone(); + + // Try to load session-specific extensions first, fall back to global config + let session_extensions = + EnabledExtensionsState::from_extension_data(&session.extension_data); + let enabled_configs = session_extensions + .map(|state| state.extensions) + .unwrap_or_else(|| { + tracing::info!("load_extensions_from_session: falling back to global config"); + get_enabled_extensions() + }); + + let extension_futures = enabled_configs + .into_iter() + .map(|config| { + let config_clone = config.clone(); + let agent_ref = self.clone(); + let wd = Some(working_dir.clone()); + + async move { + let name = config_clone.name().to_string(); + let normalized_name = normalize(&name); + + if agent_ref + .extension_manager + .is_extension_enabled(&normalized_name) + .await + { + tracing::debug!("Extension {} already loaded, skipping", name); + return ExtensionLoadResult { + name, + success: true, + error: None, + }; + } + + match agent_ref.add_extension(config_clone, wd).await { + Ok(_) => ExtensionLoadResult { + name, + success: true, + error: None, + }, + Err(e) => { + let error_msg = e.to_string(); + warn!("Failed to load extension {}: {}", name, error_msg); + ExtensionLoadResult { + name, + success: false, + error: Some(error_msg), + } + } + } + } + }) + .collect::>(); + + futures::future::join_all(extension_futures).await + } + + pub async fn add_extension( + &self, + extension: ExtensionConfig, + working_dir: Option, + ) -> ExtensionResult<()> { match &extension { ExtensionConfig::Frontend { tools, diff --git a/crates/goose/src/agents/mod.rs b/crates/goose/src/agents/mod.rs index d7ec26286704..badece6751ae 100644 --- a/crates/goose/src/agents/mod.rs +++ b/crates/goose/src/agents/mod.rs @@ -24,7 +24,7 @@ pub(crate) mod todo_extension; mod tool_execution; pub mod types; -pub use agent::{Agent, AgentEvent}; +pub use agent::{Agent, AgentEvent, ExtensionLoadResult}; pub use execute_commands::COMPACT_TRIGGERS; pub use extension::ExtensionConfig; pub use extension_manager::{normalize, ExtensionManager}; diff --git a/crates/goose/src/agents/reply_parts.rs b/crates/goose/src/agents/reply_parts.rs index e98bcf145c2f..22bd307fde76 100644 --- a/crates/goose/src/agents/reply_parts.rs +++ b/crates/goose/src/agents/reply_parts.rs @@ -466,14 +466,17 @@ mod tests { ]; agent - .add_extension(crate::agents::extension::ExtensionConfig::Frontend { - name: "frontend".to_string(), - description: "desc".to_string(), - tools: frontend_tools, - instructions: None, - bundled: None, - available_tools: vec![], - }) + .add_extension( + crate::agents::extension::ExtensionConfig::Frontend { + name: "frontend".to_string(), + description: "desc".to_string(), + tools: frontend_tools, + instructions: None, + bundled: None, + available_tools: vec![], + }, + None, + ) .await .unwrap(); diff --git a/crates/goose/src/agents/subagent_handler.rs b/crates/goose/src/agents/subagent_handler.rs index b54d747db199..c91abc8231bf 100644 --- a/crates/goose/src/agents/subagent_handler.rs +++ b/crates/goose/src/agents/subagent_handler.rs @@ -137,8 +137,12 @@ fn get_agent_messages( .await .map_err(|e| anyhow!("Failed to set provider on sub agent: {}", e))?; + let working_dir = task_config.parent_working_dir.clone(); for extension in task_config.extensions { - if let Err(e) = agent.add_extension(extension.clone()).await { + if let Err(e) = agent + .add_extension(extension.clone(), Some(working_dir.clone())) + .await + { debug!( "Failed to add extension '{}' to subagent: {}", extension.name(), diff --git a/crates/goose/src/scheduler.rs b/crates/goose/src/scheduler.rs index 2a21a9484da2..9cfaa610ee6c 100644 --- a/crates/goose/src/scheduler.rs +++ b/crates/goose/src/scheduler.rs @@ -735,8 +735,11 @@ async fn execute_job( let agent_provider = create(&provider_name, model_config).await?; if let Some(ref extensions) = recipe.extensions { + let working_dir = std::env::current_dir().ok(); for ext in extensions { - agent.add_extension(ext.clone()).await?; + agent + .add_extension(ext.clone(), working_dir.clone()) + .await?; } } diff --git a/crates/goose/tests/agent.rs b/crates/goose/tests/agent.rs index 967f56d51e02..1fb610a25365 100644 --- a/crates/goose/tests/agent.rs +++ b/crates/goose/tests/agent.rs @@ -487,7 +487,7 @@ mod tests { }; agent - .add_extension(ext_config) + .add_extension(ext_config, None) .await .expect("Failed to add extension manager"); agent diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index aa35061a066d..d52ab7c16c0e 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -3667,6 +3667,7 @@ }, "ExtensionLoadResult": { "type": "object", + "description": "Result of loading an extension", "required": [ "name", "success" diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 1e3fd150f6e6..cbd51dcfd2dc 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -335,6 +335,9 @@ export type ExtensionEntry = ExtensionConfig & { enabled: boolean; }; +/** + * Result of loading an extension + */ export type ExtensionLoadResult = { error?: string | null; name: string; From dc855a0a85cc27dc309976a668d681acb5af3a97 Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Mon, 5 Jan 2026 11:16:59 -0800 Subject: [PATCH 20/36] restore_provider_from_session and remove agent restart in ui for extensions --- crates/goose-server/src/routes/agent.rs | 4 +- crates/goose-server/src/routes/agent_utils.rs | 41 ++----------------- crates/goose/src/agents/agent.rs | 29 +++++++++++++ ui/desktop/src/components/ChatInput.tsx | 6 +-- .../BottomMenuExtensionSelection.tsx | 14 +------ 5 files changed, 38 insertions(+), 56 deletions(-) diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs index 37083cf7f8f0..81677c19d3bd 100644 --- a/crates/goose-server/src/routes/agent.rs +++ b/crates/goose-server/src/routes/agent.rs @@ -340,7 +340,7 @@ async fn resume_agent( status: code, })?; - restore_agent_provider(&agent, &session, &payload.session_id).await?; + restore_agent_provider(&agent, &session).await?; let extension_results = if let Some(results) = state.take_extension_loading_task(&payload.session_id).await { @@ -652,7 +652,7 @@ async fn restart_agent_internal( status: code, })?; - let provider_result = restore_agent_provider(&agent, session, session_id); + let provider_result = restore_agent_provider(&agent, session); let extensions_future = restore_agent_extensions(agent.clone(), session); let (provider_result, extension_results) = tokio::join!(provider_result, extensions_future); diff --git a/crates/goose-server/src/routes/agent_utils.rs b/crates/goose-server/src/routes/agent_utils.rs index f17fb6012f1c..05e8322efed2 100644 --- a/crates/goose-server/src/routes/agent_utils.rs +++ b/crates/goose-server/src/routes/agent_utils.rs @@ -1,54 +1,21 @@ use crate::routes::errors::ErrorResponse; use axum::http::StatusCode; use goose::agents::{Agent, ExtensionLoadResult}; -use goose::config::Config; -use goose::model::ModelConfig; -use goose::providers::create; use goose::session::Session; use std::sync::Arc; use tracing::error; +/// Restore the provider from session into the agent +/// Delegates to Agent::restore_provider_from_session pub async fn restore_agent_provider( agent: &Arc, session: &Session, - session_id: &str, ) -> Result<(), ErrorResponse> { - let config = Config::global(); - let provider_name = session - .provider_name - .clone() - .or_else(|| config.get_goose_provider().ok()) - .ok_or_else(|| ErrorResponse { - message: "Could not configure agent: missing provider".into(), - status: StatusCode::INTERNAL_SERVER_ERROR, - })?; - - let model_config = match session.model_config.clone() { - Some(saved_config) => saved_config, - None => { - let model_name = config.get_goose_model().map_err(|_| ErrorResponse { - message: "Could not configure agent: missing model".into(), - status: StatusCode::INTERNAL_SERVER_ERROR, - })?; - ModelConfig::new(&model_name).map_err(|e| ErrorResponse { - message: format!("Could not configure agent: invalid model {}", e), - status: StatusCode::INTERNAL_SERVER_ERROR, - })? - } - }; - - let provider = create(&provider_name, model_config) - .await - .map_err(|e| ErrorResponse { - message: format!("Could not create provider: {}", e), - status: StatusCode::INTERNAL_SERVER_ERROR, - })?; - agent - .update_provider(provider, session_id) + .restore_provider_from_session(session) .await .map_err(|e| ErrorResponse { - message: format!("Could not configure agent: {}", e), + message: e.to_string(), status: StatusCode::INTERNAL_SERVER_ERROR, }) } diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 8de6936fb101..8e81e78a02fc 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -1421,6 +1421,35 @@ impl Agent { .context("Failed to persist provider config to session") } + /// Restore the provider from session data or fall back to global config + /// This is used when resuming a session to restore the provider state + pub async fn restore_provider_from_session(&self, session: &Session) -> Result<()> { + let config = Config::global(); + + let provider_name = session + .provider_name + .clone() + .or_else(|| config.get_goose_provider().ok()) + .ok_or_else(|| anyhow!("Could not configure agent: missing provider"))?; + + let model_config = match session.model_config.clone() { + Some(saved_config) => saved_config, + None => { + let model_name = config + .get_goose_model() + .map_err(|_| anyhow!("Could not configure agent: missing model"))?; + crate::model::ModelConfig::new(&model_name) + .map_err(|e| anyhow!("Could not configure agent: invalid model {}", e))? + } + }; + + let provider = crate::providers::create(&provider_name, model_config) + .await + .map_err(|e| anyhow!("Could not create provider: {}", e))?; + + self.update_provider(provider, &session.id).await + } + /// Override the system prompt with a custom template pub async fn override_system_prompt(&self, template: String) { let mut prompt_manager = self.prompt_manager.lock().await; diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index 841b6516d8c2..40798035de0b 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -1583,11 +1583,7 @@ export default function ChatInput({
- setChatState?.(ChatState.RestartingAgent)} - onRestartEnd={() => setChatState?.(ChatState.Idle)} - /> + {sessionId && messages.length > 0 && ( <>
diff --git a/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx b/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx index 6a5cfbbb2cc3..730c74a8b2bf 100644 --- a/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx +++ b/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx @@ -16,15 +16,9 @@ import { interface BottomMenuExtensionSelectionProps { sessionId: string | null; - onRestartStart?: () => void; - onRestartEnd?: () => void; } -export const BottomMenuExtensionSelection = ({ - sessionId, - onRestartStart, - onRestartEnd, -}: BottomMenuExtensionSelectionProps) => { +export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionSelectionProps) => { const [searchQuery, setSearchQuery] = useState(''); const [isOpen, setIsOpen] = useState(false); const [sessionExtensions, setSessionExtensions] = useState([]); @@ -111,8 +105,6 @@ export const BottomMenuExtensionSelection = ({ return; } - onRestartStart?.(); - try { if (extensionConfig.enabled) { await removeFromAgent(extensionConfig.name, sessionId, true); @@ -137,16 +129,14 @@ export const BottomMenuExtensionSelection = ({ setPendingSort(false); setIsTransitioning(false); setTogglingExtension(null); - onRestartEnd?.(); }, 800); } catch { setIsTransitioning(false); setPendingSort(false); setTogglingExtension(null); - onRestartEnd?.(); } }, - [sessionId, isHubView, togglingExtension, onRestartStart, onRestartEnd] + [sessionId, isHubView, togglingExtension] ); // Merge all available extensions with session-specific or hub override state From 82adfdd61d07957088f8c93575e4b0612ba9e438 Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Mon, 5 Jan 2026 11:32:34 -0800 Subject: [PATCH 21/36] cleanup --- crates/goose/src/agents/agent.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 8e81e78a02fc..4e0c82b0a3f2 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -77,7 +77,6 @@ pub struct ToolCategorizeResult { pub filtered_response: Message, } -/// Result of loading an extension #[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)] pub struct ExtensionLoadResult { pub name: String, From 658ff3ff464526cd155219f8a5b7a5ffa3724b6a Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Mon, 5 Jan 2026 11:59:08 -0800 Subject: [PATCH 22/36] regen types --- ui/desktop/openapi.json | 1 - ui/desktop/src/api/types.gen.ts | 3 --- 2 files changed, 4 deletions(-) diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 957bbadf5ca1..c5b98eda0e8c 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -3707,7 +3707,6 @@ }, "ExtensionLoadResult": { "type": "object", - "description": "Result of loading an extension", "required": [ "name", "success" diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index dabf5544d508..3d101190ca6d 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -335,9 +335,6 @@ export type ExtensionEntry = ExtensionConfig & { enabled: boolean; }; -/** - * Result of loading an extension - */ export type ExtensionLoadResult = { error?: string | null; name: string; From 9f1815a1ad192af761453d0c262a3337b68846ac Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Tue, 6 Jan 2026 07:56:09 -0800 Subject: [PATCH 23/36] change to restarting session --- ui/desktop/src/components/ChatInput.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index 40798035de0b..3278012dc31a 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -1385,7 +1385,7 @@ export default function ChatInput({ : isTranscribing ? 'Transcribing...' : chatState === ChatState.RestartingAgent - ? 'Restarting agent...' + ? 'Restarting session...' : 'Send'}

From d1e22fabfe243a154ff282ff8ee406ea80feebc1 Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Tue, 6 Jan 2026 08:17:44 -0800 Subject: [PATCH 24/36] remove agent utils out to agent --- crates/goose-server/src/routes/agent.rs | 44 +++++++++++++++-- crates/goose-server/src/routes/agent_utils.rs | 48 ------------------- crates/goose-server/src/routes/mod.rs | 1 - 3 files changed, 41 insertions(+), 52 deletions(-) delete mode 100644 crates/goose-server/src/routes/agent_utils.rs diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs index 81677c19d3bd..e055b74ea1cb 100644 --- a/crates/goose-server/src/routes/agent.rs +++ b/crates/goose-server/src/routes/agent.rs @@ -1,6 +1,3 @@ -use crate::routes::agent_utils::{ - persist_session_extensions, restore_agent_extensions, restore_agent_provider, -}; use crate::routes::errors::ErrorResponse; use crate::routes::recipe_utils::{ apply_recipe_to_agent, build_recipe_with_parameter_values, load_recipe_by_id, validate_recipe, @@ -41,6 +38,47 @@ use std::sync::Arc; use tokio_util::sync::CancellationToken; use tracing::error; +use goose::agents::Agent; + +/// Restore the provider from session into the agent +async fn restore_agent_provider( + agent: &Arc, + session: &Session, +) -> Result<(), ErrorResponse> { + agent + .restore_provider_from_session(session) + .await + .map_err(|e| ErrorResponse { + message: e.to_string(), + status: StatusCode::INTERNAL_SERVER_ERROR, + }) +} + +/// Load extensions from session into the agent +async fn restore_agent_extensions( + agent: Arc, + session: &Session, +) -> Vec { + agent.load_extensions_from_session(session).await +} + +/// Persist current extension state to session +async fn persist_session_extensions( + agent: &Arc, + session_id: &str, +) -> Result<(), ErrorResponse> { + agent + .persist_extension_state(session_id) + .await + .map_err(|e| { + error!("Failed to persist extension state: {}", e); + ErrorResponse { + message: format!("Failed to persist extension state: {}", e), + status: StatusCode::INTERNAL_SERVER_ERROR, + } + }) +} + #[derive(Deserialize, utoipa::ToSchema)] pub struct UpdateFromSessionRequest { session_id: String, diff --git a/crates/goose-server/src/routes/agent_utils.rs b/crates/goose-server/src/routes/agent_utils.rs deleted file mode 100644 index 05e8322efed2..000000000000 --- a/crates/goose-server/src/routes/agent_utils.rs +++ /dev/null @@ -1,48 +0,0 @@ -use crate::routes::errors::ErrorResponse; -use axum::http::StatusCode; -use goose::agents::{Agent, ExtensionLoadResult}; -use goose::session::Session; -use std::sync::Arc; -use tracing::error; - -/// Restore the provider from session into the agent -/// Delegates to Agent::restore_provider_from_session -pub async fn restore_agent_provider( - agent: &Arc, - session: &Session, -) -> Result<(), ErrorResponse> { - agent - .restore_provider_from_session(session) - .await - .map_err(|e| ErrorResponse { - message: e.to_string(), - status: StatusCode::INTERNAL_SERVER_ERROR, - }) -} - -/// Load extensions from session into the agent -/// Delegates to Agent::load_extensions_from_session -pub async fn restore_agent_extensions( - agent: Arc, - session: &Session, -) -> Vec { - agent.load_extensions_from_session(session).await -} - -/// Persist current extension state to session -/// Delegates to Agent::persist_extension_state -pub async fn persist_session_extensions( - agent: &Arc, - session_id: &str, -) -> Result<(), ErrorResponse> { - agent - .persist_extension_state(session_id) - .await - .map_err(|e| { - error!("Failed to persist extension state: {}", e); - ErrorResponse { - message: format!("Failed to persist extension state: {}", e), - status: StatusCode::INTERNAL_SERVER_ERROR, - } - }) -} diff --git a/crates/goose-server/src/routes/mod.rs b/crates/goose-server/src/routes/mod.rs index c302f65c01d8..03039e98df15 100644 --- a/crates/goose-server/src/routes/mod.rs +++ b/crates/goose-server/src/routes/mod.rs @@ -1,6 +1,5 @@ pub mod action_required; pub mod agent; -pub mod agent_utils; pub mod audio; pub mod config_management; pub mod errors; From 92f8dc5e20e459735bdf7adfca73ff16bf9d2cd1 Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Wed, 7 Jan 2026 08:59:38 -0800 Subject: [PATCH 25/36] cleanup --- ui/desktop/src/components/Hub.tsx | 2 +- ui/desktop/src/components/LoadingGoose.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/desktop/src/components/Hub.tsx b/ui/desktop/src/components/Hub.tsx index 251a10bfcd64..f4528769aed5 100644 --- a/ui/desktop/src/components/Hub.tsx +++ b/ui/desktop/src/components/Hub.tsx @@ -50,7 +50,7 @@ export default function Hub({ try { const session = await createSession(workingDir, { extensionConfigs, - allExtensions: extensionConfigs ? undefined : extensionsList, + allExtensions: extensionConfigs.length > 0 ? undefined : extensionsList, }); setView('pair', { diff --git a/ui/desktop/src/components/LoadingGoose.tsx b/ui/desktop/src/components/LoadingGoose.tsx index 92f8b81375ea..c4bbb1c929f0 100644 --- a/ui/desktop/src/components/LoadingGoose.tsx +++ b/ui/desktop/src/components/LoadingGoose.tsx @@ -15,7 +15,7 @@ const STATE_MESSAGES: Record = { [ChatState.WaitingForUserInput]: 'goose is waiting…', [ChatState.Compacting]: 'goose is compacting the conversation...', [ChatState.Idle]: 'goose is working on it…', - [ChatState.RestartingAgent]: 'restarting agent...', + [ChatState.RestartingAgent]: 'restarting session...', }; const STATE_ICONS: Record = { From 8899d174343c536ede604d0cb5244d5d69d23a10 Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Wed, 7 Jan 2026 10:17:04 -0800 Subject: [PATCH 26/36] fix new chat from pair not submitting --- ui/desktop/src/App.tsx | 61 +++++++++++++++++++++++--- ui/desktop/src/components/BaseChat.tsx | 24 +++++++++- 2 files changed, 78 insertions(+), 7 deletions(-) diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index a656256c8077..7997cae0db1c 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -69,18 +69,46 @@ const PairRouteWrapper = ({ const navigate = useNavigate(); const routeState = (location.state as PairRouteState) || {}; const [searchParams] = useSearchParams(); + const [isCreatingSession, setIsCreatingSession] = useState(false); + + // Capture initialMessage in local state to survive route state being cleared + const [capturedInitialMessage, setCapturedInitialMessage] = useState( + undefined + ); + const resumeSessionId = searchParams.get('resumeSessionId') ?? undefined; const recipeId = searchParams.get('recipeId') ?? undefined; const recipeDeeplinkFromConfig = window.appConfig?.get('recipeDeeplink') as string | undefined; // Session ID and initialMessage come from route state (Hub, fork) or URL params (refresh, deeplink) const sessionIdFromState = routeState.resumeSessionId; - const initialMessage = routeState.initialMessage; const sessionId = sessionIdFromState || resumeSessionId || chat.sessionId || undefined; - // Handle recipe deeplinks - create session if needed + // Use route state if available, otherwise use captured state + const initialMessage = routeState.initialMessage || capturedInitialMessage; + + // Capture initialMessage when it comes from route state useEffect(() => { - if ((recipeId || recipeDeeplinkFromConfig) && !sessionId) { + console.log( + '[PairRouteWrapper] capture effect:', + JSON.stringify({ + routeStateInitialMessage: routeState.initialMessage, + }) + ); + if (routeState.initialMessage) { + setCapturedInitialMessage(routeState.initialMessage); + } + }, [routeState.initialMessage]); + + // Create session if we have an initialMessage, recipeId, or recipeDeeplink but no sessionId + useEffect(() => { + if ( + (initialMessage || recipeId || recipeDeeplinkFromConfig) && + !sessionId && + !isCreatingSession + ) { + setIsCreatingSession(true); + (async () => { try { const newSession = await createSession(getInitialWorkingDir(), { @@ -90,19 +118,29 @@ const PairRouteWrapper = ({ }); navigate(`/pair?resumeSessionId=${newSession.id}`, { replace: true, - state: { resumeSessionId: newSession.id }, + state: { resumeSessionId: newSession.id, initialMessage }, }); } catch (error) { - console.error('Failed to create session for recipe:', error); + console.error('Failed to create session:', error); trackErrorWithContext(error, { component: 'PairRouteWrapper', action: 'create_session', recoverable: true, }); + } finally { + setIsCreatingSession(false); } })(); } - }, [recipeId, recipeDeeplinkFromConfig, sessionId, extensionsList, navigate]); + }, [ + initialMessage, + recipeId, + recipeDeeplinkFromConfig, + sessionId, + isCreatingSession, + extensionsList, + navigate, + ]); // Sync URL with session ID for refresh support (only if not already in URL) useEffect(() => { @@ -114,6 +152,17 @@ const PairRouteWrapper = ({ } }, [sessionId, resumeSessionId, navigate, sessionIdFromState, initialMessage]); + // Clear captured initialMessage when session changes (to prevent re-sending on navigation) + useEffect(() => { + if (sessionId && capturedInitialMessage && sessionIdFromState) { + const timer = setTimeout(() => { + setCapturedInitialMessage(undefined); + }, 100); + return () => clearTimeout(timer); + } + return undefined; + }, [sessionId, capturedInitialMessage, sessionIdFromState]); + return ( (null); @@ -66,11 +69,13 @@ function BaseChatContent({ const navigate = useNavigate(); const [searchParams] = useSearchParams(); const scrollRef = useRef(null); + const { extensionsList } = useConfig(); const disableAnimation = location.state?.disableAnimation || false; const [hasStartedUsingRecipe, setHasStartedUsingRecipe] = React.useState(false); const [hasNotAcceptedRecipe, setHasNotAcceptedRecipe] = useState(); const [hasRecipeSecurityWarnings, setHasRecipeSecurityWarnings] = useState(false); + const [isCreatingSession, setIsCreatingSession] = useState(false); const isMobile = useIsMobile(); const { state: sidebarState } = useSidebar(); @@ -145,10 +150,27 @@ function BaseChatContent({ } }, [session, initialMessage, searchParams, handleSubmit, navigate, location]); - const handleFormSubmit = (e: React.FormEvent) => { + const handleFormSubmit = async (e: React.FormEvent) => { const customEvent = e as unknown as CustomEvent; const textValue = customEvent.detail?.value || ''; + // If no session exists, create one and navigate with the initial message + if (!session && !sessionId && textValue.trim() && !isCreatingSession) { + setIsCreatingSession(true); + try { + const newSession = await createSession(getInitialWorkingDir(), { + allExtensions: extensionsList, + }); + navigate(`/pair?resumeSessionId=${newSession.id}`, { + replace: true, + state: { resumeSessionId: newSession.id, initialMessage: textValue }, + }); + } catch { + setIsCreatingSession(false); + } + return; + } + if (recipe && textValue.trim()) { setHasStartedUsingRecipe(true); } From 6143a2eb6fcecd34ad97b20b3348c4f6dd5886d4 Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Thu, 8 Jan 2026 08:26:29 -0800 Subject: [PATCH 27/36] remove messageHistoryIndex no longer needed --- ui/desktop/src/App.tsx | 1 - ui/desktop/src/components/BaseChat.tsx | 1 - ui/desktop/src/contexts/ChatContext.tsx | 1 - ui/desktop/src/types/chat.ts | 1 - 4 files changed, 4 deletions(-) diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index 7997cae0db1c..5c1a575decbe 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -366,7 +366,6 @@ export function AppInner() { sessionId: '', name: 'Pair Chat', messages: [], - messageHistoryIndex: 0, recipe: null, }); diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index 7471c117ecfc..67e38830f729 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -296,7 +296,6 @@ function BaseChatContent({ const chat: ChatType = { messages, - messageHistoryIndex: 0, recipe, sessionId, name: session?.name || 'No Session', diff --git a/ui/desktop/src/contexts/ChatContext.tsx b/ui/desktop/src/contexts/ChatContext.tsx index 97abc29b1ecb..d7836e5c30de 100644 --- a/ui/desktop/src/contexts/ChatContext.tsx +++ b/ui/desktop/src/contexts/ChatContext.tsx @@ -36,7 +36,6 @@ export const ChatProvider: React.FC = ({ sessionId: '', name: DEFAULT_CHAT_TITLE, messages: [], - messageHistoryIndex: 0, recipe: null, recipeParameterValues: null, }); diff --git a/ui/desktop/src/types/chat.ts b/ui/desktop/src/types/chat.ts index 2178d19d0fde..358e728f5600 100644 --- a/ui/desktop/src/types/chat.ts +++ b/ui/desktop/src/types/chat.ts @@ -5,7 +5,6 @@ export interface ChatType { sessionId: string; name: string; messages: Message[]; - messageHistoryIndex: number; recipe?: Recipe | null; // Add recipe configuration to chat state resolvedRecipe?: Recipe | null; // Add resolved recipe with parameter values rendered to chat state recipeParameterValues?: Record | null; // Add recipe parameters to chat state From bc2108c87c624f414320f2a57ee4a8c20d114e49 Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Thu, 8 Jan 2026 09:50:01 -0800 Subject: [PATCH 28/36] cleanup --- crates/goose-server/src/openapi.rs | 2 - crates/goose-server/src/routes/agent.rs | 86 ++++++++++------------- crates/goose-server/src/routes/reply.rs | 2 - crates/goose-server/src/routes/session.rs | 56 --------------- crates/goose/src/agents/agent.rs | 1 - ui/desktop/openapi.json | 62 ---------------- ui/desktop/src/api/sdk.gen.ts | 11 +-- ui/desktop/src/api/types.gen.ts | 42 ----------- 8 files changed, 37 insertions(+), 225 deletions(-) diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index 7f47cb214144..384fad28e1dc 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -369,7 +369,6 @@ derive_utoipa!(Icon as IconSchema); super::routes::session::get_session, super::routes::session::get_session_insights, super::routes::session::update_session_name, - super::routes::session::update_session_working_dir, super::routes::session::delete_session, super::routes::session::export_session, super::routes::session::import_session, @@ -430,7 +429,6 @@ derive_utoipa!(Icon as IconSchema); super::routes::session::ImportSessionRequest, super::routes::session::SessionListResponse, super::routes::session::UpdateSessionNameRequest, - super::routes::session::UpdateSessionWorkingDirRequest, super::routes::session::UpdateSessionUserRecipeValuesRequest, super::routes::session::UpdateSessionUserRecipeValuesResponse, super::routes::session::EditType, diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs index e055b74ea1cb..513a160fe61b 100644 --- a/crates/goose-server/src/routes/agent.rs +++ b/crates/goose-server/src/routes/agent.rs @@ -38,47 +38,6 @@ use std::sync::Arc; use tokio_util::sync::CancellationToken; use tracing::error; -use goose::agents::Agent; - -/// Restore the provider from session into the agent -async fn restore_agent_provider( - agent: &Arc, - session: &Session, -) -> Result<(), ErrorResponse> { - agent - .restore_provider_from_session(session) - .await - .map_err(|e| ErrorResponse { - message: e.to_string(), - status: StatusCode::INTERNAL_SERVER_ERROR, - }) -} - -/// Load extensions from session into the agent -async fn restore_agent_extensions( - agent: Arc, - session: &Session, -) -> Vec { - agent.load_extensions_from_session(session).await -} - -/// Persist current extension state to session -async fn persist_session_extensions( - agent: &Arc, - session_id: &str, -) -> Result<(), ErrorResponse> { - agent - .persist_extension_state(session_id) - .await - .map_err(|e| { - error!("Failed to persist extension state: {}", e); - ErrorResponse { - message: format!("Failed to persist extension state: {}", e), - status: StatusCode::INTERNAL_SERVER_ERROR, - } - }) -} - #[derive(Deserialize, utoipa::ToSchema)] pub struct UpdateFromSessionRequest { session_id: String, @@ -317,7 +276,7 @@ async fn start_agent( .await { Ok(agent) => { - let results = restore_agent_extensions(agent, &session_for_spawn).await; + let results = agent.load_extensions_from_session(&session_for_spawn).await; tracing::debug!( "Background extension loading completed for session {}", session_for_spawn.id @@ -378,7 +337,13 @@ async fn resume_agent( status: code, })?; - restore_agent_provider(&agent, &session).await?; + agent + .restore_provider_from_session(&session) + .await + .map_err(|e| ErrorResponse { + message: e.to_string(), + status: StatusCode::INTERNAL_SERVER_ERROR, + })?; let extension_results = if let Some(results) = state.take_extension_loading_task(&payload.session_id).await { @@ -395,7 +360,7 @@ async fn resume_agent( "No background task found, loading extensions for session {}", payload.session_id ); - restore_agent_extensions(agent.clone(), &session).await + agent.load_extensions_from_session(&session).await }; Some(extension_results) @@ -619,7 +584,16 @@ async fn agent_add_extension( ErrorResponse::internal(format!("Failed to add extension: {}", e)) })?; - persist_session_extensions(&agent, &request.session_id).await?; + agent + .persist_extension_state(&request.session_id) + .await + .map_err(|e| { + error!("Failed to persist extension state: {}", e); + ErrorResponse { + message: format!("Failed to persist extension state: {}", e), + status: StatusCode::INTERNAL_SERVER_ERROR, + } + })?; Ok(StatusCode::OK) } @@ -641,7 +615,16 @@ async fn agent_remove_extension( let agent = state.get_agent(request.session_id.clone()).await?; agent.remove_extension(&request.name).await?; - persist_session_extensions(&agent, &request.session_id).await?; + agent + .persist_extension_state(&request.session_id) + .await + .map_err(|e| { + error!("Failed to persist extension state: {}", e); + ErrorResponse { + message: format!("Failed to persist extension state: {}", e), + status: StatusCode::INTERNAL_SERVER_ERROR, + } + })?; Ok(StatusCode::OK) } @@ -690,11 +673,14 @@ async fn restart_agent_internal( status: code, })?; - let provider_result = restore_agent_provider(&agent, session); - let extensions_future = restore_agent_extensions(agent.clone(), session); + let provider_future = agent.restore_provider_from_session(session); + let extensions_future = agent.load_extensions_from_session(session); - let (provider_result, extension_results) = tokio::join!(provider_result, extensions_future); - provider_result?; + let (provider_result, extension_results) = tokio::join!(provider_future, extensions_future); + provider_result.map_err(|e| ErrorResponse { + message: e.to_string(), + status: StatusCode::INTERNAL_SERVER_ERROR, + })?; let context: HashMap<&str, Value> = HashMap::new(); let desktop_prompt = diff --git a/crates/goose-server/src/routes/reply.rs b/crates/goose-server/src/routes/reply.rs index 71291a202524..eced7bc7bb8c 100644 --- a/crates/goose-server/src/routes/reply.rs +++ b/crates/goose-server/src/routes/reply.rs @@ -274,8 +274,6 @@ pub async fn reply( } }; - std::env::set_var("GOOSE_WORKING_DIR", &session.working_dir); - let session_config = SessionConfig { id: session_id.clone(), schedule_id: session.schedule_id.clone(), diff --git a/crates/goose-server/src/routes/session.rs b/crates/goose-server/src/routes/session.rs index 610e7a27157a..1f3fc922bcaa 100644 --- a/crates/goose-server/src/routes/session.rs +++ b/crates/goose-server/src/routes/session.rs @@ -16,7 +16,6 @@ use goose::session::session_manager::SessionInsights; use goose::session::{EnabledExtensionsState, Session, SessionManager}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; -use std::path::PathBuf; use std::sync::Arc; use utoipa::ToSchema; @@ -41,12 +40,6 @@ pub struct UpdateSessionUserRecipeValuesRequest { user_recipe_values: HashMap, } -#[derive(Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct UpdateSessionWorkingDirRequest { - working_dir: String, -} - #[derive(Debug, Serialize, ToSchema)] pub struct UpdateSessionUserRecipeValuesResponse { recipe: Recipe, @@ -190,51 +183,6 @@ async fn update_session_name( Ok(StatusCode::OK) } -#[utoipa::path( - put, - path = "/sessions/{session_id}/working_dir", - request_body = UpdateSessionWorkingDirRequest, - params( - ("session_id" = String, Path, description = "Unique identifier for the session") - ), - responses( - (status = 200, description = "Session working directory updated successfully"), - (status = 400, description = "Bad request - Invalid directory path"), - (status = 401, description = "Unauthorized - Invalid or missing API key"), - (status = 404, description = "Session not found"), - (status = 500, description = "Internal server error") - ), - security( - ("api_key" = []) - ), - tag = "Session Management" -)] -async fn update_session_working_dir( - Path(session_id): Path, - Json(request): Json, -) -> Result { - let working_dir = request.working_dir.trim(); - if working_dir.is_empty() { - return Err(StatusCode::BAD_REQUEST); - } - - let path = PathBuf::from(working_dir); - if !path.exists() || !path.is_dir() { - return Err(StatusCode::BAD_REQUEST); - } - - SessionManager::update_session(&session_id) - .working_dir(path) - .apply() - .await - .map_err(|e| { - tracing::error!("Failed to update session working directory: {}", e); - StatusCode::INTERNAL_SERVER_ERROR - })?; - - Ok(StatusCode::OK) -} - #[utoipa::path( put, path = "/sessions/{session_id}/user_recipe_values", @@ -494,10 +442,6 @@ pub fn routes(state: Arc) -> Router { .route("/sessions/import", post(import_session)) .route("/sessions/insights", get(get_session_insights)) .route("/sessions/{session_id}/name", put(update_session_name)) - .route( - "/sessions/{session_id}/working_dir", - put(update_session_working_dir), - ) .route( "/sessions/{session_id}/user_recipe_values", put(update_session_user_recipe_values), diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 4e0c82b0a3f2..ca75b2a4ad38 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -574,7 +574,6 @@ impl Agent { } /// Save current extension state to session by session_id - /// Simpler version of save_extension_state that just takes a session_id pub async fn persist_extension_state(&self, session_id: &str) -> Result<()> { let extension_configs = self.extension_manager.get_extension_configs().await; let extensions_state = EnabledExtensionsState::new(extension_configs); diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 143cf08aaad6..f7091b80cc63 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -2511,57 +2511,6 @@ ] } }, - "/sessions/{session_id}/working_dir": { - "put": { - "tags": [ - "Session Management" - ], - "operationId": "update_session_working_dir", - "parameters": [ - { - "name": "session_id", - "in": "path", - "description": "Unique identifier for the session", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateSessionWorkingDirRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Session working directory updated successfully" - }, - "400": { - "description": "Bad request - Invalid directory path" - }, - "401": { - "description": "Unauthorized - Invalid or missing API key" - }, - "404": { - "description": "Session not found" - }, - "500": { - "description": "Internal server error" - } - }, - "security": [ - { - "api_key": [] - } - ] - } - }, "/status": { "get": { "tags": [ @@ -6227,17 +6176,6 @@ } } }, - "UpdateSessionWorkingDirRequest": { - "type": "object", - "required": [ - "workingDir" - ], - "properties": { - "workingDir": { - "type": "string" - } - } - }, "UpdateWorkingDirRequest": { "type": "object", "required": [ diff --git a/ui/desktop/src/api/sdk.gen.ts b/ui/desktop/src/api/sdk.gen.ts index f2890b343180..b5325697e506 100644 --- a/ui/desktop/src/api/sdk.gen.ts +++ b/ui/desktop/src/api/sdk.gen.ts @@ -2,7 +2,7 @@ import type { Client, Options as Options2, TDataShape } from './client'; import { client } from './client.gen'; -import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponses, BackupConfigData, BackupConfigErrors, BackupConfigResponses, CallToolData, CallToolErrors, CallToolResponses, CheckProviderData, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DetectProviderData, DetectProviderErrors, DetectProviderResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, EditMessageData, EditMessageErrors, EditMessageResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetPricingData, GetPricingResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionExtensionsData, GetSessionExtensionsErrors, GetSessionExtensionsResponses, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsResponses, GetTunnelStatusData, GetTunnelStatusResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, ReadResourceData, ReadResourceErrors, ReadResourceResponses, RecipeToYamlData, RecipeToYamlErrors, RecipeToYamlResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, RestartAgentData, RestartAgentErrors, RestartAgentResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeResponses, SendTelemetryEventData, SendTelemetryEventResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, SetConfigProviderData, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StartTunnelData, StartTunnelErrors, StartTunnelResponses, StatusData, StatusResponses, StopTunnelData, StopTunnelErrors, StopTunnelResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpdateSessionWorkingDirData, UpdateSessionWorkingDirErrors, UpdateSessionWorkingDirResponses, UpdateWorkingDirData, UpdateWorkingDirErrors, UpdateWorkingDirResponses, 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, CallToolData, CallToolErrors, CallToolResponses, CheckProviderData, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DetectProviderData, DetectProviderErrors, DetectProviderResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, EditMessageData, EditMessageErrors, EditMessageResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetPricingData, GetPricingResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionExtensionsData, GetSessionExtensionsErrors, GetSessionExtensionsResponses, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsResponses, GetTunnelStatusData, GetTunnelStatusResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, ReadResourceData, ReadResourceErrors, ReadResourceResponses, RecipeToYamlData, RecipeToYamlErrors, RecipeToYamlResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, RestartAgentData, RestartAgentErrors, RestartAgentResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeResponses, SendTelemetryEventData, SendTelemetryEventResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, SetConfigProviderData, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StartTunnelData, StartTunnelErrors, StartTunnelResponses, StatusData, StatusResponses, StopTunnelData, StopTunnelErrors, StopTunnelResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpdateWorkingDirData, UpdateWorkingDirErrors, UpdateWorkingDirResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen'; export type Options = Options2 & { /** @@ -433,15 +433,6 @@ export const updateSessionUserRecipeValues = (options: Options) => (options.client ?? client).put({ - url: '/sessions/{session_id}/working_dir', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - export const status = (options?: Options) => (options?.client ?? client).get({ url: '/status', ...options }); export const sendTelemetryEvent = (options: Options) => (options.client ?? client).post({ diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 3d101190ca6d..e6f6d6745585 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -1168,10 +1168,6 @@ export type UpdateSessionUserRecipeValuesResponse = { recipe: Recipe; }; -export type UpdateSessionWorkingDirRequest = { - workingDir: string; -}; - export type UpdateWorkingDirRequest = { session_id: string; working_dir: string; @@ -3125,44 +3121,6 @@ export type UpdateSessionUserRecipeValuesResponses = { export type UpdateSessionUserRecipeValuesResponse2 = UpdateSessionUserRecipeValuesResponses[keyof UpdateSessionUserRecipeValuesResponses]; -export type UpdateSessionWorkingDirData = { - body: UpdateSessionWorkingDirRequest; - path: { - /** - * Unique identifier for the session - */ - session_id: string; - }; - query?: never; - url: '/sessions/{session_id}/working_dir'; -}; - -export type UpdateSessionWorkingDirErrors = { - /** - * Bad request - Invalid directory path - */ - 400: unknown; - /** - * Unauthorized - Invalid or missing API key - */ - 401: unknown; - /** - * Session not found - */ - 404: unknown; - /** - * Internal server error - */ - 500: unknown; -}; - -export type UpdateSessionWorkingDirResponses = { - /** - * Session working directory updated successfully - */ - 200: unknown; -}; - export type StatusData = { body?: never; path?: never; From a59c7687239cd96c009bb6d619811c0e64431f7c Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Thu, 8 Jan 2026 11:12:29 -0800 Subject: [PATCH 29/36] change acp working dir to use cwd and make required --- crates/goose-cli/src/commands/acp.rs | 8 ++++---- crates/goose-cli/src/commands/configure.rs | 2 +- crates/goose-cli/src/commands/web.rs | 2 +- crates/goose-cli/src/session/builder.rs | 4 ++-- crates/goose-cli/src/session/mod.rs | 6 +++--- crates/goose-server/src/routes/agent.rs | 2 +- crates/goose/examples/agent.rs | 2 +- crates/goose/src/agents/agent.rs | 6 ++++-- crates/goose/src/agents/extension_manager.rs | 10 ++++++---- .../goose/src/agents/extension_manager_extension.rs | 12 +++++++++++- crates/goose/src/agents/reply_parts.rs | 2 +- crates/goose/src/agents/subagent_handler.rs | 2 +- crates/goose/src/scheduler.rs | 2 +- crates/goose/tests/agent.rs | 2 +- crates/goose/tests/mcp_integration_test.rs | 2 +- 15 files changed, 39 insertions(+), 25 deletions(-) diff --git a/crates/goose-cli/src/commands/acp.rs b/crates/goose-cli/src/commands/acp.rs index 867dd9229aed..cb963f109734 100644 --- a/crates/goose-cli/src/commands/acp.rs +++ b/crates/goose-cli/src/commands/acp.rs @@ -241,7 +241,7 @@ fn format_tool_name(tool_name: &str) -> String { } async fn add_builtins(agent: &Agent, builtins: Vec) { - let working_dir = std::env::current_dir().ok(); + let working_dir = std::env::current_dir().unwrap_or_default(); for builtin in builtins { let config = if PLATFORM_EXTENSIONS.contains_key(builtin.as_str()) { ExtensionConfig::Platform { @@ -319,7 +319,7 @@ impl GooseAcpAgent { let mut set = JoinSet::new(); let mut waiting_on = HashSet::new(); - let working_dir = std::env::current_dir().ok(); + let working_dir = std::env::current_dir().unwrap_or_default(); for extension in extensions_to_run { waiting_on.insert(extension.name()); @@ -713,7 +713,7 @@ impl GooseAcpAgent { debug!(?args, "new session request"); let goose_session = SessionManager::create_session( - std::env::current_dir().unwrap_or_default(), + args.cwd.clone(), "ACP Session".to_string(), // just an initial name - may be replaced by maybe_update_name SessionType::User, ) @@ -735,7 +735,7 @@ impl GooseAcpAgent { sessions.insert(goose_session.id.clone(), session); // Add MCP servers specified in the session request - let working_dir = std::env::current_dir().ok(); + let working_dir = args.cwd; for mcp_server in args.mcp_servers { let config = match mcp_server_to_extension_config(mcp_server) { Ok(c) => c, diff --git a/crates/goose-cli/src/commands/configure.rs b/crates/goose-cli/src/commands/configure.rs index d7d0d42f8b11..c7dc6486e7ff 100644 --- a/crates/goose-cli/src/commands/configure.rs +++ b/crates/goose-cli/src/commands/configure.rs @@ -1305,7 +1305,7 @@ pub async fn configure_tool_permissions_dialog() -> anyhow::Result<()> { let new_provider = create(&provider_name, model_config).await?; agent.update_provider(new_provider, &session.id).await?; if let Some(config) = get_extension_by_name(&selected_extension_name) { - let working_dir = std::env::current_dir().ok(); + let working_dir = std::env::current_dir().unwrap_or_default(); agent .add_extension(config.clone(), working_dir) .await diff --git a/crates/goose-cli/src/commands/web.rs b/crates/goose-cli/src/commands/web.rs index aa019c99cfff..95b57b38a44a 100644 --- a/crates/goose-cli/src/commands/web.rs +++ b/crates/goose-cli/src/commands/web.rs @@ -165,7 +165,7 @@ pub async fn handle_web( agent.update_provider(provider, &init_session.id).await?; let enabled_configs = goose::config::get_enabled_extensions(); - let working_dir = std::env::current_dir().ok(); + let working_dir = std::env::current_dir().unwrap_or_default(); for config in enabled_configs { if let Err(e) = agent .add_extension(config.clone(), working_dir.clone()) diff --git a/crates/goose-cli/src/session/builder.rs b/crates/goose-cli/src/session/builder.rs index 8baf086f888d..608046001480 100644 --- a/crates/goose-cli/src/session/builder.rs +++ b/crates/goose-cli/src/session/builder.rs @@ -158,7 +158,7 @@ async fn offer_extension_debugging_help( // Add the developer extension if available to help with debugging let extensions = get_all_extensions(); - let working_dir = std::env::current_dir().ok(); + let working_dir = std::env::current_dir().unwrap_or_default(); for ext_wrapper in extensions { if ext_wrapper.enabled && ext_wrapper.config.name() == "developer" { if let Err(e) = debug_agent @@ -457,7 +457,7 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession { for extension in extensions_to_run { waiting_on.insert(extension.name()); let agent_ptr = agent_ptr.clone(); - let wd = Some(working_dir.clone()); + let wd = working_dir.clone(); set.spawn(async move { ( extension.name(), diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index 188674e0192e..2035b9e0338f 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -290,7 +290,7 @@ impl CliSession { available_tools: Vec::new(), }; - let working_dir = std::env::current_dir().ok(); + let working_dir = std::env::current_dir().unwrap_or_default(); self.agent .add_extension(config, working_dir) .await @@ -320,7 +320,7 @@ impl CliSession { available_tools: Vec::new(), }; - let working_dir = std::env::current_dir().ok(); + let working_dir = std::env::current_dir().unwrap_or_default(); self.agent .add_extension(config, working_dir) .await @@ -337,7 +337,7 @@ impl CliSession { /// # Arguments /// * `builtin_name` - Name of the builtin extension(s), comma separated pub async fn add_builtin(&mut self, builtin_name: String) -> Result<()> { - let working_dir = std::env::current_dir().ok(); + let working_dir = std::env::current_dir().unwrap_or_default(); for name in builtin_name.split(',') { let extension_name = name.trim(); diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs index 513a160fe61b..5d3421d6373d 100644 --- a/crates/goose-server/src/routes/agent.rs +++ b/crates/goose-server/src/routes/agent.rs @@ -574,7 +574,7 @@ async fn agent_add_extension( let agent = state.get_agent(request.session_id.clone()).await?; agent - .add_extension(request.config, Some(session.working_dir)) + .add_extension(request.config, session.working_dir) .await .map_err(|e| { goose::posthog::emit_error( diff --git a/crates/goose/examples/agent.rs b/crates/goose/examples/agent.rs index 4816f516e724..481da4572acb 100644 --- a/crates/goose/examples/agent.rs +++ b/crates/goose/examples/agent.rs @@ -33,7 +33,7 @@ async fn main() -> anyhow::Result<()> { DEFAULT_EXTENSION_TIMEOUT, ) .with_args(vec!["mcp", "developer"]); - let working_dir = std::env::current_dir().ok(); + let working_dir = std::env::current_dir().unwrap_or_default(); agent.add_extension(config, working_dir).await?; println!("Extensions:"); diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index ca75b2a4ad38..74b1b32198b5 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -616,7 +616,7 @@ impl Agent { .map(|config| { let config_clone = config.clone(); let agent_ref = self.clone(); - let wd = Some(working_dir.clone()); + let wd = working_dir.clone(); async move { let name = config_clone.name().to_string(); @@ -658,10 +658,12 @@ impl Agent { futures::future::join_all(extension_futures).await } + /// Add an extension to the agent. + /// TODO: working_dir should be looked up from the session instead of passed explicitly pub async fn add_extension( &self, extension: ExtensionConfig, - working_dir: Option, + working_dir: std::path::PathBuf, ) -> ExtensionResult<()> { match &extension { ExtensionConfig::Frontend { diff --git a/crates/goose/src/agents/extension_manager.rs b/crates/goose/src/agents/extension_manager.rs index 7fd61278c2a1..1a2be9e54502 100644 --- a/crates/goose/src/agents/extension_manager.rs +++ b/crates/goose/src/agents/extension_manager.rs @@ -477,10 +477,12 @@ impl ExtensionManager { .any(|ext| ext.supports_resources()) } + /// Add an extension to the manager. + /// TODO: working_dir should be looked up from the session instead of passed explicitly pub async fn add_extension( &self, config: ExtensionConfig, - working_dir: Option, + working_dir: PathBuf, ) -> ExtensionResult<()> { let config_name = config.key().to_string(); let sanitized_name = normalize(&config_name); @@ -539,7 +541,7 @@ impl ExtensionManager { command, timeout, self.provider.clone(), - working_dir.as_ref(), + Some(&working_dir), ) .await?; Box::new(client) @@ -567,7 +569,7 @@ impl ExtensionManager { command, timeout, self.provider.clone(), - working_dir.as_ref(), + Some(&working_dir), ) .await?; Box::new(client) @@ -606,7 +608,7 @@ impl ExtensionManager { command, timeout, self.provider.clone(), - working_dir.as_ref(), + Some(&working_dir), ) .await?; diff --git a/crates/goose/src/agents/extension_manager_extension.rs b/crates/goose/src/agents/extension_manager_extension.rs index b90641ec83a2..88e72cc4006e 100644 --- a/crates/goose/src/agents/extension_manager_extension.rs +++ b/crates/goose/src/agents/extension_manager_extension.rs @@ -207,8 +207,18 @@ impl ExtensionManagerClient { } }; + // Get working_dir from session if available, otherwise use current directory + let working_dir = if let Some(session_id) = &self.context.session_id { + crate::session::SessionManager::get_session(session_id, false) + .await + .map(|s| s.working_dir) + .unwrap_or_else(|_| std::env::current_dir().unwrap_or_default()) + } else { + std::env::current_dir().unwrap_or_default() + }; + extension_manager - .add_extension(config, None) + .add_extension(config, working_dir) .await .map(|_| { vec![Content::text(format!( diff --git a/crates/goose/src/agents/reply_parts.rs b/crates/goose/src/agents/reply_parts.rs index 22bd307fde76..4a455d5f8798 100644 --- a/crates/goose/src/agents/reply_parts.rs +++ b/crates/goose/src/agents/reply_parts.rs @@ -475,7 +475,7 @@ mod tests { bundled: None, available_tools: vec![], }, - None, + std::path::PathBuf::default(), ) .await .unwrap(); diff --git a/crates/goose/src/agents/subagent_handler.rs b/crates/goose/src/agents/subagent_handler.rs index c91abc8231bf..625b5824d0cc 100644 --- a/crates/goose/src/agents/subagent_handler.rs +++ b/crates/goose/src/agents/subagent_handler.rs @@ -140,7 +140,7 @@ fn get_agent_messages( let working_dir = task_config.parent_working_dir.clone(); for extension in task_config.extensions { if let Err(e) = agent - .add_extension(extension.clone(), Some(working_dir.clone())) + .add_extension(extension.clone(), working_dir.clone()) .await { debug!( diff --git a/crates/goose/src/scheduler.rs b/crates/goose/src/scheduler.rs index 9cfaa610ee6c..322bbf424ebb 100644 --- a/crates/goose/src/scheduler.rs +++ b/crates/goose/src/scheduler.rs @@ -735,7 +735,7 @@ async fn execute_job( let agent_provider = create(&provider_name, model_config).await?; if let Some(ref extensions) = recipe.extensions { - let working_dir = std::env::current_dir().ok(); + let working_dir = std::env::current_dir().unwrap_or_default(); for ext in extensions { agent .add_extension(ext.clone(), working_dir.clone()) diff --git a/crates/goose/tests/agent.rs b/crates/goose/tests/agent.rs index 1fb610a25365..3f96294d9fe0 100644 --- a/crates/goose/tests/agent.rs +++ b/crates/goose/tests/agent.rs @@ -487,7 +487,7 @@ mod tests { }; agent - .add_extension(ext_config, None) + .add_extension(ext_config, std::path::PathBuf::default()) .await .expect("Failed to add extension manager"); agent diff --git a/crates/goose/tests/mcp_integration_test.rs b/crates/goose/tests/mcp_integration_test.rs index aae1dbb1b85a..80486d8200d5 100644 --- a/crates/goose/tests/mcp_integration_test.rs +++ b/crates/goose/tests/mcp_integration_test.rs @@ -208,7 +208,7 @@ async fn test_replayed_session( #[allow(clippy::redundant_closure_call)] let result = (async || -> Result<(), Box> { extension_manager - .add_extension(extension_config, None) + .add_extension(extension_config, std::path::PathBuf::default()) .await?; let mut results = Vec::new(); for tool_call in tool_calls { From b3bb4061cd285c97f9ebfe888a527ea64cc0f71c Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Thu, 8 Jan 2026 11:48:14 -0800 Subject: [PATCH 30/36] get working dir from session --- crates/goose-cli/src/commands/acp.rs | 10 ++---- crates/goose-cli/src/commands/configure.rs | 3 +- crates/goose-cli/src/commands/web.rs | 6 +--- crates/goose-cli/src/session/builder.rs | 11 ++---- crates/goose-cli/src/session/mod.rs | 9 ++--- crates/goose-server/src/routes/agent.rs | 27 ++++---------- crates/goose/examples/agent.rs | 3 +- crates/goose/src/agents/agent.rs | 15 +++----- crates/goose/src/agents/extension_manager.rs | 35 ++++++++++++++----- .../src/agents/extension_manager_extension.rs | 12 +------ crates/goose/src/agents/reply_parts.rs | 19 +++++----- crates/goose/src/agents/subagent_handler.rs | 6 +--- crates/goose/src/scheduler.rs | 5 +-- crates/goose/tests/agent.rs | 2 +- crates/goose/tests/mcp_integration_test.rs | 4 +-- 15 files changed, 61 insertions(+), 106 deletions(-) diff --git a/crates/goose-cli/src/commands/acp.rs b/crates/goose-cli/src/commands/acp.rs index cb963f109734..eee282083574 100644 --- a/crates/goose-cli/src/commands/acp.rs +++ b/crates/goose-cli/src/commands/acp.rs @@ -241,7 +241,6 @@ fn format_tool_name(tool_name: &str) -> String { } async fn add_builtins(agent: &Agent, builtins: Vec) { - let working_dir = std::env::current_dir().unwrap_or_default(); for builtin in builtins { let config = if PLATFORM_EXTENSIONS.contains_key(builtin.as_str()) { ExtensionConfig::Platform { @@ -260,7 +259,7 @@ async fn add_builtins(agent: &Agent, builtins: Vec) { available_tools: Vec::new(), } }; - match agent.add_extension(config, working_dir.clone()).await { + match agent.add_extension(config).await { Ok(_) => info!(extension = %builtin, "builtin extension loaded"), Err(e) => warn!(extension = %builtin, error = %e, "builtin extension load failed"), } @@ -319,16 +318,14 @@ impl GooseAcpAgent { let mut set = JoinSet::new(); let mut waiting_on = HashSet::new(); - let working_dir = std::env::current_dir().unwrap_or_default(); for extension in extensions_to_run { waiting_on.insert(extension.name()); let agent_ptr_clone = agent_ptr.clone(); - let wd = working_dir.clone(); set.spawn(async move { ( extension.name(), - agent_ptr_clone.add_extension(extension.clone(), wd).await, + agent_ptr_clone.add_extension(extension.clone()).await, ) }); } @@ -735,7 +732,6 @@ impl GooseAcpAgent { sessions.insert(goose_session.id.clone(), session); // Add MCP servers specified in the session request - let working_dir = args.cwd; for mcp_server in args.mcp_servers { let config = match mcp_server_to_extension_config(mcp_server) { Ok(c) => c, @@ -744,7 +740,7 @@ impl GooseAcpAgent { } }; let name = config.name().to_string(); - if let Err(e) = self.agent.add_extension(config, working_dir.clone()).await { + if let Err(e) = self.agent.add_extension(config).await { return Err(sacp::Error::new( sacp::ErrorCode::InternalError.into(), format!("Failed to add MCP server '{}': {}", name, e), diff --git a/crates/goose-cli/src/commands/configure.rs b/crates/goose-cli/src/commands/configure.rs index c7dc6486e7ff..cf26f3acb6ab 100644 --- a/crates/goose-cli/src/commands/configure.rs +++ b/crates/goose-cli/src/commands/configure.rs @@ -1305,9 +1305,8 @@ pub async fn configure_tool_permissions_dialog() -> anyhow::Result<()> { let new_provider = create(&provider_name, model_config).await?; agent.update_provider(new_provider, &session.id).await?; if let Some(config) = get_extension_by_name(&selected_extension_name) { - let working_dir = std::env::current_dir().unwrap_or_default(); agent - .add_extension(config.clone(), working_dir) + .add_extension(config.clone()) .await .unwrap_or_else(|_| { println!( diff --git a/crates/goose-cli/src/commands/web.rs b/crates/goose-cli/src/commands/web.rs index 95b57b38a44a..c6aa47f6b82e 100644 --- a/crates/goose-cli/src/commands/web.rs +++ b/crates/goose-cli/src/commands/web.rs @@ -165,12 +165,8 @@ pub async fn handle_web( agent.update_provider(provider, &init_session.id).await?; let enabled_configs = goose::config::get_enabled_extensions(); - let working_dir = std::env::current_dir().unwrap_or_default(); for config in enabled_configs { - if let Err(e) = agent - .add_extension(config.clone(), working_dir.clone()) - .await - { + if let Err(e) = agent.add_extension(config.clone()).await { eprintln!("Warning: Failed to load extension {}: {}", config.name(), e); } } diff --git a/crates/goose-cli/src/session/builder.rs b/crates/goose-cli/src/session/builder.rs index 608046001480..6ab2255d07b8 100644 --- a/crates/goose-cli/src/session/builder.rs +++ b/crates/goose-cli/src/session/builder.rs @@ -158,13 +158,9 @@ async fn offer_extension_debugging_help( // Add the developer extension if available to help with debugging let extensions = get_all_extensions(); - let working_dir = std::env::current_dir().unwrap_or_default(); for ext_wrapper in extensions { if ext_wrapper.enabled && ext_wrapper.config.name() == "developer" { - if let Err(e) = debug_agent - .add_extension(ext_wrapper.config, working_dir.clone()) - .await - { + if let Err(e) = debug_agent.add_extension(ext_wrapper.config).await { // If we can't add developer extension, continue without it eprintln!( "Note: Could not load developer extension for debugging: {}", @@ -423,8 +419,6 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession { // Setup extensions for the agent // Extensions need to be added after the session is created because we change directory when resuming a session - let working_dir = std::env::current_dir().expect("Could not get working directory"); - for warning in goose::config::get_warnings() { eprintln!("{}", style(format!("Warning: {}", warning)).yellow()); } @@ -457,11 +451,10 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession { for extension in extensions_to_run { waiting_on.insert(extension.name()); let agent_ptr = agent_ptr.clone(); - let wd = working_dir.clone(); set.spawn(async move { ( extension.name(), - agent_ptr.add_extension(extension.clone(), wd).await, + agent_ptr.add_extension(extension.clone()).await, ) }); } diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index 2035b9e0338f..8f63894a104c 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -290,9 +290,8 @@ impl CliSession { available_tools: Vec::new(), }; - let working_dir = std::env::current_dir().unwrap_or_default(); self.agent - .add_extension(config, working_dir) + .add_extension(config) .await .map_err(|e| anyhow::anyhow!("Failed to start extension: {}", e))?; @@ -320,9 +319,8 @@ impl CliSession { available_tools: Vec::new(), }; - let working_dir = std::env::current_dir().unwrap_or_default(); self.agent - .add_extension(config, working_dir) + .add_extension(config) .await .map_err(|e| anyhow::anyhow!("Failed to start extension: {}", e))?; @@ -337,7 +335,6 @@ impl CliSession { /// # Arguments /// * `builtin_name` - Name of the builtin extension(s), comma separated pub async fn add_builtin(&mut self, builtin_name: String) -> Result<()> { - let working_dir = std::env::current_dir().unwrap_or_default(); for name in builtin_name.split(',') { let extension_name = name.trim(); @@ -359,7 +356,7 @@ impl CliSession { } }; self.agent - .add_extension(config, working_dir.clone()) + .add_extension(config) .await .map_err(|e| anyhow::anyhow!("Failed to start builtin extension: {}", e))?; } diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs index 5d3421d6373d..e2e1f4eb5e89 100644 --- a/crates/goose-server/src/routes/agent.rs +++ b/crates/goose-server/src/routes/agent.rs @@ -560,29 +560,16 @@ async fn agent_add_extension( State(state): State>, Json(request): Json, ) -> Result { - let session = SessionManager::get_session(&request.session_id, false) - .await - .map_err(|err| { - error!("Failed to get session for add_extension: {}", err); - ErrorResponse { - message: format!("Failed to get session: {}", err), - status: StatusCode::NOT_FOUND, - } - })?; - let extension_name = request.config.name(); let agent = state.get_agent(request.session_id.clone()).await?; - agent - .add_extension(request.config, session.working_dir) - .await - .map_err(|e| { - goose::posthog::emit_error( - "extension_add_failed", - &format!("{}: {}", extension_name, e), - ); - ErrorResponse::internal(format!("Failed to add extension: {}", e)) - })?; + agent.add_extension(request.config).await.map_err(|e| { + goose::posthog::emit_error( + "extension_add_failed", + &format!("{}: {}", extension_name, e), + ); + ErrorResponse::internal(format!("Failed to add extension: {}", e)) + })?; agent .persist_extension_state(&request.session_id) diff --git a/crates/goose/examples/agent.rs b/crates/goose/examples/agent.rs index 481da4572acb..4e4bb5795903 100644 --- a/crates/goose/examples/agent.rs +++ b/crates/goose/examples/agent.rs @@ -33,8 +33,7 @@ async fn main() -> anyhow::Result<()> { DEFAULT_EXTENSION_TIMEOUT, ) .with_args(vec!["mcp", "developer"]); - let working_dir = std::env::current_dir().unwrap_or_default(); - agent.add_extension(config, working_dir).await?; + agent.add_extension(config).await?; println!("Extensions:"); for extension in agent.list_extensions().await { diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 74b1b32198b5..5681954f26a6 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -599,8 +599,6 @@ impl Agent { self: &Arc, session: &Session, ) -> Vec { - let working_dir = session.working_dir.clone(); - // Try to load session-specific extensions first, fall back to global config let session_extensions = EnabledExtensionsState::from_extension_data(&session.extension_data); @@ -616,7 +614,6 @@ impl Agent { .map(|config| { let config_clone = config.clone(); let agent_ref = self.clone(); - let wd = working_dir.clone(); async move { let name = config_clone.name().to_string(); @@ -635,7 +632,7 @@ impl Agent { }; } - match agent_ref.add_extension(config_clone, wd).await { + match agent_ref.add_extension(config_clone).await { Ok(_) => ExtensionLoadResult { name, success: true, @@ -659,12 +656,8 @@ impl Agent { } /// Add an extension to the agent. - /// TODO: working_dir should be looked up from the session instead of passed explicitly - pub async fn add_extension( - &self, - extension: ExtensionConfig, - working_dir: std::path::PathBuf, - ) -> ExtensionResult<()> { + /// The working_dir is resolved from the session if available, otherwise falls back to current_dir(). + pub async fn add_extension(&self, extension: ExtensionConfig) -> ExtensionResult<()> { match &extension { ExtensionConfig::Frontend { tools, @@ -693,7 +686,7 @@ impl Agent { } _ => { self.extension_manager - .add_extension(extension.clone(), working_dir) + .add_extension(extension.clone()) .await?; } } diff --git a/crates/goose/src/agents/extension_manager.rs b/crates/goose/src/agents/extension_manager.rs index 1a2be9e54502..222c25c8e993 100644 --- a/crates/goose/src/agents/extension_manager.rs +++ b/crates/goose/src/agents/extension_manager.rs @@ -469,6 +469,22 @@ impl ExtensionManager { self.context.lock().await.clone() } + /// Resolve the working directory for an extension. + /// Priority: session working_dir > current_dir + async fn resolve_working_dir(&self) -> PathBuf { + // Try to get working_dir from session via context + if let Some(ref session_id) = self.context.lock().await.session_id { + if let Ok(session) = + crate::session::SessionManager::get_session(session_id, false).await + { + return session.working_dir; + } + } + + // Fall back to current_dir + std::env::current_dir().unwrap_or_default() + } + pub async fn supports_resources(&self) -> bool { self.extensions .lock() @@ -478,18 +494,19 @@ impl ExtensionManager { } /// Add an extension to the manager. - /// TODO: working_dir should be looked up from the session instead of passed explicitly - pub async fn add_extension( - &self, - config: ExtensionConfig, - working_dir: PathBuf, - ) -> ExtensionResult<()> { + /// The working_dir is resolved from the session (via context's session_id) if available, + /// otherwise falls back to current_dir(). + pub async fn add_extension(&self, config: ExtensionConfig) -> ExtensionResult<()> { let config_name = config.key().to_string(); let sanitized_name = normalize(&config_name); if self.extensions.lock().await.contains_key(&sanitized_name) { return Ok(()); } + + // Resolve working_dir: session > current_dir + let effective_working_dir = self.resolve_working_dir().await; + let mut temp_dir = None; let client: Box = match &config { @@ -541,7 +558,7 @@ impl ExtensionManager { command, timeout, self.provider.clone(), - Some(&working_dir), + Some(&effective_working_dir), ) .await?; Box::new(client) @@ -569,7 +586,7 @@ impl ExtensionManager { command, timeout, self.provider.clone(), - Some(&working_dir), + Some(&effective_working_dir), ) .await?; Box::new(client) @@ -608,7 +625,7 @@ impl ExtensionManager { command, timeout, self.provider.clone(), - Some(&working_dir), + Some(&effective_working_dir), ) .await?; diff --git a/crates/goose/src/agents/extension_manager_extension.rs b/crates/goose/src/agents/extension_manager_extension.rs index 88e72cc4006e..49e09ed066aa 100644 --- a/crates/goose/src/agents/extension_manager_extension.rs +++ b/crates/goose/src/agents/extension_manager_extension.rs @@ -207,18 +207,8 @@ impl ExtensionManagerClient { } }; - // Get working_dir from session if available, otherwise use current directory - let working_dir = if let Some(session_id) = &self.context.session_id { - crate::session::SessionManager::get_session(session_id, false) - .await - .map(|s| s.working_dir) - .unwrap_or_else(|_| std::env::current_dir().unwrap_or_default()) - } else { - std::env::current_dir().unwrap_or_default() - }; - extension_manager - .add_extension(config, working_dir) + .add_extension(config) .await .map(|_| { vec![Content::text(format!( diff --git a/crates/goose/src/agents/reply_parts.rs b/crates/goose/src/agents/reply_parts.rs index 4a455d5f8798..e98bcf145c2f 100644 --- a/crates/goose/src/agents/reply_parts.rs +++ b/crates/goose/src/agents/reply_parts.rs @@ -466,17 +466,14 @@ mod tests { ]; agent - .add_extension( - crate::agents::extension::ExtensionConfig::Frontend { - name: "frontend".to_string(), - description: "desc".to_string(), - tools: frontend_tools, - instructions: None, - bundled: None, - available_tools: vec![], - }, - std::path::PathBuf::default(), - ) + .add_extension(crate::agents::extension::ExtensionConfig::Frontend { + name: "frontend".to_string(), + description: "desc".to_string(), + tools: frontend_tools, + instructions: None, + bundled: None, + available_tools: vec![], + }) .await .unwrap(); diff --git a/crates/goose/src/agents/subagent_handler.rs b/crates/goose/src/agents/subagent_handler.rs index 625b5824d0cc..b54d747db199 100644 --- a/crates/goose/src/agents/subagent_handler.rs +++ b/crates/goose/src/agents/subagent_handler.rs @@ -137,12 +137,8 @@ fn get_agent_messages( .await .map_err(|e| anyhow!("Failed to set provider on sub agent: {}", e))?; - let working_dir = task_config.parent_working_dir.clone(); for extension in task_config.extensions { - if let Err(e) = agent - .add_extension(extension.clone(), working_dir.clone()) - .await - { + if let Err(e) = agent.add_extension(extension.clone()).await { debug!( "Failed to add extension '{}' to subagent: {}", extension.name(), diff --git a/crates/goose/src/scheduler.rs b/crates/goose/src/scheduler.rs index 322bbf424ebb..2a21a9484da2 100644 --- a/crates/goose/src/scheduler.rs +++ b/crates/goose/src/scheduler.rs @@ -735,11 +735,8 @@ async fn execute_job( let agent_provider = create(&provider_name, model_config).await?; if let Some(ref extensions) = recipe.extensions { - let working_dir = std::env::current_dir().unwrap_or_default(); for ext in extensions { - agent - .add_extension(ext.clone(), working_dir.clone()) - .await?; + agent.add_extension(ext.clone()).await?; } } diff --git a/crates/goose/tests/agent.rs b/crates/goose/tests/agent.rs index 3f96294d9fe0..967f56d51e02 100644 --- a/crates/goose/tests/agent.rs +++ b/crates/goose/tests/agent.rs @@ -487,7 +487,7 @@ mod tests { }; agent - .add_extension(ext_config, std::path::PathBuf::default()) + .add_extension(ext_config) .await .expect("Failed to add extension manager"); agent diff --git a/crates/goose/tests/mcp_integration_test.rs b/crates/goose/tests/mcp_integration_test.rs index 80486d8200d5..08849135815f 100644 --- a/crates/goose/tests/mcp_integration_test.rs +++ b/crates/goose/tests/mcp_integration_test.rs @@ -207,9 +207,7 @@ async fn test_replayed_session( #[allow(clippy::redundant_closure_call)] let result = (async || -> Result<(), Box> { - extension_manager - .add_extension(extension_config, std::path::PathBuf::default()) - .await?; + extension_manager.add_extension(extension_config).await?; let mut results = Vec::new(); for tool_call in tool_calls { let tool_call = CallToolRequestParam { From 6f83b36c36422b5ed8b5cb080087b8dc6a9c8829 Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Thu, 8 Jan 2026 12:23:08 -0800 Subject: [PATCH 31/36] perist extension state in add_extension --- crates/goose-server/src/routes/agent.rs | 10 ---------- crates/goose/src/agents/agent.rs | 7 ++++++- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs index e2e1f4eb5e89..b4832a127e4a 100644 --- a/crates/goose-server/src/routes/agent.rs +++ b/crates/goose-server/src/routes/agent.rs @@ -571,16 +571,6 @@ async fn agent_add_extension( ErrorResponse::internal(format!("Failed to add extension: {}", e)) })?; - agent - .persist_extension_state(&request.session_id) - .await - .map_err(|e| { - error!("Failed to persist extension state: {}", e); - ErrorResponse { - message: format!("Failed to persist extension state: {}", e), - status: StatusCode::INTERNAL_SERVER_ERROR, - } - })?; Ok(StatusCode::OK) } diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 5681954f26a6..60107d9f54c7 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -656,7 +656,6 @@ impl Agent { } /// Add an extension to the agent. - /// The working_dir is resolved from the session if available, otherwise falls back to current_dir(). pub async fn add_extension(&self, extension: ExtensionConfig) -> ExtensionResult<()> { match &extension { ExtensionConfig::Frontend { @@ -691,6 +690,12 @@ impl Agent { } } + if let Some(ref session_id) = self.extension_manager.get_context().await.session_id { + if let Err(e) = self.persist_extension_state(session_id).await { + warn!("Failed to persist extension state: {}", e); + } + } + Ok(()) } From f2bc9d90fc8c2d3a781b645a1a020ca2f05542d3 Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Thu, 8 Jan 2026 12:30:10 -0800 Subject: [PATCH 32/36] dont fallback to global extensions if not found --- crates/goose/src/agents/agent.rs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 60107d9f54c7..08c12a9ecfce 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -599,15 +599,18 @@ impl Agent { self: &Arc, session: &Session, ) -> Vec { - // Try to load session-specific extensions first, fall back to global config let session_extensions = EnabledExtensionsState::from_extension_data(&session.extension_data); - let enabled_configs = session_extensions - .map(|state| state.extensions) - .unwrap_or_else(|| { - tracing::info!("load_extensions_from_session: falling back to global config"); - get_enabled_extensions() - }); + let enabled_configs = match session_extensions { + Some(state) => state.extensions, + None => { + tracing::warn!( + "No extensions found in session {}. This is unexpected.", + session.id + ); + return vec![]; + } + }; let extension_futures = enabled_configs .into_iter() From 694a743363456b1320d3f96631cb922500836a8b Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Thu, 8 Jan 2026 13:50:55 -0800 Subject: [PATCH 33/36] cleanup --- crates/goose-cli/src/commands/acp.rs | 2 +- crates/goose-cli/src/session/builder.rs | 1 + crates/goose/src/agents/agent.rs | 1 - crates/goose/src/agents/extension_manager.rs | 3 --- 4 files changed, 2 insertions(+), 5 deletions(-) diff --git a/crates/goose-cli/src/commands/acp.rs b/crates/goose-cli/src/commands/acp.rs index eee282083574..77d9308a21e6 100644 --- a/crates/goose-cli/src/commands/acp.rs +++ b/crates/goose-cli/src/commands/acp.rs @@ -710,7 +710,7 @@ impl GooseAcpAgent { debug!(?args, "new session request"); let goose_session = SessionManager::create_session( - args.cwd.clone(), + std::env::current_dir().unwrap_or_default(), "ACP Session".to_string(), // just an initial name - may be replaced by maybe_update_name SessionType::User, ) diff --git a/crates/goose-cli/src/session/builder.rs b/crates/goose-cli/src/session/builder.rs index 6ab2255d07b8..11e15f7717b0 100644 --- a/crates/goose-cli/src/session/builder.rs +++ b/crates/goose-cli/src/session/builder.rs @@ -419,6 +419,7 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession { // Setup extensions for the agent // Extensions need to be added after the session is created because we change directory when resuming a session + for warning in goose::config::get_warnings() { eprintln!("{}", style(format!("Warning: {}", warning)).yellow()); } diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 08c12a9ecfce..260909ce585f 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -658,7 +658,6 @@ impl Agent { futures::future::join_all(extension_futures).await } - /// Add an extension to the agent. pub async fn add_extension(&self, extension: ExtensionConfig) -> ExtensionResult<()> { match &extension { ExtensionConfig::Frontend { diff --git a/crates/goose/src/agents/extension_manager.rs b/crates/goose/src/agents/extension_manager.rs index 222c25c8e993..bc071ecfa3c0 100644 --- a/crates/goose/src/agents/extension_manager.rs +++ b/crates/goose/src/agents/extension_manager.rs @@ -493,9 +493,6 @@ impl ExtensionManager { .any(|ext| ext.supports_resources()) } - /// Add an extension to the manager. - /// The working_dir is resolved from the session (via context's session_id) if available, - /// otherwise falls back to current_dir(). pub async fn add_extension(&self, config: ExtensionConfig) -> ExtensionResult<()> { let config_name = config.key().to_string(); let sanitized_name = normalize(&config_name); From 0d32df614a1199552d086db2b2982607315a35a8 Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Thu, 8 Jan 2026 15:09:17 -0800 Subject: [PATCH 34/36] add listener to refresh extension count --- .../BottomMenuExtensionSelection.tsx | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx b/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx index 730c74a8b2bf..4cb334d3bdb7 100644 --- a/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx +++ b/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx @@ -26,10 +26,27 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS const [isTransitioning, setIsTransitioning] = useState(false); const [pendingSort, setPendingSort] = useState(false); const [togglingExtension, setTogglingExtension] = useState(null); + const [refreshTrigger, setRefreshTrigger] = useState(0); const sortTimeoutRef = useRef | null>(null); const { extensionsList: allExtensions } = useConfig(); const isHubView = !sessionId; + useEffect(() => { + const handleSessionLoaded = () => { + setTimeout(() => { + setRefreshTrigger((prev) => prev + 1); + }, 500); + }; + + window.addEventListener('session-created', handleSessionLoaded); + window.addEventListener('message-stream-finished', handleSessionLoaded); + + return () => { + window.removeEventListener('session-created', handleSessionLoaded); + window.removeEventListener('message-stream-finished', handleSessionLoaded); + }; + }, []); + useEffect(() => { return () => { if (sortTimeoutRef.current) { @@ -59,7 +76,7 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS }; fetchExtensions(); - }, [sessionId, isOpen]); + }, [sessionId, isOpen, refreshTrigger]); const handleToggle = useCallback( async (extensionConfig: FixedExtensionEntry) => { From 211a0ca5d0e7a482603de1e3e3fbcf1af9400ff1 Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Thu, 8 Jan 2026 16:20:56 -0800 Subject: [PATCH 35/36] add back selective extension persistence --- crates/goose-server/src/routes/agent.rs | 11 +++++++++++ crates/goose/src/agents/agent.rs | 6 ------ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs index b4832a127e4a..e4c150c3ff3b 100644 --- a/crates/goose-server/src/routes/agent.rs +++ b/crates/goose-server/src/routes/agent.rs @@ -571,6 +571,17 @@ async fn agent_add_extension( ErrorResponse::internal(format!("Failed to add extension: {}", e)) })?; + // Persist here rather than in add_extension to ensure we only save state + // after the extension successfully loads. This prevents failed extensions + // from being persisted as enabled in the session. + agent + .persist_extension_state(&request.session_id) + .await + .map_err(|e| { + error!("Failed to persist extension state: {}", e); + ErrorResponse::internal(format!("Failed to persist extension state: {}", e)) + })?; + Ok(StatusCode::OK) } diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 260909ce585f..e5b6cbe6a44a 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -692,12 +692,6 @@ impl Agent { } } - if let Some(ref session_id) = self.extension_manager.get_context().await.session_id { - if let Err(e) = self.persist_extension_state(session_id).await { - warn!("Failed to persist extension state: {}", e); - } - } - Ok(()) } From 4235f8fbba3fb44bb0eb9200d7078993ff9ca1ff Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Thu, 8 Jan 2026 16:39:16 -0800 Subject: [PATCH 36/36] fix navigating back to pair with valid session --- .../components/GooseSidebar/AppSidebar.tsx | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/ui/desktop/src/components/GooseSidebar/AppSidebar.tsx b/ui/desktop/src/components/GooseSidebar/AppSidebar.tsx index e99bfb0584d9..b91b21adf015 100644 --- a/ui/desktop/src/components/GooseSidebar/AppSidebar.tsx +++ b/ui/desktop/src/components/GooseSidebar/AppSidebar.tsx @@ -1,6 +1,6 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useRef } from 'react'; import { FileText, Clock, Home, Puzzle, History } from 'lucide-react'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useSearchParams } from 'react-router-dom'; import { SidebarContent, SidebarFooter, @@ -96,7 +96,16 @@ const menuItems: NavigationEntry[] = [ const AppSidebar: React.FC = ({ currentPath }) => { const navigate = useNavigate(); + const [searchParams] = useSearchParams(); const chatContext = useChatContext(); + const lastSessionIdRef = useRef(null); + const currentSessionId = currentPath === '/pair' ? searchParams.get('resumeSessionId') : null; + + useEffect(() => { + if (currentSessionId) { + lastSessionIdRef.current = currentSessionId; + } + }, [currentSessionId]); useEffect(() => { const timer = setTimeout(() => { @@ -130,6 +139,17 @@ const AppSidebar: React.FC = ({ currentPath }) => { return currentPath === path; }; + const handleNavigation = (path: string) => { + // For /pair, preserve the current session if one exists + // Priority: current URL param > last known session > context + const sessionId = currentSessionId || lastSessionIdRef.current || chatContext?.chat?.sessionId; + if (path === '/pair' && sessionId && sessionId.length > 0) { + navigate(`/pair?resumeSessionId=${sessionId}`); + } else { + navigate(path); + } + }; + const renderMenuItem = (entry: NavigationEntry, index: number) => { if (entry.type === 'separator') { return ; @@ -144,7 +164,7 @@ const AppSidebar: React.FC = ({ currentPath }) => { navigate(entry.path)} + onClick={() => handleNavigation(entry.path)} isActive={isActivePath(entry.path)} tooltip={entry.tooltip} className="w-full justify-start px-3 rounded-lg h-fit hover:bg-background-medium/50 transition-all duration-200 data-[active=true]:bg-background-medium"