From 2ad979af6609dbeadb913b2081f39a6fde495ac7 Mon Sep 17 00:00:00 2001 From: arookieofc <2128194521@qq.com> Date: Fri, 30 Oct 2026 12:22:04 +0800 Subject: [PATCH] feat(agents): complete OpenCode's /compact builtin (#257) OpenCode handles /compact through its ACP session but does not list it in availableCommands, so the composer never offered it. Agent builtins the agent implements without advertising are now merged into slash-command completion without duplicating listed commands; the table holds only commands verified against the agent (OpenCode /compact). Claude Code already advertises /compact. Executable lookup keeps using the shared PATHEXT-aware resolution on main, with a test that the newest install across PATH entries is chosen. Co-authored-by: a-rookie-of-C-language <186315048+a-rookie-of-C-language@users.noreply.github.com> --- crates/ide-agent/src/lib.rs | 22 +++++++++++++++-- crates/ide-agent/src/native/mod.rs | 20 ++++++++++++++++ crates/ide-agent/src/tests.rs | 38 +++++++++++++++++++++++++++++- src/platform/locales/text/zh.json | 1 + 4 files changed, 78 insertions(+), 3 deletions(-) diff --git a/crates/ide-agent/src/lib.rs b/crates/ide-agent/src/lib.rs index a1cdaeab3..af22f4ace 100644 --- a/crates/ide-agent/src/lib.rs +++ b/crates/ide-agent/src/lib.rs @@ -314,8 +314,8 @@ impl AgentManager { } /// The slash commands to complete for `provider`, in `task`'s directory or (without one) - /// in the project: what the agent offered last time there, plus, for Claude Code, the - /// skills and custom commands on disk it has not listed. + /// in the project: what the agent offered last time there, unadvertised builtins, + /// and, for Claude Code, skills and custom commands on disk it has not listed. pub async fn commands(&self, provider: &str, task: Option<&str>) -> IdeResult> { let (provider, offered, cwd) = match task { Some(id) => { @@ -325,6 +325,14 @@ impl AgentManager { } None => (provider.to_owned(), self.shared.known_commands(provider), self.shared.paths.project_root.clone()), }; + // Commands the agent implements itself and never lists (its CLI builtins): + // merged without duplicating what `availableCommands` or the disk offer. + let mut offered = offered; + for &(name, description) in builtin_commands(&provider) { + if !offered.iter().any(|c| c.name == name) { + offered.push(AgentCommand { name: name.to_owned(), description: description.to_owned(), hint: None }); + } + } if provider != "claude" && provider != "claude-acp" { return Ok(offered); } @@ -1629,6 +1637,16 @@ fn model_of(config: &[AgentConfigOption]) -> Option { config.iter().find(|o| o.category.as_deref() == Some("model")).and_then(|o| o.current.clone()) } +/// Commands the agent implements on its own and never lists in `availableCommands` — +/// its CLI builtins, passed through as prompt text (e.g. `/compact`). `(name, description)`. +fn builtin_commands(provider: &str) -> &'static [(&'static str, &'static str)] { + match provider { + // OpenCode ACP handles `/compact` via session.summarize but omits it from its command list. + "opencode" => &[("compact", "compact the conversation")], + _ => &[], + } +} + fn file_name(path: &Path) -> String { path.file_name().map_or_else(|| path.to_string_lossy().into_owned(), |n| n.to_string_lossy().into_owned()) } diff --git a/crates/ide-agent/src/native/mod.rs b/crates/ide-agent/src/native/mod.rs index 10f113092..665e9a049 100644 --- a/crates/ide-agent/src/native/mod.rs +++ b/crates/ide-agent/src/native/mod.rs @@ -124,4 +124,24 @@ mod tests { assert_eq!(newest_install_in("nope", &search), "nope"); assert_eq!(newest_install_in("bin/claude", &search), "bin/claude"); } + + #[cfg(unix)] + #[test] + fn newest_install_compares_versions_across_path_entries() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let old = dir.path().join("old"); + let new = dir.path().join("new"); + for (path, version) in [(&old, "2.9.0"), (&new, "2.10.0")] { + std::fs::create_dir_all(path).unwrap(); + let executable = path.join("agent"); + std::fs::write(&executable, format!("#!/bin/sh\necho {version}\n")).unwrap(); + std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + for paths in [[&old, &new], [&new, &old]] { + let search = std::env::join_paths(paths).unwrap(); + assert_eq!(newest_install_in("agent", &search), new.join("agent").to_string_lossy()); + } + } } diff --git a/crates/ide-agent/src/tests.rs b/crates/ide-agent/src/tests.rs index f30fc2b5e..0acf9ffc9 100644 --- a/crates/ide-agent/src/tests.rs +++ b/crates/ide-agent/src/tests.rs @@ -210,7 +210,7 @@ async fn commands_are_remembered_per_project_and_merged_with_skills_on_disk() { let names = |c: Vec| c.into_iter().map(|c| c.name).collect::>(); let mgr_a = manager_at(&a, &models, Arc::new(FakeAgent::default())); - // Before any session: the skills on disk. + // Before any session: only the skills on disk. assert_eq!(names(mgr_a.commands("claude", None).await.unwrap()), ["triage"]); let task = mgr_a .create(NewAgentTask { @@ -239,6 +239,42 @@ async fn commands_are_remembered_per_project_and_merged_with_skills_on_disk() { assert_eq!(names(mgr_a.providers().into_iter().find(|p| p.id == "claude").unwrap().commands), ["review"]); } +#[tokio::test(flavor = "multi_thread")] +async fn builtin_commands_only_fill_verified_gaps_and_preserve_advertised_metadata() { + let tmp = tempfile::tempdir().unwrap(); + let agent = Arc::new(FakeAgent::default()); + let mgr = manager_at(tmp.path(), &tmp.path().join("models.json"), agent); + for provider in ["claude", "claude-acp", "opencode2", "codex", "custom"] { + assert!(mgr.commands(provider, None).await.unwrap().is_empty(), "{provider}"); + } + let commands = mgr.commands("opencode", None).await.unwrap(); + assert_eq!(commands.iter().map(|c| c.name.as_str()).collect::>(), ["compact"]); + let task = mgr + .create(NewAgentTask { + prompt: text("Hello"), + provider: "opencode".into(), + config: Vec::new(), + isolation: Isolation::Local, + base: None, + }) + .await + .unwrap(); + wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await; + let advertised = AgentCommand { + name: "compact".into(), + description: "Agent supplied description".into(), + hint: Some("instructions".into()), + }; + mgr.task(&task.id).unwrap().state.lock().task.commands = vec![advertised.clone()]; + // The task's provider wins over the caller's provider; metadata is not replaced. + let commands = mgr.commands("claude", Some(&task.id)).await.unwrap(); + assert_eq!(commands.len(), 1); + assert_eq!(commands[0].name, advertised.name); + assert_eq!(commands[0].description, advertised.description); + assert_eq!(commands[0].hint, advertised.hint); + mgr.shutdown(); +} + async fn wait_for(mgr: &AgentManager, id: &str, status: AgentStatus) { for _ in 0..500 { let task = mgr.task(id).unwrap(); diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 8c3969670..db7eb7706 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -2278,6 +2278,7 @@ "cargo run configurations and rust-analyzer use this toolchain (PATH, RUSTUP_TOOLCHAIN).": "cargo 运行配置和 rust-analyzer 使用此工具链(PATH、RUSTUP_TOOLCHAIN)。", "chord prefix": "组合键前缀", "command line tool. Install it, then run": "命令行工具。请安装它,然后运行", + "compact the conversation": "压缩对话上下文", "configured in": "配置位置:", "const declaration": "const 声明", "current branch": "当前分支",