From cb6bcd2fa29d96d4f7e0b1f66d24a3afac6e40e0 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 9 Nov 2026 00:27:03 +0800 Subject: [PATCH] fix(agents): discover Claude commands before the first task (#2166) Claude Code reports its slash commands only after a session starts, so New Task listed only skills on disk. When the native provider has no remembered commands, a CLI process now runs only the initialize request, with a five-second timeout, shared by concurrent calls, and its result goes into the existing per-project store. A failed attempt keeps the disk list and is not repeated. No prompt or session is created. ACP and other native providers still report commands after a session starts. Fixes #2144 --- crates/ide-agent/src/lib.rs | 44 ++++++++++++ crates/ide-agent/src/native/claude/mod.rs | 32 +++++++++ crates/ide-agent/src/tests.rs | 86 ++++++++++++++++++++++- 3 files changed, 161 insertions(+), 1 deletion(-) diff --git a/crates/ide-agent/src/lib.rs b/crates/ide-agent/src/lib.rs index 986ee777e..4f3400d70 100644 --- a/crates/ide-agent/src/lib.rs +++ b/crates/ide-agent/src/lib.rs @@ -100,6 +100,8 @@ struct Shared { providers: RwLock>, /// Last probe of each provider. probes: Mutex>, + /// One pre-session command lookup per provider, including failed attempts. + command_fetches: Mutex>>, /// [`IDLE_RELEASE`], in milliseconds (shorter in tests). idle_release_ms: AtomicU64, } @@ -151,6 +153,40 @@ impl Shared { cache.get(provider).and_then(|k| k.commands.get(&self.root_key())).cloned().unwrap_or_default() } + async fn fetch_commands(self: &Arc, spec: provider::ProviderSpec) { + let mut ready = { + let mut fetches = self.command_fetches.lock(); + fetches + .entry(spec.id.clone()) + .or_insert_with(|| { + let (done, ready) = tokio::sync::watch::channel(false); + let shared = self.clone(); + // Keep the lookup alive if a composer unmounts while it is waiting. + self.runtime.spawn(async move { + if let Ok(commands) = + native::claude::commands(spec.clone(), shared.paths.project_root.clone()).await + { + let commands = config::commands_of(&json!({ "availableCommands": commands }), &spec.id); + let _ = tokio::task::spawn_blocking(move || { + if shared.known_commands(&spec.id).is_empty() { + shared.remember(&spec.id, None, Some(&commands), None); + } + }) + .await; + } + let _ = done.send(true); + }); + ready + }) + .clone() + }; + while !*ready.borrow_and_update() { + if ready.changed().await.is_err() { + break; + } + } + } + fn root_key(&self) -> String { self.paths.project_root.to_string_lossy().into_owned() } @@ -317,6 +353,7 @@ impl AgentManager { known: Mutex::new(known), providers, probes: Mutex::new(HashMap::new()), + command_fetches: Mutex::new(HashMap::new()), idle_release_ms: AtomicU64::new(IDLE_RELEASE.as_millis() as u64), }); let mut tasks = HashMap::new(); @@ -420,6 +457,13 @@ impl AgentManager { // Commands the agent implements itself and never lists (its CLI builtins): // merged without duplicating what `availableCommands` or the disk offer. let mut offered = offered; + if task.is_none() + && offered.is_empty() + && let Some(spec) = self.shared.spec(&provider).filter(|s| s.driver == provider::Driver::Claude) + { + self.shared.fetch_commands(spec).await; + offered = self.shared.known_commands(&provider); + } for &(name, description) in builtin_commands(&provider) { if !offered.iter().any(|c| c.name == name) { offered.push(AgentCommand { diff --git a/crates/ide-agent/src/native/claude/mod.rs b/crates/ide-agent/src/native/claude/mod.rs index 18adee689..e4e557162 100644 --- a/crates/ide-agent/src/native/claude/mod.rs +++ b/crates/ide-agent/src/native/claude/mod.rs @@ -69,6 +69,38 @@ pub(crate) fn connect( Ok(super::serve(shim, client)) } +/// Discover commands without a prompt, ACP session, or pending session file. +pub(crate) async fn commands(spec: ProviderSpec, cwd: PathBuf) -> Result, String> { + tokio::time::timeout(Duration::from_secs(5), async move { + let spec = tokio::task::spawn_blocking(move || (spec.resolve_for_launch(&cwd), cwd)); + let (spec, cwd) = spec.await.map_err(|e| e.to_string())?; + let mut args = spec.args; + args.extend(Start::New { id: uuid::Uuid::new_v4().to_string() }.args()); + let launch = Launch { program: spec.command, args, cwd, env: spec.env }; + let mut io = cli::Process.launch(&launch, Arc::new(|_| {})).map_err(|e| e.to_string())?; + let _stop = io.kill.clone().drop_guard(); + let request = json!({ "type": "control_request", "request_id": "commands", + "request": { "subtype": "initialize", "perTaskStopAffordance": true } }); + io.stdin.write_all(format!("{request}\n").as_bytes()).await.map_err(|e| e.to_string())?; + io.stdin.flush().await.map_err(|e| e.to_string())?; + let mut lines = BufReader::new(io.stdout).lines(); + while let Some(line) = lines.next_line().await.map_err(|e| e.to_string())? { + let Ok(msg) = serde_json::from_str::(&line) else { continue }; + if msg["type"] == "control_response" && msg["response"]["request_id"] == "commands" { + let response = &msg["response"]; + return if response["subtype"] == "success" { + Ok(Settings::from_cli(&response["response"], &Value::Null).commands) + } else { + Err("Claude Code did not initialize".into()) + }; + } + } + Err("Claude Code exited".into()) + }) + .await + .map_err(|_| "Claude Code command discovery timed out".to_owned())? +} + struct Config { pub program: String, /// Extra arguments (a custom agent's), before the session's own. diff --git a/crates/ide-agent/src/tests.rs b/crates/ide-agent/src/tests.rs index 6193d45f2..9d5e4ef86 100644 --- a/crates/ide-agent/src/tests.rs +++ b/crates/ide-agent/src/tests.rs @@ -339,6 +339,9 @@ fn manager(dir: &Path, agent: Arc) -> AgentManager { } fn manager_at(dir: &Path, models_file: &Path, agent: Arc) -> AgentManager { + // These tests speak to FakeAgent, never an installed CLI, including after a provider reload. + let agents = dir.join("agents"); + provider::set_builtin_command(&agents, "claude", Some(&dir.join("missing-claude").to_string_lossy())).unwrap(); let connector: Connector = Arc::new(move |_spec, _cwd, client, _stderr| { let (a, b) = duplex(1 << 20); let (ar, aw) = tokio::io::split(a); @@ -353,7 +356,7 @@ fn manager_at(dir: &Path, models_file: &Path, agent: Arc) -> AgentMan store_dir: dir.join("store"), worktrees_dir: dir.join("worktrees"), models_file: models_file.to_path_buf(), - custom_agents_dir: None, + custom_agents_dir: Some(agents), home_dir: None, }, tokio::runtime::Handle::current(), @@ -361,6 +364,87 @@ fn manager_at(dir: &Path, models_file: &Path, agent: Arc) -> AgentMan ) } +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread")] +async fn claude_commands_before_session_are_fetched_once_and_saved() { + use std::os::unix::fs::PermissionsExt; + + for outcome in ["success", "error", "exit", "timeout", "missing"] { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let script = root.join("fake-claude"); + std::fs::write( + &script, + r#"#!/usr/bin/env python3 +import json, os, pathlib, sys, time +root = pathlib.Path.cwd() +with (root / 'launches').open('a') as f: + f.write(json.dumps(sys.argv[1:]) + '\n') +request = json.loads(sys.stdin.readline()) +with (root / 'requests').open('a') as f: + f.write(json.dumps(request) + '\n') +outcome = os.environ['OUTCOME'] +if outcome == 'exit': + sys.exit(1) +if outcome == 'timeout': + time.sleep(60) +response = {'subtype': outcome, 'request_id': request['request_id']} +if outcome == 'success': + response['response'] = {'commands': [ + {'name': 'compact', 'description': 'Free up context', 'argumentHint': '[instructions]'}, + {'name': 'clear'}, {'name': 'model'}, {'name': 'context'}]} +else: + response['error'] = 'Authentication required' +print(json.dumps({'type': 'control_response', 'response': response}), flush=True) +for line in sys.stdin: + with (root / 'requests').open('a') as f: + f.write(line) +"#, + ) + .unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + std::fs::create_dir_all(root.join(".claude/skills/triage")).unwrap(); + std::fs::write(root.join(".claude/skills/triage/SKILL.md"), "---\nname: triage\ndescription: Triage\n---\n") + .unwrap(); + let mgr = manager(root, Arc::new(FakeAgent::default())); + { + let mut providers = mgr.shared.providers.write(); + let spec = providers.iter_mut().find(|p| p.id == "claude").unwrap(); + if outcome != "missing" { + spec.command = script.to_string_lossy().into_owned(); + } + spec.env = vec![("OUTCOME".into(), outcome.into())]; + } + let (first, concurrent) = tokio::join!(mgr.commands("claude", None), mgr.commands("claude", None)); + let first = first.unwrap(); + assert_eq!(first, concurrent.unwrap()); + assert_eq!(first, mgr.commands("claude", None).await.unwrap()); + let names: Vec<_> = first.iter().map(|c| c.name.as_str()).collect(); + if outcome == "success" { + assert_eq!(names, ["compact", "clear", "model", "context", "triage"]); + assert_eq!(first[0].hint.as_deref(), Some("[instructions]")); + let restored = manager(root, Arc::new(FakeAgent::default())); + assert_eq!(first, restored.commands("claude", None).await.unwrap()); + } else { + assert_eq!(names, ["triage"], "{outcome}"); + assert!(!root.join("models.json").exists()); + } + assert!(mgr.tasks.read().is_empty()); + assert!(!root.join("store").exists()); + if outcome != "missing" { + let launches = std::fs::read_to_string(root.join("launches")).unwrap(); + assert_eq!(launches.lines().count(), 1, "{outcome}"); + let args: Vec = serde_json::from_str(launches.trim()).unwrap(); + assert!(args.windows(2).any(|a| a == ["--input-format", "stream-json"])); + let requests = std::fs::read_to_string(root.join("requests")).unwrap(); + assert_eq!(requests.lines().count(), 1, "only initialize, no prompt or other control request"); + let request: Value = serde_json::from_str(requests.trim()).unwrap(); + assert_eq!(request["type"], "control_request"); + assert_eq!(request["request"]["subtype"], "initialize"); + } + } +} + #[tokio::test(flavor = "multi_thread")] async fn authentication_failures_name_the_session_executable_at_start_and_during_prompts() { for auth_on_new in [true, false] {