From 08a2611a5d4c24b251ecd8ff5e14db9b91ac02c8 Mon Sep 17 00:00:00 2001 From: jquant <115501859+JQuantAnalytics@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:55:43 -0500 Subject: [PATCH] feat(acp): Slack-parity run options, env targeting, and Cursor preset polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parse repo=/env=/branch=/… from mentions, resolve nest .buzz/environments.toml, inject [Run Options], and let subscription rules set target_repo/target_env. Add plan/handoff norms to the base prompt, ✅/❌ outcome reactions, and default cursor-agent acp args so Desktop Cursor agents start ACP mode reliably. Co-authored-by: Cursor Co-authored-by: jquant <115501859+JQuantAnalytics@users.noreply.github.com> Signed-off-by: jquant <115501859+JQuantAnalytics@users.noreply.github.com> --- crates/buzz-acp/README.md | 32 + crates/buzz-acp/src/base_prompt.md | 5 + crates/buzz-acp/src/config.rs | 2 + crates/buzz-acp/src/filter.rs | 20 + crates/buzz-acp/src/lib.rs | 29 +- crates/buzz-acp/src/options.rs | 613 ++++++++++++++++++ crates/buzz-acp/src/pool.rs | 39 ++ crates/buzz-acp/src/queue.rs | 134 ++++ crates/buzz-acp/src/setup_mode.rs | 2 + .../src-tauri/src/managed_agents/discovery.rs | 6 +- .../src/managed_agents/discovery/tests.rs | 17 + 11 files changed, 894 insertions(+), 5 deletions(-) create mode 100644 crates/buzz-acp/src/options.rs diff --git a/crates/buzz-acp/README.md b/crates/buzz-acp/README.md index e6164b02dd..d572fe928d 100644 --- a/crates/buzz-acp/README.md +++ b/crates/buzz-acp/README.md @@ -248,6 +248,38 @@ Forum event kinds: > **Note:** Without `--no-mention-filter` (or `require_mention = false`), the default `subscribe=mentions` mode filters events that don't @mention the agent — forum posts will be invisible. +## Chat targeting (Slack-parity) + +Mentions may include inline options that the harness injects as a `[Run Options]` prompt section: + +| Option | Example | Effect | +|--------|---------|--------| +| `repo=` | `repo=web` | Prefer nest checkout `REPOS/web` | +| `env=` / `environment=` | `env=Platform` | Named multi-repo environment | +| `branch=` | `branch=dev` | Prefer base branch | +| `model=` | `model=gpt-5` | Prefer model when the runtime supports it | +| `autopr=` | `autopr=false` | Prefer / disallow auto `buzz pr open` | +| `channel=` | `channel=#eng` | Post updates to another channel (membership required) | + +Quoted values with spaces are supported (`env="Platform Staging"`). Light natural language also works: `use the Platform environment`, `use the "Platform Staging" environment`, and `in web` (single-token repo name only). + +### Nest environments file + +Optional `{cwd}/.buzz/environments.toml`: + +```toml +[[environment]] +name = "Platform" +repos = ["web", "api"] +default_branch = "main" # optional +``` + +Repo names are directories under nest `REPOS/`. When `env=` matches, preferred paths are `{cwd}/REPOS/{name}` for each listed repo that exists. + +### Rule defaults + +Subscription rules (`--subscribe config`) may set optional `target_repo` / `target_env`. Explicit inline options in the mention text always win over rule defaults. + ## How It Works 1. **Startup** — Spawns N agent subprocesses (default 1), sends ACP `initialize` to each, connects to the relay with NIP-42 auth. diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index c42e65cb83..5b2fc68e0e 100644 --- a/crates/buzz-acp/src/base_prompt.md +++ b/crates/buzz-acp/src/base_prompt.md @@ -113,6 +113,11 @@ Your `core` memory is auto-injected into your context every turn — it holds id These are guidelines, not a fixed procedure — apply judgment to the task in front of you. +- **Plan before start.** On multi-step or coding work, your **first** channel message is a short plan (goal, steps, repos/paths in scope, risks). Then wait for redirect or post that you are starting and execute. +- **Honor `[Run Options]` when present.** Prefer the listed repo/env checkouts, branch, model, autopr, and output channel. `autopr=false` means do not auto-open a PR; `channel=` means post updates there when membership allows. +- **Owner session controls.** The owner can `@mention` you with `!cancel` (stop the current turn) or `!rotate` (fresh ACP session). These are harness commands — acknowledge only if useful; do not invent equivalents. +- **Handoff.** When you open a PR, use `buzz pr open --channel `. Completion messages include the PR URL and the absolute worktree path so a human can Open in Cursor Desktop. +- **Status.** The harness shows 👀 (seen) and 💬 (working). You post **milestones only** (picked up with plan, blocked + need input, PR up, done). Optional ✅/❌ on done/fail is harness-side. - **Work in the open.** Your tool calls and reasoning are invisible to humans — narrate as you go in brief messages, and never go dark between "picked up" and "done." If you didn't post it, it didn't happen. - **Be candid.** Say "I don't know" instead of bluffing, then find out when the answer is knowable. - **Understand before changing.** Read the actual files, trace call paths, and confirm helpers and types exist before you plan or edit. diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index 9a1b74c276..7fc6b7eaa4 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -1468,6 +1468,8 @@ mod tests { prompt_tag: None, compiled_filter: None, consecutive_timeouts: Arc::new(AtomicU32::new(0)), + target_repo: None, + target_env: None, } } diff --git a/crates/buzz-acp/src/filter.rs b/crates/buzz-acp/src/filter.rs index 43edd969dd..fe17fc007a 100644 --- a/crates/buzz-acp/src/filter.rs +++ b/crates/buzz-acp/src/filter.rs @@ -111,6 +111,14 @@ pub struct SubscriptionRule { /// disabled and `match_event` returns `None` (fail-closed). #[serde(skip)] pub consecutive_timeouts: Arc, + /// Optional default repo name for Slack-parity targeting (`REPOS/{name}`). + /// Overridden by explicit `repo=` / NL cues in the mention text. + #[serde(default)] + pub target_repo: Option, + /// Optional default nest environment name (see `.buzz/environments.toml`). + /// Overridden by explicit `env=` / NL cues in the mention text. + #[serde(default)] + pub target_env: Option, } impl Default for SubscriptionRule { @@ -124,6 +132,8 @@ impl Default for SubscriptionRule { prompt_tag: None, compiled_filter: None, consecutive_timeouts: Arc::new(AtomicU32::new(0)), + target_repo: None, + target_env: None, } } } @@ -141,6 +151,8 @@ impl Clone for SubscriptionRule { // Share the same counter across clones so all copies of a rule // agree on the timeout state. consecutive_timeouts: self.consecutive_timeouts.clone(), + target_repo: self.target_repo.clone(), + target_env: self.target_env.clone(), } } } @@ -153,6 +165,10 @@ pub struct MatchedRule { pub rule_index: usize, /// Prompt tag to use (rule's `prompt_tag` or its `name`). pub prompt_tag: String, + /// Rule-level default repo (if any). + pub target_repo: Option, + /// Rule-level default environment (if any). + pub target_env: Option, } /// Maximum expression length accepted by `evaluate_filter`. @@ -453,6 +469,8 @@ pub async fn match_event( return Some(MatchedRule { rule_index: index, prompt_tag, + target_repo: rule.target_repo.clone(), + target_env: rule.target_env.clone(), }); } @@ -505,6 +523,8 @@ mod tests { prompt_tag: prompt_tag.map(|s| s.into()), compiled_filter: None, consecutive_timeouts: Arc::new(AtomicU32::new(0)), + target_repo: None, + target_env: None, } } diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index b11d96d8f7..1f5a030ba0 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -5,6 +5,7 @@ mod config; mod engram_fetch; mod filter; mod observer; +mod options; mod pool; mod pool_lifecycle; mod queue; @@ -1453,6 +1454,8 @@ async fn tokio_main() -> Result<()> { compiled_filter: None, consecutive_timeouts: std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0)), prompt_tag: Some("@mention".into()), + target_repo: None, + target_env: None, }] } SubscribeMode::All => { @@ -1465,6 +1468,8 @@ async fn tokio_main() -> Result<()> { compiled_filter: None, consecutive_timeouts: std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0)), prompt_tag: Some("all".into()), + target_repo: None, + target_env: None, }] } SubscribeMode::Config => { @@ -2173,8 +2178,8 @@ async fn tokio_main() -> Result<()> { } let matched = filter::match_event(&buzz_event.event, buzz_event.channel_id, &rules, &pubkey_hex).await; - let prompt_tag = match matched { - Some(m) => m.prompt_tag, + let (prompt_tag, target_repo, target_env) = match matched { + Some(m) => (m.prompt_tag, m.target_repo, m.target_env), None => { tracing::debug!(channel_id = %buzz_event.channel_id, kind = buzz_event.event.kind.as_u16(), "event matched no rule — dropping"); continue; @@ -2201,6 +2206,8 @@ async fn tokio_main() -> Result<()> { event: buzz_event.event, received_at: std::time::Instant::now(), prompt_tag, + target_repo, + target_env, }); // 👀 — immediate "seen" reaction, only if the event // was actually queued (not dropped by DedupMode::Drop). @@ -2828,6 +2835,8 @@ fn try_native_steer( event, prompt_tag: prompt_tag.clone(), received_at: std::time::Instant::now(), + target_repo: None, + target_env: None, }; let event_block = queue::format_event_block(channel_id, None, &be, None); let body = format!("{header}\n\n[Buzz event: {prompt_tag}]\n{event_block}\n\n{closing}"); @@ -5517,6 +5526,8 @@ mod error_outcome_emission_tests { event, prompt_tag: "test".into(), received_at: std::time::Instant::now(), + target_repo: None, + target_env: None, }], cancelled_events: vec![], cancel_reason: None, @@ -5623,6 +5634,8 @@ mod error_outcome_emission_tests { event, prompt_tag: "test".into(), received_at: std::time::Instant::now(), + target_repo: None, + target_env: None, }], cancelled_events: vec![], cancel_reason: None, @@ -5741,6 +5754,8 @@ mod error_outcome_emission_tests { .unwrap(), prompt_tag: "test".into(), received_at: std::time::Instant::now(), + target_repo: None, + target_env: None, }], cancelled_events: vec![], cancel_reason: None, @@ -5834,6 +5849,8 @@ mod error_outcome_emission_tests { .unwrap(), prompt_tag: "test".into(), received_at: std::time::Instant::now(), + target_repo: None, + target_env: None, }], cancelled_events: vec![], cancel_reason: None, @@ -5912,6 +5929,8 @@ mod error_outcome_emission_tests { event: original_event.clone(), prompt_tag: "test".into(), received_at: std::time::Instant::now(), + target_repo: None, + target_env: None, }], cancelled_events: vec![], cancel_reason: Some(CancelReason::Steer), @@ -5941,6 +5960,8 @@ mod error_outcome_emission_tests { event: new_event.clone(), received_at: std::time::Instant::now(), prompt_tag: "test".into(), + target_repo: None, + target_env: None, }); let config = test_config(); let mut heartbeat_in_flight = false; @@ -6233,6 +6254,8 @@ mod error_outcome_emission_tests { event, prompt_tag: "test".into(), received_at: std::time::Instant::now(), + target_repo: None, + target_env: None, }], cancelled_events: vec![], cancel_reason: None, @@ -6318,6 +6341,8 @@ mod error_outcome_emission_tests { event, prompt_tag: "test".into(), received_at: std::time::Instant::now(), + target_repo: None, + target_env: None, }], cancelled_events: vec![], cancel_reason: None, diff --git a/crates/buzz-acp/src/options.rs b/crates/buzz-acp/src/options.rs new file mode 100644 index 0000000000..a5650f6132 --- /dev/null +++ b/crates/buzz-acp/src/options.rs @@ -0,0 +1,613 @@ +//! Slack-parity chat targeting: inline `key=value` options, light NL cues, +//! and nest `environments.toml` resolution for `[Run Options]` prompt injection. + +use std::path::{Path, PathBuf}; + +use serde::Deserialize; + +/// A named multi-repo environment from `{cwd}/.buzz/environments.toml`. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct NestEnvironment { + pub name: String, + pub repos: Vec, + #[serde(default)] + pub default_branch: Option, +} + +#[derive(Debug, Default, Deserialize)] +struct EnvironmentsFile { + #[serde(default)] + environment: Vec, +} + +/// Parsed targeting for a turn — explicit mention options + rule defaults. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ResolvedTargeting { + pub repo: Option, + pub env: Option, + pub branch: Option, + pub model: Option, + pub autopr: Option, + pub channel: Option, + /// Repo names in scope (from env manifest and/or explicit `repo=`). + pub repos: Vec, + /// Absolute `{cwd}/REPOS/{name}` paths that exist on disk. + pub preferred_repo_paths: Vec, +} + +impl ResolvedTargeting { + fn is_empty(&self) -> bool { + self.repo.is_none() + && self.env.is_none() + && self.branch.is_none() + && self.model.is_none() + && self.autopr.is_none() + && self.channel.is_none() + && self.repos.is_empty() + && self.preferred_repo_paths.is_empty() + } +} + +/// Load nest environments from `{cwd}/.buzz/environments.toml`. +/// +/// Missing or unreadable files yield an empty list (never an error). +pub fn load_environments(nest_cwd: &str) -> Vec { + let path = Path::new(nest_cwd).join(".buzz").join("environments.toml"); + let contents = match std::fs::read_to_string(&path) { + Ok(c) => c, + Err(_) => return Vec::new(), + }; + match toml::from_str::(&contents) { + Ok(file) => file.environment, + Err(e) => { + tracing::warn!( + path = %path.display(), + error = %e, + "failed to parse environments.toml — ignoring" + ); + Vec::new() + } + } +} + +/// Parse inline `key=value` options and light NL cues from mention text. +/// +/// Supported keys: `repo`, `env`/`environment`, `branch`, `model`, `autopr`, +/// `channel`. Values may be bare tokens or double/single-quoted (spaces OK). +fn parse_mention_options(content: &str) -> ResolvedTargeting { + let mut out = ResolvedTargeting::default(); + let mut rest = content; + + while let Some((key, value, next)) = take_next_option(rest) { + apply_option(&mut out, &key, &value); + rest = next; + } + + // Natural language fills only fields not already set by key=value. + if out.env.is_none() { + if let Some(env) = parse_use_environment(content) { + out.env = Some(env); + } + } + if out.repo.is_none() { + if let Some(repo) = parse_in_repo(content) { + out.repo = Some(repo); + } + } + + out +} + +fn apply_option(out: &mut ResolvedTargeting, key: &str, value: &str) { + let value = value.trim(); + if value.is_empty() { + return; + } + match key { + "repo" => out.repo = Some(value.to_string()), + "env" | "environment" => out.env = Some(value.to_string()), + "branch" => out.branch = Some(value.to_string()), + "model" => out.model = Some(value.to_string()), + "autopr" => { + out.autopr = match value.to_ascii_lowercase().as_str() { + "true" | "1" | "yes" => Some(true), + "false" | "0" | "no" => Some(false), + _ => None, + }; + } + "channel" => out.channel = Some(value.to_string()), + _ => {} + } +} + +/// Find the next `key=value` / `key="value"` occurrence in `s`. +/// +/// Returns `(key, value, remainder_after_match)`. +fn take_next_option(s: &str) -> Option<(String, String, &str)> { + // Scan for a known key followed by `=`. + const KEYS: &[&str] = &[ + "environment", + "repo", + "env", + "branch", + "model", + "autopr", + "channel", + ]; + + let lower = s.to_ascii_lowercase(); + let mut best: Option<(usize, &str)> = None; + for key in KEYS { + let mut search_from = 0; + while let Some(rel) = lower[search_from..].find(key) { + let start = search_from + rel; + let after_key = start + key.len(); + // Boundary: start of string or non-identifier char before key. + let ok_before = start == 0 + || !s.as_bytes()[start - 1].is_ascii_alphanumeric() + && s.as_bytes()[start - 1] != b'_'; + if ok_before && lower.get(after_key..after_key + 1) == Some("=") { + match best { + Some((best_start, _)) if best_start <= start => {} + _ => best = Some((start, key)), + } + break; + } + search_from = start + 1; + } + } + + let (start, key) = best?; + let value_start = start + key.len() + 1; // skip `key=` + let (value, value_end) = parse_option_value(&s[value_start..])?; + let abs_end = value_start + value_end; + Some((key.to_string(), value, &s[abs_end..])) +} + +/// Parse a value after `=`: quoted string or bare token. +/// +/// Returns `(value, bytes_consumed_from_input)`. +fn parse_option_value(s: &str) -> Option<(String, usize)> { + let bytes = s.as_bytes(); + if bytes.is_empty() { + return None; + } + match bytes[0] { + b'"' | b'\'' => { + let quote = bytes[0]; + let mut i = 1; + while i < bytes.len() { + if bytes[i] == quote { + let value = s[1..i].to_string(); + return Some((value, i + 1)); + } + // Allow simple backslash-escape of the quote. + if bytes[i] == b'\\' && i + 1 < bytes.len() { + i += 2; + continue; + } + i += 1; + } + None + } + _ => { + let end = s + .find(|c: char| c.is_whitespace() || c == ',') + .unwrap_or(s.len()); + if end == 0 { + return None; + } + Some((s[..end].to_string(), end)) + } + } +} + +/// `use the X environment` / `use the "X Y" environment`. +fn parse_use_environment(content: &str) -> Option { + let lower = content.to_ascii_lowercase(); + let marker = "use the "; + let mut search_from = 0; + while let Some(rel) = lower[search_from..].find(marker) { + let start = search_from + rel + marker.len(); + let after = &content[start..]; + let (name, name_end) = match after.as_bytes().first() { + Some(b'"') | Some(b'\'') => parse_option_value(after)?, + _ => { + // Unquoted: take tokens until ` environment`. + let env_word = " environment"; + let after_lower = after.to_ascii_lowercase(); + let end = after_lower.find(env_word)?; + let name = after[..end].trim(); + if name.is_empty() { + search_from = start + 1; + continue; + } + (name.to_string(), end) + } + }; + let after_name = &after[name_end..]; + if after_name + .to_ascii_lowercase() + .trim_start() + .starts_with("environment") + { + let name = name.trim(); + if !name.is_empty() { + return Some(name.to_string()); + } + } + search_from = start + 1; + } + None +} + +/// Stopwords that must not match as `in ` (single-token, no spaces). +const IN_REPO_STOPWORDS: &[&str] = &[ + "a", "an", "the", "this", "that", "these", "those", "my", "our", "your", "their", "its", "his", + "her", "order", "fact", "case", "progress", "general", "particular", "question", "mind", + "practice", "addition", "short", "summary", "turn", "channel", "thread", "reply", "here", + "there", "place", "time", "front", "back", "parallel", "series", "production", "staging", + "development", "code", "chat", "scope", "context", +]; + +/// `in ` — single token, no spaces; avoid common English stopwords. +fn parse_in_repo(content: &str) -> Option { + let lower = content.to_ascii_lowercase(); + let mut search_from = 0; + while let Some(rel) = lower[search_from..].find(" in ") { + let start = search_from + rel + " in ".len(); + let after = &content[start..]; + let token_end = after + .find(|c: char| !(c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')) + .unwrap_or(after.len()); + if token_end == 0 { + search_from = start; + continue; + } + let token = &after[..token_end]; + let token_lower = token.to_ascii_lowercase(); + if IN_REPO_STOPWORDS.contains(&token_lower.as_str()) { + search_from = start; + continue; + } + // Require at least one alphanumeric so punctuation-only tokens drop. + if !token.bytes().any(|b| b.is_ascii_alphanumeric()) { + search_from = start; + continue; + } + return Some(token.to_string()); + } + // Also allow message starting with `in ` (case-insensitive). + let trimmed = content.trim_start(); + if trimmed.len() >= 3 && trimmed[..2].eq_ignore_ascii_case("in") && trimmed.as_bytes()[2] == b' ' + { + let rest = &trimmed[3..]; + let token_end = rest + .find(|c: char| !(c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')) + .unwrap_or(rest.len()); + if token_end > 0 { + let token = &rest[..token_end]; + let token_lower = token.to_ascii_lowercase(); + if !IN_REPO_STOPWORDS.contains(&token_lower.as_str()) + && token.bytes().any(|b| b.is_ascii_alphanumeric()) + { + return Some(token.to_string()); + } + } + } + None +} + +/// Resolve run targeting for a turn. +/// +/// Explicit inline / NL options from `content` win over `rule_target_*`. +/// When an env is set, repos come from `{nest_cwd}/.buzz/environments.toml`. +/// Preferred paths are `{nest_cwd}/REPOS/{name}` when that directory exists. +pub fn resolve_run_targeting( + content: &str, + rule_target_repo: Option<&str>, + rule_target_env: Option<&str>, + nest_cwd: Option<&str>, +) -> ResolvedTargeting { + let mut parsed = parse_mention_options(content); + + if parsed.repo.is_none() { + if let Some(r) = rule_target_repo.map(str::trim).filter(|s| !s.is_empty()) { + parsed.repo = Some(r.to_string()); + } + } + if parsed.env.is_none() { + if let Some(e) = rule_target_env.map(str::trim).filter(|s| !s.is_empty()) { + parsed.env = Some(e.to_string()); + } + } + + let cwd = nest_cwd.map(str::trim).filter(|s| !s.is_empty()); + let envs = cwd.map(load_environments).unwrap_or_default(); + + if let Some(ref env_name) = parsed.env { + if let Some(entry) = envs + .iter() + .find(|e| e.name.eq_ignore_ascii_case(env_name)) + { + // Normalize to the manifest spelling. + parsed.env = Some(entry.name.clone()); + parsed.repos = entry.repos.clone(); + if parsed.branch.is_none() { + if let Some(ref b) = entry.default_branch { + parsed.branch = Some(b.clone()); + } + } + } + } + + // Explicit repo is always in scope even without a matching env. + if let Some(ref repo) = parsed.repo { + if !parsed + .repos + .iter() + .any(|r| r.eq_ignore_ascii_case(repo)) + { + parsed.repos.push(repo.clone()); + } + } + + if let Some(cwd) = cwd { + let mut paths = Vec::new(); + for name in &parsed.repos { + let path = PathBuf::from(cwd).join("REPOS").join(name); + if path.is_dir() { + paths.push(path.display().to_string()); + } + } + // Explicit repo preferred path even when not yet in repos list (edge). + if let Some(ref repo) = parsed.repo { + let path = PathBuf::from(cwd).join("REPOS").join(repo); + if path.is_dir() { + let display = path.display().to_string(); + if !paths.iter().any(|p| p == &display) { + paths.insert(0, display); + } + } + } + parsed.preferred_repo_paths = paths; + } + + parsed +} + +/// Render a `[Run Options]` prompt section, or `None` when nothing is set. +pub fn format_run_options_section(targeting: &ResolvedTargeting) -> Option { + if targeting.is_empty() { + return None; + } + + let mut lines = vec!["[Run Options]".to_string()]; + if let Some(ref env) = targeting.env { + lines.push(format!("env: {env}")); + } + if let Some(ref repo) = targeting.repo { + lines.push(format!("repo: {repo}")); + } + if !targeting.repos.is_empty() { + lines.push(format!("repos: {}", targeting.repos.join(", "))); + } + if let Some(ref branch) = targeting.branch { + lines.push(format!("branch: {branch}")); + } + if let Some(ref model) = targeting.model { + lines.push(format!("model: {model}")); + } + if let Some(autopr) = targeting.autopr { + lines.push(format!("autopr: {autopr}")); + } + if let Some(ref channel) = targeting.channel { + lines.push(format!("channel: {channel}")); + } + if !targeting.preferred_repo_paths.is_empty() { + lines.push("preferred repo paths:".to_string()); + for p in &targeting.preferred_repo_paths { + lines.push(format!("- {p}")); + } + } + + lines.push( + "Prefer these checkouts for this turn when present. Honor branch / model / autopr / \ + channel when set (autopr=false means do not auto-open a PR; channel= means post \ + updates there when membership allows)." + .to_string(), + ); + lines.push( + "Handoff: on completion include the PR URL (with buzz pr --channel) and the absolute \ + worktree path so a human can Open in Cursor Desktop." + .to_string(), + ); + + Some(lines.join("\n")) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn tmp_nest() -> tempfile_dir::TmpNest { + tempfile_dir::TmpNest::new() + } + + /// Minimal temp-dir helper so tests don't need the `tempfile` crate. + mod tempfile_dir { + use std::path::PathBuf; + + pub struct TmpNest { + pub path: PathBuf, + } + + impl TmpNest { + pub fn new() -> Self { + let path = std::env::temp_dir().join(format!( + "buzz-acp-options-test-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(path.join(".buzz")).unwrap(); + std::fs::create_dir_all(path.join("REPOS")).unwrap(); + Self { path } + } + + pub fn path_str(&self) -> &str { + self.path.to_str().unwrap() + } + } + + impl Drop for TmpNest { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } + } + } + + #[test] + fn parses_quoted_env_and_autopr_false() { + let t = parse_mention_options( + r#"@Cursor env="Platform Staging" branch=dev autopr=false fix auth"#, + ); + assert_eq!(t.env.as_deref(), Some("Platform Staging")); + assert_eq!(t.branch.as_deref(), Some("dev")); + assert_eq!(t.autopr, Some(false)); + } + + #[test] + fn parses_single_quoted_channel_with_spaces() { + let t = parse_mention_options(r#"channel='eng platform' repo=web"#); + assert_eq!(t.channel.as_deref(), Some("eng platform")); + assert_eq!(t.repo.as_deref(), Some("web")); + } + + #[test] + fn parses_use_the_environment_nl() { + let t = parse_mention_options(r#"please use the "Platform Staging" environment for this"#); + assert_eq!(t.env.as_deref(), Some("Platform Staging")); + + let t2 = parse_mention_options("use the Platform environment please"); + assert_eq!(t2.env.as_deref(), Some("Platform")); + } + + #[test] + fn parses_in_repo_without_overmatching() { + let t = parse_mention_options("fix the bug in web please"); + assert_eq!(t.repo.as_deref(), Some("web")); + + let t2 = parse_mention_options("do this in the meantime"); + assert!(t2.repo.is_none(), "must not match stopword 'the'"); + + let t3 = parse_mention_options("keep work in progress"); + assert!(t3.repo.is_none(), "must not match stopword 'progress'"); + } + + #[test] + fn explicit_inline_wins_over_rule_targets() { + let nest = tmp_nest(); + let resolved = resolve_run_targeting( + "repo=web env=FromChat", + Some("rule-repo"), + Some("rule-env"), + Some(nest.path_str()), + ); + assert_eq!(resolved.repo.as_deref(), Some("web")); + assert_eq!(resolved.env.as_deref(), Some("FromChat")); + } + + #[test] + fn rule_targets_apply_when_content_has_none() { + let nest = tmp_nest(); + let resolved = + resolve_run_targeting("please fix auth", Some("api"), Some("Platform"), Some(nest.path_str())); + assert_eq!(resolved.repo.as_deref(), Some("api")); + assert_eq!(resolved.env.as_deref(), Some("Platform")); + } + + #[test] + fn loads_environments_toml_and_resolves_paths() { + let nest = tmp_nest(); + let cwd = nest.path_str(); + fs::write( + nest.path.join(".buzz/environments.toml"), + r#" +[[environment]] +name = "Platform" +repos = ["web", "api"] +default_branch = "main" +"#, + ) + .unwrap(); + fs::create_dir_all(nest.path.join("REPOS/web")).unwrap(); + fs::create_dir_all(nest.path.join("REPOS/api")).unwrap(); + + let envs = load_environments(cwd); + assert_eq!(envs.len(), 1); + assert_eq!(envs[0].name, "Platform"); + assert_eq!(envs[0].repos, vec!["web", "api"]); + assert_eq!(envs[0].default_branch.as_deref(), Some("main")); + + let resolved = resolve_run_targeting("env=Platform", None, None, Some(cwd)); + assert_eq!(resolved.env.as_deref(), Some("Platform")); + assert_eq!(resolved.repos, vec!["web", "api"]); + assert_eq!(resolved.branch.as_deref(), Some("main")); + assert_eq!(resolved.preferred_repo_paths.len(), 2); + assert!(resolved + .preferred_repo_paths + .iter() + .any(|p| p.ends_with("REPOS/web"))); + assert!(resolved + .preferred_repo_paths + .iter() + .any(|p| p.ends_with("REPOS/api"))); + } + + #[test] + fn missing_environments_file_is_empty() { + let nest = tmp_nest(); + let envs = load_environments(nest.path_str()); + assert!(envs.is_empty()); + } + + #[test] + fn preferred_path_for_explicit_repo_when_dir_exists() { + let nest = tmp_nest(); + fs::create_dir_all(nest.path.join("REPOS/buzz")).unwrap(); + let resolved = resolve_run_targeting("repo=buzz", None, None, Some(nest.path_str())); + assert_eq!(resolved.repo.as_deref(), Some("buzz")); + assert_eq!(resolved.preferred_repo_paths.len(), 1); + assert!(resolved.preferred_repo_paths[0].ends_with("REPOS/buzz")); + } + + #[test] + fn format_run_options_none_when_empty() { + assert!(format_run_options_section(&ResolvedTargeting::default()).is_none()); + } + + #[test] + fn format_run_options_lists_set_fields() { + let section = format_run_options_section(&ResolvedTargeting { + repo: Some("web".into()), + env: Some("Platform".into()), + branch: Some("dev".into()), + model: None, + autopr: Some(false), + channel: None, + repos: vec!["web".into(), "api".into()], + preferred_repo_paths: vec!["/nest/REPOS/web".into()], + }) + .unwrap(); + assert!(section.starts_with("[Run Options]")); + assert!(section.contains("env: Platform")); + assert!(section.contains("repo: web")); + assert!(section.contains("autopr: false")); + assert!(section.contains("- /nest/REPOS/web")); + assert!(section.contains("Handoff:")); + assert!(section.contains("Open in Cursor")); + } +} diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 0c51fe954f..91baab9e46 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -1839,6 +1839,9 @@ pub async fn run_prompt_task( system_prompt: ctx.system_prompt.as_deref(), team_instructions: ctx.team_instructions.as_deref(), agent_canvas: agent_canvas.as_deref(), + nest_cwd: Some(&ctx.cwd), + rule_target_repo: b.events.last().and_then(|e| e.target_repo.as_deref()), + rule_target_env: b.events.last().and_then(|e| e.target_env.as_deref()), }, ) } else { @@ -2031,6 +2034,7 @@ pub async fn run_prompt_task( Some(buzz_core::agent_turn_metric::StopReason::EndTurn), ) .await; + spawn_outcome_reaction(&ctx.rest_client, &reaction_ids, REACTION_DONE); send_prompt_result( &result_tx, &turn_id, @@ -2050,6 +2054,10 @@ pub async fn run_prompt_task( Ok(stop_reason) => { log_stop_reason(&source, &stop_reason); + if matches!(stop_reason, StopReason::EndTurn) { + spawn_outcome_reaction(&ctx.rest_client, &reaction_ids, REACTION_DONE); + } + let should_rotate = matches!( stop_reason, StopReason::MaxTokens | StopReason::MaxTurnRequests @@ -2105,6 +2113,7 @@ pub async fn run_prompt_task( } Err(AcpError::AgentExited) => { tracing::error!(target: "pool::prompt", "agent {} exited during prompt", agent.index); + spawn_outcome_reaction(&ctx.rest_client, &reaction_ids, REACTION_FAILED); agent.state.invalidate_all(); let usage = agent.acp.take_turn_usage(); publish_agent_turn_metric( @@ -2131,6 +2140,7 @@ pub async fn run_prompt_task( "idle timeout ({}s) — cancelling session {session_id}", ctx.idle_timeout.as_secs() ); + spawn_outcome_reaction(&ctx.rest_client, &reaction_ids, REACTION_FAILED); match agent .acp .cancel_with_cleanup(&session_id, ctx.idle_timeout) @@ -2219,6 +2229,7 @@ pub async fn run_prompt_task( "hard timeout ({}s cap, silence {silence:?}, recently_active={recently_active}) — agent process is unrecoverable, invalidating all sessions", ctx.max_turn_duration.as_secs() ); + spawn_outcome_reaction(&ctx.rest_client, &reaction_ids, REACTION_FAILED); agent.state.invalidate_all(); let usage = agent.acp.take_turn_usage(); publish_agent_turn_metric( @@ -2241,6 +2252,7 @@ pub async fn run_prompt_task( } Err(e) => { tracing::error!(target: "pool::prompt", "session_prompt error: {e}"); + spawn_outcome_reaction(&ctx.rest_client, &reaction_ids, REACTION_FAILED); // AgentError means the agent caught a problem before mutating // session state (e.g. bad LLM response). The session is healthy — // don't invalidate it. Other errors may have corrupted state. @@ -3491,6 +3503,8 @@ async fn publish_agent_turn_metric( const REACTION_SEEN: &str = "👀"; const REACTION_WORKING: &str = "💬"; +const REACTION_DONE: &str = "✅"; +const REACTION_FAILED: &str = "❌"; /// Best-effort timeout for a single reaction REST call. const REACTION_TIMEOUT: Duration = Duration::from_millis(500); @@ -3689,6 +3703,27 @@ async fn react_working(rest: &crate::relay::RestClient, event_ids: &[String]) { } } +/// Fire-and-forget outcome reaction (✅ / ❌) before `ReactionGuard` clears 👀/💬. +fn spawn_outcome_reaction( + rest: &crate::relay::RestClient, + event_ids: &[String], + emoji: &'static str, +) { + if event_ids.is_empty() { + return; + } + let rest = rest.clone(); + let ids = event_ids.to_vec(); + tokio::spawn(async move { + for chunk in ids.chunks(REACTION_CONCURRENCY) { + futures_util::future::join_all( + chunk.iter().map(|eid| reaction_add(&rest, eid, emoji)), + ) + .await; + } + }); +} + /// Fire-and-forget: remove both 👀 and 💬 from all events. Spawned on turn complete. /// Capped at `REACTION_CONCURRENCY` concurrent requests per chunk to avoid /// unbounded HTTP fan-out on large batches. @@ -4185,6 +4220,8 @@ mod tests { event, prompt_tag: "@mention".into(), received_at: std::time::Instant::now(), + target_repo: None, + target_env: None, }], cancelled_events: vec![], cancel_reason: None, @@ -4511,6 +4548,8 @@ mod tests { event, prompt_tag: "test".into(), received_at: std::time::Instant::now(), + target_repo: None, + target_env: None, }], cancelled_events: vec![], cancel_reason: None, diff --git a/crates/buzz-acp/src/queue.rs b/crates/buzz-acp/src/queue.rs index 029bf86dbf..73bae1261d 100644 --- a/crates/buzz-acp/src/queue.rs +++ b/crates/buzz-acp/src/queue.rs @@ -49,6 +49,10 @@ pub struct QueuedEvent { pub received_at: Instant, /// Tag identifying which rule (or mode) matched this event. pub prompt_tag: String, + /// Rule-level default repo for Slack-parity targeting (if any). + pub target_repo: Option, + /// Rule-level default environment for Slack-parity targeting (if any). + pub target_env: Option, } /// A single event inside a [`FlushBatch`]. @@ -57,6 +61,10 @@ pub struct BatchEvent { pub event: Event, pub prompt_tag: String, pub received_at: Instant, + /// Rule-level default repo for Slack-parity targeting (if any). + pub target_repo: Option, + /// Rule-level default environment for Slack-parity targeting (if any). + pub target_env: Option, } /// Why a batch's prior turn was cancelled — controls how `format_prompt` @@ -341,6 +349,8 @@ impl EventQueue { event: qe.event, prompt_tag: qe.prompt_tag, received_at: qe.received_at, + target_repo: qe.target_repo, + target_env: qe.target_env, }) .collect(); // Relay replay delivers stored events newest-first (`ORDER BY @@ -480,6 +490,8 @@ impl EventQueue { event: be.event, prompt_tag: be.prompt_tag, received_at: be.received_at, // preserve original timestamp (#46) + target_repo: be.target_repo, + target_env: be.target_env, }); } // Enforce per-channel cap: trim oldest (back) events if requeue pushed @@ -515,6 +527,8 @@ impl EventQueue { event: be.event, prompt_tag: be.prompt_tag, received_at: be.received_at, + target_repo: be.target_repo, + target_env: be.target_env, }); } // Enforce per-channel cap: trim newest (back) events if over limit. @@ -1372,6 +1386,15 @@ pub struct FormatPromptArgs<'a> { /// For legacy agents it rides in the user message on every turn of the /// session, alongside `[Base]`/`[System]`/`[Agent Memory — core]`. pub agent_canvas: Option<&'a str>, + /// Nest working directory used to resolve `REPOS/` paths and + /// `.buzz/environments.toml`. + pub nest_cwd: Option<&'a str>, + /// Rule-level default repo from the last batch event (overridden by + /// explicit inline options in the mention text). + pub rule_target_repo: Option<&'a str>, + /// Rule-level default environment from the last batch event (overridden + /// by explicit inline options in the mention text). + pub rule_target_env: Option<&'a str>, } /// Format the `[Base]` section for the base prompt. @@ -1487,6 +1510,17 @@ pub fn format_prompt(batch: &FlushBatch, args: &FormatPromptArgs<'_>) -> Vec) -> filter::SubscriptionRule { compiled_filter: None, consecutive_timeouts: Arc::new(AtomicU32::new(0)), prompt_tag: Some("@mention".into()), + target_repo: None, + target_env: None, } } diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 71e689330f..7e47c5819c 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -462,7 +462,7 @@ pub fn try_record_agent_command( fn default_agent_args(command: &str) -> Option> { match normalize_command_identity(command).as_str() { - "goose" => Some(vec!["acp".to_string()]), + "goose" | "cursor-agent" | "cursor" => Some(vec!["acp".to_string()]), "codex" | "codex-acp" | "claude-agent-acp" | "claude-code-acp" | "claude-code" | "claudecode" | "buzz-agent" => Some(Vec::new()), _ => None, @@ -1525,8 +1525,8 @@ const PRESET_HARNESSES: &[PresetHarness] = &[ command: "cursor-agent", args: &["acp"], install_instructions_url: "https://cursor.com/downloads", - install_hint: "Buzz talks to Cursor through the cursor-agent CLI's ACP mode.", - underlying_cli: None, + install_hint: "Buzz talks to Cursor through the cursor-agent CLI's ACP mode (cursor-agent acp). Install the Cursor CLI, then run cursor-agent login.", + underlying_cli: Some("cursor"), }, PresetHarness { id: "omp", diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index 48e8d5479c..3552098320 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -72,6 +72,23 @@ fn normalizes_claude_and_codex_args_to_empty() { ); } +#[test] +fn cursor_agent_defaults_to_acp_subcommand() { + assert_eq!( + normalize_agent_args("cursor-agent", Vec::new()), + vec!["acp".to_string()] + ); + assert_eq!( + normalize_agent_args("cursor", Vec::new()), + vec!["acp".to_string()] + ); + // Explicit args still win over the default. + assert_eq!( + normalize_agent_args("cursor-agent", vec!["acp".into(), "--verbose".into()]), + vec!["acp".to_string(), "--verbose".to_string()] + ); +} + #[test] fn resolves_buzz_agent_avatar() { assert_eq!(