fix(core/agents): skip missing agent CLIs by default (fixes #512)

This commit is contained in:
James Peter
2026-01-13 21:32:42 +10:00
parent 7d16217189
commit ca1bd4ef78
3 changed files with 127 additions and 5 deletions
+1
View File
@@ -479,6 +479,7 @@ impl Stream for ResponseStream {
#[cfg(test)]
mod tests {
use crate::model_family::find_family_for_model;
use code_apply_patch::APPLY_PATCH_TOOL_INSTRUCTIONS;
use pretty_assertions::assert_eq;
use super::*;
+86 -1
View File
@@ -1,3 +1,4 @@
use crate::agent_defaults::agent_model_spec;
use crate::agent_defaults::enabled_agent_model_specs;
use crate::config_types::AgentConfig;
use crate::config_types::SubagentCommandConfig;
@@ -7,11 +8,68 @@ use crate::config_types::SubagentCommandConfig;
// the user documentation in `docs/slash-commands.md` so the list stays in sync
// with the UI and behavior.
fn command_exists(cmd: &str) -> bool {
if cmd.contains(std::path::MAIN_SEPARATOR) || cmd.contains('/') || cmd.contains('\\') {
return std::fs::metadata(cmd).map(|m| m.is_file()).unwrap_or(false);
}
#[cfg(target_os = "windows")]
{
which::which(cmd).map(|p| p.is_file()).unwrap_or(false)
}
#[cfg(not(target_os = "windows"))]
{
use std::os::unix::fs::PermissionsExt;
let Some(path_os) = std::env::var_os("PATH") else {
return false;
};
for dir in std::env::split_paths(&path_os) {
if dir.as_os_str().is_empty() {
continue;
}
let candidate = dir.join(cmd);
if let Ok(meta) = std::fs::metadata(&candidate) {
if meta.is_file() && (meta.permissions().mode() & 0o111 != 0) {
return true;
}
}
}
false
}
}
/// Get the list of enabled agent names from the configuration
pub fn get_enabled_agents(agents: &[AgentConfig]) -> Vec<String> {
fn command_for_check(command: &str) -> &str {
command.split_whitespace().next().unwrap_or(command)
}
fn agent_is_runnable(agent: &AgentConfig) -> bool {
let spec = agent_model_spec(&agent.name).or_else(|| agent_model_spec(&agent.command));
if let Some(spec) = spec {
if matches!(spec.family, "code" | "codex" | "cloud") {
return true;
}
}
let cmd = agent.command.trim();
let cmd = if cmd.is_empty() { agent.name.trim() } else { cmd };
let cmd = command_for_check(cmd);
if !cmd.is_empty() && command_exists(cmd) {
return true;
}
if let Some(spec) = spec {
return command_exists(spec.cli);
}
false
}
agents
.iter()
.filter(|agent| agent.enabled)
.filter(|agent| agent.enabled && agent_is_runnable(agent))
.map(|agent| agent.name.clone())
.collect()
}
@@ -20,6 +78,9 @@ pub fn get_enabled_agents(agents: &[AgentConfig]) -> Vec<String> {
fn get_default_models() -> Vec<String> {
enabled_agent_model_specs()
.into_iter()
.filter(|spec| {
matches!(spec.family, "code" | "codex" | "cloud") || command_exists(spec.cli)
})
.map(|spec| spec.slug.to_string())
.collect()
}
@@ -233,6 +294,30 @@ pub fn handle_slash_command(input: &str, agents: Option<&[AgentConfig]>) -> Opti
mod tests {
use super::*;
#[test]
fn default_models_skip_missing_agent_clis() {
let orig_path = std::env::var_os("PATH");
unsafe {
std::env::set_var("PATH", "");
}
let defaults = get_default_models();
assert!(defaults.iter().any(|v| v == "code-gpt-5.2"));
assert!(!defaults.iter().any(|v| v == "qwen-3-coder"));
assert!(!defaults.iter().any(|v| v == "gemini-3-flash"));
assert!(!defaults.iter().any(|v| v == "claude-sonnet-4.5"));
if let Some(path) = orig_path {
unsafe {
std::env::set_var("PATH", path);
}
} else {
unsafe {
std::env::remove_var("PATH");
}
}
}
#[test]
fn test_slash_command_parsing() {
// Test /plan command
+40 -4
View File
@@ -21011,7 +21011,7 @@ Have we met every part of this goal and is there no further work to do?"#
let build_editor = || {
AgentEditorView::new(
name.clone(),
true,
builtin,
if ro.is_empty() { None } else { Some(ro.clone()) },
if wr.is_empty() { None } else { Some(wr.clone()) },
None,
@@ -23484,7 +23484,7 @@ Have we met every part of this goal and is there no further work to do?"#
};
agent_rows.push(AgentOverviewRow {
name: cfg.name.clone(),
enabled: cfg.enabled,
enabled: cfg.enabled && installed,
installed,
description: Self::agent_description_for(
&cfg.name,
@@ -23509,7 +23509,7 @@ Have we met every part of this goal and is there no further work to do?"#
};
agent_rows.push(AgentOverviewRow {
name: cfg.name.clone(),
enabled: cfg.enabled,
enabled: cfg.enabled && installed,
installed,
description: Self::agent_description_for(
&cfg.name,
@@ -23530,7 +23530,7 @@ Have we met every part of this goal and is there no further work to do?"#
};
agent_rows.push(AgentOverviewRow {
name: name.clone(),
enabled: true,
enabled: installed,
installed,
description: Self::agent_description_for(name, Some(&cmd), None),
});
@@ -29521,6 +29521,42 @@ use code_core::protocol::OrderMeta;
assert!(chat.processed_auto_review_agents.contains("agent-blank"));
}
#[test]
fn missing_agent_clis_start_disabled_in_overview() {
let orig_path = std::env::var_os("PATH");
unsafe {
std::env::set_var("PATH", "");
}
let mut harness = ChatWidgetHarness::new();
let chat = harness.chat();
let (rows, _commands) = chat.collect_agents_overview_rows();
let qwen = rows
.iter()
.find(|row| row.name == "qwen-3-coder")
.expect("qwen row present");
assert!(!qwen.installed);
assert!(!qwen.enabled);
let code = rows
.iter()
.find(|row| row.name == "code-gpt-5.2")
.expect("code row present");
assert!(code.installed);
assert!(code.enabled);
if let Some(path) = orig_path {
unsafe {
std::env::set_var("PATH", path);
}
} else {
unsafe {
std::env::remove_var("PATH");
}
}
}
#[test]
fn skipped_auto_review_with_findings_defers_to_next_turn() {
let _rt = enter_test_runtime_guard();