From 94fdcdd07a13a151d5b12cc4aae11873977e75c9 Mon Sep 17 00:00:00 2001 From: Adrian Cole <64215+codefromthecrypt@users.noreply.github.com> Date: Mon, 16 Mar 2026 19:37:21 +0800 Subject: [PATCH] feat: persist GooseMode per-session via session DB (#7854) Signed-off-by: Adrian Cole --- crates/goose-acp/src/server.rs | 29 ++-- crates/goose-acp/tests/common_tests/mod.rs | 1 - crates/goose-acp/tests/provider_test.rs | 1 - crates/goose-acp/tests/server_test.rs | 2 - crates/goose-cli/src/cli.rs | 27 +++- crates/goose-cli/src/commands/configure.rs | 1 + crates/goose-cli/src/commands/term.rs | 2 + .../src/scenario_tests/scenario_runner.rs | 1 + crates/goose-cli/src/session/builder.rs | 22 ++- crates/goose-cli/src/session/mod.rs | 2 +- crates/goose-server/src/openapi.rs | 3 + crates/goose-server/src/routes/agent.rs | 73 ++++++++- crates/goose/examples/agent.rs | 3 +- crates/goose/src/acp/provider.rs | 41 ++--- crates/goose/src/agents/agent.rs | 56 ++++++- .../src/agents/platform_extensions/summon.rs | 20 ++- crates/goose/src/agents/prompt_manager.rs | 14 +- crates/goose/src/agents/reply_parts.rs | 5 + crates/goose/src/execution/manager.rs | 97 +++++++++++- crates/goose/src/gateway/handler.rs | 18 ++- crates/goose/src/model.rs | 16 ++ crates/goose/src/providers/base.rs | 11 +- crates/goose/src/providers/claude_code.rs | 17 +++ crates/goose/src/providers/codex.rs | 59 ++++++-- crates/goose/src/providers/ollama.rs | 11 +- crates/goose/src/scheduler.rs | 1 + crates/goose/src/session/session_manager.rs | 142 ++++++++++++++++-- crates/goose/tests/agent.rs | 3 + crates/goose/tests/compaction.rs | 2 + crates/goose/tests/providers.rs | 24 +++ ui/desktop/openapi.json | 56 +++++++ ui/desktop/src/App.tsx | 6 +- ui/desktop/src/api/index.ts | 4 +- ui/desktop/src/api/sdk.gen.ts | 11 +- ui/desktop/src/api/types.gen.ts | 33 ++++ ui/desktop/src/components/ChatInput.tsx | 2 +- .../BottomMenuModeSelection.test.tsx | 115 ++++++++++++++ .../bottom_menu/BottomMenuModeSelection.tsx | 35 +++-- .../src/components/settings/SettingsView.tsx | 3 +- .../settings/chat/ChatSettingsSection.tsx | 4 +- .../components/settings/mode/ModeSection.tsx | 25 ++- ui/desktop/tests/integration/goosed.test.ts | 102 +++++++++++++ 42 files changed, 953 insertions(+), 147 deletions(-) create mode 100644 ui/desktop/src/components/bottom_menu/BottomMenuModeSelection.test.tsx diff --git a/crates/goose-acp/src/server.rs b/crates/goose-acp/src/server.rs index cb6d4be246..2a93c9122f 100644 --- a/crates/goose-acp/src/server.rs +++ b/crates/goose-acp/src/server.rs @@ -375,12 +375,14 @@ impl GooseAcpAgent { &self, cx: Option<&JrConnectionCx>, session_id: Option<&SessionId>, + goose_mode: Option, ) -> Result> { + let mode = goose_mode.unwrap_or(self.goose_mode); let agent = Agent::with_config(AgentConfig::new( Arc::clone(&self.session_manager), Arc::clone(&self.permission_manager), None, - self.goose_mode, + mode, self.disable_session_naming, GoosePlatform::GooseCli, )); @@ -806,6 +808,7 @@ impl GooseAcpAgent { args.cwd.clone(), "ACP Session".to_string(), SessionType::User, + self.goose_mode, ) .await .map_err(|e| { @@ -815,7 +818,7 @@ impl GooseAcpAgent { let session_id = SessionId::new(goose_session.id.clone()); let agent = self - .create_agent_for_session(Some(cx), Some(&session_id)) + .create_agent_for_session(Some(cx), Some(&session_id), None) .await .map_err(|e| { sacp::Error::internal_error().data(format!("Failed to create agent: {}", e)) @@ -925,10 +928,11 @@ impl GooseAcpAgent { .data(format!("Failed to load session {}: {}", session_id, e)) })?; + let loaded_mode = goose_session.goose_mode; let acp_session_id = SessionId::new(session_id.clone()); let agent = self - .create_agent_for_session(Some(cx), Some(&acp_session_id)) + .create_agent_for_session(Some(cx), Some(&acp_session_id), Some(loaded_mode)) .await .map_err(|e| { sacp::Error::internal_error().data(format!("Failed to create agent: {}", e)) @@ -1014,8 +1018,7 @@ impl GooseAcpAgent { let mut sessions = self.sessions.lock().await; sessions.insert(session_id.clone(), session); - // TODO: read from goose_session.goose_mode after #7603 - let goose_mode = self.goose_mode; + let goose_mode = loaded_mode; info!( session_id = %session_id, @@ -1168,15 +1171,13 @@ impl GooseAcpAgent { sacp::Error::invalid_params().data(format!("Invalid mode: {}", mode_id)) })?; - self.get_session_agent(session_id, None).await?; - - // Reject mode changes until per-session persistence lands (#7603) - if mode != self.goose_mode { - return Err(sacp::Error::invalid_params().data(format!( - "Mode change not supported: session is {}, requested {}", - self.goose_mode, mode_id - ))); - } + let agent = self.get_session_agent(session_id, None).await?; + agent + .update_goose_mode(mode, session_id) + .await + .map_err(|e| { + sacp::Error::internal_error().data(format!("Failed to update mode: {}", e)) + })?; Ok(SetSessionModeResponse::new()) } diff --git a/crates/goose-acp/tests/common_tests/mod.rs b/crates/goose-acp/tests/common_tests/mod.rs index a58855c664..8b62e9550b 100644 --- a/crates/goose-acp/tests/common_tests/mod.rs +++ b/crates/goose-acp/tests/common_tests/mod.rs @@ -753,7 +753,6 @@ macro_rules! tests_mode_set_error { ($conn:ty) => { #[test_case::test_case("not_a_mode", None, sacp::Error::invalid_params().data("Invalid mode: not_a_mode") ; "invalid mode")] #[test_case::test_case("auto", Some("nonexistent-session-id"), sacp::Error::invalid_params().data("Session not found: nonexistent-session-id") ; "session not found")] - #[test_case::test_case("approve", None, sacp::Error::invalid_params().data("Mode change not supported: session is auto, requested approve") ; "mode change rejected")] fn test_mode_set_error( mode_id: &'static str, session_id: Option<&'static str>, diff --git a/crates/goose-acp/tests/provider_test.rs b/crates/goose-acp/tests/provider_test.rs index fea565886d..316f862f70 100644 --- a/crates/goose-acp/tests/provider_test.rs +++ b/crates/goose-acp/tests/provider_test.rs @@ -59,7 +59,6 @@ fn test_load_session_mcp() { } #[test] -#[ignore = "TODO: on_set_mode is a no-op until mode is threaded per-session (#7603)"] fn test_mode_set() { run_test(async { run_mode_set::().await }); } diff --git a/crates/goose-acp/tests/server_test.rs b/crates/goose-acp/tests/server_test.rs index 1a232d75a3..0ac9d168e3 100644 --- a/crates/goose-acp/tests/server_test.rs +++ b/crates/goose-acp/tests/server_test.rs @@ -37,7 +37,6 @@ fn test_initialize_doesnt_hit_provider() { } #[test] -#[ignore = "TODO: on_set_mode is a no-op until mode is threaded per-session (#7603)"] fn test_load_mode() { run_test(async { run_load_mode::().await }); } @@ -53,7 +52,6 @@ fn test_load_session_mcp() { } #[test] -#[ignore = "TODO: on_set_mode is a no-op until mode is threaded per-session (#7603)"] fn test_mode_set() { run_test(async { run_mode_set::().await }); } diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index b6a5072acb..063e73b8f2 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -2,7 +2,7 @@ use anyhow::Result; use clap::{Args, CommandFactory, Parser, Subcommand}; use clap_complete::{generate, Shell as ClapShell}; use goose::builtin_extension::register_builtin_extensions; -use goose::config::Config; +use goose::config::{Config, GooseMode}; use goose::posthog::get_telemetry_choice; use goose::recipe::Recipe; use goose_mcp::mcp_server_runner::{serve, McpCommand}; @@ -356,6 +356,7 @@ async fn get_or_create_session_id( identifier: Option, resume: bool, no_session: bool, + goose_mode: GooseMode, ) -> Result> { if no_session { return Ok(None); @@ -399,6 +400,7 @@ async fn get_or_create_session_id( std::env::current_dir()?, "CLI Session".to_string(), SessionType::User, + goose_mode, ) .await?; return Ok(Some(session.id)); @@ -411,7 +413,12 @@ async fn get_or_create_session_id( let has_user_provided_name = id.name.is_some(); let name = id.name.unwrap_or_else(|| "CLI Session".to_string()); let session = session_manager - .create_session(std::env::current_dir()?, name.clone(), SessionType::User) + .create_session( + std::env::current_dir()?, + name.clone(), + SessionType::User, + goose_mode, + ) .await?; if has_user_provided_name { @@ -1129,7 +1136,8 @@ async fn handle_interactive_session( } } - let mut session_id = get_or_create_session_id(identifier, resume, false).await?; + let goose_mode = Config::global().get_goose_mode().unwrap_or_default(); + let mut session_id = get_or_create_session_id(identifier, resume, false, goose_mode).await?; if fork { if let Some(id) = session_id { @@ -1342,8 +1350,14 @@ async fn handle_run_command( } } - let session_id = - get_or_create_session_id(identifier, run_behavior.resume, run_behavior.no_session).await?; + let goose_mode = Config::global().get_goose_mode().unwrap_or_default(); + let session_id = get_or_create_session_id( + identifier, + run_behavior.resume, + run_behavior.no_session, + goose_mode, + ) + .await?; let mut session = build_session(SessionBuilderConfig { session_id, @@ -1629,7 +1643,8 @@ async fn handle_default_session() -> Result<()> { configure_telemetry_consent_dialog()?; } - let session_id = get_or_create_session_id(None, false, false).await?; + let goose_mode = Config::global().get_goose_mode().unwrap_or_default(); + let session_id = get_or_create_session_id(None, false, false, goose_mode).await?; let mut session = build_session(SessionBuilderConfig { session_id, diff --git a/crates/goose-cli/src/commands/configure.rs b/crates/goose-cli/src/commands/configure.rs index 122b143eb6..0d10c18bb4 100644 --- a/crates/goose-cli/src/commands/configure.rs +++ b/crates/goose-cli/src/commands/configure.rs @@ -1557,6 +1557,7 @@ pub async fn configure_tool_permissions_dialog() -> anyhow::Result<()> { std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")), "Tool Permission Configuration".to_string(), SessionType::Hidden, + agent.config.goose_mode, ) .await?; diff --git a/crates/goose-cli/src/commands/term.rs b/crates/goose-cli/src/commands/term.rs index 19661a31e3..dd539d5f24 100644 --- a/crates/goose-cli/src/commands/term.rs +++ b/crates/goose-cli/src/commands/term.rs @@ -1,5 +1,6 @@ use anyhow::{anyhow, Result}; use chrono; +use goose::config::Config; use goose::conversation::message::{Message, MessageContent, MessageMetadata}; use goose::session::{SessionManager, SessionType}; use rmcp::model::Role; @@ -138,6 +139,7 @@ pub async fn handle_term_init( working_dir, "Goose Term Session".to_string(), SessionType::Terminal, + Config::global().get_goose_mode().unwrap_or_default(), ) .await?; diff --git a/crates/goose-cli/src/scenario_tests/scenario_runner.rs b/crates/goose-cli/src/scenario_tests/scenario_runner.rs index a1472aa0f5..d5b4909626 100644 --- a/crates/goose-cli/src/scenario_tests/scenario_runner.rs +++ b/crates/goose-cli/src/scenario_tests/scenario_runner.rs @@ -242,6 +242,7 @@ where PathBuf::default(), "scenario-runner".to_string(), SessionType::Hidden, + GooseMode::default(), ) .await?; diff --git a/crates/goose-cli/src/session/builder.rs b/crates/goose-cli/src/session/builder.rs index 6b1e6a0b83..9c71d1286a 100644 --- a/crates/goose-cli/src/session/builder.rs +++ b/crates/goose-cli/src/session/builder.rs @@ -5,7 +5,7 @@ use super::CliSession; use console::style; use goose::agents::{Agent, Container, ExtensionError}; use goose::config::resolve_extensions_for_new_session; -use goose::config::{get_all_extensions, Config, ExtensionConfig}; +use goose::config::{get_all_extensions, Config, ExtensionConfig, GooseMode}; use goose::providers::create; use goose::recipe::Recipe; use goose::session::session_manager::SessionType; @@ -201,6 +201,7 @@ async fn offer_extension_debugging_help( std::env::current_dir()?, "CLI Session".to_string(), SessionType::Hidden, + debug_agent.config.goose_mode, ) .await?; @@ -400,11 +401,17 @@ fn resolve_provider_and_model( async fn resolve_session_id( session_config: &SessionBuilderConfig, session_manager: &goose::session::session_manager::SessionManager, + goose_mode: GooseMode, ) -> String { if session_config.no_session { let working_dir = std::env::current_dir().expect("Could not get working directory"); let session = session_manager - .create_session(working_dir, "CLI Session".to_string(), SessionType::Hidden) + .create_session( + working_dir, + "CLI Session".to_string(), + SessionType::Hidden, + goose_mode, + ) .await .expect("Could not create session"); session.id @@ -606,7 +613,8 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession { .apply_recipe_components(recipe.and_then(|r| r.response.clone()), true) .await; - let session_id = resolve_session_id(&session_config, &session_manager).await; + let session_id = + resolve_session_id(&session_config, &session_manager, agent.config.goose_mode).await; if session_config.resume { handle_resumed_session_workdir(&agent, &session_id, session_config.interactive).await; @@ -661,6 +669,14 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession { process::exit(1); }); + agent + .update_goose_mode(agent.config.goose_mode, &session_id) + .await + .unwrap_or_else(|e| { + output::render_error(&format!("Failed to set session mode: {}", e)); + process::exit(1); + }); + if let Some(recipe) = session_config.recipe.clone() { if let Err(e) = session_manager .update(&session_id) diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index 49f3a23f9b..416adec726 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -886,7 +886,7 @@ impl CliSession { self.run_mode = RunMode::Normal; // set goose mode: auto if that isn't already the case let config = Config::global(); - let curr_goose_mode = config.get_goose_mode().unwrap_or(GooseMode::Auto); + let curr_goose_mode = config.get_goose_mode().unwrap_or_default(); if curr_goose_mode != GooseMode::Auto { config.set_goose_mode(GooseMode::Auto).unwrap(); } diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index d5770225e1..431e9bf914 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -432,6 +432,7 @@ derive_utoipa!(Icon as IconSchema); super::routes::agent::agent_add_extension, super::routes::agent::agent_remove_extension, super::routes::agent::update_agent_provider, + super::routes::agent::update_session, super::routes::action_required::confirm_tool_action, super::routes::reply::reply, super::routes::session::list_sessions, @@ -579,6 +580,7 @@ derive_utoipa!(Icon as IconSchema); ModelInfo, ModelConfig, Session, + goose::config::goose_mode::GooseMode, SessionInsights, SessionType, SystemInfo, @@ -625,6 +627,7 @@ derive_utoipa!(Icon as IconSchema); goose::agents::types::RetryConfig, goose::agents::types::SuccessCheck, super::routes::agent::UpdateProviderRequest, + super::routes::agent::UpdateSessionRequest, super::routes::agent::GetToolsQuery, super::routes::agent::ReadResourceRequest, super::routes::agent::ReadResourceResponse, diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs index 0250a1bdef..9c6b2be5fe 100644 --- a/crates/goose-server/src/routes/agent.rs +++ b/crates/goose-server/src/routes/agent.rs @@ -50,6 +50,12 @@ pub struct UpdateProviderRequest { request_params: Option>, } +#[derive(Deserialize, utoipa::ToSchema)] +pub struct UpdateSessionRequest { + session_id: String, + goose_mode: Option, +} + #[derive(Deserialize, utoipa::ToSchema)] pub struct GetToolsQuery { extension_name: Option, @@ -233,9 +239,16 @@ async fn start_agent( let name = "New Chat".to_string(); let manager = state.session_manager(); + let config = Config::global(); + let current_mode = config.get_goose_mode().unwrap_or_default(); let mut session = manager - .create_session(PathBuf::from(&working_dir), name, SessionType::User) + .create_session( + PathBuf::from(&working_dir), + name, + SessionType::User, + current_mode, + ) .await .map_err(|err| { error!("Failed to create session: {}", err); @@ -251,6 +264,7 @@ async fn start_agent( .and_then(|r| r.extensions.as_deref()); let extensions_to_use = resolve_extensions_for_new_session(recipe_extensions, extension_overrides); + 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) { @@ -492,10 +506,9 @@ async fn get_tools( State(state): State>, Query(query): Query, ) -> Result>, StatusCode> { - let config = Config::global(); - let goose_mode = config.get_goose_mode().unwrap_or(GooseMode::Auto); let session_id = query.session_id; let agent = state.get_agent_for_route(session_id.clone()).await?; + let goose_mode = agent.goose_mode().await; let permission_manager = agent.config.permission_manager.clone(); let mut tools: Vec = agent @@ -594,6 +607,59 @@ async fn update_agent_provider( ) })?; + // Propagate session mode to the new provider + let mode = agent.goose_mode().await; + agent + .update_goose_mode(mode, &payload.session_id) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to propagate mode to provider: {}", e), + ) + })?; + + Ok(()) +} + +#[utoipa::path( + post, + path = "/agent/update_session", + request_body = UpdateSessionRequest, + responses( + (status = 200, description = "Session updated"), + (status = 400, description = "Invalid request"), + (status = 500, description = "Internal error") + ) +)] +async fn update_session( + State(state): State>, + Json(payload): Json, +) -> Result<(), (StatusCode, String)> { + let agent = state + .get_agent_for_route(payload.session_id.clone()) + .await + .map_err(|e| (e, "No agent for session id".to_owned()))?; + + if let Some(mode_str) = payload.goose_mode { + let mode: GooseMode = mode_str.parse().map_err(|_| { + ( + StatusCode::BAD_REQUEST, + format!("Invalid mode: {}", mode_str), + ) + })?; + + agent + .update_goose_mode(mode, &payload.session_id) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to update mode: {}", e), + ) + })?; + } + Ok(()) } @@ -1231,6 +1297,7 @@ pub fn routes(state: Arc) -> Router { .route("/agent/export_app/{name}", get(export_app)) .route("/agent/import_app", post(import_app)) .route("/agent/update_provider", post(update_agent_provider)) + .route("/agent/update_session", post(update_session)) .route("/agent/update_from_session", post(update_from_session)) .route("/agent/add_extension", post(agent_add_extension)) .route("/agent/remove_extension", post(agent_remove_extension)) diff --git a/crates/goose/examples/agent.rs b/crates/goose/examples/agent.rs index 34e2a30ae8..c28f1df0f7 100644 --- a/crates/goose/examples/agent.rs +++ b/crates/goose/examples/agent.rs @@ -1,7 +1,7 @@ use dotenvy::dotenv; use futures::StreamExt; use goose::agents::{Agent, AgentEvent, ExtensionConfig, SessionConfig}; -use goose::config::{DEFAULT_EXTENSION_DESCRIPTION, DEFAULT_EXTENSION_TIMEOUT}; +use goose::config::{GooseMode, DEFAULT_EXTENSION_DESCRIPTION, DEFAULT_EXTENSION_TIMEOUT}; use goose::conversation::message::Message; use goose::providers::create_with_named_model; use goose::providers::databricks::DATABRICKS_DEFAULT_MODEL; @@ -24,6 +24,7 @@ async fn main() -> anyhow::Result<()> { PathBuf::default(), "max-turn-test".to_string(), SessionType::Hidden, + GooseMode::default(), ) .await?; diff --git a/crates/goose/src/acp/provider.rs b/crates/goose/src/acp/provider.rs index 44917a1f22..833b2440b0 100644 --- a/crates/goose/src/acp/provider.rs +++ b/crates/goose/src/acp/provider.rs @@ -286,28 +286,33 @@ impl Provider for AcpProvider { } async fn update_mode(&self, session_id: &str, mode: GooseMode) -> Result<(), ProviderError> { - let _acp_session_id = self - .goose_to_acp_id - .lock() + let map = self.goose_to_acp_id.lock().await; + if map.is_empty() { + // Pre-initialization: no ACP session yet, just store the mode. + // The shared Arc> is read at session creation time. + drop(map); + } else if let Some(acp_session_id) = map.get(session_id).map(|r| r.session_id.clone()) { + drop(map); + self.send_untyped( + "session/set_mode", + serde_json::json!({ + "sessionId": acp_session_id, + "modeId": mode.to_string().to_lowercase() + }), + ) .await - .get(session_id) - .map(|r| r.session_id.clone()) - .ok_or_else(|| { - ProviderError::RequestFailed(format!("Session not found: {session_id}")) - })?; - - let current = self - .goose_mode - .lock() - .map_err(|_| ProviderError::RequestFailed("Failed to read mode".into()))?; - - if mode != *current { - // TODO: "session/set_mode" when session-scoped mode lands (#7603) + .map_err(|e| ProviderError::RequestFailed(format!("Failed to set mode: {e}")))?; + } else { return Err(ProviderError::RequestFailed(format!( - "Mode change not supported: session is {}, requested {}", - current, mode + "Session not found: {session_id}" ))); } + + let mut current = self + .goose_mode + .lock() + .map_err(|_| ProviderError::RequestFailed("Failed to update mode".into()))?; + *current = mode; Ok(()) } diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index e35ace9969..9cd398b770 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -137,6 +137,7 @@ impl AgentConfig { pub struct Agent { pub(super) provider: SharedProvider, pub config: AgentConfig, + pub(super) current_goose_mode: Mutex, pub extension_manager: Arc, pub(super) final_output_tool: Arc>>, @@ -203,14 +204,13 @@ where impl Agent { pub fn new() -> Self { + let config = Config::global(); Self::with_config(AgentConfig::new( Arc::new(SessionManager::instance()), PermissionManager::instance(), None, - Config::global().get_goose_mode().unwrap_or(GooseMode::Auto), - Config::global() - .get_goose_disable_session_naming() - .unwrap_or(false), + config.get_goose_mode().unwrap_or_default(), + config.get_goose_disable_session_naming().unwrap_or(false), GoosePlatform::GooseCli, )) } @@ -220,6 +220,7 @@ impl Agent { let provider = Arc::new(Mutex::new(None)); let goose_platform = config.goose_platform.clone(); + let initial_mode = config.goose_mode; let capabilities = match config.goose_platform { GoosePlatform::GooseDesktop => ExtensionManagerCapabilities { mcpui: true }, GoosePlatform::GooseCli => ExtensionManagerCapabilities { mcpui: false }, @@ -229,6 +230,7 @@ impl Agent { Self { provider: provider.clone(), config, + current_goose_mode: Mutex::new(initial_mode), extension_manager: Arc::new(ExtensionManager::new( provider.clone(), session_manager, @@ -351,7 +353,9 @@ impl Agent { .prepare_tools_and_prompt(session_id, working_dir) .await?; - if self.config.goose_mode == GooseMode::SmartApprove { + let goose_mode = *self.current_goose_mode.lock().await; + + if goose_mode == GooseMode::SmartApprove { self.tool_inspection_manager.apply_tool_annotations(&tools); } @@ -360,7 +364,7 @@ impl Agent { tools, toolshim_tools, system_prompt, - goose_mode: self.config.goose_mode, + goose_mode, tool_call_cut_off: Config::global() .get_param::("GOOSE_TOOL_CALL_CUTOFF") .unwrap_or(10), @@ -1721,6 +1725,28 @@ impl Agent { .context("Failed to persist provider config to session") } + pub async fn update_goose_mode(&self, mode: GooseMode, session_id: &str) -> Result<()> { + if let Some(provider) = self.provider.lock().await.as_ref() { + provider + .update_mode(session_id, mode) + .await + .map_err(|e| anyhow::anyhow!("Provider rejected mode update: {e}"))?; + } + *self.current_goose_mode.lock().await = mode; + self.config + .session_manager + .clone() + .update(session_id) + .goose_mode(mode) + .apply() + .await + .context("Failed to persist goose_mode to session") + } + + pub async fn goose_mode(&self) -> GooseMode { + *self.current_goose_mode.lock().await + } + /// 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<()> { @@ -1752,7 +1778,16 @@ impl Agent { .await .map_err(|e| anyhow!("Could not create provider: {}", e))?; - self.update_provider(provider, &session.id).await + self.update_provider(provider, &session.id).await?; + // Propagate session mode to the new provider + if let Some(provider) = self.provider.lock().await.as_ref() { + provider + .update_mode(&session.id, session.goose_mode) + .await + .map_err(|e| anyhow!("Failed to propagate mode to provider: {}", e))?; + } + *self.current_goose_mode.lock().await = session.goose_mode; + Ok(()) } /// Override the system prompt with a custom template @@ -1864,12 +1899,14 @@ impl Agent { let model_name = &model_config.model_name; tracing::debug!("Using model: {}", model_name); + let goose_mode = *self.current_goose_mode.lock().await; let prompt_manager = self.prompt_manager.lock().await; let system_prompt = prompt_manager .builder() .with_extensions(extensions_info.into_iter()) .with_frontend_instructions(self.frontend_instructions.lock().await.clone()) .with_extension_and_tool_counts(extension_count, tool_count) + .with_goose_mode(goose_mode) .build(); let recipe_prompt = prompt_manager.get_recipe_prompt().await; @@ -2220,7 +2257,10 @@ mod tests { ); let prompt_manager = agent.prompt_manager.lock().await; - let system_prompt = prompt_manager.builder().build(); + let system_prompt = prompt_manager + .builder() + .with_goose_mode(GooseMode::default()) + .build(); let final_output_tool_ref = agent.final_output_tool.lock().await; let final_output_tool_system_prompt = diff --git a/crates/goose/src/agents/platform_extensions/summon.rs b/crates/goose/src/agents/platform_extensions/summon.rs index 84345b1eba..34879e725c 100644 --- a/crates/goose/src/agents/platform_extensions/summon.rs +++ b/crates/goose/src/agents/platform_extensions/summon.rs @@ -12,7 +12,7 @@ use crate::agents::subagent_task_config::{TaskConfig, DEFAULT_SUBAGENT_MAX_TURNS use crate::agents::tool_execution::ToolCallContext; use crate::agents::AgentConfig; use crate::config::paths::Paths; -use crate::config::Config; +use crate::config::{Config, GooseMode}; use crate::providers; use crate::recipe::build_recipe::build_recipe_from_template; use crate::recipe::local_recipes::load_local_recipe_file; @@ -1243,11 +1243,14 @@ impl SummonClient { .await .map_err(|e| format!("Failed to build task config: {}", e))?; + // Subagents must use Auto until get_agent_messages forwards + // ActionRequired messages to the parent. Until then, any mode + // that requires approval will hang on the subagent's confirmation_rx. let agent_config = AgentConfig::new( self.context.session_manager.clone(), crate::config::permission::PermissionManager::instance(), None, - crate::config::GooseMode::Auto, + GooseMode::Auto, true, // disable session naming for subagents crate::agents::GoosePlatform::GooseCli, ); @@ -1259,6 +1262,7 @@ impl SummonClient { working_dir, "Delegated task".to_string(), SessionType::SubAgent, + GooseMode::Auto, ) .await .map_err(|e| format!("Failed to create subagent session: {}", e))?; @@ -1704,11 +1708,14 @@ impl SummonClient { let description = truncate(&Self::get_task_description(¶ms), 40); + // Subagents must use Auto until get_agent_messages forwards + // ActionRequired messages to the parent. Until then, any mode + // that requires approval will hang on the subagent's confirmation_rx. let agent_config = AgentConfig::new( self.context.session_manager.clone(), crate::config::permission::PermissionManager::instance(), None, - crate::config::GooseMode::Auto, + GooseMode::Auto, true, // disable session naming for subagents crate::agents::GoosePlatform::GooseCli, ); @@ -1716,7 +1723,12 @@ impl SummonClient { let subagent_session = self .context .session_manager - .create_session(working_dir, description.clone(), SessionType::SubAgent) + .create_session( + working_dir, + description.clone(), + SessionType::SubAgent, + GooseMode::Auto, + ) .await .map_err(|e| format!("Failed to create subagent session: {}", e))?; diff --git a/crates/goose/src/agents/prompt_manager.rs b/crates/goose/src/agents/prompt_manager.rs index 4049056a4d..dae26e70b6 100644 --- a/crates/goose/src/agents/prompt_manager.rs +++ b/crates/goose/src/agents/prompt_manager.rs @@ -55,6 +55,7 @@ pub struct SystemPromptBuilder<'a, M> { subagents_enabled: bool, hints: Option, code_execution_mode: bool, + goose_mode: Option, } impl<'a> SystemPromptBuilder<'a, PromptManager> { @@ -106,6 +107,11 @@ impl<'a> SystemPromptBuilder<'a, PromptManager> { self } + pub fn with_goose_mode(mut self, mode: GooseMode) -> Self { + self.goose_mode = Some(mode); + self + } + pub fn build(self) -> String { let mut extensions_info = self.extensions_info; @@ -128,8 +134,9 @@ impl<'a> SystemPromptBuilder<'a, PromptManager> { }) .collect(); - let config = Config::global(); - let goose_mode = config.get_goose_mode().unwrap_or(GooseMode::Auto); + let goose_mode = self + .goose_mode + .unwrap_or_else(|| Config::global().get_goose_mode().unwrap_or_default()); let extension_tool_limits = self .extension_tool_count @@ -250,6 +257,7 @@ impl PromptManager { subagents_enabled: false, hints: None, code_execution_mode: false, + goose_mode: None, } } @@ -403,6 +411,7 @@ mod tests { #[tokio::test] async fn test_all_platform_extensions() { use crate::agents::platform_extensions::{PlatformExtensionContext, PLATFORM_EXTENSIONS}; + use crate::config::GooseMode; use crate::session::SessionManager; use std::sync::Arc; @@ -413,6 +422,7 @@ mod tests { tmp_dir.path().to_path_buf(), "test session".to_owned(), crate::session::SessionType::Hidden, + GooseMode::default(), ) .await .unwrap(); diff --git a/crates/goose/src/agents/reply_parts.rs b/crates/goose/src/agents/reply_parts.rs index b31ac44aba..0fc3cf6c86 100644 --- a/crates/goose/src/agents/reply_parts.rs +++ b/crates/goose/src/agents/reply_parts.rs @@ -181,6 +181,8 @@ impl Agent { let provider = self.provider().await?; let model_config = provider.get_model_config(); + let goose_mode = *self.current_goose_mode.lock().await; + let prompt_manager = self.prompt_manager.lock().await; let mut system_prompt = prompt_manager .builder() @@ -189,6 +191,7 @@ impl Agent { .with_extension_and_tool_counts(extension_count, tool_count) .with_code_execution_mode(code_execution_active) .with_hints(working_dir) + .with_goose_mode(goose_mode) .build(); // Handle toolshim if enabled @@ -435,6 +438,7 @@ impl Agent { #[cfg(test)] mod tests { use super::*; + use crate::config::GooseMode; use crate::conversation::message::Message; use crate::model::ModelConfig; use crate::providers::base::{Provider, ProviderUsage, Usage}; @@ -483,6 +487,7 @@ mod tests { std::env::current_dir().unwrap(), "test-prepare-tools".to_string(), SessionType::Hidden, + GooseMode::default(), ) .await?; diff --git a/crates/goose/src/execution/manager.rs b/crates/goose/src/execution/manager.rs index 270ba5d01f..49311086d1 100644 --- a/crates/goose/src/execution/manager.rs +++ b/crates/goose/src/execution/manager.rs @@ -21,6 +21,7 @@ pub struct AgentManager { scheduler: Arc, session_manager: Arc, default_provider: Arc>>>, + default_mode: GooseMode, } impl AgentManager { @@ -28,6 +29,7 @@ impl AgentManager { session_manager: Arc, schedule_file_path: std::path::PathBuf, max_sessions: Option, + default_mode: GooseMode, ) -> Result { let scheduler = Scheduler::new(schedule_file_path, session_manager.clone()).await?; @@ -39,6 +41,7 @@ impl AgentManager { scheduler, session_manager, default_provider: Arc::new(RwLock::new(None)), + default_mode, }; Ok(manager) @@ -47,13 +50,20 @@ impl AgentManager { pub async fn instance() -> Result> { AGENT_MANAGER .get_or_try_init(|| async { - let max_sessions = Config::global() + let config = Config::global(); + let max_sessions = config .get_goose_max_active_agents() .unwrap_or(DEFAULT_MAX_SESSION); + let default_mode = config.get_goose_mode().unwrap_or_default(); let schedule_file_path = Paths::data_dir().join("schedule.json"); let session_manager = Arc::new(SessionManager::instance()); - let manager = - Self::new(session_manager, schedule_file_path, Some(max_sessions)).await?; + let manager = Self::new( + session_manager, + schedule_file_path, + Some(max_sessions), + default_mode, + ) + .await?; Ok(Arc::new(manager)) }) .await @@ -82,8 +92,14 @@ impl AgentManager { } } - let mode = Config::global().get_goose_mode().unwrap_or(GooseMode::Auto); + let mut mode = self.default_mode; let permission_manager = PermissionManager::instance(); + + if let Ok(session) = self.session_manager.get_session(&session_id, false).await { + mode = session.goose_mode; + info!(goose_mode = %mode, session_id = %session_id, "Session loaded"); + } + let config = AgentConfig::new( Arc::clone(&self.session_manager), permission_manager, @@ -118,6 +134,10 @@ impl AgentManager { agent .update_provider(Arc::clone(provider), &session_id) .await?; + provider + .update_mode(&session_id, mode) + .await + .map_err(|e| anyhow::anyhow!("Failed to propagate mode to provider: {}", e))?; } } @@ -153,6 +173,9 @@ mod tests { use std::sync::Arc; use tempfile::TempDir; + use test_case::test_case; + + use crate::config::GooseMode; use crate::execution::SessionExecutionMode; use crate::session::SessionManager; @@ -161,9 +184,14 @@ mod tests { async fn create_test_manager(temp_dir: &TempDir) -> AgentManager { let session_manager = Arc::new(SessionManager::new(temp_dir.path().to_path_buf())); let schedule_path = temp_dir.path().join("schedule.json"); - AgentManager::new(session_manager, schedule_path, Some(100)) - .await - .unwrap() + AgentManager::new( + session_manager, + schedule_path, + Some(100), + GooseMode::default(), + ) + .await + .unwrap() } #[test] @@ -369,4 +397,59 @@ mod tests { assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("not found")); } + + #[test_case(GooseMode::Approve ; "approve")] + #[test_case(GooseMode::Chat ; "chat")] + #[test_case(GooseMode::SmartApprove ; "smart_approve")] + #[tokio::test] + async fn test_agent_inherits_session_mode(mode: GooseMode) { + let temp_dir = TempDir::new().unwrap(); + let manager = create_test_manager(&temp_dir).await; + + let session = manager + .session_manager() + .create_session( + temp_dir.path().to_path_buf(), + "test".into(), + crate::session::SessionType::User, + mode, + ) + .await + .unwrap(); + + let agent = manager.get_or_create_agent(session.id).await.unwrap(); + assert_eq!(agent.goose_mode().await, mode); + } + + #[tokio::test] + async fn test_session_mode_isolation() { + let temp_dir = TempDir::new().unwrap(); + let manager = create_test_manager(&temp_dir).await; + let sm = manager.session_manager(); + + let s1 = sm + .create_session( + temp_dir.path().to_path_buf(), + "s1".into(), + crate::session::SessionType::User, + GooseMode::Approve, + ) + .await + .unwrap(); + let s2 = sm + .create_session( + temp_dir.path().to_path_buf(), + "s2".into(), + crate::session::SessionType::User, + GooseMode::Auto, + ) + .await + .unwrap(); + + let a1 = manager.get_or_create_agent(s1.id).await.unwrap(); + let a2 = manager.get_or_create_agent(s2.id).await.unwrap(); + + assert_eq!(a1.goose_mode().await, GooseMode::Approve); + assert_eq!(a2.goose_mode().await, GooseMode::Auto); + } } diff --git a/crates/goose/src/gateway/handler.rs b/crates/goose/src/gateway/handler.rs index 0c134da13a..3825337470 100644 --- a/crates/goose/src/gateway/handler.rs +++ b/crates/goose/src/gateway/handler.rs @@ -128,14 +128,19 @@ impl GatewayHandler { user.display_name.as_deref().unwrap_or(&user.user_id) ); + let config = Config::global(); let session = self .agent_manager .session_manager() - .create_session(working_dir, session_name, SessionType::Gateway) + .create_session( + working_dir, + session_name, + SessionType::Gateway, + config.get_goose_mode().unwrap_or_default(), + ) .await?; let manager = self.agent_manager.session_manager(); - let config = Config::global(); // Store the current provider and model config on the session so the agent // can be restored after LRU eviction, matching the start_agent flow. @@ -197,6 +202,7 @@ impl GatewayHandler { let current_provider = config.get_goose_provider().ok(); let current_model_name = config.get_goose_model().ok(); let current_extensions = get_enabled_extensions(); + let current_mode = config.get_goose_mode().unwrap_or_default(); // --- what the session has --- let session_extensions: Vec = @@ -208,8 +214,9 @@ impl GatewayHandler { let model_changed = current_model_name.as_deref() != session.model_config.as_ref().map(|m| m.model_name.as_str()); let extensions_changed = current_extensions != session_extensions; + let mode_changed = current_mode != session.goose_mode; - if !provider_changed && !model_changed && !extensions_changed { + if !provider_changed && !model_changed && !extensions_changed && !mode_changed { return Ok(false); } @@ -218,6 +225,7 @@ impl GatewayHandler { provider_changed, model_changed, extensions_changed, + mode_changed, "syncing gateway session with current config" ); @@ -242,6 +250,10 @@ impl GatewayHandler { } } + if mode_changed { + update = update.goose_mode(current_mode); + } + update.apply().await?; Ok(extensions_changed) } diff --git a/crates/goose/src/model.rs b/crates/goose/src/model.rs index ea70b36e59..b8184841d2 100644 --- a/crates/goose/src/model.rs +++ b/crates/goose/src/model.rs @@ -429,6 +429,10 @@ mod tests { #[test] fn sets_limits_from_canonical_model() { + let _guard = env_lock::lock_env([ + ("GOOSE_MAX_TOKENS", None::<&str>), + ("GOOSE_CONTEXT_LIMIT", None::<&str>), + ]); let config = ModelConfig::new_or_fail("gpt-4o").with_canonical_limits("openai"); assert_eq!(config.context_limit, Some(128_000)); @@ -438,6 +442,10 @@ mod tests { #[test] fn does_not_override_existing_context_limit() { + let _guard = env_lock::lock_env([ + ("GOOSE_MAX_TOKENS", None::<&str>), + ("GOOSE_CONTEXT_LIMIT", None::<&str>), + ]); let mut config = ModelConfig::new_or_fail("gpt-4o"); config.context_limit = Some(64_000); let config = config.with_canonical_limits("openai"); @@ -447,6 +455,10 @@ mod tests { #[test] fn does_not_override_existing_max_tokens() { + let _guard = env_lock::lock_env([ + ("GOOSE_MAX_TOKENS", None::<&str>), + ("GOOSE_CONTEXT_LIMIT", None::<&str>), + ]); let mut config = ModelConfig::new_or_fail("gpt-4o"); config.max_tokens = Some(1_000); let config = config.with_canonical_limits("openai"); @@ -456,6 +468,10 @@ mod tests { #[test] fn unknown_model_leaves_fields_none() { + let _guard = env_lock::lock_env([ + ("GOOSE_MAX_TOKENS", None::<&str>), + ("GOOSE_CONTEXT_LIMIT", None::<&str>), + ]); let config = ModelConfig::new_or_fail("totally-unknown-model").with_canonical_limits("openai"); diff --git a/crates/goose/src/providers/base.rs b/crates/goose/src/providers/base.rs index 97ef2bdd38..f9968b7601 100644 --- a/crates/goose/src/providers/base.rs +++ b/crates/goose/src/providers/base.rs @@ -8,8 +8,7 @@ use super::canonical::{map_to_canonical_model, CanonicalModelRegistry}; use super::errors::ProviderError; use super::retry::RetryConfig; use crate::config::base::ConfigValue; -use crate::config::goose_mode::GooseMode; -use crate::config::ExtensionConfig; +use crate::config::{ExtensionConfig, GooseMode}; use crate::conversation::message::{Message, MessageContent}; use crate::conversation::Conversation; use crate::model::ModelConfig; @@ -708,6 +707,10 @@ pub trait Provider: Send + Sync { )) } + async fn update_mode(&self, _session_id: &str, _mode: GooseMode) -> Result<(), ProviderError> { + Ok(()) + } + fn permission_routing(&self) -> PermissionRouting { PermissionRouting::Noop } @@ -719,10 +722,6 @@ pub trait Provider: Send + Sync { ) -> bool { false } - - async fn update_mode(&self, _session_id: &str, _mode: GooseMode) -> Result<(), ProviderError> { - Ok(()) - } } /// A message stream yields partial text content but complete tool calls, all within the Message object diff --git a/crates/goose/src/providers/claude_code.rs b/crates/goose/src/providers/claude_code.rs index d574f26641..503c28e5c7 100644 --- a/crates/goose/src/providers/claude_code.rs +++ b/crates/goose/src/providers/claude_code.rs @@ -270,6 +270,8 @@ pub struct ClaudeCodeProvider { #[serde(skip)] pending_confirmations: Arc>>>, + #[serde(skip)] + initial_mode: tokio::sync::Mutex>, } impl ClaudeCodeProvider { @@ -623,6 +625,7 @@ impl ProviderDef for ClaudeCodeProvider { mcp_config_file, cli_process: tokio::sync::OnceCell::new(), pending_confirmations: Arc::new(tokio::sync::Mutex::new(HashMap::new())), + initial_mode: tokio::sync::Mutex::new(None), }) }) } @@ -669,6 +672,19 @@ impl Provider for ClaudeCodeProvider { Ok(extract_model_aliases(response.ok().flatten().as_ref())) } + async fn update_mode(&self, _session_id: &str, mode: GooseMode) -> Result<(), ProviderError> { + // Mode is baked into the subprocess at spawn; claude-acp replaces + // this provider (#7801). + let mut guard = self.initial_mode.lock().await; + let current = *guard.get_or_insert(mode); + if current != mode { + return Err(ProviderError::RequestFailed(format!( + "Mode change not supported: session is {current}, requested {mode}", + ))); + } + Ok(()) + } + fn permission_routing(&self) -> PermissionRouting { PermissionRouting::ActionRequired } @@ -1192,6 +1208,7 @@ mod tests { mcp_config_file: None, cli_process: tokio::sync::OnceCell::new(), pending_confirmations: Arc::new(tokio::sync::Mutex::new(HashMap::new())), + initial_mode: tokio::sync::Mutex::new(None), } } diff --git a/crates/goose/src/providers/codex.rs b/crates/goose/src/providers/codex.rs index 96705daa08..ab6208b62e 100644 --- a/crates/goose/src/providers/codex.rs +++ b/crates/goose/src/providers/codex.rs @@ -3,6 +3,7 @@ use async_trait::async_trait; use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; use futures::future::BoxFuture; use serde_json::json; +use std::collections::HashMap; use std::io::Write; use std::path::{Path, PathBuf}; use std::process::Stdio; @@ -54,6 +55,8 @@ pub struct CodexProvider { skip_git_check: bool, /// CLI config overrides for MCP servers mcp_config_overrides: Vec, + #[serde(skip)] + mode_by_session: tokio::sync::RwLock>, } impl CodexProvider { @@ -69,11 +72,11 @@ impl CodexProvider { true } - /// Apply permission flags based on GOOSE_MODE setting - fn apply_permission_flags(cmd: &mut Command) -> Result<(), ProviderError> { - let config = Config::global(); - let goose_mode = config.get_goose_mode().unwrap_or(GooseMode::Auto); - + /// Apply permission flags based on GooseMode + fn apply_permission_flags( + cmd: &mut Command, + goose_mode: GooseMode, + ) -> Result<(), ProviderError> { match goose_mode { GooseMode::Auto => { // --yolo is shorthand for --dangerously-bypass-approvals-and-sandbox @@ -101,6 +104,7 @@ impl CodexProvider { system: &str, messages: &[Message], _tools: &[Tool], + goose_mode: GooseMode, ) -> Result, ProviderError> { // Single pass: text → prompt (stdin), images → temp files (-i flags) let image_dir = Paths::state_dir().join("codex/images"); @@ -151,8 +155,7 @@ impl CodexProvider { // JSON output format for structured parsing cmd.arg("--json"); - // Apply permission mode based on GOOSE_MODE - Self::apply_permission_flags(&mut cmd)?; + Self::apply_permission_flags(&mut cmd, goose_mode)?; // Skip git repo check if configured if self.skip_git_check { @@ -653,6 +656,7 @@ impl ProviderDef for CodexProvider { reasoning_effort, skip_git_check, mcp_config_overrides: codex_mcp_config_overrides(&resolved), + mode_by_session: tokio::sync::RwLock::new(HashMap::new()), }) }) } @@ -671,7 +675,7 @@ impl Provider for CodexProvider { async fn stream( &self, model_config: &ModelConfig, - _session_id: &str, // CLI has no external session-id flag to propagate. + session_id: &str, system: &str, messages: &[Message], tools: &[Tool], @@ -687,7 +691,13 @@ impl Provider for CodexProvider { )); } - let lines = self.execute_command(system, messages, tools).await?; + let goose_mode = { + let map = self.mode_by_session.read().await; + map.get(session_id).copied().unwrap_or_default() + }; + let lines = self + .execute_command(system, messages, tools, goose_mode) + .await?; let (message, usage) = self.parse_response(&lines)?; @@ -720,6 +730,14 @@ impl Provider for CodexProvider { )) } + async fn update_mode(&self, session_id: &str, mode: GooseMode) -> Result<(), ProviderError> { + self.mode_by_session + .write() + .await + .insert(session_id.to_string(), mode); + Ok(()) + } + async fn fetch_supported_models(&self) -> Result, ProviderError> { Ok(CODEX_KNOWN_MODELS.iter().map(|s| s.to_string()).collect()) } @@ -908,6 +926,7 @@ mod tests { reasoning_effort: "high".to_string(), skip_git_check: false, mcp_config_overrides: Vec::new(), + mode_by_session: tokio::sync::RwLock::new(HashMap::new()), }; let lines = vec!["Hello, world!".to_string()]; @@ -928,6 +947,7 @@ mod tests { reasoning_effort: "high".to_string(), skip_git_check: false, mcp_config_overrides: Vec::new(), + mode_by_session: tokio::sync::RwLock::new(HashMap::new()), }; // Test with actual Codex CLI output format @@ -961,6 +981,7 @@ mod tests { reasoning_effort: "high".to_string(), skip_git_check: false, mcp_config_overrides: Vec::new(), + mode_by_session: tokio::sync::RwLock::new(HashMap::new()), }; let lines: Vec = vec![]; @@ -1009,6 +1030,7 @@ mod tests { reasoning_effort: "high".to_string(), skip_git_check: false, mcp_config_overrides: Vec::new(), + mode_by_session: tokio::sync::RwLock::new(HashMap::new()), }; let lines = vec![ @@ -1034,6 +1056,7 @@ mod tests { reasoning_effort: "high".to_string(), skip_git_check: false, mcp_config_overrides: Vec::new(), + mode_by_session: tokio::sync::RwLock::new(HashMap::new()), }; let lines = vec![ @@ -1106,6 +1129,7 @@ mod tests { reasoning_effort: "high".to_string(), skip_git_check: false, mcp_config_overrides: Vec::new(), + mode_by_session: tokio::sync::RwLock::new(HashMap::new()), }; let lines: Vec = lines.iter().map(|s| s.to_string()).collect(); @@ -1122,6 +1146,7 @@ mod tests { reasoning_effort: "high".to_string(), skip_git_check: false, mcp_config_overrides: Vec::new(), + mode_by_session: tokio::sync::RwLock::new(HashMap::new()), }; let lines = vec![ @@ -1212,6 +1237,7 @@ mod tests { reasoning_effort: "high".to_string(), skip_git_check: false, mcp_config_overrides: Vec::new(), + mode_by_session: tokio::sync::RwLock::new(HashMap::new()), }; let lines = vec![ @@ -1239,4 +1265,19 @@ mod tests { fn test_default_model() { assert_eq!(CODEX_DEFAULT_MODEL, "gpt-5.2-codex"); } + + #[test_case(GooseMode::Auto, &["--yolo"] ; "auto_yolo")] + #[test_case(GooseMode::SmartApprove, &["--full-auto"] ; "smart_approve_full_auto")] + #[test_case(GooseMode::Approve, &[] as &[&str] ; "approve_no_flags")] + #[test_case(GooseMode::Chat, &["--sandbox", "read-only"] ; "chat_read_only")] + fn test_apply_permission_flags(mode: GooseMode, expected: &[&str]) { + let mut cmd = tokio::process::Command::new("codex"); + CodexProvider::apply_permission_flags(&mut cmd, mode).unwrap(); + let args: Vec<&str> = cmd + .as_std() + .get_args() + .map(|a| a.to_str().unwrap()) + .collect(); + assert_eq!(args, expected); + } } diff --git a/crates/goose/src/providers/ollama.rs b/crates/goose/src/providers/ollama.rs index 7b6104d658..49e0c82a91 100644 --- a/crates/goose/src/providers/ollama.rs +++ b/crates/goose/src/providers/ollama.rs @@ -5,7 +5,6 @@ use super::openai_compatible::handle_status_openai_compat; use super::retry::ProviderRetry; use super::utils::{ImageFormat, RequestLog}; use crate::config::declarative_providers::DeclarativeProviderConfig; -use crate::config::GooseMode; use crate::conversation::message::Message; use crate::model::ModelConfig; use crate::providers::formats::ollama::{create_request, response_to_streaming_message_ollama}; @@ -220,19 +219,11 @@ impl Provider for OllamaProvider { messages: &[Message], tools: &[Tool], ) -> Result { - let config = crate::config::Config::global(); - let goose_mode = config.get_goose_mode().unwrap_or(GooseMode::Auto); - let filtered_tools = if goose_mode == GooseMode::Chat { - &[] - } else { - tools - }; - let mut payload = create_request( model_config, system, messages, - filtered_tools, + tools, &ImageFormat::OpenAi, true, )?; diff --git a/crates/goose/src/scheduler.rs b/crates/goose/src/scheduler.rs index e172e20b37..6612efcd42 100644 --- a/crates/goose/src/scheduler.rs +++ b/crates/goose/src/scheduler.rs @@ -819,6 +819,7 @@ async fn execute_job( std::env::current_dir()?, format!("Scheduled job: {}", job.id), SessionType::Scheduled, + agent.config.goose_mode, ) .await?; diff --git a/crates/goose/src/session/session_manager.rs b/crates/goose/src/session/session_manager.rs index 68bd430316..d948337319 100644 --- a/crates/goose/src/session/session_manager.rs +++ b/crates/goose/src/session/session_manager.rs @@ -1,4 +1,5 @@ use crate::config::paths::Paths; +use crate::config::GooseMode; use crate::conversation::message::Message; use crate::conversation::Conversation; use crate::model::ModelConfig; @@ -18,7 +19,7 @@ use std::sync::{Arc, LazyLock}; use tracing::{info, warn}; use utoipa::ToSchema; -pub const CURRENT_SCHEMA_VERSION: i32 = 7; +pub const CURRENT_SCHEMA_VERSION: i32 = 8; pub const SESSIONS_FOLDER: &str = "sessions"; pub const DB_NAME: &str = "sessions.db"; @@ -93,6 +94,8 @@ pub struct Session { pub message_count: usize, pub provider_name: Option, pub model_config: Option, + #[serde(default)] + pub goose_mode: GooseMode, } pub struct SessionUpdateBuilder<'a> { @@ -114,6 +117,7 @@ pub struct SessionUpdateBuilder<'a> { user_recipe_values: Option>>, provider_name: Option>, model_config: Option>, + goose_mode: Option, } #[derive(Serialize, ToSchema, Debug)] @@ -144,6 +148,7 @@ impl<'a> SessionUpdateBuilder<'a> { user_recipe_values: None, provider_name: None, model_config: None, + goose_mode: None, } } @@ -241,6 +246,11 @@ impl<'a> SessionUpdateBuilder<'a> { self.model_config = Some(Some(model_config)); self } + + pub fn goose_mode(mut self, mode: GooseMode) -> Self { + self.goose_mode = Some(mode); + self + } } pub struct SessionManager { @@ -269,9 +279,10 @@ impl SessionManager { working_dir: PathBuf, name: String, session_type: SessionType, + goose_mode: GooseMode, ) -> Result { self.storage - .create_session(working_dir, name, session_type) + .create_session(working_dir, name, session_type, goose_mode) .await } @@ -417,6 +428,7 @@ impl Default for Session { message_count: 0, provider_name: None, model_config: None, + goose_mode: GooseMode::default(), } } } @@ -481,6 +493,11 @@ impl sqlx::FromRow<'_, sqlx::sqlite::SqliteRow> for Session { message_count: row.try_get("message_count").unwrap_or(0) as usize, provider_name: row.try_get("provider_name").ok().flatten(), model_config, + goose_mode: row + .try_get::("goose_mode") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or_default(), }) } } @@ -579,7 +596,8 @@ impl SessionStorage { recipe_json TEXT, user_recipe_values_json TEXT, provider_name TEXT, - model_config_json TEXT + model_config_json TEXT, + goose_mode TEXT NOT NULL DEFAULT 'auto' ) "#, ) @@ -692,8 +710,8 @@ impl SessionStorage { total_tokens, input_tokens, output_tokens, accumulated_total_tokens, accumulated_input_tokens, accumulated_output_tokens, schedule_id, recipe_json, user_recipe_values_json, - provider_name, model_config_json - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + provider_name, model_config_json, goose_mode + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "#, ) .bind(&session.id) @@ -715,6 +733,7 @@ impl SessionStorage { .bind(user_recipe_values_json) .bind(&session.provider_name) .bind(model_config_json) + .bind(session.goose_mode.to_string()) .execute(&mut *tx) .await?; @@ -887,6 +906,15 @@ impl SessionStorage { .execute(&mut **tx) .await?; } + 8 => { + sqlx::query( + r#" + ALTER TABLE sessions ADD COLUMN goose_mode TEXT NOT NULL DEFAULT 'auto' + "#, + ) + .execute(&mut **tx) + .await?; + } _ => { anyhow::bail!("Unknown migration version: {}", version); } @@ -900,6 +928,7 @@ impl SessionStorage { working_dir: PathBuf, name: String, session_type: SessionType, + goose_mode: GooseMode, ) -> Result { let pool = self.pool().await?; let mut tx = pool.begin_with("BEGIN IMMEDIATE").await?; @@ -907,7 +936,7 @@ impl SessionStorage { let today = chrono::Utc::now().format("%Y%m%d").to_string(); let session = sqlx::query_as( r#" - INSERT INTO sessions (id, name, user_set_name, session_type, working_dir, extension_data) + INSERT INTO sessions (id, name, user_set_name, session_type, working_dir, extension_data, goose_mode) VALUES ( ? || '_' || CAST(COALESCE(( SELECT MAX(CAST(SUBSTR(id, 10) AS INTEGER)) @@ -918,7 +947,8 @@ impl SessionStorage { FALSE, ?, ?, - '{}' + '{}', + ? ) RETURNING * "#, @@ -928,6 +958,7 @@ impl SessionStorage { .bind(&name) .bind(session_type.to_string()) .bind(&*working_dir.to_string_lossy()) + .bind(goose_mode.to_string()) .fetch_one(&mut *tx) .await?; @@ -944,7 +975,7 @@ impl SessionStorage { total_tokens, input_tokens, output_tokens, accumulated_total_tokens, accumulated_input_tokens, accumulated_output_tokens, schedule_id, recipe_json, user_recipe_values_json, - provider_name, model_config_json + provider_name, model_config_json, goose_mode FROM sessions WHERE id = ? "#, @@ -1007,6 +1038,7 @@ impl SessionStorage { add_update!(builder.user_recipe_values, "user_recipe_values_json"); add_update!(builder.provider_name, "provider_name"); add_update!(builder.model_config, "model_config_json"); + add_update!(builder.goose_mode, "goose_mode"); if updates.is_empty() { return Ok(()); @@ -1072,6 +1104,9 @@ impl SessionStorage { .transpose()?; q = q.bind(model_config_json); } + if let Some(goose_mode) = builder.goose_mode { + q = q.bind(goose_mode.to_string()); + } let pool = self.pool().await?; let mut tx = pool.begin_with("BEGIN IMMEDIATE").await?; @@ -1216,7 +1251,7 @@ impl SessionStorage { s.total_tokens, s.input_tokens, s.output_tokens, s.accumulated_total_tokens, s.accumulated_input_tokens, s.accumulated_output_tokens, s.schedule_id, s.recipe_json, s.user_recipe_values_json, - s.provider_name, s.model_config_json, + s.provider_name, s.model_config_json, s.goose_mode, COUNT(m.id) as message_count FROM sessions s INNER JOIN messages m ON s.id = m.session_id @@ -1304,6 +1339,7 @@ impl SessionStorage { import.working_dir.clone(), import.name.clone(), import.session_type, + import.goose_mode, ) .await?; @@ -1347,6 +1383,7 @@ impl SessionStorage { original_session.working_dir.clone(), new_name, original_session.session_type, + original_session.goose_mode, ) .await?; @@ -1357,7 +1394,7 @@ impl SessionStorage { .recipe(original_session.recipe) .user_recipe_values(original_session.user_recipe_values); - // Preserve provider and model config from original session + // Preserve provider, model config, and goose_mode from original session if let Some(provider_name) = original_session.provider_name { builder = builder.provider_name(provider_name); } @@ -1366,6 +1403,8 @@ impl SessionStorage { builder = builder.model_config(model_config); } + builder = builder.goose_mode(original_session.goose_mode); + builder.apply().await?; if let Some(conversation) = original_session.conversation { @@ -1458,6 +1497,7 @@ mod tests { use super::*; use crate::conversation::message::{Message, MessageContent}; use tempfile::TempDir; + use test_case::test_case; const NUM_CONCURRENT_SESSIONS: i32 = 10; @@ -1529,6 +1569,7 @@ mod tests { PathBuf::from("/tmp/lock-upgrade-test"), "Lock Upgrade Session".to_string(), SessionType::User, + GooseMode::default(), ) .await .unwrap(); @@ -1566,7 +1607,12 @@ mod tests { let description = format!("Test session {}", i); let session = sm - .create_session(working_dir.clone(), description, SessionType::User) + .create_session( + working_dir.clone(), + description, + SessionType::User, + GooseMode::default(), + ) .await .unwrap(); @@ -1654,6 +1700,7 @@ mod tests { PathBuf::from("/tmp/test"), DESCRIPTION.to_string(), SessionType::User, + GooseMode::default(), ) .await .unwrap(); @@ -1733,4 +1780,77 @@ mod tests { assert!(imported.user_set_name); assert_eq!(imported.working_dir, PathBuf::from("/tmp/test")); } + + #[test_case(GooseMode::Approve)] + #[test_case(GooseMode::SmartApprove)] + #[test_case(GooseMode::Chat)] + #[tokio::test] + async fn test_goose_mode_persists(mode: GooseMode) { + let temp_dir = TempDir::new().unwrap(); + let sm = SessionManager::new(temp_dir.path().to_path_buf()); + + let session = sm + .create_session( + temp_dir.path().to_path_buf(), + "test".into(), + SessionType::User, + mode, + ) + .await + .unwrap(); + + let reloaded = sm.get_session(&session.id, false).await.unwrap(); + assert_eq!(reloaded.goose_mode, mode); + } + + #[tokio::test] + async fn test_goose_mode_update() { + let temp_dir = TempDir::new().unwrap(); + let sm = SessionManager::new(temp_dir.path().to_path_buf()); + + let session = sm + .create_session( + temp_dir.path().to_path_buf(), + "test".into(), + SessionType::User, + GooseMode::default(), + ) + .await + .unwrap(); + + sm.update(&session.id) + .goose_mode(GooseMode::Approve) + .apply() + .await + .unwrap(); + + let reloaded = sm.get_session(&session.id, false).await.unwrap(); + assert_eq!(reloaded.goose_mode, GooseMode::Approve); + } + + #[tokio::test] + async fn test_goose_mode_malformed_defaults_to_auto() { + let temp_dir = TempDir::new().unwrap(); + let sm = SessionManager::new(temp_dir.path().to_path_buf()); + + let session = sm + .create_session( + temp_dir.path().to_path_buf(), + "test".into(), + SessionType::User, + GooseMode::Approve, + ) + .await + .unwrap(); + + let pool = &sm.storage().pool; + sqlx::query("UPDATE sessions SET goose_mode = 'garbage' WHERE id = ?") + .bind(&session.id) + .execute(pool) + .await + .unwrap(); + + let reloaded = sm.get_session(&session.id, false).await.unwrap(); + assert_eq!(reloaded.goose_mode, GooseMode::default()); + } } diff --git a/crates/goose/tests/agent.rs b/crates/goose/tests/agent.rs index d884c37a64..830e3f1bfc 100644 --- a/crates/goose/tests/agent.rs +++ b/crates/goose/tests/agent.rs @@ -340,6 +340,7 @@ mod tests { use super::*; use async_trait::async_trait; use goose::agents::SessionConfig; + use goose::config::GooseMode; use goose::conversation::message::{Message, MessageContent}; use goose::model::ModelConfig; use goose::providers::base::{ @@ -427,6 +428,7 @@ mod tests { PathBuf::default(), "max-turn-test".to_string(), SessionType::Hidden, + GooseMode::default(), ) .await?; @@ -542,6 +544,7 @@ mod tests { std::path::PathBuf::from("."), "Test Session".to_string(), SessionType::Hidden, + GooseMode::default(), ) .await .expect("Failed to create session"); diff --git a/crates/goose/tests/compaction.rs b/crates/goose/tests/compaction.rs index 43ac2a6559..2cf1aa6e6e 100644 --- a/crates/goose/tests/compaction.rs +++ b/crates/goose/tests/compaction.rs @@ -2,6 +2,7 @@ use anyhow::Result; use async_trait::async_trait; use futures::StreamExt; use goose::agents::{Agent, AgentEvent, SessionConfig}; +use goose::config::GooseMode; use goose::conversation::message::{Message, MessageContent}; use goose::conversation::Conversation; use goose::model::ModelConfig; @@ -215,6 +216,7 @@ async fn setup_test_session( temp_dir.path().to_path_buf(), session_name.to_string(), SessionType::Hidden, + GooseMode::default(), ) .await?; diff --git a/crates/goose/tests/providers.rs b/crates/goose/tests/providers.rs index 27263f6a13..f456a5275f 100644 --- a/crates/goose/tests/providers.rs +++ b/crates/goose/tests/providers.rs @@ -119,6 +119,7 @@ struct ProviderTestConfig { expected_session_id: fn() -> Arc, test_permissions: bool, test_smart_approve: bool, + test_mode_update: bool, test_context_length_exceeded: bool, expect_context_length_exceeded: bool, context_length_exceeded: usize, @@ -141,6 +142,7 @@ impl ProviderTestConfig { expected_session_id: || Arc::new(EnforceSessionId::default()), test_permissions: true, test_smart_approve: true, + test_mode_update: true, test_context_length_exceeded: true, expect_context_length_exceeded: true, context_length_exceeded: 600_000, @@ -188,6 +190,7 @@ impl ProviderTestConfig { skip, expected_session_id: || Arc::new(IgnoreSessionId), test_smart_approve: false, + test_mode_update: false, test_context_length_exceeded: false, ..Self::with_llm_provider(name, model_name, &[]) } @@ -246,6 +249,7 @@ impl ProviderFixture { std::env::current_dir()?, "provider_test".to_string(), SessionType::User, + GooseMode::default(), ) .await?; let session_id = session.id; @@ -586,6 +590,23 @@ impl ProviderFixture { ) .await } + + async fn test_mode_update(&self) -> Result<()> { + // Start in Auto mode (fixture default), tools auto-approved. + // Switch to Approve mode dynamically via agent. + self.agent + .update_goose_mode(GooseMode::Approve, &self.session_id) + .await?; + // Verify tool call now requires permission (ActionRequired). + // Cancel prevents the task from completing → tool fails. + self.run_permission_test( + Permission::Cancel, + true, + "Use the get_code tool and output only its result.", + "mode_update", + ) + .await + } } fn load_env() { @@ -669,6 +690,9 @@ async fn test_provider(config: ProviderTestConfig) -> Result<()> { .await?; } } + if config.test_mode_update { + run_test(GooseMode::Auto).await?.test_mode_update().await?; + } Ok(()) } .await; diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 3f953ea395..3498e27e10 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -663,6 +663,35 @@ } } }, + "/agent/update_session": { + "post": { + "tags": [ + "super::routes::agent" + ], + "operationId": "update_session", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateSessionRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Session updated" + }, + "400": { + "description": "Invalid request" + }, + "500": { + "description": "Internal error" + } + } + } + }, "/agent/update_working_dir": { "post": { "tags": [ @@ -5198,6 +5227,15 @@ } ] }, + "GooseMode": { + "type": "string", + "enum": [ + "auto", + "approve", + "smart_approve", + "chat" + ] + }, "HfGgufFile": { "type": "object", "description": "A single downloadable GGUF file (used internally and for downloads).", @@ -7478,6 +7516,9 @@ "extension_data": { "$ref": "#/components/schemas/ExtensionData" }, + "goose_mode": { + "$ref": "#/components/schemas/GooseMode" + }, "id": { "type": "string" }, @@ -8496,6 +8537,21 @@ } } }, + "UpdateSessionRequest": { + "type": "object", + "required": [ + "session_id" + ], + "properties": { + "goose_mode": { + "type": "string", + "nullable": true + }, + "session_id": { + "type": "string" + } + } + }, "UpdateSessionUserRecipeValuesRequest": { "type": "object", "required": [ diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index 2e9b0655fb..05945e05ba 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -158,7 +158,7 @@ const PairRouteWrapper = ({ return null; }; -const SettingsRoute = () => { +const SettingsRoute = ({ activeSessionId }: { activeSessionId?: string }) => { const location = useLocation(); const navigate = useNavigate(); const [searchParams] = useSearchParams(); @@ -174,7 +174,7 @@ const SettingsRoute = () => { viewOptions.section = sectionFromUrl; } - return navigate('/')} setView={setView} viewOptions={viewOptions} />; + return navigate('/')} setView={setView} viewOptions={{...viewOptions, sessionId: activeSessionId}} />; }; const SessionsRoute = () => { @@ -667,7 +667,7 @@ export function AppInner() { /> } /> - } /> + } /> = Options2 & { /** @@ -132,6 +132,15 @@ export const updateAgentProvider = (option } }); +export const updateSession = (options: Options) => (options.client ?? client).post({ + url: '/agent/update_session', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + export const updateWorkingDir = (options: Options) => (options.client ?? client).post({ url: '/agent/update_working_dir', ...options, diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 4fea314c47..b96c24ce04 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -492,6 +492,8 @@ export type GooseApp = McpAppResource & (WindowProps | null) & { prd?: string | null; }; +export type GooseMode = 'auto' | 'approve' | 'smart_approve' | 'chat'; + /** * A single downloadable GGUF file (used internally and for downloads). */ @@ -1200,6 +1202,7 @@ export type Session = { conversation?: Conversation | null; created_at: string; extension_data: ExtensionData; + goose_mode?: GooseMode; id: string; input_tokens?: number | null; message_count: number; @@ -1548,6 +1551,11 @@ export type UpdateSessionNameRequest = { name: string; }; +export type UpdateSessionRequest = { + goose_mode?: string | null; + session_id: string; +}; + export type UpdateSessionUserRecipeValuesRequest = { /** * Recipe parameter values entered by the user @@ -2079,6 +2087,31 @@ export type UpdateAgentProviderResponses = { 200: unknown; }; +export type UpdateSessionData = { + body: UpdateSessionRequest; + path?: never; + query?: never; + url: '/agent/update_session'; +}; + +export type UpdateSessionErrors = { + /** + * Invalid request + */ + 400: unknown; + /** + * Internal error + */ + 500: unknown; +}; + +export type UpdateSessionResponses = { + /** + * Session updated + */ + 200: unknown; +}; + export type UpdateWorkingDirData = { body: UpdateWorkingDirRequest; path?: never; diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index 1c7ea48b17..88438ea5a4 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -1571,7 +1571,7 @@ export default function ChatInput({
- +
{sessionId && messages.length > 0 && ( diff --git a/ui/desktop/src/components/bottom_menu/BottomMenuModeSelection.test.tsx b/ui/desktop/src/components/bottom_menu/BottomMenuModeSelection.test.tsx new file mode 100644 index 0000000000..62964a703e --- /dev/null +++ b/ui/desktop/src/components/bottom_menu/BottomMenuModeSelection.test.tsx @@ -0,0 +1,115 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import { BottomMenuModeSelection } from './BottomMenuModeSelection'; + +let mockConfig: Record = {}; +const mockUpdateSession = vi.fn().mockResolvedValue({}); +const mockGetSession = vi.fn().mockResolvedValue({ data: null }); + +vi.mock('../ConfigContext', () => ({ + useConfig: () => ({ + config: mockConfig, + }), +})); + +vi.mock('../../utils/analytics', () => ({ + trackModeChanged: vi.fn(), +})); + +vi.mock('../../api', () => ({ + updateSession: (...args: unknown[]) => mockUpdateSession(...args), + getSession: (...args: unknown[]) => mockGetSession(...args), +})); + +// Radix dropdown doesn't open in jsdom — render children directly +vi.mock('../ui/dropdown-menu', () => ({ + DropdownMenu: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuItem: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +describe('BottomMenuModeSelection', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockConfig = {}; + }); + + it('displays mode from config when no session', async () => { + mockConfig.GOOSE_MODE = 'approve'; + render(); + await waitFor(() => { + expect(screen.getByText('manual')).toBeInTheDocument(); + }); + }); + + it('defaults to auto when config has no mode', async () => { + mockConfig.GOOSE_MODE = undefined; + render(); + await waitFor(() => { + expect(screen.getByText('autonomous')).toBeInTheDocument(); + }); + }); + + it('fetches mode from session when sessionId is present', async () => { + mockConfig.GOOSE_MODE = 'auto'; + mockGetSession.mockResolvedValue({ data: { goose_mode: 'approve' } }); + render(); + await waitFor(() => { + expect(screen.getByText('manual')).toBeInTheDocument(); + }); + expect(mockGetSession).toHaveBeenCalledWith({ + path: { session_id: 'test-session-123' }, + }); + }); + + it('calls updateSession and does not write global config', async () => { + mockConfig.GOOSE_MODE = 'auto'; + render(); + + fireEvent.click(screen.getByText('Manual')); + + await waitFor(() => { + expect(mockUpdateSession).toHaveBeenCalledWith({ + body: { session_id: 'test-session-123', goose_mode: 'approve' }, + }); + }); + }); + + it('does not call updateSession when sessionId is null', async () => { + mockConfig.GOOSE_MODE = 'auto'; + render(); + + fireEvent.click(screen.getByText('Manual')); + + await waitFor(() => { + expect(screen.getByText('manual')).toBeInTheDocument(); + }); + expect(mockUpdateSession).not.toHaveBeenCalled(); + }); + + it('ignores stale session fetch after sessionId changes', async () => { + let resolveA: (value: unknown) => void; + const promiseA = new Promise((resolve) => { + resolveA = resolve; + }); + + mockGetSession + .mockImplementationOnce(() => promiseA) + .mockResolvedValueOnce({ data: { goose_mode: 'auto' } }); + + const { rerender } = render(); + rerender(); + + await waitFor(() => { + expect(screen.getByText('autonomous')).toBeInTheDocument(); + }); + + resolveA!({ data: { goose_mode: 'approve' } }); + + await waitFor(() => { + expect(screen.getByText('autonomous')).toBeInTheDocument(); + }); + expect(screen.queryByText('manual')).not.toBeInTheDocument(); + }); +}); diff --git a/ui/desktop/src/components/bottom_menu/BottomMenuModeSelection.tsx b/ui/desktop/src/components/bottom_menu/BottomMenuModeSelection.tsx index c04ca3b920..361c5747b2 100644 --- a/ui/desktop/src/components/bottom_menu/BottomMenuModeSelection.tsx +++ b/ui/desktop/src/components/bottom_menu/BottomMenuModeSelection.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { Tornado } from 'lucide-react'; import { all_goose_modes, ModeSelectionItem } from '../settings/mode/ModeSelectionItem'; import { useConfig } from '../ConfigContext'; @@ -9,25 +9,30 @@ import { DropdownMenuTrigger, } from '../ui/dropdown-menu'; import { trackModeChanged } from '../../utils/analytics'; +import { getSession, updateSession } from '../../api'; -export const BottomMenuModeSelection = () => { +export const BottomMenuModeSelection = ({ sessionId }: { sessionId: string | null }) => { const [gooseMode, setGooseMode] = useState('auto'); - const { read, upsert } = useConfig(); + const { config } = useConfig(); - const fetchCurrentMode = useCallback(async () => { - try { - const mode = (await read('GOOSE_MODE', false)) as string; + useEffect(() => { + let cancelled = false; + if (sessionId) { + getSession({ path: { session_id: sessionId } }).then((res) => { + if (!cancelled && res.data?.goose_mode) { + setGooseMode(res.data.goose_mode); + } + }); + } else { + const mode = config.GOOSE_MODE as string | undefined; if (mode) { setGooseMode(mode); } - } catch (error) { - console.error('Error fetching current mode:', error); } - }, [read]); - - useEffect(() => { - fetchCurrentMode(); - }, [fetchCurrentMode]); + return () => { + cancelled = true; + }; + }, [sessionId, config.GOOSE_MODE]); const handleModeChange = async (newMode: string) => { if (gooseMode === newMode) { @@ -35,7 +40,9 @@ export const BottomMenuModeSelection = () => { } try { - await upsert('GOOSE_MODE', newMode, false); + if (sessionId) { + await updateSession({ body: { session_id: sessionId, goose_mode: newMode } }); + } setGooseMode(newMode); trackModeChanged(gooseMode, newMode); } catch (error) { diff --git a/ui/desktop/src/components/settings/SettingsView.tsx b/ui/desktop/src/components/settings/SettingsView.tsx index ba533da72f..ed655af406 100644 --- a/ui/desktop/src/components/settings/SettingsView.tsx +++ b/ui/desktop/src/components/settings/SettingsView.tsx @@ -24,6 +24,7 @@ export type SettingsViewOptions = { deepLinkConfig?: ExtensionConfig; showEnvVars?: boolean; section?: string; + sessionId?: string; }; export default function SettingsView({ @@ -191,7 +192,7 @@ export default function SettingsView({ value="chat" className="mt-0 focus-visible:outline-none focus-visible:ring-0" > - + @@ -15,7 +15,7 @@ export default function ChatSettingsSection() { Configure how Goose interacts with tools and extensions - + diff --git a/ui/desktop/src/components/settings/mode/ModeSection.tsx b/ui/desktop/src/components/settings/mode/ModeSection.tsx index c19248a1e6..b0a52cd349 100644 --- a/ui/desktop/src/components/settings/mode/ModeSection.tsx +++ b/ui/desktop/src/components/settings/mode/ModeSection.tsx @@ -2,14 +2,18 @@ import { useEffect, useState, useCallback } from 'react'; import { all_goose_modes, ModeSelectionItem } from './ModeSelectionItem'; import { useConfig } from '../../ConfigContext'; import { ConversationLimitsDropdown } from './ConversationLimitsDropdown'; +import { updateSession } from '../../../api'; -export const ModeSection = () => { +export const ModeSection = ({ sessionId }: { sessionId?: string }) => { const [currentMode, setCurrentMode] = useState('auto'); const [maxTurns, setMaxTurns] = useState(1000); - const { read, upsert } = useConfig(); + const { config, read, upsert } = useConfig(); const handleModeChange = async (newMode: string) => { try { + if (sessionId) { + await updateSession({ body: { session_id: sessionId, goose_mode: newMode } }); + } await upsert('GOOSE_MODE', newMode, false); setCurrentMode(newMode); } catch (error) { @@ -18,16 +22,12 @@ export const ModeSection = () => { } }; - const fetchCurrentMode = useCallback(async () => { - try { - const mode = (await read('GOOSE_MODE', false)) as string; - if (mode) { - setCurrentMode(mode); - } - } catch (error) { - console.error('Error fetching current mode:', error); + useEffect(() => { + const mode = config.GOOSE_MODE as string | undefined; + if (mode) { + setCurrentMode(mode); } - }, [read]); + }, [config.GOOSE_MODE]); const fetchMaxTurns = useCallback(async () => { try { @@ -50,9 +50,8 @@ export const ModeSection = () => { }; useEffect(() => { - fetchCurrentMode(); fetchMaxTurns(); - }, [fetchCurrentMode, fetchMaxTurns]); + }, [fetchMaxTurns]); return (
diff --git a/ui/desktop/tests/integration/goosed.test.ts b/ui/desktop/tests/integration/goosed.test.ts index 3e621f8465..045055e9c2 100644 --- a/ui/desktop/tests/integration/goosed.test.ts +++ b/ui/desktop/tests/integration/goosed.test.ts @@ -16,6 +16,8 @@ import { listSessions, getSession, updateAgentProvider, + updateSession, + upsertConfig, reply, } from '../../src/api'; import { execSync } from 'child_process'; @@ -110,6 +112,7 @@ extensions: const session = startResponse.data!; expect(session.id).toBeDefined(); expect(session.name).toBeDefined(); + expect(session.goose_mode).toBe('auto'); const getResponse = await getSession({ client: ctx.client, @@ -129,6 +132,105 @@ extensions: expect(sessionsResponse.data!.sessions).toBeDefined(); expect(Array.isArray(sessionsResponse.data!.sessions)).toBe(true); }); + + it('should persist goose_mode on the session', async () => { + await upsertConfig({ + client: ctx.client, + body: { key: 'GOOSE_MODE', value: 'approve', is_secret: false }, + }); + + try { + const startResponse = await startAgent({ + client: ctx.client, + body: { working_dir: os.tmpdir() }, + }); + expect(startResponse.response).toBeOkResponse(); + expect(startResponse.data!.goose_mode).toBe('approve'); + + const getResponse = await getSession({ + client: ctx.client, + path: { session_id: startResponse.data!.id }, + }); + expect(getResponse.response).toBeOkResponse(); + expect(getResponse.data!.goose_mode).toBe('approve'); + } finally { + // Restore default so subsequent tests don't inherit approve mode + await upsertConfig({ + client: ctx.client, + body: { key: 'GOOSE_MODE', value: 'auto', is_secret: false }, + }); + } + }); + + it('should update goose_mode on an active session via /agent/update_session', async () => { + const startResponse = await startAgent({ + client: ctx.client, + body: { working_dir: os.tmpdir() }, + }); + expect(startResponse.response).toBeOkResponse(); + const sessionId = startResponse.data!.id; + + const updateResponse = await updateSession({ + client: ctx.client, + body: { session_id: sessionId, goose_mode: 'approve' }, + }); + expect(updateResponse.response).toBeOkResponse(); + + const getResponse = await getSession({ + client: ctx.client, + path: { session_id: sessionId }, + }); + expect(getResponse.response).toBeOkResponse(); + expect(getResponse.data!.goose_mode).toBe('approve'); + }); + + it('should preserve goose_mode after provider swap via /agent/update_provider', async (testContext) => { + const configResponse = await readConfig({ + client: ctx.client, + body: { key: 'GOOSE_PROVIDER', is_secret: false }, + }); + const providerName = configResponse.data as string | null | undefined; + if (!providerName) { + testContext.skip('Skipping - no GOOSE_PROVIDER configured'); + return; + } + + const modelResponse = await readConfig({ + client: ctx.client, + body: { key: 'GOOSE_MODEL', is_secret: false }, + }); + const modelName = (modelResponse.data as string | null) || undefined; + + const startResponse = await startAgent({ + client: ctx.client, + body: { working_dir: os.tmpdir() }, + }); + expect(startResponse.response).toBeOkResponse(); + const sessionId = startResponse.data!.id; + + const updateResponse = await updateSession({ + client: ctx.client, + body: { session_id: sessionId, goose_mode: 'approve' }, + }); + expect(updateResponse.response).toBeOkResponse(); + + const providerResponse = await updateAgentProvider({ + client: ctx.client, + body: { + session_id: sessionId, + provider: providerName, + model: modelName, + }, + }); + expect(providerResponse.response).toBeOkResponse(); + + const getResponse = await getSession({ + client: ctx.client, + path: { session_id: sessionId }, + }); + expect(getResponse.response).toBeOkResponse(); + expect(getResponse.data!.goose_mode).toBe('approve'); + }); }); describe('messaging', () => {