From cd78c0ae15e657d1344e0fe88a026a40f5bd4ac8 Mon Sep 17 00:00:00 2001 From: Guoyu Su Date: Mon, 10 Aug 2026 23:02:42 +0800 Subject: [PATCH] fix(ai): discover Grok CLI reasoning effort dynamically via ACP --- crates/dbx-core/src/ai_grok_cli.rs | 540 +++++++++++++++++++++++++---- 1 file changed, 470 insertions(+), 70 deletions(-) diff --git a/crates/dbx-core/src/ai_grok_cli.rs b/crates/dbx-core/src/ai_grok_cli.rs index 23248e4ba2..9eaa257331 100644 --- a/crates/dbx-core/src/ai_grok_cli.rs +++ b/crates/dbx-core/src/ai_grok_cli.rs @@ -7,18 +7,20 @@ use crate::ai_cli_agent::{ build_cli_agent_prompt, cli_command, dbx_mcp_enabled_tools, dbx_mcp_scope_env, run_cli_jsonl_agent, toml_string, toml_string_array, CliAgentCommandSpec, CliAgentJsonlDialect, CliAgentProcessSpec, CliAgentRunOptions, }; +use serde_json::{json, Value}; use std::collections::{BTreeMap, BTreeSet}; use std::env; use std::path::{Path, PathBuf}; use std::process::Stdio; use std::time::{Duration, Instant}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::sync::Notify; const DEFAULT_GROK_MODELS: &[&str] = &["default", "grok-4.5"]; -/// Matches Grok CLI model metadata (`supports_reasoning_effort` + `reasoning_efforts`). -const DEFAULT_GROK_EFFORTS: &[&str] = &["low", "medium", "high"]; -const DEFAULT_GROK_EFFORT: &str = "high"; const GROK_MODEL_DISCOVERY_TIMEOUT: Duration = Duration::from_secs(10); +const GROK_PROCESS_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2); +const GROK_ACP_REQUEST_ID: &str = "dbx_model_discovery"; +const GROK_ISOLATION_ENV_KEYS: &[&str] = &["HOME", "USERPROFILE", "GROK_HOME", "GROK_AUTH_PATH"]; const DISALLOWED_BUILTIN_TOOLS: &str = "run_terminal_command,read_file,search_replace,list_dir,grep,kill_command_or_subagent,todo_write,get_command_or_subagent_output,spawn_subagent,scheduler_create,scheduler_delete,scheduler_list,monitor,search_tool,use_tool,workflow,enter_plan_mode,exit_plan_mode,ask_user_question,image_gen,image_edit,image_to_video,reference_to_video,write,bash,shell,edit,Glob,Grep"; pub type GrokRunOptions = CliAgentRunOptions; @@ -26,29 +28,23 @@ pub type GrokCommandSpec = CliAgentCommandSpec; struct GrokIsolatedHome { path: PathBuf, + grok_home: PathBuf, + auth_path: Option, } impl GrokIsolatedHome { - fn create(options: &GrokRunOptions) -> Result { + fn create(config: &AiConfig, options: Option<&GrokRunOptions>) -> Result { let path = env::temp_dir().join(format!("dbx-grok-cli-{}", uuid::Uuid::new_v4())); let grok_dir = path.join(".grok"); std::fs::create_dir_all(&grok_dir) .map_err(|error| format!("[grokCliRunFailed] Failed to create isolated Grok home: {error}"))?; - if let Some(auth_source) = real_grok_auth_path() { - let auth_dest = grok_dir.join("auth.json"); - std::fs::copy(&auth_source, &auth_dest).map_err(|error| { - format!( - "[grokCliNotAuthenticated] Failed to copy Grok auth credentials into the isolated home: {error}" - ) - })?; - } - let config_path = grok_dir.join("config.toml"); - std::fs::write(&config_path, grok_mcp_config_toml(options)) + let config_contents = options.map(grok_mcp_config_toml).unwrap_or_else(grok_discovery_config_toml); + std::fs::write(&config_path, config_contents) .map_err(|error| format!("[grokCliRunFailed] Failed to write isolated Grok MCP configuration: {error}"))?; - Ok(Self { path }) + Ok(Self { path, grok_home: grok_dir, auth_path: resolve_grok_auth_path(config) }) } fn write_prompt(&self, prompt: &str) -> Result { @@ -65,12 +61,37 @@ impl Drop for GrokIsolatedHome { } } -fn real_grok_home() -> Option { - env::var_os("HOME").map(PathBuf::from).map(|home| home.join(".grok")).filter(|path| path.is_dir()) +fn grok_discovery_config_toml() -> String { + "[cli]\nauto_update = false\nuse_leader = false\n".to_string() } -fn real_grok_auth_path() -> Option { - real_grok_home().map(|home| home.join("auth.json")).filter(|path| path.is_file()) +fn configured_grok_env_value(config: &AiConfig, name: &str) -> Option { + config + .grok_cli_env + .iter() + .find(|(key, _)| key.eq_ignore_ascii_case(name)) + .map(|(_, value)| value.trim()) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) +} + +fn resolve_grok_auth_path(config: &AiConfig) -> Option { + configured_grok_env_value(config, "GROK_AUTH_PATH") + .or_else(|| configured_grok_env_value(config, "GROK_HOME").map(|home| home.join("auth.json"))) + .or_else(|| env::var_os("GROK_AUTH_PATH").filter(|value| !value.is_empty()).map(PathBuf::from)) + .or_else(|| { + env::var_os("GROK_HOME") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .map(|home| home.join("auth.json")) + }) + .or_else(|| { + env::var_os("HOME") + .or_else(|| env::var_os("USERPROFILE")) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .map(|home| home.join(".grok").join("auth.json")) + }) } fn grok_program(config: &AiConfig) -> String { @@ -80,11 +101,18 @@ fn grok_program(config: &AiConfig) -> String { fn grok_process_env( config: &AiConfig, command: &GrokCommandSpec, - home: Option<&Path>, + isolated_home: Option<&GrokIsolatedHome>, ) -> Result, String> { let mut env = BTreeMap::from_iter(grok_cli_env(config)?); - if let Some(home) = home { - env.insert("HOME".to_string(), home.to_string_lossy().to_string()); + if let Some(isolated_home) = isolated_home { + env.retain(|key, _| !GROK_ISOLATION_ENV_KEYS.iter().any(|managed| key.eq_ignore_ascii_case(managed))); + let home = isolated_home.path.to_string_lossy().to_string(); + env.insert("HOME".to_string(), home.clone()); + env.insert("USERPROFILE".to_string(), home); + env.insert("GROK_HOME".to_string(), isolated_home.grok_home.to_string_lossy().to_string()); + if let Some(auth_path) = &isolated_home.auth_path { + env.insert("GROK_AUTH_PATH".to_string(), auth_path.to_string_lossy().to_string()); + } } if let Some(dir) = command_parent_dir(command) { let user_path = env.get("PATH").map(String::as_str); @@ -258,6 +286,7 @@ fn grok_mcp_config_toml(options: &GrokRunOptions) -> String { let mut lines = vec![ "[cli]".to_string(), "auto_update = false".to_string(), + "use_leader = false".to_string(), String::new(), // Tool auto-approval is driven by the `--always-approve` CLI flag, not a // config key: grok's permission_mode enum has no "always-approve" value @@ -289,6 +318,7 @@ pub fn build_grok_command(config: &AiConfig, prompt_file: &Path, options: &GrokR // `--always-approve`, `--effort`). Prompt is written to a temp file so long // DBX system+conversation prompts stay under OS argv limits. let mut args = vec![ + "--no-leader".to_string(), "--prompt-file".to_string(), prompt_file.to_string_lossy().to_string(), "--output-format".to_string(), @@ -335,41 +365,12 @@ pub fn build_grok_prompt(system_prompt: &str, messages: &[crate::ai::AiMessage], build_cli_agent_prompt("Grok", system_prompt, messages, allow_write_sql) } -fn grok_effort_option(id: &str, label: &str, description: &str) -> AiEffortOption { - AiEffortOption { - id: id.to_string(), - label: label.to_string(), - description: Some(description.to_string()), - selection: AiEffortSelection::Enum(id.to_string()), - } -} - -/// Grok CLI reasoning effort levels (same surface as Codex model effort in the assistant). -pub fn grok_effort_capability() -> AiEffortCapability { - AiEffortCapability::Enum { - options: vec![ - grok_effort_option("low", "Low", "Quick, fast implementations"), - grok_effort_option("medium", "Medium", "Balanced effort with standard implementation and testing"), - grok_effort_option("high", "High", "Highest implementation quality with extensive reasoning"), - ], - default: AiEffortSelection::Enum(DEFAULT_GROK_EFFORT.to_string()), - source: AiCapabilitySource::LocalCli, - } -} - -fn with_grok_effort(mut model: AiModelInfo) -> AiModelInfo { - model.effort_capability = Some(grok_effort_capability()); - model.supported_effort_levels = - DEFAULT_GROK_EFFORTS.iter().filter_map(|level| level.parse::().ok()).collect(); - model -} - fn default_grok_models() -> Vec { DEFAULT_GROK_MODELS .iter() .map(|id| { let display = if *id == "default" { Some("Default".to_string()) } else { None }; - with_grok_effort(AiModelInfo::new(*id, display)) + AiModelInfo::new(*id, display) }) .collect() } @@ -380,6 +381,71 @@ pub async fn list_grok_models(config: &AiConfig) -> Result, Str } async fn discover_grok_models(config: &AiConfig, program: String) -> Option> { + if let Some(models) = discover_grok_models_acp(config, program.clone()).await { + return Some(models); + } + discover_grok_models_listing(config, program).await +} + +async fn discover_grok_models_acp(config: &AiConfig, program: String) -> Option> { + let isolated_home = GrokIsolatedHome::create(config, None).ok()?; + let command = + GrokCommandSpec { program, args: vec!["agent".to_string(), "--no-leader".to_string(), "stdio".to_string()] }; + let env = grok_process_env(config, &command, Some(&isolated_home)).ok()?; + let mut process = cli_command(&command.program); + process + .args(command.args.iter().map(String::as_str)) + .current_dir(&isolated_home.path) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .kill_on_drop(true); + for key in GROK_ISOLATION_ENV_KEYS { + process.env_remove(key); + } + process.envs(env.iter().map(|(key, value)| (key.as_str(), value.as_str()))); + + let mut child = process.spawn().ok()?; + let discovery = async { + let mut stdin = child.stdin.take()?; + let stdout = child.stdout.take()?; + let mut request = serde_json::to_vec(&json!({ + "jsonrpc": "2.0", + "id": GROK_ACP_REQUEST_ID, + "method": "initialize", + "params": { + "protocolVersion": 1, + "clientCapabilities": { + "fs": { "readTextFile": false, "writeTextFile": false }, + "terminal": false, + }, + "clientInfo": { "name": "DBX", "version": env!("CARGO_PKG_VERSION") }, + }, + })) + .ok()?; + request.push(b'\n'); + stdin.write_all(&request).await.ok()?; + stdin.flush().await.ok()?; + + let mut lines = BufReader::new(stdout).lines(); + while let Ok(Some(line)) = lines.next_line().await { + if let Some(models) = parse_grok_acp_initialize(&line) { + return Some(models); + } + } + None + }; + let result = tokio::time::timeout(GROK_MODEL_DISCOVERY_TIMEOUT, discovery).await.ok().flatten(); + stop_grok_process(&mut child).await; + result +} + +async fn stop_grok_process(child: &mut tokio::process::Child) { + let _ = child.start_kill(); + let _ = tokio::time::timeout(GROK_PROCESS_SHUTDOWN_TIMEOUT, child.wait()).await; +} + +async fn discover_grok_models_listing(config: &AiConfig, program: String) -> Option> { let command = GrokCommandSpec { program, args: vec!["models".to_string()] }; let env = grok_process_env(config, &command, None).ok()?; let mut process = cli_command(&command.program); @@ -398,6 +464,159 @@ async fn discover_grok_models(config: &AiConfig, program: String) -> Option Option> { + let response = serde_json::from_str::(line.trim()).ok()?; + if response.get("id").and_then(Value::as_str) != Some(GROK_ACP_REQUEST_ID) || response.get("error").is_some() { + return None; + } + let result = response.get("result")?; + let model_state = result + .get("_meta") + .and_then(|metadata| metadata.get("modelState").or_else(|| metadata.get("model_state"))) + .or_else(|| { + result.get("meta").and_then(|metadata| metadata.get("modelState").or_else(|| metadata.get("model_state"))) + }) + .or_else(|| result.get("modelState")) + .or_else(|| result.get("model_state"))?; + parse_grok_model_state(model_state) +} + +fn parse_grok_model_state(model_state: &Value) -> Option> { + let current_model_id = model_state + .get("currentModelId") + .or_else(|| model_state.get("current_model_id")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|id| !id.is_empty()) + .map(ToString::to_string); + let available_models = + model_state.get("availableModels").or_else(|| model_state.get("available_models")).and_then(Value::as_array)?; + + let mut seen = BTreeSet::new(); + let mut models = Vec::new(); + for model in available_models { + let Some(id) = model + .get("modelId") + .or_else(|| model.get("model_id")) + .or_else(|| model.get("value")) + .or_else(|| model.get("id")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|id| !id.is_empty()) + else { + continue; + }; + if !seen.insert(id.to_string()) { + continue; + } + let display_name = model + .get("name") + .or_else(|| model.get("displayName")) + .or_else(|| model.get("display_name")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(ToString::to_string); + let mut info = AiModelInfo::new(id, display_name); + info.effort_capability = parse_grok_effort_capability(model); + info.supported_effort_levels = grok_legacy_effort_levels(info.effort_capability.as_ref()); + models.push(info); + } + if models.is_empty() { + return None; + } + + let inherited = current_model_id + .as_deref() + .and_then(|current| models.iter().find(|model| model.id == current)) + .or_else(|| models.iter().find(|model| model.id.eq_ignore_ascii_case("default"))) + .cloned(); + models.retain(|model| !model.id.eq_ignore_ascii_case("default")); + let mut default_model = AiModelInfo::new("default", Some("Default".to_string())); + if let Some(inherited) = inherited { + default_model.effort_capability = inherited.effort_capability; + default_model.supported_effort_levels = inherited.supported_effort_levels; + } + models.insert(0, default_model); + + Some(models) +} + +fn grok_model_metadata_field<'a>(model: &'a Value, camel_case: &str, snake_case: &str) -> Option<&'a Value> { + model + .get("_meta") + .and_then(|metadata| metadata.get(camel_case).or_else(|| metadata.get(snake_case))) + .or_else(|| { + model.get("meta").and_then(|metadata| metadata.get(camel_case).or_else(|| metadata.get(snake_case))) + }) + .or_else(|| model.get(camel_case)) + .or_else(|| model.get(snake_case)) +} + +fn parse_grok_effort_capability(model: &Value) -> Option { + if grok_model_metadata_field(model, "supportsReasoningEffort", "supports_reasoning_effort").and_then(Value::as_bool) + == Some(false) + { + return Some(AiEffortCapability::Unsupported); + } + let efforts = grok_model_metadata_field(model, "reasoningEfforts", "reasoning_efforts")?.as_array()?; + let mut seen = BTreeSet::new(); + let mut provider_default = None; + let options = efforts + .iter() + .filter_map(|effort| { + let id = match effort { + Value::String(value) => value.as_str(), + Value::Object(_) => effort.get("value").or_else(|| effort.get("id")).and_then(Value::as_str)?, + _ => return None, + } + .trim(); + if id.is_empty() || !seen.insert(id.to_string()) { + return None; + } + if provider_default.is_none() + && effort + .get("default") + .or_else(|| effort.get("isDefault")) + .or_else(|| effort.get("is_default")) + .and_then(Value::as_bool) + == Some(true) + { + provider_default = Some(AiEffortSelection::Enum(id.to_string())); + } + let label = effort + .get("label") + .or_else(|| effort.get("name")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|label| !label.is_empty()) + .unwrap_or(id) + .to_string(); + let description = effort + .get("description") + .and_then(Value::as_str) + .map(str::trim) + .filter(|description| !description.is_empty()) + .map(ToString::to_string); + Some(AiEffortOption { + id: id.to_string(), + label, + description, + selection: AiEffortSelection::Enum(id.to_string()), + }) + }) + .collect::>(); + let default = provider_default.or_else(|| options.first().map(|option| option.selection.clone()))?; + Some(AiEffortCapability::Enum { options, default, source: AiCapabilitySource::LocalCli }) +} + +fn grok_legacy_effort_levels(capability: Option<&AiEffortCapability>) -> Vec { + let Some(AiEffortCapability::Enum { options, .. }) = capability else { + return Vec::new(); + }; + options.iter().filter_map(|option| option.id.parse::().ok()).collect() +} + fn parse_grok_models(stdout: &str) -> Option> { let mut seen = BTreeSet::new(); let mut models = Vec::new(); @@ -410,26 +629,37 @@ fn parse_grok_models(stdout: &str) -> Option> { if id.is_empty() || !seen.insert(id.to_string()) { continue; } - models.push(with_grok_effort(AiModelInfo::new(id, None))); + models.push(AiModelInfo::new(id, None)); } if models.is_empty() { return None; } if seen.insert("default".to_string()) { - models.insert(0, with_grok_effort(AiModelInfo::new("default", Some("Default".to_string())))); + models.insert(0, AiModelInfo::new("default", Some("Default".to_string()))); } Some(models) } pub async fn test_grok_connection(config: &AiConfig) -> Result { + test_grok_connection_with_timeout(config, GROK_MODEL_DISCOVERY_TIMEOUT).await +} + +async fn test_grok_connection_with_timeout( + config: &AiConfig, + timeout: Duration, +) -> Result { let start = Instant::now(); let program = validate_grok_program(config)?; let command = GrokCommandSpec { program, args: vec!["models".to_string()] }; let mut process = cli_command(&command.program); process.args(command.args.iter().map(String::as_str)); process.envs(grok_process_env(config, &command, None)?.iter().map(|(key, value)| (key.as_str(), value.as_str()))); + process.kill_on_drop(true); - let output = process.output().await.map_err(|e| classify_grok_spawn_error(&e.to_string()))?; + let output = tokio::time::timeout(timeout, process.output()) + .await + .map_err(|_| "[grokCliRunFailed] Grok CLI connection test timed out.".to_string())? + .map_err(|e| classify_grok_spawn_error(&e.to_string()))?; let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); let combined = @@ -507,16 +737,16 @@ pub async fn run_grok_agent( on_event: impl Fn(AgentEvent) + Send + Sync + 'static, ) -> Result { let program = validate_grok_program(config)?; - let isolated_home = GrokIsolatedHome::create(&options)?; + let isolated_home = GrokIsolatedHome::create(config, Some(&options))?; let prompt_path = isolated_home.write_prompt(prompt)?; let mut command = build_grok_command(config, &prompt_path, &options); command.program = program; - let env = grok_process_env(config, &command, Some(&isolated_home.path))?; + let env = grok_process_env(config, &command, Some(&isolated_home))?; run_cli_jsonl_agent( CliAgentProcessSpec { command, env, - env_remove: Vec::new(), + env_remove: GROK_ISOLATION_ENV_KEYS.iter().map(|key| (*key).to_string()).collect(), current_dir: Some(isolated_home.path.clone()), stdin: None, // Grok headless `streaming-json`: text/thought/tool_call/tool_call_update/end/error. @@ -533,14 +763,17 @@ pub async fn run_grok_agent( #[cfg(test)] mod tests { use super::{ - build_grok_command, classify_grok_run_error, default_grok_models, grok_cli_env, grok_effort_capability, - grok_mcp_config_toml, parse_grok_models, validate_grok_program, GrokRunOptions, + build_grok_command, classify_grok_run_error, default_grok_models, grok_cli_env, grok_mcp_config_toml, + grok_process_env, parse_grok_acp_initialize, parse_grok_models, validate_grok_program, GrokIsolatedHome, + GrokRunOptions, }; use crate::ai::{ AiApiStyle, AiAuthMethod, AiCapabilitySource, AiConfig, AiEffortCapability, AiEffortLevel, AiEffortSelection, AiProvider, AiReasoningLevel, }; use crate::ai_cli_agent::CliAgentCommandSpec; + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; use std::path::PathBuf; fn base_config() -> AiConfig { @@ -616,6 +849,7 @@ mod tests { let prompt = PathBuf::from("/tmp/dbx-prompt.txt"); let command = build_grok_command(&config, &prompt, &run_options()); assert_eq!(command.program, "grok"); + assert!(command.args.iter().any(|arg| arg == "--no-leader")); assert!(command.args.windows(2).any(|pair| pair == ["--prompt-file", "/tmp/dbx-prompt.txt"])); assert!(command.args.windows(2).any(|pair| pair == ["--output-format", "streaming-json"])); assert!(command.args.iter().any(|arg| arg == "--always-approve")); @@ -638,20 +872,185 @@ mod tests { } #[test] - fn models_expose_low_medium_high_effort_like_codex() { - let capability = grok_effort_capability(); - let AiEffortCapability::Enum { options, default, source } = capability else { - panic!("expected enum effort capability"); + fn parses_model_specific_effort_from_acp_initialize() { + let models = parse_grok_acp_initialize( + r#"{"jsonrpc":"2.0","id":"dbx_model_discovery","result":{"_meta":{"modelState":{"currentModelId":"grok-4.5","availableModels":[{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"supportsReasoningEffort":true,"reasoningEfforts":[{"value":"medium","label":"Medium Effort","default":false},{"id":"deep","value":"high","label":"High Effort","description":"Deep reasoning","default":true},{"value":"future","label":"Future Effort"},{"value":"high","label":"Duplicate","default":true}]}},{"modelId":"grok-fast","name":"Grok Fast","meta":{"supportsReasoningEffort":false}}]}}}}"#, + ) + .unwrap(); + + assert_eq!( + models.iter().map(|model| model.id.as_str()).collect::>(), + vec!["default", "grok-4.5", "grok-fast"] + ); + for id in ["default", "grok-4.5"] { + let model = models.iter().find(|model| model.id == id).unwrap(); + let Some(AiEffortCapability::Enum { options, default, source }) = &model.effort_capability else { + panic!("expected dynamic Grok effort capability"); + }; + assert_eq!(*source, AiCapabilitySource::LocalCli); + assert_eq!(*default, AiEffortSelection::Enum("high".to_string())); + assert_eq!( + options.iter().map(|option| option.id.as_str()).collect::>(), + vec!["medium", "high", "future"] + ); + assert_eq!(options[1].label, "High Effort"); + assert_eq!(options[1].description.as_deref(), Some("Deep reasoning")); + assert_eq!(model.supported_effort_levels, vec![AiEffortLevel::Medium, AiEffortLevel::High]); + } + let fast = models.iter().find(|model| model.id == "grok-fast").unwrap(); + assert_eq!(fast.effort_capability, Some(AiEffortCapability::Unsupported)); + assert!(fast.supported_effort_levels.is_empty()); + } + + #[test] + fn accepts_compatibility_shapes_and_bare_effort_values() { + let models = parse_grok_acp_initialize( + r#"{"jsonrpc":"2.0","id":"dbx_model_discovery","result":{"meta":{"model_state":{"current_model_id":"grok-next","available_models":[{"model_id":"grok-next","display_name":"Grok Next","supports_reasoning_effort":true,"reasoning_efforts":["low","xhigh","quantum","low"]}]}}}}"#, + ) + .unwrap(); + let model = models.iter().find(|model| model.id == "grok-next").unwrap(); + let Some(AiEffortCapability::Enum { options, .. }) = &model.effort_capability else { + panic!("expected dynamic Grok effort capability"); }; - assert_eq!(source, AiCapabilitySource::LocalCli); - assert_eq!(default, AiEffortSelection::Enum("high".to_string())); - assert_eq!(options.iter().map(|option| option.id.as_str()).collect::>(), vec!["low", "medium", "high"]); + assert_eq!( + options.iter().map(|option| option.id.as_str()).collect::>(), + vec!["low", "xhigh", "quantum"] + ); + assert_eq!(model.supported_effort_levels, vec![AiEffortLevel::Low, AiEffortLevel::Xhigh]); + } + + #[test] + fn missing_effort_metadata_and_listing_fallback_do_not_invent_levels() { + let models = parse_grok_acp_initialize( + r#"{"jsonrpc":"2.0","id":"dbx_model_discovery","result":{"_meta":{"modelState":{"currentModelId":"grok-plain","availableModels":[{"modelId":"grok-plain"},{"modelId":"grok-empty","_meta":{"supportsReasoningEffort":true}}]}}}}"#, + ) + .unwrap(); + assert!(models.iter().all(|model| model.effort_capability.is_none())); + assert!(models.iter().all(|model| model.supported_effort_levels.is_empty())); let stdout = "You are logged in with grok.com.\n\nDefault model: grok-4.5\n\nAvailable models:\n * grok-4.5 (default)\n"; let models = parse_grok_models(stdout).unwrap(); - let grok = models.iter().find(|model| model.id == "grok-4.5").unwrap(); - assert!(matches!(grok.effort_capability, Some(AiEffortCapability::Enum { .. }))); - assert_eq!(grok.supported_effort_levels, vec![AiEffortLevel::Low, AiEffortLevel::Medium, AiEffortLevel::High]); + assert!(models.iter().all(|model| model.effort_capability.is_none())); + assert!(default_grok_models().iter().all(|model| model.effort_capability.is_none())); + } + + #[test] + fn isolated_environment_overrides_config_home_but_reuses_selected_auth() { + let mut config = base_config(); + config.grok_cli_env.insert("GROK_HOME".to_string(), "/configured/grok-home".to_string()); + config.grok_cli_env.insert("GROK_AUTH_PATH".to_string(), "/selected/auth.json".to_string()); + let isolated = GrokIsolatedHome::create(&config, None).unwrap(); + let command = super::GrokCommandSpec { + program: "grok".to_string(), + args: vec!["agent".to_string(), "stdio".to_string()], + }; + let env = grok_process_env(&config, &command, Some(&isolated)) + .unwrap() + .into_iter() + .collect::>(); + + assert_eq!(env.get("HOME"), Some(&isolated.path.to_string_lossy().to_string())); + assert_eq!(env.get("USERPROFILE"), Some(&isolated.path.to_string_lossy().to_string())); + assert_eq!(env.get("GROK_HOME"), Some(&isolated.grok_home.to_string_lossy().to_string())); + assert_eq!(env.get("GROK_AUTH_PATH").map(String::as_str), Some("/selected/auth.json")); + assert_ne!(env.get("GROK_HOME").map(String::as_str), Some("/configured/grok-home")); + } + + #[cfg(unix)] + fn fake_grok_config() -> (AiConfig, PathBuf, PathBuf, PathBuf) { + let project_dir = std::env::temp_dir().join(format!("dbx-grok-project-test-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(project_dir.join(".grok")).unwrap(); + let fallback_marker = project_dir.join("models-command-used"); + let inference_marker = project_dir.join("inference-command-used"); + let isolation_marker = project_dir.join("isolation-failed"); + let executable = project_dir.join("grok"); + std::fs::write( + &executable, + r#"#!/bin/sh +case " $* " in + *" --prompt-file "*) + printf used > "$DBX_TEST_INFERENCE_MARKER" + exit 12 + ;; + *" agent --no-leader stdio "*) + if [ "$GROK_HOME" = "$DBX_TEST_PROJECT_DIR/.grok" ] || [ "$PWD" = "$DBX_TEST_PROJECT_DIR" ] || [ "$GROK_AUTH_PATH" != "/selected/auth.json" ]; then + printf leaked > "$DBX_TEST_ISOLATION_MARKER" + exit 13 + fi + IFS= read -r request + case "$request" in + *'"method":"initialize"'*) + printf '%s\n' '{"jsonrpc":"2.0","id":"dbx_model_discovery","result":{"_meta":{"modelState":{"currentModelId":"grok-dynamic","availableModels":[{"modelId":"grok-dynamic","name":"Grok Dynamic","_meta":{"supportsReasoningEffort":true,"reasoningEfforts":[{"value":"xhigh","label":"Extra High"},{"value":"future","label":"Future"}]}}]}}}}' + ;; + *) + exit 14 + ;; + esac + ;; + *" models "*) + if [ "$DBX_TEST_MODELS_HANG" = "1" ]; then + while :; do :; done + fi + printf used > "$DBX_TEST_MODELS_MARKER" + printf '%s\n' 'You are logged in with grok.com.' 'Available models:' ' * grok-fallback (default)' + ;; + *) + exit 15 + ;; +esac +"#, + ) + .unwrap(); + let mut permissions = std::fs::metadata(&executable).unwrap().permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&executable, permissions).unwrap(); + + let mut config = base_config(); + config.grok_cli_path = Some(executable.to_string_lossy().to_string()); + config.grok_cli_env.insert("GROK_HOME".to_string(), project_dir.join(".grok").to_string_lossy().to_string()); + config.grok_cli_env.insert("GROK_AUTH_PATH".to_string(), "/selected/auth.json".to_string()); + config.grok_cli_env.insert("DBX_TEST_PROJECT_DIR".to_string(), project_dir.to_string_lossy().to_string()); + config.grok_cli_env.insert("DBX_TEST_MODELS_MARKER".to_string(), fallback_marker.to_string_lossy().to_string()); + config + .grok_cli_env + .insert("DBX_TEST_INFERENCE_MARKER".to_string(), inference_marker.to_string_lossy().to_string()); + config + .grok_cli_env + .insert("DBX_TEST_ISOLATION_MARKER".to_string(), isolation_marker.to_string_lossy().to_string()); + (config, project_dir, fallback_marker, inference_marker) + } + + #[cfg(unix)] + #[tokio::test] + async fn discovery_uses_isolated_acp_initialize_without_inference() { + let (config, project_dir, models_marker, inference_marker) = fake_grok_config(); + let models = super::list_grok_models(&config).await.unwrap(); + assert_eq!(models.iter().map(|model| model.id.as_str()).collect::>(), ["default", "grok-dynamic"]); + let Some(AiEffortCapability::Enum { options, .. }) = &models[1].effort_capability else { + panic!("expected dynamic Grok effort capability"); + }; + assert_eq!(options.iter().map(|option| option.id.as_str()).collect::>(), ["xhigh", "future"]); + assert!(!models_marker.exists(), "ACP success must not fall back to the human-readable model listing"); + assert!(!inference_marker.exists(), "model discovery must not issue an inference request"); + assert!(!project_dir.join("isolation-failed").exists()); + + let result = super::test_grok_connection(&config).await.unwrap(); + assert!(result.success); + assert!(models_marker.exists(), "connection testing should use the non-inference models command"); + assert!(!inference_marker.exists()); + let _ = std::fs::remove_dir_all(project_dir); + } + + #[cfg(unix)] + #[tokio::test] + async fn connection_test_is_timeout_bounded() { + let (mut config, project_dir, _, _) = fake_grok_config(); + config.grok_cli_env.insert("DBX_TEST_MODELS_HANG".to_string(), "1".to_string()); + let error = + super::test_grok_connection_with_timeout(&config, std::time::Duration::from_millis(50)).await.unwrap_err(); + assert!(error.starts_with("[grokCliRunFailed]")); + assert!(error.contains("timed out")); + let _ = std::fs::remove_dir_all(project_dir); } #[test] @@ -663,6 +1062,7 @@ mod tests { assert!(toml.contains("DBX_MCP_SCOPE_CONNECTION_ID = \"conn-1\"")); assert!(toml.contains("DBX_MCP_ALLOW_WRITES = \"0\"")); assert!(toml.contains("[permission]")); + assert!(toml.contains("use_leader = false")); assert!(toml.contains("MCPTool(dbx__*)")); // Invalid fields removed: grok has no "always-approve" permission_mode, // and startup_timeout_sec/tool_timeout_sec/enabled_tools are not mcp_servers keys.