diff --git a/crates/app-server/src/lib.rs b/crates/app-server/src/lib.rs index 7330dbe10e..62ca657234 100644 --- a/crates/app-server/src/lib.rs +++ b/crates/app-server/src/lib.rs @@ -1053,6 +1053,18 @@ async fn dispatch_stdio_request( dispatch_stdio_request_with_writer(state, &mut sink, method, params).await } +async fn dispatch_stdio_app_request( + state: &AppState, + request: AppRequest, +) -> std::result::Result { + let response = Box::pin(process_app_request(state, request, AppTransport::Stdio)).await; + Ok(StdioDispatchResult { + result: serde_json::to_value(response) + .map_err(|err| JsonRpcError::internal(err.to_string()))?, + should_exit: false, + }) +} + async fn dispatch_stdio_request_with_writer( state: &AppState, writer: &mut W, @@ -1095,6 +1107,7 @@ async fn dispatch_stdio_request_with_writer( "app/config/set", "app/config/unset", "app/config/list", + "app/config/reload", "app/models", "app/thread_loaded_list", "prompt/capabilities", @@ -1306,95 +1319,35 @@ async fn dispatch_stdio_request_with_writer( should_exit: false, } } - "app/capabilities" => { - let response = - process_app_request(state, AppRequest::Capabilities, AppTransport::Stdio).await; - StdioDispatchResult { - result: serde_json::to_value(response) - .map_err(|err| JsonRpcError::internal(err.to_string()))?, - should_exit: false, - } - } + "app/capabilities" => dispatch_stdio_app_request(state, AppRequest::Capabilities).await?, "app/request" => { let request: AppRequest = parse_params(params)?; - let response = process_app_request(state, request, AppTransport::Stdio).await; - StdioDispatchResult { - result: serde_json::to_value(response) - .map_err(|err| JsonRpcError::internal(err.to_string()))?, - should_exit: false, - } + dispatch_stdio_app_request(state, request).await? } "app/config/get" => { let parsed: ConfigGetParams = parse_params(params_or_object(params))?; - let response = process_app_request( - state, - AppRequest::ConfigGet { key: parsed.key }, - AppTransport::Stdio, - ) - .await; - StdioDispatchResult { - result: serde_json::to_value(response) - .map_err(|err| JsonRpcError::internal(err.to_string()))?, - should_exit: false, - } + dispatch_stdio_app_request(state, AppRequest::ConfigGet { key: parsed.key }).await? } "app/config/set" => { let parsed: ConfigSetParams = parse_params(params_or_object(params))?; - let response = process_app_request( + dispatch_stdio_app_request( state, AppRequest::ConfigSet { key: parsed.key, value: parsed.value, }, - AppTransport::Stdio, ) - .await; - StdioDispatchResult { - result: serde_json::to_value(response) - .map_err(|err| JsonRpcError::internal(err.to_string()))?, - should_exit: false, - } + .await? } "app/config/unset" => { let parsed: ConfigGetParams = parse_params(params_or_object(params))?; - let response = process_app_request( - state, - AppRequest::ConfigUnset { key: parsed.key }, - AppTransport::Stdio, - ) - .await; - StdioDispatchResult { - result: serde_json::to_value(response) - .map_err(|err| JsonRpcError::internal(err.to_string()))?, - should_exit: false, - } - } - "app/config/list" => { - let response = - process_app_request(state, AppRequest::ConfigList, AppTransport::Stdio).await; - StdioDispatchResult { - result: serde_json::to_value(response) - .map_err(|err| JsonRpcError::internal(err.to_string()))?, - should_exit: false, - } - } - "app/models" => { - let response = - process_app_request(state, AppRequest::Models, AppTransport::Stdio).await; - StdioDispatchResult { - result: serde_json::to_value(response) - .map_err(|err| JsonRpcError::internal(err.to_string()))?, - should_exit: false, - } + dispatch_stdio_app_request(state, AppRequest::ConfigUnset { key: parsed.key }).await? } + "app/config/list" => dispatch_stdio_app_request(state, AppRequest::ConfigList).await?, + "app/config/reload" => dispatch_stdio_app_request(state, AppRequest::ConfigReload).await?, + "app/models" => dispatch_stdio_app_request(state, AppRequest::Models).await?, "app/thread_loaded_list" | "app/thread-loaded-list" => { - let response = - process_app_request(state, AppRequest::ThreadLoadedList, AppTransport::Stdio).await; - StdioDispatchResult { - result: serde_json::to_value(response) - .map_err(|err| JsonRpcError::internal(err.to_string()))?, - should_exit: false, - } + dispatch_stdio_app_request(state, AppRequest::ThreadLoadedList).await? } "prompt/capabilities" => StdioDispatchResult { result: json!({ @@ -1430,7 +1383,7 @@ async fn process_app_request( ok: true, data: json!({ "routes": ["/thread", "/app", "/prompt", "/tool", "/jobs", "/mcp/startup"], - "config": ["get", "set", "unset", "list"], + "config": ["get", "set", "unset", "list", "reload"], "events": ["response_start", "response_delta", "response_end", "tool_call_start", "tool_call_result", "mcp_startup_update", "mcp_startup_complete"], "transport": "stdio+http", "config_path": state.config_path.as_ref().map(|p| p.display().to_string()), @@ -1456,9 +1409,22 @@ async fn process_app_request( let message = result.err().map(|e| e.to_string()); let snapshot = cfg.clone(); drop(cfg); + // Clone for the runtime before persist consumes `snapshot`. + let runtime_snapshot = snapshot.clone(); if let Err(e) = persist_config(state, snapshot).await { tracing::error!("Failed to persist config after set: {e}"); } + // Sync the updated config into the live Runtime so + // the next turn picks up the change without a restart. + // Only `config.toml` is touched here; `permissions.toml` + // (and therefore `exec_policy`) is intentionally left alone + // — use `ConfigReload` to pick up external permission edits. + let mut runtime = state.runtime.lock().await; + runtime.update_config(runtime_snapshot); + drop(runtime); + // Invalidate the cached stdio bridge child so the next + // request spawns a fresh runtime that picks up the + // persisted config from disk. invalidate_stdio_bridge(state).await; AppResponse { ok, @@ -1473,9 +1439,22 @@ async fn process_app_request( let message = result.err().map(|e| e.to_string()); let snapshot = cfg.clone(); drop(cfg); + // Clone for the runtime before persist consumes `snapshot`. + let runtime_snapshot = snapshot.clone(); if let Err(e) = persist_config(state, snapshot).await { tracing::error!("Failed to persist config after unset: {e}"); } + // Sync the updated config into the live Runtime so + // the next turn picks up the change without a restart. + // Only `config.toml` is touched here; `permissions.toml` + // (and therefore `exec_policy`) is intentionally left alone + // — use `ConfigReload` to pick up external permission edits. + let mut runtime = state.runtime.lock().await; + runtime.update_config(runtime_snapshot); + drop(runtime); + // Invalidate the cached stdio bridge child so the next + // request spawns a fresh runtime that picks up the + // persisted config from disk. invalidate_stdio_bridge(state).await; AppResponse { ok, @@ -1491,6 +1470,52 @@ async fn process_app_request( events: Vec::new(), } } + AppRequest::ConfigReload => { + // Re-read both `config.toml` and the sibling `permissions.toml` + // from disk (the headless equivalent of the TUI + // `reload_runtime_config` codepath) and push the fresh + // snapshots into `state.config` and the live `Runtime`. + // + // `ConfigStore::load` resolves the same default config path + // that `build_state` used at startup when `config_path` is + // `None`, so a `None` here reloads from the same on-disk file + // the server booted from. + let store = match ConfigStore::load(state.config_path.clone()) { + Ok(store) => store, + Err(e) => { + return AppResponse { + ok: false, + data: json!({ "error": format!("failed to load config: {e}") }), + events: Vec::new(), + }; + } + }; + let new_config = store.config.clone(); + let new_exec_policy = store.exec_policy_engine(); + + // Update the shared config lock so future + // ConfigGet / tool_handler reads see the new values. + { + let mut cfg = state.config.write().await; + *cfg = new_config.clone(); + } + + // Push both the config and the (possibly changed) exec policy + // into the live Runtime so the next prompt / thread turn uses + // the reloaded state. MCP server connections are NOT refreshed + // here — see `Runtime::reload_config_and_policy` for the + // rationale and the matching TUI `mcp_restart_required` note. + { + let mut runtime = state.runtime.lock().await; + runtime.reload_config_and_policy(new_config, new_exec_policy); + } + + AppResponse { + ok: true, + data: json!({ "reloaded": true }), + events: Vec::new(), + } + } AppRequest::Models => AppResponse { ok: true, data: json!({ "models": state.registry.list() }), @@ -1720,6 +1745,218 @@ mod tests { ); } + #[tokio::test] + async fn config_reload_refreshes_runtime_config_and_exec_policy_from_disk() { + let tmp = tempfile::tempdir().expect("tempdir"); + let config_path = tmp.path().join("config.toml"); + fs::write( + &config_path, + "api_key = \"sk-deepseek-secret\"\nmodel = \"deepseek-chat\"\n", + ) + .expect("write config"); + // No permissions.toml at startup → exec_policy starts empty. + let state = build_state(Some(config_path.clone()), None).expect("state"); + + // Sanity: initial runtime sees the on-disk model and has no rule. + { + let runtime = state.runtime.lock().await; + assert_eq!(runtime.config.model.as_deref(), Some("deepseek-chat")); + let decision = runtime + .exec_policy + .check(codewhale_execpolicy::ExecPolicyContext { + command: "cargo test", + cwd: "/workspace", + tool: Some("exec_shell"), + path: None, + ask_for_approval: codewhale_execpolicy::AskForApproval::UnlessTrusted, + sandbox_mode: Some("workspace-write"), + }) + .expect("policy check"); + assert!(decision.matched_rule.is_none()); + } + + // Edit both files on disk: new model + a permission rule. + fs::write( + &config_path, + "api_key = \"sk-deepseek-secret\"\nmodel = \"deepseek-reasoner\"\n", + ) + .expect("rewrite config"); + fs::write( + tmp.path().join("permissions.toml"), + r#" + [[rules]] + tool = "exec_shell" + command = "cargo test" + "#, + ) + .expect("write permissions"); + + // ConfigReload must re-read both files and push them into the + // live Runtime without a restart. + let response = + process_app_request(&state, AppRequest::ConfigReload, AppTransport::Stdio).await; + assert!(response.ok, "reload should succeed"); + assert_eq!(response.data["reloaded"], true); + + // The shared config lock reflects the new model. + { + let cfg = state.config.read().await; + assert_eq!(cfg.model.as_deref(), Some("deepseek-reasoner")); + } + // The live Runtime reflects both the new model and the new rule. + { + let runtime = state.runtime.lock().await; + assert_eq!(runtime.config.model.as_deref(), Some("deepseek-reasoner")); + let decision = runtime + .exec_policy + .check(codewhale_execpolicy::ExecPolicyContext { + command: "cargo test --workspace", + cwd: "/workspace", + tool: Some("exec_shell"), + path: None, + ask_for_approval: codewhale_execpolicy::AskForApproval::UnlessTrusted, + sandbox_mode: Some("workspace-write"), + }) + .expect("policy check"); + assert!(decision.allow); + assert!(decision.requires_approval); + assert_eq!( + decision.matched_rule.as_deref(), + Some("tool=exec_shell command=cargo test") + ); + } + } + + #[tokio::test] + async fn config_set_propagates_to_runtime_config_without_touching_exec_policy() { + let tmp = tempfile::tempdir().expect("tempdir"); + let config_path = tmp.path().join("config.toml"); + fs::write( + &config_path, + "api_key = \"sk-deepseek-secret\"\nmodel = \"deepseek-chat\"\n", + ) + .expect("write config"); + let state = build_state(Some(config_path.clone()), None).expect("state"); + + // Set a new model via the API. Only config.toml is touched; no + // permissions.toml exists, so exec_policy must stay empty. + let response = process_app_request( + &state, + AppRequest::ConfigSet { + key: "model".to_string(), + value: "deepseek-reasoner".to_string(), + }, + AppTransport::Stdio, + ) + .await; + assert!(response.ok, "set should succeed"); + + // Live runtime sees the new model. + { + let runtime = state.runtime.lock().await; + assert_eq!(runtime.config.model.as_deref(), Some("deepseek-reasoner")); + // exec_policy was empty at startup and must remain empty. + let decision = runtime + .exec_policy + .check(codewhale_execpolicy::ExecPolicyContext { + command: "cargo test", + cwd: "/workspace", + tool: Some("exec_shell"), + path: None, + ask_for_approval: codewhale_execpolicy::AskForApproval::UnlessTrusted, + sandbox_mode: Some("workspace-write"), + }) + .expect("policy check"); + assert!(decision.matched_rule.is_none()); + } + // The on-disk file was persisted. + let persisted = fs::read_to_string(&config_path).expect("read config"); + assert!(persisted.contains("deepseek-reasoner")); + } + + #[tokio::test] + async fn config_unset_propagates_to_runtime_config() { + let tmp = tempfile::tempdir().expect("tempdir"); + let config_path = tmp.path().join("config.toml"); + fs::write( + &config_path, + "api_key = \"sk-deepseek-secret\"\nmodel = \"deepseek-chat\"\n", + ) + .expect("write config"); + let state = build_state(Some(config_path.clone()), None).expect("state"); + + // Sanity: runtime starts with the on-disk model. + { + let runtime = state.runtime.lock().await; + assert_eq!(runtime.config.model.as_deref(), Some("deepseek-chat")); + } + + // Unset the model via the API. This walks a separate code path + // from ConfigSet (unset_value + update_config), so it needs its + // own regression coverage. + let response = process_app_request( + &state, + AppRequest::ConfigUnset { + key: "model".to_string(), + }, + AppTransport::Stdio, + ) + .await; + assert!(response.ok, "unset should succeed"); + + // Live runtime sees the cleared model. + { + let runtime = state.runtime.lock().await; + assert!(runtime.config.model.is_none()); + } + // Shared config lock agrees. + { + let cfg = state.config.read().await; + assert!(cfg.model.is_none()); + } + // The on-disk file no longer carries the model value. + let persisted = fs::read_to_string(&config_path).expect("read config"); + assert!(!persisted.contains("deepseek-chat")); + } + + #[tokio::test] + async fn config_reload_returns_error_when_disk_config_is_invalid() { + let tmp = tempfile::tempdir().expect("tempdir"); + let config_path = tmp.path().join("config.toml"); + fs::write( + &config_path, + "api_key = \"sk-deepseek-secret\"\nmodel = \"deepseek-chat\"\n", + ) + .expect("write config"); + let state = build_state(Some(config_path.clone()), None).expect("state"); + + // Corrupt the on-disk config so ConfigStore::load fails to parse. + fs::write(&config_path, "api_key = \"unterminated\n").expect("corrupt config"); + + let response = + process_app_request(&state, AppRequest::ConfigReload, AppTransport::Stdio).await; + assert!(!response.ok, "reload of corrupt config must fail"); + let err = response.data["error"] + .as_str() + .expect("error message present") + .to_string(); + assert!( + err.contains("failed to load config"), + "error should mention load failure, got: {err}" + ); + + // Live state is untouched: the early-return on load error must + // not have clobbered runtime.config or state.config. + { + let runtime = state.runtime.lock().await; + assert_eq!(runtime.config.model.as_deref(), Some("deepseek-chat")); + } + { + let cfg = state.config.read().await; + assert_eq!(cfg.model.as_deref(), Some("deepseek-chat")); + } + } + #[test] fn non_loopback_bind_without_auth_fails_fast() { let options = AppServerOptions { @@ -2011,6 +2248,7 @@ mod tests { "app/config/set", "app/config/unset", "app/config/list", + "app/config/reload", "app/models", "app/thread_loaded_list", "prompt/capabilities", diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 580e352eb6..8378e5b6f1 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -830,6 +830,46 @@ impl Runtime { } } + /// Update the live configuration in-place so the next turn picks up + /// changes without a restart. Called by the app-server after + /// `ConfigSet` or `ConfigUnset`. + /// + /// Only `config.toml` is touched by those operations, so the sibling + /// `permissions.toml` (and therefore `exec_policy`) is left unchanged. + /// + /// Fields that the TUI caches on its `App` struct (`api_provider`, + /// `reasoning_effort`, `mcp_config_path`, `skills_dir`, …) are read + /// live from `self.config` here via `resolve_runtime_options`, so they + /// take effect on the next prompt turn without any extra plumbing. + pub fn update_config(&mut self, config: ConfigToml) { + self.config = config; + } + + /// Reload the live configuration **and** the exec policy from a + /// freshly-loaded `ConfigStore`. Used by the app-server's + /// `ConfigReload` request, which re-reads both `config.toml` and the + /// sibling `permissions.toml` from disk. + /// + /// Unlike `update_config`, this also refreshes `self.exec_policy` so + /// externally edited permission rules take effect without a restart. + /// + /// Mirrors the TUI `reload_runtime_config` codepath for everything + /// that is reachable from the headless `Runtime`. The TUI-only caches + /// (`last_effective_reasoning_effort`, `model_compaction_budget`, + /// `ui_locale`, …) do not exist on `Runtime` and need no work here. + /// + /// **Not** refreshed by this call: + /// * `mcp_manager` — MCP server connections are loaded once at + /// startup from `mcp_config_path`. Changing `mcp_config_path` or the + /// referenced `mcp.json` still requires a restart, exactly as the + /// TUI flags via `mcp_restart_required`. + /// * `tool_registry` — built once at startup. + /// * `model_registry` — static catalog. + pub fn reload_config_and_policy(&mut self, config: ConfigToml, exec_policy: ExecPolicyEngine) { + self.config = config; + self.exec_policy = exec_policy; + } + fn persisted_thread_data(&self, thread_id: &str) -> Result { let history = self .thread_manager diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index de3a23ddae..32ae1e3e52 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -276,6 +276,19 @@ pub enum AppRequest { ConfigUnset { key: String }, /// List all configuration entries. ConfigList, + /// Reload configuration from disk and apply to the live runtime. + /// + /// Re-reads both `config.toml` and the sibling `permissions.toml`, + /// refreshing the live `Runtime.config` and `Runtime.exec_policy` + /// so headless clients can pick up external config-file *and* + /// permission-rule edits without restarting. + /// + /// Mirrors the TUI `reload_runtime_config` codepath for everything + /// reachable from the headless `Runtime`. MCP server connections + /// are not refreshed — changing `mcp_config_path` or the referenced + /// `mcp.json` still requires a restart, matching the TUI's + /// `mcp_restart_required` behavior. + ConfigReload, /// List available models. Models, /// List threads that are currently loaded in memory. diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 164a8d37fa..165a949d66 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -1367,6 +1367,7 @@ async fn main() -> Result<()> { insecure_no_auth: args.insecure_no_auth, mobile: args.mobile, show_qr: args.qr, + config_path: cli.config.clone(), }, ) .await diff --git a/crates/tui/src/runtime_api.rs b/crates/tui/src/runtime_api.rs index 6914e331a3..cbb0ed228b 100644 --- a/crates/tui/src/runtime_api.rs +++ b/crates/tui/src/runtime_api.rs @@ -87,13 +87,17 @@ use self::workspace::{collect_workspace_git_metadata, workspace_status}; #[derive(Clone)] pub struct RuntimeApiState { - config: Config, + config: Arc>, workspace: PathBuf, task_manager: SharedTaskManager, runtime_threads: SharedRuntimeThreadManager, cors_origins: Vec, sessions_dir: PathBuf, - mcp_config_path: PathBuf, + /// Original `--config` path (if any) used to load the initial config. + /// Passed to `Config::load` on reload and to persistence helpers so + /// GUI-driven config changes target the same file the server was + /// started with, instead of falling back to the default discovery. + config_path: Option, automations: SharedAutomationManager, sub_agent_manager: SharedSubAgentManager, runtime_token: Option, @@ -130,6 +134,10 @@ pub struct RuntimeApiOptions { pub mobile: bool, /// Show a QR code for the mobile URL in the terminal. pub show_qr: bool, + /// Original `--config` path used to load the initial config. When + /// `Some`, GUI-driven config reloads and persistence target this file + /// instead of the default discovery path. + pub config_path: Option, } impl Default for RuntimeApiOptions { @@ -143,6 +151,7 @@ impl Default for RuntimeApiOptions { insecure_no_auth: false, mobile: false, show_qr: false, + config_path: None, } } } @@ -445,13 +454,13 @@ pub async fn run_http_server( }); let sub_agent_manager = runtime_api_sub_agent_manager(&workspace, options.workers); let state = RuntimeApiState { - config: config.clone(), + config: Arc::new(parking_lot::RwLock::new(config.clone())), workspace, task_manager, runtime_threads, cors_origins: options.cors_origins.clone(), sessions_dir, - mcp_config_path: config.mcp_config_path(), + config_path: options.config_path.clone(), automations, sub_agent_manager, runtime_token: runtime_token.clone(), @@ -591,6 +600,8 @@ pub fn build_router(state: RuntimeApiState) -> Router { .route("/v1/usage", get(get_usage)) .route("/v1/snapshots", get(list_snapshots)) .route("/v1/snapshots/{id}/restore", post(restore_snapshot)) + .route("/v1/config", get(get_config).post(set_config)) + .route("/v1/config/reload", post(reload_config)) .route_layer(middleware::from_fn_with_state( state.clone(), require_runtime_token, @@ -708,6 +719,7 @@ async fn create_task( req.model = Some( state .config + .read() .default_text_model .clone() .unwrap_or_else(|| DEFAULT_TEXT_MODEL.to_string()), @@ -729,6 +741,7 @@ async fn create_thread( req.model = Some( state .config + .read() .default_text_model .clone() .unwrap_or_else(|| DEFAULT_TEXT_MODEL.to_string()), @@ -1022,6 +1035,7 @@ async fn stop_fleet_run( fn open_fleet_manager(state: &RuntimeApiState) -> Result { let exec_config = state .config + .read() .fleet .as_ref() .map(|fleet| fleet.exec.clone()) @@ -1251,10 +1265,14 @@ fn fleet_event_label(payload: &FleetWorkerEventPayload) -> String { async fn list_skills( State(state): State, ) -> Result, ApiError> { - let skills_dir = resolve_skills_dir(&state.config, &state.workspace); - let mode = crate::skills::SkillDiscoveryMode::from_codewhale_only( - state.config.skills_config().scan_codewhale_only(), - ); + let (skills_dir, mode) = { + let config = state.config.read(); + let skills_dir = resolve_skills_dir(&config, &state.workspace); + let mode = crate::skills::SkillDiscoveryMode::from_codewhale_only( + config.skills_config().scan_codewhale_only(), + ); + (skills_dir, mode) + }; let (registry, directories) = discover_skills_for_runtime_api(&state.workspace, &skills_dir, mode); let skill_state = state.skill_state.lock().await; @@ -1282,10 +1300,14 @@ async fn set_skill_enabled( Path(name): Path, Json(req): Json, ) -> Result, ApiError> { - let skills_dir = resolve_skills_dir(&state.config, &state.workspace); - let mode = crate::skills::SkillDiscoveryMode::from_codewhale_only( - state.config.skills_config().scan_codewhale_only(), - ); + let (skills_dir, mode) = { + let config = state.config.read(); + let skills_dir = resolve_skills_dir(&config, &state.workspace); + let mode = crate::skills::SkillDiscoveryMode::from_codewhale_only( + config.skills_config().scan_codewhale_only(), + ); + (skills_dir, mode) + }; let (registry, directories) = discover_skills_for_runtime_api(&state.workspace, &skills_dir, mode); let exists = registry.list().iter().any(|skill| skill.name == name); @@ -1387,7 +1409,8 @@ async fn runtime_info(State(state): State) -> Json, ) -> Result, ApiError> { - let config = crate::mcp::load_config_with_workspace(&state.mcp_config_path, &state.workspace) + let mcp_config_path = state.config.read().mcp_config_path(); + let config = crate::mcp::load_config_with_workspace(&mcp_config_path, &state.workspace) .map_err(|e| ApiError::internal(format!("Failed to load MCP config: {e}")))?; let mut servers = Vec::new(); @@ -1414,9 +1437,9 @@ async fn list_mcp_tools( ) -> Result, ApiError> { let mut pool_guard = state.mcp_pool.lock().await; if query.connect && pool_guard.is_none() { - let new_pool = - McpPool::from_config_path_with_workspace(&state.mcp_config_path, &state.workspace) - .map_err(|e| ApiError::internal(format!("Failed to load MCP config: {e}")))?; + let mcp_config_path = state.config.read().mcp_config_path(); + let new_pool = McpPool::from_config_path_with_workspace(&mcp_config_path, &state.workspace) + .map_err(|e| ApiError::internal(format!("Failed to load MCP config: {e}")))?; pool_guard.replace(new_pool); } @@ -2019,6 +2042,7 @@ async fn stream_turn( let model = req.model.clone().unwrap_or_else(|| { state .config + .read() .default_text_model .clone() .unwrap_or_else(|| DEFAULT_TEXT_MODEL.to_string()) @@ -2028,7 +2052,7 @@ async fn stream_turn( .clone() .unwrap_or_else(|| state.workspace.clone()); let mode = req.mode.clone().unwrap_or_else(|| "agent".to_string()); - let allow_shell = req.allow_shell.unwrap_or(state.config.allow_shell()); + let allow_shell = req.allow_shell.unwrap_or(state.config.read().allow_shell()); let trust_mode = req.trust_mode.unwrap_or(false); let auto_approve = req.auto_approve.unwrap_or(false); let prompt = req.prompt; @@ -2464,6 +2488,332 @@ fn snapshot_entries_for_workspace( .collect()) } +// ── Config endpoints ── + +/// GUI-relevant config snapshot returned by `GET /v1/config`. +#[derive(Debug, Clone, Serialize)] +struct GuiConfigResponse { + model: String, + provider: String, + approval_mode: String, + reasoning_effort: String, + auto_compact: bool, + cost_currency: String, + default_mode: String, + default_model: String, + base_url: String, + allow_shell: bool, + mcp_config_path: String, + subagents_enabled: bool, + subagents_max_depth: u32, + show_thinking: bool, + show_tool_details: bool, + locale: String, + max_history: usize, + prefer_external_pdftotext: bool, + workspace_follow_symlinks: bool, + calm_mode: bool, +} + +/// Request body for `POST /v1/config` (set a single config key). +#[derive(Debug, Deserialize)] +struct SetConfigRequest { + key: String, + value: String, + #[serde(default)] + persist: bool, +} + +/// Response for `POST /v1/config` (set a single config key). +#[derive(Debug, Serialize)] +struct SetConfigResponse { + key: String, + value: String, + message: String, + persisted: bool, + requires_reload: bool, +} + +/// Response for `POST /v1/config/reload`. +#[derive(Debug, Serialize)] +struct ReloadConfigResponse { + message: String, +} + +async fn get_config( + State(state): State, +) -> Result, ApiError> { + let config = state.config.read(); + let settings = crate::settings::Settings::load().unwrap_or_default(); + let mcp_config_path = config.mcp_config_path().display().to_string(); + + // Determine effective model: prefer config default, then constant. + let model = config + .default_text_model + .clone() + .unwrap_or_else(|| DEFAULT_TEXT_MODEL.to_string()); + + let provider = config.api_provider().as_str().to_string(); + let approval_mode = config + .approval_policy + .as_deref() + .unwrap_or("suggest") + .to_string(); + let reasoning_effort = config.reasoning_effort().unwrap_or("auto").to_string(); + let cost_currency = settings.cost_currency.clone(); + let default_mode = settings.default_mode.as_str().to_string(); + let default_model = settings + .default_model + .unwrap_or_else(|| DEFAULT_TEXT_MODEL.to_string()); + let base_url = config.deepseek_base_url().to_string(); + + Ok(Json(GuiConfigResponse { + model, + provider, + approval_mode, + reasoning_effort, + auto_compact: settings.auto_compact, + cost_currency, + default_mode, + default_model, + base_url, + allow_shell: config.allow_shell(), + mcp_config_path, + subagents_enabled: config.subagents_enabled(), + subagents_max_depth: config.subagent_max_spawn_depth(), + show_thinking: settings.show_thinking, + show_tool_details: settings.show_tool_details, + locale: settings.locale.clone(), + max_history: settings.max_input_history, + prefer_external_pdftotext: settings.prefer_external_pdftotext, + workspace_follow_symlinks: settings.workspace_follow_symlinks, + calm_mode: settings.calm_mode, + })) +} + +async fn set_config( + State(state): State, + Json(req): Json, +) -> Result, ApiError> { + use crate::config_persistence; + + let key = req.key.to_lowercase(); + let value = req.value; + let persist = req.persist; + + // All persisted config keys require a reload to take effect in the + // runtime (including syncing to active engines). The caller should + // POST /v1/config/reload after persisting. + let requires_reload = persist; + + // Handle persistence directly via config_persistence. + // The runtime's in-memory state is NOT mutated here; the caller + // should POST /v1/config/reload after persisting to apply changes. + if persist { + let config_path = state.config_path.as_deref(); + let result: anyhow::Result = match key.as_str() { + "model" | "default_model" => config_persistence::persist_root_string_key( + config_path, + "default_text_model", + &value, + ), + "reasoning_effort" => { + config_persistence::persist_root_string_key(config_path, "reasoning_effort", &value) + } + "approval_mode" | "approval_policy" => { + config_persistence::persist_root_string_key(config_path, "approval_policy", &value) + } + "base_url" => config_persistence::persist_root_string_key( + config_path, + "deepseek_base_url", + &value, + ), + "provider_url" | "provider_base_url" => { + let provider = state.config.read().api_provider(); + config_persistence::persist_provider_base_url_key(config_path, provider, &value) + } + "cost_currency" => { + let mut settings = crate::settings::Settings::load() + .map_err(|e| ApiError::internal(format!("Failed to load settings: {e}")))?; + settings.cost_currency = match value.as_str() { + "cny" | "yuan" | "rmb" => "cny".to_string(), + _ => "usd".to_string(), + }; + settings + .save() + .map_err(|e| ApiError::internal(format!("Failed to save settings: {e}")))?; + return Ok(Json(SetConfigResponse { + key, + value, + message: "Config persisted. Call /v1/config/reload to apply.".to_string(), + persisted: true, + requires_reload, + })); + } + "default_mode" => { + let mut settings = crate::settings::Settings::load() + .map_err(|e| ApiError::internal(format!("Failed to load settings: {e}")))?; + settings.default_mode = crate::tui::app::AppMode::from_setting(&value) + .as_setting() + .into(); + settings + .save() + .map_err(|e| ApiError::internal(format!("Failed to save settings: {e}")))?; + return Ok(Json(SetConfigResponse { + key, + value, + message: "Config persisted. Call /v1/config/reload to apply.".to_string(), + persisted: true, + requires_reload, + })); + } + "auto_compact" => { + let mut settings = crate::settings::Settings::load() + .map_err(|e| ApiError::internal(format!("Failed to load settings: {e}")))?; + settings.auto_compact = value.parse::().unwrap_or(true); + settings + .save() + .map_err(|e| ApiError::internal(format!("Failed to save settings: {e}")))?; + return Ok(Json(SetConfigResponse { + key, + value, + message: "Config persisted. Call /v1/config/reload to apply.".to_string(), + persisted: true, + requires_reload, + })); + } + "allow_shell" => { + config_persistence::persist_root_string_key(config_path, "allow_shell", &value) + } + "mcp_config_path" => { + config_persistence::persist_root_string_key(config_path, "mcp_config_path", &value) + } + "show_thinking" + | "show_tool_details" + | "calm_mode" + | "prefer_external_pdftotext" + | "workspace_follow_symlinks" => { + let mut settings = crate::settings::Settings::load() + .map_err(|e| ApiError::internal(format!("Failed to load settings: {e}")))?; + let bool_val = value.parse::().unwrap_or(false); + match key.as_str() { + "show_thinking" => settings.show_thinking = bool_val, + "show_tool_details" => settings.show_tool_details = bool_val, + "calm_mode" => settings.calm_mode = bool_val, + "prefer_external_pdftotext" => settings.prefer_external_pdftotext = bool_val, + "workspace_follow_symlinks" => settings.workspace_follow_symlinks = bool_val, + _ => {} + } + settings + .save() + .map_err(|e| ApiError::internal(format!("Failed to save settings: {e}")))?; + return Ok(Json(SetConfigResponse { + key, + value, + message: "Config persisted. Call /v1/config/reload to apply.".to_string(), + persisted: true, + requires_reload, + })); + } + "locale" => { + let mut settings = crate::settings::Settings::load() + .map_err(|e| ApiError::internal(format!("Failed to load settings: {e}")))?; + settings.locale = value.clone(); + settings + .save() + .map_err(|e| ApiError::internal(format!("Failed to save settings: {e}")))?; + return Ok(Json(SetConfigResponse { + key, + value, + message: "Config persisted. Call /v1/config/reload to apply.".to_string(), + persisted: true, + requires_reload, + })); + } + "max_history" => { + let mut settings = crate::settings::Settings::load() + .map_err(|e| ApiError::internal(format!("Failed to load settings: {e}")))?; + settings.max_input_history = value.parse::().map_err(|_| { + ApiError::bad_request(format!( + "Invalid value '{value}' for max_history: expected a non-negative integer" + )) + })?; + settings + .save() + .map_err(|e| ApiError::internal(format!("Failed to save settings: {e}")))?; + return Ok(Json(SetConfigResponse { + key, + value, + message: "Config persisted. Call /v1/config/reload to apply.".to_string(), + persisted: true, + requires_reload, + })); + } + "subagents_enabled" => { + let enabled = value.parse::().map_err(|_| { + ApiError::bad_request(format!( + "Invalid value '{value}' for subagents_enabled: expected 'true' or 'false'" + )) + })?; + config_persistence::persist_subagents_bool_key(config_path, "enabled", enabled) + } + "subagents_max_depth" => { + let raw = value.parse::().map_err(|_| { + ApiError::bad_request(format!( + "Invalid value '{value}' for subagents_max_depth: expected a non-negative integer" + )) + })?; + let clamped = raw.min(u64::from(codewhale_config::MAX_SPAWN_DEPTH_CEILING)); + config_persistence::persist_subagents_integer_key(config_path, "max_depth", clamped) + } + _ => { + return Err(ApiError::bad_request(format!( + "Unknown config key '{key}'. Supported keys: model, default_model, reasoning_effort, approval_mode, base_url, provider_url, cost_currency, default_mode, auto_compact, allow_shell, mcp_config_path, show_thinking, show_tool_details, locale, max_history, calm_mode, prefer_external_pdftotext, workspace_follow_symlinks, subagents_enabled, subagents_max_depth" + ))); + } + }; + + if let Err(e) = result { + return Err(ApiError::internal(format!( + "Failed to persist config key '{key}': {e}" + ))); + } + } + + Ok(Json(SetConfigResponse { + key, + value, + message: if persist { + "Config persisted. Call /v1/config/reload to apply.".to_string() + } else { + "Config not persisted (add persist: true to save)".to_string() + }, + persisted: persist, + requires_reload, + })) +} + +async fn reload_config( + State(state): State, +) -> Result, ApiError> { + let reloaded = Config::load(state.config_path.clone(), None) + .map_err(|e| ApiError::internal(format!("Failed to reload config: {e}")))?; + { + let mut config = state.config.write(); + *config = reloaded; + } + // Propagate config to RuntimeThreadManager so model routing uses the new values. + state + .runtime_threads + .reload_config(state.config.read().clone()); + // Sync running engines with the new config (model, compaction, timeouts, subagent settings). + state.runtime_threads.sync_engines_with_config().await; + Ok(Json(ReloadConfigResponse { + message: "Config reloaded from disk, propagated to runtime and synced to active engines" + .to_string(), + })) +} + const MOBILE_HTML: &str = include_str!("runtime_mobile.html"); /// Built-in dev origins always allowed by the runtime API (whalescale#255). diff --git a/crates/tui/src/runtime_api/tests.rs b/crates/tui/src/runtime_api/tests.rs index a5bfc732ec..391e7d930f 100644 --- a/crates/tui/src/runtime_api/tests.rs +++ b/crates/tui/src/runtime_api/tests.rs @@ -496,10 +496,39 @@ async fn spawn_test_server_with_root_token_mobile_workspace_and_subagents( SharedRuntimeThreadManager, tokio::task::JoinHandle<()>, )>, +> { + spawn_test_server_with_root_token_mobile_workspace_subagents_and_config_path( + root, + sessions_dir, + runtime_token, + mobile_enabled, + workspace, + sub_agent_manager, + None, + ) + .await +} + +async fn spawn_test_server_with_root_token_mobile_workspace_subagents_and_config_path( + root: PathBuf, + sessions_dir: PathBuf, + runtime_token: Option, + mobile_enabled: bool, + workspace: PathBuf, + sub_agent_manager: Option, + config_path: Option, +) -> Result< + Option<( + SocketAddr, + SharedRuntimeThreadManager, + tokio::task::JoinHandle<()>, + )>, > { let _ = rustls::crypto::ring::default_provider().install_default(); fs::create_dir_all(&sessions_dir)?; fs::create_dir_all(&workspace)?; + let mut config = Config::default(); + config.mcp_config_path = Some(root.join("mcp.json").to_string_lossy().to_string()); let manager = TaskManager::start_with_executor( TaskManagerConfig { data_dir: root.join("tasks"), @@ -515,7 +544,7 @@ async fn spawn_test_server_with_root_token_mobile_workspace_and_subagents( ) .await?; let runtime_threads: SharedRuntimeThreadManager = Arc::new(RuntimeThreadManager::open( - Config::default(), + config.clone(), workspace.clone(), RuntimeThreadManagerConfig::from_task_data_dir(root.join("runtime")), )?); @@ -529,13 +558,13 @@ async fn spawn_test_server_with_root_token_mobile_workspace_and_subagents( let sub_agent_manager = sub_agent_manager.unwrap_or_else(|| runtime_api_sub_agent_manager(&workspace, 2)); let state = RuntimeApiState { - config: Config::default(), + config: Arc::new(parking_lot::RwLock::new(config)), workspace, task_manager: manager, runtime_threads: runtime_threads.clone(), cors_origins: Vec::new(), sessions_dir, - mcp_config_path: root.join("mcp.json"), + config_path: config_path.clone(), mcp_pool: Arc::new(Mutex::new(None)), automations, sub_agent_manager, @@ -573,6 +602,31 @@ async fn spawn_test_server() -> Result< spawn_test_server_with_root(root, sessions_dir).await } +async fn spawn_test_server_with_config_path( + config_path: PathBuf, +) -> Result< + Option<( + SocketAddr, + SharedRuntimeThreadManager, + tokio::task::JoinHandle<()>, + )>, +> { + let root = std::env::temp_dir().join(format!("codewhale-config-api-{}", Uuid::new_v4())); + let sessions_dir = root.join("sessions"); + let workspace = root.join("workspace"); + fs::create_dir_all(&root)?; + spawn_test_server_with_root_token_mobile_workspace_subagents_and_config_path( + root, + sessions_dir, + None, + false, + workspace, + None, + Some(config_path), + ) + .await +} + async fn read_first_sse_frame(resp: reqwest::Response) -> Result { let mut stream = resp.bytes_stream(); let mut buf = Vec::new(); @@ -3737,3 +3791,790 @@ fn resolve_skills_dir_rejects_codewhale_only_symlink_escaping_workspace() { "with no valid in-workspace CodeWhale skills dir, resolution should fall back to config" ); } + +// --------------------------------------------------------------------------- +// /v1/config + /v1/config/reload endpoint tests +// --------------------------------------------------------------------------- + +/// Helper: POST to `/v1/config` with the given key/value and return the +/// response status + body JSON. +async fn post_set_config( + client: &reqwest::Client, + addr: &SocketAddr, + key: &str, + value: &str, + persist: bool, +) -> (reqwest::StatusCode, serde_json::Value) { + let resp = client + .post(format!("http://{addr}/v1/config")) + .json(&serde_json::json!({ + "key": key, + "value": value, + "persist": persist, + })) + .send() + .await + .expect("POST /v1/config should not fail at transport level"); + let status = resp.status(); + let body: serde_json::Value = resp + .json() + .await + .unwrap_or_else(|_| serde_json::json!({"_error": "non-json response body"})); + (status, body) +} + +#[tokio::test] +async fn set_config_rejects_unknown_key_with_bad_request() -> Result<()> { + let root = std::env::temp_dir().join(format!("codewhale-config-unknown-{}", Uuid::new_v4())); + let sessions_dir = root.join("sessions"); + let Some((addr, _runtime_threads, handle)) = + spawn_test_server_with_root(root, sessions_dir).await? + else { + return Ok(()); + }; + let client = crate::tls::reqwest_client(); + + let (status, body) = post_set_config(&client, &addr, "nonexistent_key", "x", true).await; + assert_eq!( + status, + StatusCode::BAD_REQUEST, + "unknown key should return 400, body: {body}" + ); + let message = body["error"]["message"] + .as_str() + .unwrap_or_default() + .to_lowercase(); + assert!( + message.contains("unknown config key"), + "error message should mention 'unknown config key', got: {message}" + ); + + handle.abort(); + Ok(()) +} + +#[tokio::test] +async fn set_config_validates_max_history_input() -> Result<()> { + // Fix #4: invalid max_history input must return 400 instead of silently + // falling back to a default value. + let root = std::env::temp_dir().join(format!("codewhale-config-maxhist-{}", Uuid::new_v4())); + let sessions_dir = root.join("sessions"); + let Some((addr, _runtime_threads, handle)) = + spawn_test_server_with_root(root, sessions_dir).await? + else { + return Ok(()); + }; + let client = crate::tls::reqwest_client(); + + // Non-integer input must be rejected. + let (status, body) = post_set_config(&client, &addr, "max_history", "not-a-number", true).await; + assert_eq!( + status, + StatusCode::BAD_REQUEST, + "invalid max_history should return 400, body: {body}" + ); + + // Negative input must also be rejected (parse:: rejects negatives). + let (status, body) = post_set_config(&client, &addr, "max_history", "-5", true).await; + assert_eq!( + status, + StatusCode::BAD_REQUEST, + "negative max_history should return 400, body: {body}" + ); + + handle.abort(); + Ok(()) +} + +#[tokio::test] +async fn set_config_validates_subagents_enabled_input() -> Result<()> { + // Fix #1: subagents_enabled must validate input and reject non-boolean + // values with a descriptive 400 error. + let root = std::env::temp_dir().join(format!("codewhale-config-subenabled-{}", Uuid::new_v4())); + let sessions_dir = root.join("sessions"); + let Some((addr, _runtime_threads, handle)) = + spawn_test_server_with_root(root, sessions_dir).await? + else { + return Ok(()); + }; + let client = crate::tls::reqwest_client(); + + let (status, body) = post_set_config(&client, &addr, "subagents_enabled", "maybe", true).await; + assert_eq!( + status, + StatusCode::BAD_REQUEST, + "non-boolean subagents_enabled should return 400, body: {body}" + ); + let message = body["error"]["message"] + .as_str() + .unwrap_or_default() + .to_lowercase(); + assert!( + message.contains("subagents_enabled"), + "error message should name the key, got: {message}" + ); + + handle.abort(); + Ok(()) +} + +#[tokio::test] +async fn set_config_validates_subagents_max_depth_input() -> Result<()> { + // Fix #1: subagents_max_depth must validate input and reject non-integer + // values with a descriptive 400 error. + let root = std::env::temp_dir().join(format!("codewhale-config-subdepth-{}", Uuid::new_v4())); + let sessions_dir = root.join("sessions"); + let Some((addr, _runtime_threads, handle)) = + spawn_test_server_with_root(root, sessions_dir).await? + else { + return Ok(()); + }; + let client = crate::tls::reqwest_client(); + + let (status, body) = post_set_config(&client, &addr, "subagents_max_depth", "deep", true).await; + assert_eq!( + status, + StatusCode::BAD_REQUEST, + "non-integer subagents_max_depth should return 400, body: {body}" + ); + + handle.abort(); + Ok(()) +} + +#[tokio::test] +async fn set_config_with_config_path_writes_to_specified_file() -> Result<()> { + // Fix #2: when the server is started with --config, set_config must + // persist to that specific file rather than the default discovery path. + let root = + std::env::temp_dir().join(format!("codewhale-config-path-persist-{}", Uuid::new_v4())); + fs::create_dir_all(&root)?; + let config_file = root.join("custom-config.toml"); + fs::write(&config_file, "# initial\n")?; + + let Some((addr, _runtime_threads, handle)) = + spawn_test_server_with_config_path(config_file.clone()).await? + else { + return Ok(()); + }; + let client = crate::tls::reqwest_client(); + + // Persist a subagents_max_depth value above the ceiling to also verify + // clamping (Fix #1). + let over_ceiling = u64::from(codewhale_config::MAX_SPAWN_DEPTH_CEILING) + 10; + let (status, body) = post_set_config( + &client, + &addr, + "subagents_max_depth", + &over_ceiling.to_string(), + true, + ) + .await; + assert_eq!( + status, + StatusCode::OK, + "persisting subagents_max_depth should succeed, body: {body}" + ); + assert!( + body["persisted"].as_bool().unwrap_or(false), + "response should report persisted=true, body: {body}" + ); + + // Read the config file and verify the value was clamped and written. + let contents = fs::read_to_string(&config_file) + .with_context(|| format!("config file should exist at {}", config_file.display()))?; + assert!( + contents.contains("max_depth"), + "config file should contain max_depth key, got: {contents}" + ); + // The value should be clamped to MAX_SPAWN_DEPTH_CEILING. + let expected = format!( + "max_depth = {}", + u64::from(codewhale_config::MAX_SPAWN_DEPTH_CEILING) + ); + assert!( + contents.contains(&expected), + "config file should contain clamped value '{expected}', got: {contents}" + ); + + // Also verify a subagents_enabled persistence writes to the same file. + let (status, body) = post_set_config(&client, &addr, "subagents_enabled", "true", true).await; + assert_eq!(status, StatusCode::OK, "body: {body}"); + let contents = fs::read_to_string(&config_file)?; + assert!( + contents.contains("enabled = true"), + "config file should contain enabled = true, got: {contents}" + ); + + handle.abort(); + Ok(()) +} + +#[tokio::test] +async fn reload_config_endpoint_returns_success() -> Result<()> { + // Basic smoke test that /v1/config/reload returns 200 with a message. + let root = std::env::temp_dir().join(format!("codewhale-config-reload-{}", Uuid::new_v4())); + let sessions_dir = root.join("sessions"); + let Some((addr, _runtime_threads, handle)) = + spawn_test_server_with_root(root, sessions_dir).await? + else { + return Ok(()); + }; + let client = crate::tls::reqwest_client(); + + let resp = client + .post(format!("http://{addr}/v1/config/reload")) + .send() + .await?; + assert_eq!(resp.status(), StatusCode::OK); + let body: serde_json::Value = resp.json().await?; + let message = body["message"].as_str().unwrap_or_default().to_string(); + assert!( + !message.is_empty(), + "reload response should include a non-empty message" + ); + + handle.abort(); + Ok(()) +} + +/// Helper: GET `/v1/config` and return the parsed response body. +async fn get_config(client: &reqwest::Client, addr: &SocketAddr) -> serde_json::Value { + client + .get(format!("http://{addr}/v1/config")) + .send() + .await + .expect("GET /v1/config should not fail at transport level") + .error_for_status() + .expect("GET /v1/config should return 200") + .json() + .await + .expect("GET /v1/config should return valid JSON") +} + +#[tokio::test] +async fn reload_config_reads_from_config_path_and_updates_in_memory_state() -> Result<()> { + // Fix #2 + reload behavior: This test proves that reload reads from the + // `--config` path (not default discovery) and actually updates the + // in-memory state visible to GET /v1/config. + // + // If Fix #2 is reverted (reload uses Config::load(None, None) instead of + // state.config_path), the reload will read an empty/default config and + // the persisted value will NOT appear in GET /v1/config → test fails. + let root = + std::env::temp_dir().join(format!("codewhale-config-reload-path-{}", Uuid::new_v4())); + fs::create_dir_all(&root)?; + let config_file = root.join("custom-config.toml"); + fs::write(&config_file, "# initial\n")?; + + let Some((addr, _runtime_threads, handle)) = + spawn_test_server_with_config_path(config_file.clone()).await? + else { + return Ok(()); + }; + let client = crate::tls::reqwest_client(); + + // Step 1: Record initial model value (should be the default, since + // Config::default() has default_text_model = None). + let before = get_config(&client, &addr).await; + let initial_model = before["model"].as_str().unwrap_or_default().to_string(); + assert!( + !initial_model.is_empty(), + "initial model should not be empty" + ); + // The initial subagents_max_depth should be DEFAULT_SPAWN_DEPTH (3) + // since Config::default() has no subagents config. + let initial_depth = before["subagents_max_depth"] + .as_u64() + .expect("subagents_max_depth should be a number"); + assert_eq!( + initial_depth, + u64::from(codewhale_config::DEFAULT_SPAWN_DEPTH), + "initial subagents_max_depth should be DEFAULT_SPAWN_DEPTH" + ); + + // Step 2: Persist a new model value to the config file. + // set_config must NOT mutate in-memory state (by design — the caller + // must call /v1/config/reload to apply changes). + // Use a valid DeepSeek model ID so Config::validate() doesn't reject + // the reloaded config. + let test_model = "deepseek-v4-flash"; + let (status, body) = post_set_config(&client, &addr, "model", test_model, true).await; + assert_eq!( + status, + StatusCode::OK, + "set_config should succeed, body: {body}" + ); + + // Step 3: Verify in-memory state is NOT mutated by set_config alone. + let after_set = get_config(&client, &addr).await; + assert_eq!( + after_set["model"].as_str().unwrap_or_default(), + initial_model, + "set_config must NOT update in-memory state before reload" + ); + + // Step 4: Also persist subagents_max_depth = 5 (below ceiling of 8). + let (status, body) = post_set_config(&client, &addr, "subagents_max_depth", "5", true).await; + assert_eq!(status, StatusCode::OK, "body: {body}"); + + // Step 5: Reload — this must read from config_file (not default discovery). + let reload_resp = client + .post(format!("http://{addr}/v1/config/reload")) + .send() + .await?; + assert_eq!(reload_resp.status(), StatusCode::OK); + + // Step 6: Verify in-memory state IS now updated after reload. + let after_reload = get_config(&client, &addr).await; + + // Model should reflect the persisted value. + assert_eq!( + after_reload["model"].as_str().unwrap_or_default(), + test_model, + "after reload, model should be the persisted value — \ + if this fails, reload is not reading from config_path" + ); + + // subagents_max_depth should reflect the persisted value (5). + assert_eq!( + after_reload["subagents_max_depth"].as_u64(), + Some(5), + "after reload, subagents_max_depth should be 5" + ); + + handle.abort(); + Ok(()) +} + +#[tokio::test] +async fn reload_config_refreshes_mcp_config_path() -> Result<()> { + // Fix #3: After reload, list_mcp_servers should see the new mcp_config_path + // from the reloaded config (not a stale cached value). + // + // This test works by: + // 1. Starting with config_path pointing to custom-config.toml (initially empty) + // 2. Writing mcp_config_path = to the config file via set_config + // 3. Reloading + // 4. GET /v1/config and verifying mcp_config_path field changed + // + // If Fix #3 were still needed (stale mcp_config_path field in state), + // this test would fail because the old field wouldn't update. Since we + // removed the stale field and read directly from config, this test also + // validates that architectural decision. + let root = + std::env::temp_dir().join(format!("codewhale-config-mcp-refresh-{}", Uuid::new_v4())); + fs::create_dir_all(&root)?; + let config_file = root.join("custom-config.toml"); + fs::write(&config_file, "# initial\n")?; + + let Some((addr, _runtime_threads, handle)) = + spawn_test_server_with_config_path(config_file.clone()).await? + else { + return Ok(()); + }; + let client = crate::tls::reqwest_client(); + + // Record initial mcp_config_path (set by test helper to root/mcp.json). + let before = get_config(&client, &addr).await; + let initial_mcp_path = before["mcp_config_path"] + .as_str() + .unwrap_or_default() + .to_string(); + assert!( + !initial_mcp_path.is_empty(), + "initial mcp_config_path should not be empty" + ); + + // Persist a new mcp_config_path to the config file. + let new_mcp_path = root.join("custom-mcp.json"); + let new_mcp_path_str = new_mcp_path.to_string_lossy().to_string(); + let (status, body) = + post_set_config(&client, &addr, "mcp_config_path", &new_mcp_path_str, true).await; + assert_eq!(status, StatusCode::OK, "body: {body}"); + + // Before reload, GET should still return the old path. + let after_set = get_config(&client, &addr).await; + assert_eq!( + after_set["mcp_config_path"].as_str().unwrap_or_default(), + initial_mcp_path, + "set_config must NOT update in-memory mcp_config_path before reload" + ); + + // Reload. + let reload_resp = client + .post(format!("http://{addr}/v1/config/reload")) + .send() + .await?; + assert_eq!(reload_resp.status(), StatusCode::OK); + + // After reload, GET should return the new path. + let after_reload = get_config(&client, &addr).await; + let reloaded_mcp_path = after_reload["mcp_config_path"] + .as_str() + .unwrap_or_default() + .to_string(); + assert_eq!( + reloaded_mcp_path, new_mcp_path_str, + "after reload, mcp_config_path should reflect the persisted value — \ + if this fails, the MCP path is stale after reload" + ); + + handle.abort(); + Ok(()) +} + +#[tokio::test] +async fn set_config_with_persist_false_does_not_write_to_disk() -> Result<()> { + // Verify the persist:false branch: response reports persisted:false and + // the config file on disk is NOT modified. This is the "dry run" path + // the GUI can use to validate input without committing changes. + let root = std::env::temp_dir().join(format!("codewhale-config-nopersist-{}", Uuid::new_v4())); + fs::create_dir_all(&root)?; + let config_file = root.join("custom-config.toml"); + let initial_contents = "# initial empty config\n"; + fs::write(&config_file, initial_contents)?; + + let Some((addr, _runtime_threads, handle)) = + spawn_test_server_with_config_path(config_file.clone()).await? + else { + return Ok(()); + }; + let client = crate::tls::reqwest_client(); + + let (status, body) = post_set_config(&client, &addr, "model", "some-model", false).await; + assert_eq!( + status, + StatusCode::OK, + "persist:false should still return 200, body: {body}" + ); + assert_eq!( + body["persisted"].as_bool(), + Some(false), + "persisted should be false when persist:false, body: {body}" + ); + assert_eq!( + body["requires_reload"].as_bool(), + Some(false), + "requires_reload should be false when persist:false, body: {body}" + ); + assert_eq!( + body["key"].as_str().unwrap_or_default(), + "model", + "key should echo the request key, body: {body}" + ); + + // The config file on disk must NOT have been modified. + let contents = fs::read_to_string(&config_file)?; + assert_eq!( + contents, initial_contents, + "persist:false must not modify the config file on disk" + ); + + handle.abort(); + Ok(()) +} + +#[tokio::test] +async fn set_config_subagents_max_depth_below_ceiling_not_clamped() -> Result<()> { + // Verify that values at and below the ceiling pass through unchanged. + // The existing clamping test only verifies over-ceiling clamping; this + // test ensures legitimate values are not accidentally modified. + let root = + std::env::temp_dir().join(format!("codewhale-config-depth-noclamp-{}", Uuid::new_v4())); + fs::create_dir_all(&root)?; + let config_file = root.join("custom-config.toml"); + fs::write(&config_file, "# initial\n")?; + + let Some((addr, _runtime_threads, handle)) = + spawn_test_server_with_config_path(config_file.clone()).await? + else { + return Ok(()); + }; + let client = crate::tls::reqwest_client(); + + // Test a value at the ceiling (should not be clamped). + let ceiling = u64::from(codewhale_config::MAX_SPAWN_DEPTH_CEILING); + let (status, body) = post_set_config( + &client, + &addr, + "subagents_max_depth", + &ceiling.to_string(), + true, + ) + .await; + assert_eq!(status, StatusCode::OK, "body: {body}"); + let contents = fs::read_to_string(&config_file)?; + let expected = format!("max_depth = {ceiling}"); + assert!( + contents.contains(&expected), + "value at ceiling should be written as-is: expected '{expected}', got: {contents}" + ); + + // Test a value below the ceiling (should not be clamped). + let below = ceiling.saturating_sub(1); + let (status, body) = post_set_config( + &client, + &addr, + "subagents_max_depth", + &below.to_string(), + true, + ) + .await; + assert_eq!(status, StatusCode::OK, "body: {body}"); + let contents = fs::read_to_string(&config_file)?; + let expected = format!("max_depth = {below}"); + assert!( + contents.contains(&expected), + "value below ceiling should be written as-is: expected '{expected}', got: {contents}" + ); + + handle.abort(); + Ok(()) +} + +#[tokio::test] +async fn set_config_subagents_enabled_false_persists() -> Result<()> { + // Verify that subagents_enabled=false is properly persisted. The + // existing test only verifies the true branch; this covers the false + // branch to ensure both boolean values round-trip correctly. + let root = std::env::temp_dir().join(format!("codewhale-config-subfalse-{}", Uuid::new_v4())); + fs::create_dir_all(&root)?; + let config_file = root.join("custom-config.toml"); + fs::write(&config_file, "[subagents]\nenabled = true\n")?; + + let Some((addr, _runtime_threads, handle)) = + spawn_test_server_with_config_path(config_file.clone()).await? + else { + return Ok(()); + }; + let client = crate::tls::reqwest_client(); + + let (status, body) = post_set_config(&client, &addr, "subagents_enabled", "false", true).await; + assert_eq!(status, StatusCode::OK, "body: {body}"); + assert!( + body["persisted"].as_bool().unwrap_or(false), + "should report persisted=true, body: {body}" + ); + + let contents = fs::read_to_string(&config_file)?; + assert!( + contents.contains("enabled = false"), + "config file should contain 'enabled = false', got: {contents}" + ); + + handle.abort(); + Ok(()) +} + +#[tokio::test] +async fn reload_config_with_malformed_file_returns_error() -> Result<()> { + // Verify error handling: if the config file contains invalid TOML, + // reload should return 500 instead of crashing or silently succeeding. + // This catches regressions where the map_err is accidentally removed. + let root = std::env::temp_dir().join(format!("codewhale-config-malformed-{}", Uuid::new_v4())); + fs::create_dir_all(&root)?; + let config_file = root.join("custom-config.toml"); + fs::write(&config_file, "# initial\n")?; + + let Some((addr, _runtime_threads, handle)) = + spawn_test_server_with_config_path(config_file.clone()).await? + else { + return Ok(()); + }; + let client = crate::tls::reqwest_client(); + + // Corrupt the config file with invalid TOML. + fs::write(&config_file, "this is = = not valid toml [[[\n")?; + + let resp = client + .post(format!("http://{addr}/v1/config/reload")) + .send() + .await?; + assert_eq!( + resp.status(), + StatusCode::INTERNAL_SERVER_ERROR, + "reload with malformed config should return 500" + ); + + // Verify the error response has a meaningful message. + let body: serde_json::Value = resp.json().await.unwrap_or_default(); + let message = body["error"]["message"] + .as_str() + .unwrap_or_default() + .to_lowercase(); + assert!( + message.contains("failed to reload config"), + "error message should mention reload failure, got: {message}" + ); + + handle.abort(); + Ok(()) +} + +#[tokio::test] +async fn reload_config_applies_multiple_persisted_keys() -> Result<()> { + // Verify that multiple set_config calls accumulate on disk and a single + // reload picks up ALL changes. This catches regressions where reload + // only applies the last-written key or where set_config overwrites + // prior keys unexpectedly. + let root = std::env::temp_dir().join(format!("codewhale-config-multi-{}", Uuid::new_v4())); + fs::create_dir_all(&root)?; + let config_file = root.join("custom-config.toml"); + fs::write(&config_file, "# initial\n")?; + + let Some((addr, _runtime_threads, handle)) = + spawn_test_server_with_config_path(config_file.clone()).await? + else { + return Ok(()); + }; + let client = crate::tls::reqwest_client(); + + // Record initial values. + let before = get_config(&client, &addr).await; + let initial_model = before["model"].as_str().unwrap_or_default().to_string(); + let initial_depth = before["subagents_max_depth"].as_u64().unwrap_or(0); + let initial_enabled = before["subagents_enabled"].as_bool().unwrap_or(false); + + // Persist three different keys. + // Use a valid DeepSeek model ID so Config::validate() doesn't reject + // the reloaded config. + let test_model = "deepseek-v4-pro"; + let (status, body) = post_set_config(&client, &addr, "model", test_model, true).await; + assert_eq!(status, StatusCode::OK, "body: {body}"); + + let (status, body) = post_set_config(&client, &addr, "subagents_max_depth", "4", true).await; + assert_eq!(status, StatusCode::OK, "body: {body}"); + + // Flip subagents_enabled to the opposite of its initial value. + let target_enabled = !initial_enabled; + let (status, body) = post_set_config( + &client, + &addr, + "subagents_enabled", + &target_enabled.to_string(), + true, + ) + .await; + assert_eq!(status, StatusCode::OK, "body: {body}"); + + // Before reload, in-memory state should be unchanged for all three keys. + let after_set = get_config(&client, &addr).await; + assert_eq!( + after_set["model"].as_str().unwrap_or_default(), + initial_model, + "model should be unchanged before reload" + ); + assert_eq!( + after_set["subagents_max_depth"].as_u64(), + Some(initial_depth), + "subagents_max_depth should be unchanged before reload" + ); + assert_eq!( + after_set["subagents_enabled"].as_bool(), + Some(initial_enabled), + "subagents_enabled should be unchanged before reload" + ); + + // Reload. + let reload_resp = client + .post(format!("http://{addr}/v1/config/reload")) + .send() + .await?; + assert_eq!(reload_resp.status(), StatusCode::OK); + + // After reload, ALL three keys should reflect their persisted values. + let after_reload = get_config(&client, &addr).await; + assert_eq!( + after_reload["model"].as_str().unwrap_or_default(), + test_model, + "model should be updated after reload" + ); + assert_eq!( + after_reload["subagents_max_depth"].as_u64(), + Some(4), + "subagents_max_depth should be 4 after reload" + ); + assert_eq!( + after_reload["subagents_enabled"].as_bool(), + Some(target_enabled), + "subagents_enabled should be {} after reload", + target_enabled + ); + + handle.abort(); + Ok(()) +} + +#[tokio::test] +async fn set_config_response_contains_all_expected_fields() -> Result<()> { + // Verify the SetConfigResponse shape: key, value, message, persisted, + // requires_reload. This catches serialization regressions and ensures + // the GUI client can rely on these fields being present and correct. + let root = std::env::temp_dir().join(format!("codewhale-config-shape-{}", Uuid::new_v4())); + fs::create_dir_all(&root)?; + let config_file = root.join("custom-config.toml"); + fs::write(&config_file, "# initial\n")?; + + let Some((addr, _runtime_threads, handle)) = + spawn_test_server_with_config_path(config_file.clone()).await? + else { + return Ok(()); + }; + let client = crate::tls::reqwest_client(); + + // persist:true → persisted=true, requires_reload=true + let (status, body) = post_set_config(&client, &addr, "model", "shape-test-model", true).await; + assert_eq!(status, StatusCode::OK, "body: {body}"); + assert_eq!( + body["key"].as_str(), + Some("model"), + "key field, body: {body}" + ); + assert_eq!( + body["value"].as_str(), + Some("shape-test-model"), + "value field, body: {body}" + ); + assert!( + body["message"].as_str().is_some_and(|m| !m.is_empty()), + "message should be non-empty, body: {body}" + ); + assert_eq!( + body["persisted"].as_bool(), + Some(true), + "persisted should be true, body: {body}" + ); + assert_eq!( + body["requires_reload"].as_bool(), + Some(true), + "requires_reload should be true when persist:true, body: {body}" + ); + + // persist:false → persisted=false, requires_reload=false + let (status, body) = post_set_config(&client, &addr, "model", "another-model", false).await; + assert_eq!(status, StatusCode::OK, "body: {body}"); + assert_eq!( + body["key"].as_str(), + Some("model"), + "key field, body: {body}" + ); + assert_eq!( + body["value"].as_str(), + Some("another-model"), + "value field, body: {body}" + ); + assert_eq!( + body["persisted"].as_bool(), + Some(false), + "persisted should be false, body: {body}" + ); + assert_eq!( + body["requires_reload"].as_bool(), + Some(false), + "requires_reload should be false when persist:false, body: {body}" + ); + + handle.abort(); + Ok(()) +} diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index 383d1ef4b9..7556a28790 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -791,7 +791,7 @@ pub type SharedRuntimeThreadManager = Arc; /// preserve a consistent ordering. #[derive(Clone)] pub struct RuntimeThreadManager { - config: Config, + config: Arc>, workspace: PathBuf, store: RuntimeThreadStore, active: Arc>, @@ -844,6 +844,116 @@ pub enum ExternalApprovalDecision { } impl RuntimeThreadManager { + /// Helper to read the current config under RwLock. + fn read_config(&self) -> parking_lot::RwLockReadGuard<'_, Config> { + self.config.read() + } + + /// Reload config from an updated Config instance (called after /v1/config/reload). + pub fn reload_config(&self, new_config: Config) { + let mut guard = self.config.write(); + *guard = new_config; + } + + /// Propagate the current config to all active engines by sending + /// `Op::SetModel`, `Op::SetCompaction`, `Op::SetStreamChunkTimeout`, and + /// `Op::SetSubagentRuntimeConfig`. This mirrors what the TUI does via + /// `apply_model_and_compaction_update` after a config change, ensuring + /// running engines pick up the new settings without a restart. + pub async fn sync_engines_with_config(&self) { + let (default_model, compaction_template, stream_chunk_timeout_secs, subagent_cfg) = { + let cfg = self.read_config(); + let settings = crate::settings::Settings::load().unwrap_or_default(); + let provider = cfg.api_provider(); + let auto_compact_enabled = + if crate::settings::Settings::auto_compact_explicitly_configured() { + settings.auto_compact + } else { + auto_compact_default_for_model( + &cfg.default_text_model.clone().unwrap_or_default(), + ) + }; + let compaction = crate::compaction::CompactionConfig { + enabled: auto_compact_enabled, + model: String::new(), // per-engine, filled below + token_threshold: compaction_threshold_for_model_at_percent( + &cfg.default_text_model.clone().unwrap_or_default(), + settings.auto_compact_threshold_percent, + ), + ..Default::default() + }; + let subagent = ( + cfg.subagents_enabled_for_provider(provider), + cfg.max_subagents_for_provider(provider) + .clamp(1, crate::config::MAX_SUBAGENTS), + cfg.launch_concurrency_for_provider(provider), + cfg.subagent_max_spawn_depth_for_provider(provider), + cfg.subagent_api_timeout_secs_for_provider(provider), + cfg.subagent_heartbeat_timeout_secs_for_provider(provider), + ); + ( + cfg.default_text_model.clone().unwrap_or_default(), + compaction, + cfg.stream_chunk_timeout_secs(), + subagent, + ) + }; + + // Collect engine handles and thread IDs, then release the active lock + // before doing any async work (sending Ops, loading thread records). + let entries: Vec<(String, EngineHandle)> = { + let active = self.active.lock().await; + active + .engines + .iter() + .map(|(id, state)| (id.clone(), state.engine.clone())) + .collect() + }; + + for (thread_id, engine) in entries { + let engine_model = self + .store + .load_thread(&thread_id) + .ok() + .map(|t| t.model) + .unwrap_or_else(|| default_model.clone()); + let engine_compaction = crate::compaction::CompactionConfig { + model: engine_model.clone(), + ..compaction_template.clone() + }; + + let _ = engine + .send(Op::SetModel { + model: engine_model, + mode: crate::tui::app::AppMode::Agent, + route_limits: None, + }) + .await; + let _ = engine + .send(Op::SetCompaction { + config: engine_compaction, + }) + .await; + let _ = engine + .send(Op::SetStreamChunkTimeout { + timeout_secs: stream_chunk_timeout_secs, + }) + .await; + let _ = engine + .send(Op::SetSubagentRuntimeConfig { + enabled: subagent_cfg.0, + max_subagents: subagent_cfg.1, + launch_concurrency: subagent_cfg.2, + max_spawn_depth: subagent_cfg.3, + api_timeout_secs: subagent_cfg.4, + heartbeat_timeout_secs: subagent_cfg.5, + }) + .await; + + tracing::info!(thread_id = %thread_id, "Synced engine with reloaded config"); + } + } + pub fn open( config: Config, workspace: PathBuf, @@ -852,7 +962,7 @@ impl RuntimeThreadManager { let store = RuntimeThreadStore::open(manager_cfg.data_dir.clone())?; let (event_tx, _event_rx) = broadcast::channel(EVENT_CHANNEL_CAPACITY); let manager = Self { - config, + config: Arc::new(parking_lot::RwLock::new(config)), workspace, store, active: Arc::new(Mutex::new(ActiveThreads::default())), @@ -1086,14 +1196,16 @@ impl RuntimeThreadManager { let model = req .model .filter(|m| !m.trim().is_empty()) - .or_else(|| self.config.default_text_model.clone()) + .or_else(|| self.read_config().default_text_model.clone()) .unwrap_or_else(|| DEFAULT_TEXT_MODEL.to_string()); let workspace = req.workspace.unwrap_or_else(|| self.workspace.clone()); let mode = req .mode .filter(|m| !m.trim().is_empty()) .unwrap_or_else(|| "agent".to_string()); - let allow_shell = req.allow_shell.unwrap_or_else(|| self.config.allow_shell()); + let allow_shell = req + .allow_shell + .unwrap_or_else(|| self.read_config().allow_shell()); let trust_mode = req.trust_mode.unwrap_or(false); let auto_approve = req.auto_approve.unwrap_or(false); @@ -2037,24 +2149,31 @@ impl RuntimeThreadManager { .unwrap_or_else(|| parse_mode(&thread.mode)); let requested_model = req.model.unwrap_or_else(|| thread.model.clone()); let auto_model = requested_model.trim().eq_ignore_ascii_case("auto"); - let (provider, model, reasoning_effort) = if auto_model { - let selection = crate::model_routing::resolve_auto_route_with_inventory( - &self.config, - &prompt, - "", - "auto", - "auto", - ) - .await?; - ( - selection.provider, - selection.model, - selection - .reasoning_effort - .map(|effort| effort.as_setting().to_string()), - ) - } else { - (self.config.api_provider(), requested_model, None) + // Snapshot config to avoid holding RwLockReadGuard across await points. + let cfg_snapshot = self.config.read().clone(); + let (provider, model, reasoning_effort, verbosity) = { + let verbosity = cfg_snapshot.verbosity.clone(); + if auto_model { + let selection = crate::model_routing::resolve_auto_route_with_inventory( + &cfg_snapshot, + &prompt, + "", + "auto", + "auto", + ) + .await?; + ( + selection.provider, + selection.model, + selection + .reasoning_effort + .map(|effort| effort.as_setting().to_string()), + verbosity, + ) + } else { + let provider = cfg_snapshot.api_provider(); + (provider, requested_model, None, verbosity) + } }; let allow_shell = req.allow_shell.unwrap_or(thread.allow_shell); let trust_mode = req.trust_mode.unwrap_or(thread.trust_mode); @@ -2088,7 +2207,7 @@ impl RuntimeThreadManager { } else { crate::tui::approval::ApprovalMode::Suggest }, - verbosity: self.config.verbosity.clone(), + verbosity, provenance: crate::core::ops::UserInputProvenance::ExternalUser, }) .await @@ -2374,6 +2493,9 @@ impl RuntimeThreadManager { } } + // Snapshot config once to avoid holding RwLockReadGuard across await points. + let cfg = self.read_config().clone(); + // Resolve the model-aware auto-compaction default unless the user // persisted an explicit preference. let settings = crate::settings::Settings::load().unwrap_or_default(); @@ -2392,17 +2514,15 @@ impl RuntimeThreadManager { ), ..Default::default() }; - let network_policy = self.config.network.clone().map(|toml_cfg| { + let network_policy = cfg.network.clone().map(|toml_cfg| { crate::network_policy::NetworkPolicyDecider::with_default_audit(toml_cfg.into_runtime()) }); - let lsp_config = self - .config + let lsp_config = cfg .lsp .clone() .map(crate::config::LspConfigToml::into_runtime); - let provider = self.config.api_provider(); - let max_subagents = self - .config + let provider = cfg.api_provider(); + let max_subagents = cfg .max_subagents_for_provider(provider) .clamp(1, MAX_SUBAGENTS); let engine_cfg = EngineConfig { @@ -2411,39 +2531,36 @@ impl RuntimeThreadManager { workspace: thread.workspace.clone(), allow_shell: thread.allow_shell, trust_mode: thread.trust_mode, - notes_path: self.config.notes_path(), - mcp_config_path: self.config.mcp_config_path(), - skills_dir: self.config.skills_dir(), - skills_scan_codewhale_only: self.config.skills_config().scan_codewhale_only(), - instructions: self - .config + notes_path: cfg.notes_path(), + mcp_config_path: cfg.mcp_config_path(), + skills_dir: cfg.skills_dir(), + skills_scan_codewhale_only: cfg.skills_config().scan_codewhale_only(), + instructions: cfg .instructions_paths() .into_iter() .map(Into::into) .collect(), - project_context_pack_enabled: self.config.project_context_pack_enabled(), + project_context_pack_enabled: cfg.project_context_pack_enabled(), translation_enabled: false, show_thinking: settings.show_thinking, max_steps: 100, max_subagents, - max_admitted_subagents: self - .config + max_admitted_subagents: cfg .max_admitted_subagents_for_provider(provider) .max(max_subagents), - launch_concurrency: self.config.launch_concurrency_for_provider(provider), - subagents_enabled: self.config.subagents_enabled_for_provider(provider), - features: self.config.features(), - auto_review_policy: self.config.auto_review_policy(), + launch_concurrency: cfg.launch_concurrency_for_provider(provider), + subagents_enabled: cfg.subagents_enabled_for_provider(provider), + features: cfg.features(), + auto_review_policy: cfg.auto_review_policy(), compaction, todos: new_shared_todo_list(), plan_state: new_shared_plan_state(), goal_state: crate::tools::goal::new_shared_goal_state(), - max_spawn_depth: self.config.subagent_max_spawn_depth_for_provider(provider), - subagent_token_budget: self.config.subagent_token_budget_for_provider(provider), + max_spawn_depth: cfg.subagent_max_spawn_depth_for_provider(provider), + subagent_token_budget: cfg.subagent_token_budget_for_provider(provider), network_policy, - snapshots_enabled: self.config.snapshots_config().enabled, - snapshots_max_workspace_bytes: self - .config + snapshots_enabled: cfg.snapshots_config().enabled, + snapshots_max_workspace_bytes: cfg .snapshots_config() .max_workspace_gb .saturating_mul(1024 * 1024 * 1024), @@ -2460,24 +2577,21 @@ impl RuntimeThreadManager { handle_store: crate::tools::handle::new_shared_handle_store(), rlm_sessions: crate::rlm::session::new_shared_rlm_session_store(), }, - subagent_model_overrides: self.config.subagent_model_overrides(), + subagent_model_overrides: cfg.subagent_model_overrides(), subagent_api_timeout: std::time::Duration::from_secs( - self.config.subagent_api_timeout_secs_for_provider(provider), - ), - stream_chunk_timeout: std::time::Duration::from_secs( - self.config.stream_chunk_timeout_secs(), + cfg.subagent_api_timeout_secs_for_provider(provider), ), + stream_chunk_timeout: std::time::Duration::from_secs(cfg.stream_chunk_timeout_secs()), subagent_heartbeat_timeout: std::time::Duration::from_secs( - self.config - .subagent_heartbeat_timeout_secs_for_provider(provider), + cfg.subagent_heartbeat_timeout_secs_for_provider(provider), ), - prefer_bwrap: self.config.prefer_bwrap.unwrap_or(false), - memory_enabled: self.config.memory_enabled(), - moraine_fallback: self.config.moraine_fallback(), - memory_path: self.config.memory_path(), - speech_output_dir: self.config.speech_output_dir(), - vision_config: self.config.vision_model_config(), - strict_tool_mode: self.config.strict_tool_mode.unwrap_or(false), + prefer_bwrap: cfg.prefer_bwrap.unwrap_or(false), + memory_enabled: cfg.memory_enabled(), + moraine_fallback: cfg.moraine_fallback(), + memory_path: cfg.memory_path(), + speech_output_dir: cfg.speech_output_dir(), + vision_config: cfg.vision_model_config(), + strict_tool_mode: cfg.strict_tool_mode.unwrap_or(false), goal_objective: None, goal_token_budget: None, goal_status: crate::tools::goal::GoalStatus::Active, @@ -2487,18 +2601,18 @@ impl RuntimeThreadManager { locale_tag: crate::localization::resolve_locale(&settings.locale) .tag() .to_string(), - workshop: self.config.workshop.clone(), - search_provider: self.config.search_provider(), - search_api_key: self.config.search.as_ref().and_then(|s| s.api_key.clone()), - search_base_url: self.config.search.as_ref().and_then(|s| s.base_url.clone()), - tools_always_load: self.config.tools_always_load(), - tools: self.config.tools.clone(), - verbosity: self.config.verbosity.clone(), + workshop: cfg.workshop.clone(), + search_provider: cfg.search_provider(), + search_api_key: cfg.search.as_ref().and_then(|s| s.api_key.clone()), + search_base_url: cfg.search.as_ref().and_then(|s| s.base_url.clone()), + tools_always_load: cfg.tools_always_load(), + tools: cfg.tools.clone(), + verbosity: cfg.verbosity.clone(), workspace_follow_symlinks: settings.workspace_follow_symlinks, - exec_policy_engine: self.config.exec_policy_engine.clone(), + exec_policy_engine: cfg.exec_policy_engine.clone(), }; - let engine = spawn_engine(engine_cfg, &self.config); + let engine = spawn_engine(engine_cfg, &cfg); // When the thread has an associated session, load the full message history // (including thinking/tool blocks) from the session file. This preserves