feat: persist GooseMode per-session via session DB (#7854)

Signed-off-by: Adrian Cole <adrian@tetrate.io>
This commit is contained in:
Adrian Cole
2026-03-16 19:37:21 +08:00
committed by GitHub
parent 2631095f20
commit 94fdcdd07a
42 changed files with 953 additions and 147 deletions
+15 -14
View File
@@ -375,12 +375,14 @@ impl GooseAcpAgent {
&self,
cx: Option<&JrConnectionCx<AgentToClient>>,
session_id: Option<&SessionId>,
goose_mode: Option<GooseMode>,
) -> Result<Arc<Agent>> {
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())
}
@@ -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>,
-1
View File
@@ -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::<ClientToProviderConnection>().await });
}
-2
View File
@@ -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::<ClientToAgentConnection>().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::<ClientToAgentConnection>().await });
}
+21 -6
View File
@@ -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<Identifier>,
resume: bool,
no_session: bool,
goose_mode: GooseMode,
) -> Result<Option<String>> {
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,
@@ -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?;
+2
View File
@@ -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?;
@@ -242,6 +242,7 @@ where
PathBuf::default(),
"scenario-runner".to_string(),
SessionType::Hidden,
GooseMode::default(),
)
.await?;
+19 -3
View File
@@ -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)
+1 -1
View File
@@ -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();
}
+3
View File
@@ -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,
+70 -3
View File
@@ -50,6 +50,12 @@ pub struct UpdateProviderRequest {
request_params: Option<std::collections::HashMap<String, serde_json::Value>>,
}
#[derive(Deserialize, utoipa::ToSchema)]
pub struct UpdateSessionRequest {
session_id: String,
goose_mode: Option<String>,
}
#[derive(Deserialize, utoipa::ToSchema)]
pub struct GetToolsQuery {
extension_name: Option<String>,
@@ -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<Arc<AppState>>,
Query(query): Query<GetToolsQuery>,
) -> Result<Json<Vec<ToolInfo>>, 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<ToolInfo> = 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<Arc<AppState>>,
Json(payload): Json<UpdateSessionRequest>,
) -> 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<AppState>) -> 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))
+2 -1
View File
@@ -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?;
+23 -18
View File
@@ -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<Mutex<GooseMode>> 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(())
}
+48 -8
View File
@@ -137,6 +137,7 @@ impl AgentConfig {
pub struct Agent {
pub(super) provider: SharedProvider,
pub config: AgentConfig,
pub(super) current_goose_mode: Mutex<GooseMode>,
pub extension_manager: Arc<ExtensionManager>,
pub(super) final_output_tool: Arc<Mutex<Option<FinalOutputTool>>>,
@@ -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::<usize>("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 =
@@ -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(&params), 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))?;
+12 -2
View File
@@ -55,6 +55,7 @@ pub struct SystemPromptBuilder<'a, M> {
subagents_enabled: bool,
hints: Option<String>,
code_execution_mode: bool,
goose_mode: Option<GooseMode>,
}
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();
+5
View File
@@ -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?;
+90 -7
View File
@@ -21,6 +21,7 @@ pub struct AgentManager {
scheduler: Arc<dyn SchedulerTrait>,
session_manager: Arc<SessionManager>,
default_provider: Arc<RwLock<Option<Arc<dyn crate::providers::base::Provider>>>>,
default_mode: GooseMode,
}
impl AgentManager {
@@ -28,6 +29,7 @@ impl AgentManager {
session_manager: Arc<SessionManager>,
schedule_file_path: std::path::PathBuf,
max_sessions: Option<usize>,
default_mode: GooseMode,
) -> Result<Self> {
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<Arc<Self>> {
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);
}
}
+15 -3
View File
@@ -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<ExtensionConfig> =
@@ -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)
}
+16
View File
@@ -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");
+5 -6
View File
@@ -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
+17
View File
@@ -270,6 +270,8 @@ pub struct ClaudeCodeProvider {
#[serde(skip)]
pending_confirmations:
Arc<tokio::sync::Mutex<HashMap<String, oneshot::Sender<PermissionConfirmation>>>>,
#[serde(skip)]
initial_mode: tokio::sync::Mutex<Option<GooseMode>>,
}
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),
}
}
+50 -9
View File
@@ -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<String>,
#[serde(skip)]
mode_by_session: tokio::sync::RwLock<HashMap<String, GooseMode>>,
}
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<Vec<String>, 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<Vec<String>, 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<String> = 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<String> = 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);
}
}
+1 -10
View File
@@ -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<MessageStream, ProviderError> {
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,
)?;
+1
View File
@@ -819,6 +819,7 @@ async fn execute_job(
std::env::current_dir()?,
format!("Scheduled job: {}", job.id),
SessionType::Scheduled,
agent.config.goose_mode,
)
.await?;
+131 -11
View File
@@ -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<String>,
pub model_config: Option<ModelConfig>,
#[serde(default)]
pub goose_mode: GooseMode,
}
pub struct SessionUpdateBuilder<'a> {
@@ -114,6 +117,7 @@ pub struct SessionUpdateBuilder<'a> {
user_recipe_values: Option<Option<HashMap<String, String>>>,
provider_name: Option<Option<String>>,
model_config: Option<Option<ModelConfig>>,
goose_mode: Option<GooseMode>,
}
#[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<Session> {
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::<String, _>("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<Session> {
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());
}
}
+3
View File
@@ -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");
+2
View File
@@ -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?;
+24
View File
@@ -119,6 +119,7 @@ struct ProviderTestConfig {
expected_session_id: fn() -> Arc<dyn ExpectedSessionId>,
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;
+56
View File
@@ -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": [
+3 -3
View File
@@ -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 <SettingsView onClose={() => navigate('/')} setView={setView} viewOptions={viewOptions} />;
return <SettingsView onClose={() => navigate('/')} setView={setView} viewOptions={{...viewOptions, sessionId: activeSessionId}} />;
};
const SessionsRoute = () => {
@@ -667,7 +667,7 @@ export function AppInner() {
/>
}
/>
<Route path="settings" element={<SettingsRoute />} />
<Route path="settings" element={<SettingsRoute activeSessionId={activeSessions[activeSessions.length - 1]?.sessionId} />} />
<Route
path="extensions"
element={
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+33
View File
@@ -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;
+1 -1
View File
@@ -1571,7 +1571,7 @@ export default function ChatInput({
</div>
</Tooltip>
<div className="w-px h-4 bg-border-primary mx-2" />
<BottomMenuModeSelection />
<BottomMenuModeSelection sessionId={sessionId} />
<div className="w-px h-4 bg-border-primary mx-2" />
<BottomMenuExtensionSelection sessionId={sessionId} />
{sessionId && messages.length > 0 && (
@@ -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<string, unknown> = {};
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 }) => <div>{children}</div>,
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DropdownMenuItem: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
describe('BottomMenuModeSelection', () => {
beforeEach(() => {
vi.clearAllMocks();
mockConfig = {};
});
it('displays mode from config when no session', async () => {
mockConfig.GOOSE_MODE = 'approve';
render(<BottomMenuModeSelection sessionId={null} />);
await waitFor(() => {
expect(screen.getByText('manual')).toBeInTheDocument();
});
});
it('defaults to auto when config has no mode', async () => {
mockConfig.GOOSE_MODE = undefined;
render(<BottomMenuModeSelection sessionId={null} />);
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(<BottomMenuModeSelection sessionId="test-session-123" />);
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(<BottomMenuModeSelection sessionId="test-session-123" />);
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(<BottomMenuModeSelection sessionId={null} />);
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(<BottomMenuModeSelection sessionId="session-A" />);
rerender(<BottomMenuModeSelection sessionId="session-B" />);
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();
});
});
@@ -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) {
@@ -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"
>
<ChatSettingsSection />
<ChatSettingsSection sessionId={viewOptions.sessionId} />
</TabsContent>
<TabsContent
@@ -6,7 +6,7 @@ import { GoosehintsSection } from './GoosehintsSection';
import { SpellcheckToggle } from './SpellcheckToggle';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card';
export default function ChatSettingsSection() {
export default function ChatSettingsSection({ sessionId }: { sessionId?: string }) {
return (
<div className="space-y-4 pr-4 pb-8 mt-1">
<Card className="pb-2 rounded-lg">
@@ -15,7 +15,7 @@ export default function ChatSettingsSection() {
<CardDescription>Configure how Goose interacts with tools and extensions</CardDescription>
</CardHeader>
<CardContent className="px-2">
<ModeSection />
<ModeSection sessionId={sessionId} />
</CardContent>
</Card>
@@ -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<number>(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 (
<div className="space-y-1">
+102
View File
@@ -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', () => {