From 60d1d2334dc0b92e12d5b4690bc27282b3d8e4dc Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 17 Mar 2026 17:44:19 -0700 Subject: [PATCH] Fix Codex ACP startup and bot-to-bot mentions (#95) Co-authored-by: Tyler Longwell --- crates/sprout-acp/src/config.rs | 136 +++++++++++++++++++++- crates/sprout-mcp/src/server.rs | 199 +++++++++++++++++++++++++++++++- 2 files changed, 329 insertions(+), 6 deletions(-) diff --git a/crates/sprout-acp/src/config.rs b/crates/sprout-acp/src/config.rs index 17ba3408c..d418ecc44 100644 --- a/crates/sprout-acp/src/config.rs +++ b/crates/sprout-acp/src/config.rs @@ -202,6 +202,59 @@ pub struct Config { pub typing_enabled: bool, } +fn normalize_agent_command_identity(command: &str) -> String { + let normalized = command.trim().replace('\\', "/"); + let trimmed = normalized.trim_end_matches('/'); + let basename = trimmed + .rsplit('/') + .next() + .expect("rsplit always yields at least one element"); + let lower = basename.to_ascii_lowercase(); + let stem = lower.strip_suffix(".exe").unwrap_or(&lower); + stem.chars() + .map(|character| match character { + ' ' | '_' => '-', + _ => character, + }) + .collect() +} + +fn default_agent_args(command: &str) -> Option> { + match normalize_agent_command_identity(command).as_str() { + "goose" => Some(vec!["acp".to_string()]), + "codex" | "codex-acp" | "claude-agent-acp" | "claude-code" | "claudecode" => { + Some(Vec::new()) + } + _ => None, + } +} + +fn normalize_agent_args(command: &str, agent_args: Vec) -> Vec { + let normalized = agent_args + .into_iter() + .map(|arg| arg.trim().to_string()) + .filter(|arg| !arg.is_empty()) + .collect::>(); + + let Some(default_args) = default_agent_args(command) else { + return normalized; + }; + + if normalized.is_empty() { + return default_args; + } + + // Older callers relied on the Goose-specific default even for runtimes like + // Codex and Claude. Treat that legacy fallback as "no args" for zero-arg + // providers so desktop- and env-based launches behave the same way. + if normalized.len() == 1 && normalized[0].eq_ignore_ascii_case("acp") && default_args.is_empty() + { + return default_args; + } + + normalized +} + impl Config { pub fn from_cli() -> Result { // Propagate legacy env vars so clap sees them under the canonical names. @@ -258,12 +311,15 @@ impl Config { } } + let agent_command = args.agent_command; + let agent_args = normalize_agent_args(&agent_command, args.agent_args); + Ok(Config { keys, api_token: args.api_token, relay_url: args.relay_url, - agent_command: args.agent_command, - agent_args: args.agent_args, + agent_command, + agent_args, mcp_command: args.mcp_command, turn_timeout_secs: args.turn_timeout, agents: args.agents, @@ -658,6 +714,82 @@ mod tests { assert!(!f.require_mention); } + #[test] + fn normalizes_goose_args_to_acp() { + assert_eq!(normalize_agent_args("goose", Vec::new()), vec!["acp"]); + assert_eq!(normalize_agent_args("goose", vec!["".into()]), vec!["acp"]); + } + + #[test] + fn normalizes_codex_and_claude_args_to_empty() { + assert_eq!( + normalize_agent_args("codex-acp", Vec::new()), + Vec::::new() + ); + assert_eq!( + normalize_agent_args("codex-acp", vec!["".into()]), + Vec::::new() + ); + assert_eq!( + normalize_agent_args("codex-acp", vec!["acp".into()]), + Vec::::new() + ); + assert_eq!( + normalize_agent_args("claude-code", vec!["acp".into()]), + Vec::::new() + ); + } + + #[test] + fn preserves_explicit_nonempty_agent_args() { + assert_eq!( + normalize_agent_args("codex-acp", vec!["-c".into(), "model=\"gpt-5\"".into()]), + vec!["-c", "model=\"gpt-5\""] + ); + assert_eq!( + normalize_agent_args("custom-agent", vec!["".into(), "serve".into()]), + vec!["serve"] + ); + } + + #[test] + fn normalize_agent_command_identity_variants() { + assert_eq!(normalize_agent_command_identity("goose"), "goose"); + assert_eq!( + normalize_agent_command_identity("C:\\Program Files\\Goose\\goose.exe"), + "goose" + ); + assert_eq!( + normalize_agent_command_identity("/usr/local/bin/codex-acp"), + "codex-acp" + ); + assert_eq!(normalize_agent_command_identity("/usr/local/bin/"), "bin"); + assert_eq!( + normalize_agent_command_identity("Claude_Code"), + "claude-code" + ); + assert_eq!( + normalize_agent_command_identity("Claude Code"), + "claude-code" + ); + assert_eq!(normalize_agent_command_identity("Goose.EXE"), "goose"); + // Non-ASCII must not panic. + assert_eq!(normalize_agent_command_identity("my-agënt"), "my-agënt"); + // Edge cases: empty, whitespace-only, bare separators. + assert_eq!(normalize_agent_command_identity(""), ""); + assert_eq!(normalize_agent_command_identity(" "), ""); + assert_eq!(normalize_agent_command_identity("/"), ""); + assert_eq!(normalize_agent_command_identity("///"), ""); + } + + #[test] + fn strips_legacy_acp_arg_case_insensitively() { + assert_eq!( + normalize_agent_args("codex-acp", vec!["ACP".into()]), + Vec::::new() + ); + } + // ── resolve_channel_filters: All mode ──────────────────────────────────── #[test] diff --git a/crates/sprout-mcp/src/server.rs b/crates/sprout-mcp/src/server.rs index 102364406..b412d806d 100644 --- a/crates/sprout-mcp/src/server.rs +++ b/crates/sprout-mcp/src/server.rs @@ -1,3 +1,5 @@ +use std::collections::HashSet; + use nostr::{EventBuilder, Kind, Tag}; use rmcp::{ handler::server::{router::tool::ToolRouter, wrapper::Parameters}, @@ -42,6 +44,47 @@ fn validate_uuid(s: &str) -> Result<(), String> { Ok(()) } +fn normalize_mention_pubkeys(mention_pubkeys: &[String], sender_pubkey: &str) -> Vec { + let sender_pubkey = sender_pubkey.to_ascii_lowercase(); + let mut seen = HashSet::new(); + let mut normalized = Vec::new(); + + for pubkey in mention_pubkeys { + let lower = pubkey.to_ascii_lowercase(); + if lower == sender_pubkey || !seen.insert(lower.clone()) { + continue; + } + normalized.push(lower); + } + + normalized +} + +fn build_top_level_message_tags( + channel_id: &str, + sender_pubkey: &str, + mention_pubkeys: Option<&[String]>, +) -> Result, String> { + let sender_pubkey = sender_pubkey.to_ascii_lowercase(); + let mut tags = vec![ + Tag::parse(&["h", channel_id]).map_err(|e| format!("failed to build channel tag: {e}"))?, + Tag::parse(&["p", &sender_pubkey]) + .map_err(|e| format!("failed to build sender tag: {e}"))?, + ]; + + for mention in mention_pubkeys + .map(|mentions| normalize_mention_pubkeys(mentions, &sender_pubkey)) + .unwrap_or_default() + { + tags.push( + Tag::parse(&["p", &mention]) + .map_err(|e| format!("failed to build mention tag: {e}"))?, + ); + } + + Ok(tags) +} + /// Maximum allowed content size for a single message (64 KiB). const MAX_CONTENT_BYTES: usize = 65_536; @@ -658,10 +701,15 @@ impl SproutMcpServer { p.kind .unwrap_or(sprout_core::kind::KIND_STREAM_MESSAGE as u16), ); - let tags = vec![match Tag::parse(&["h", &p.channel_id]) { - Ok(tag) => tag, - Err(e) => return format!("Error: failed to build channel tag: {e}"), - }]; + let sender_pubkey = self.client.keys().public_key().to_hex(); + let tags = match build_top_level_message_tags( + &p.channel_id, + &sender_pubkey, + p.mention_pubkeys.as_deref(), + ) { + Ok(tags) => tags, + Err(error) => return format!("Error: {error}"), + }; let event = match EventBuilder::new(kind, p.content, tags).sign_with_keys(self.client.keys()) { @@ -1815,6 +1863,149 @@ mod tests { fn max_content_bytes_value() { assert_eq!(MAX_CONTENT_BYTES, 65_536); } + + #[test] + fn normalize_mention_pubkeys_dedupes_and_skips_sender() { + let sender = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let mentions = vec![ + sender.to_string(), + "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB".to_string(), + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(), + "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC".to_string(), + ]; + + assert_eq!( + normalize_mention_pubkeys(&mentions, sender), + vec![ + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(), + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc".to_string(), + ] + ); + } + + #[test] + fn normalize_mention_pubkeys_skips_mixed_case_sender() { + let sender = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + let mentions = vec![ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB".to_string(), + ]; + + assert_eq!( + normalize_mention_pubkeys(&mentions, sender), + vec!["bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(),] + ); + } + + #[test] + fn normalize_mention_pubkeys_empty_input() { + let sender = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + assert_eq!(normalize_mention_pubkeys(&[], sender), Vec::::new()); + } + + #[test] + fn build_top_level_message_tags_lowercases_sender() { + let sender = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + let tags = + build_top_level_message_tags("550e8400-e29b-41d4-a716-446655440000", sender, None) + .expect("tags should build"); + let tag_strings = tags + .iter() + .map(|tag| tag.clone().to_vec()) + .collect::>>(); + + assert_eq!( + tag_strings, + vec![ + vec![ + "h".to_string(), + "550e8400-e29b-41d4-a716-446655440000".to_string() + ], + vec![ + "p".to_string(), + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string() + ], + ] + ); + } + + #[test] + fn build_top_level_message_tags_includes_mention_p_tags() { + let sender = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let mention = + "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB".to_string(); + let tags = build_top_level_message_tags( + "550e8400-e29b-41d4-a716-446655440000", + sender, + Some(&[mention]), + ) + .expect("tags should build"); + let tag_strings = tags + .iter() + .map(|tag| tag.clone().to_vec()) + .collect::>>(); + + assert_eq!( + tag_strings, + vec![ + vec![ + "h".to_string(), + "550e8400-e29b-41d4-a716-446655440000".to_string() + ], + vec!["p".to_string(), sender.to_string()], + vec![ + "p".to_string(), + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string() + ], + ] + ); + } + + #[test] + fn build_top_level_message_tags_no_mentions_when_none() { + let sender = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let tags = + build_top_level_message_tags("550e8400-e29b-41d4-a716-446655440000", sender, None) + .expect("tags should build"); + let tag_strings = tags + .iter() + .map(|tag| tag.clone().to_vec()) + .collect::>>(); + + assert_eq!( + tag_strings, + vec![ + vec![ + "h".to_string(), + "550e8400-e29b-41d4-a716-446655440000".to_string() + ], + vec!["p".to_string(), sender.to_string()], + ] + ); + } + + #[test] + fn build_top_level_message_tags_no_mentions_when_empty_slice() { + let sender = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let tags = + build_top_level_message_tags("550e8400-e29b-41d4-a716-446655440000", sender, Some(&[])) + .expect("tags should build"); + let tag_strings = tags + .iter() + .map(|tag| tag.clone().to_vec()) + .collect::>>(); + + assert_eq!( + tag_strings, + vec![ + vec![ + "h".to_string(), + "550e8400-e29b-41d4-a716-446655440000".to_string() + ], + vec!["p".to_string(), sender.to_string()], + ] + ); + } } #[cfg(test)]