mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Fix Codex ACP startup and bot-to-bot mentions (#95)
Co-authored-by: Tyler Longwell <tlongwell@squareup.com>
This commit is contained in:
@@ -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<Vec<String>> {
|
||||
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<String>) -> Vec<String> {
|
||||
let normalized = agent_args
|
||||
.into_iter()
|
||||
.map(|arg| arg.trim().to_string())
|
||||
.filter(|arg| !arg.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
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<Self, ConfigError> {
|
||||
// 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::<String>::new()
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_agent_args("codex-acp", vec!["".into()]),
|
||||
Vec::<String>::new()
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_agent_args("codex-acp", vec!["acp".into()]),
|
||||
Vec::<String>::new()
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_agent_args("claude-code", vec!["acp".into()]),
|
||||
Vec::<String>::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::<String>::new()
|
||||
);
|
||||
}
|
||||
|
||||
// ── resolve_channel_filters: All mode ────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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<String> {
|
||||
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<Vec<Tag>, 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::<String>::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::<Vec<Vec<String>>>();
|
||||
|
||||
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::<Vec<Vec<String>>>();
|
||||
|
||||
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::<Vec<Vec<String>>>();
|
||||
|
||||
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::<Vec<Vec<String>>>();
|
||||
|
||||
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)]
|
||||
|
||||
Reference in New Issue
Block a user