mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00: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,
|
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 {
|
impl Config {
|
||||||
pub fn from_cli() -> Result<Self, ConfigError> {
|
pub fn from_cli() -> Result<Self, ConfigError> {
|
||||||
// Propagate legacy env vars so clap sees them under the canonical names.
|
// 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 {
|
Ok(Config {
|
||||||
keys,
|
keys,
|
||||||
api_token: args.api_token,
|
api_token: args.api_token,
|
||||||
relay_url: args.relay_url,
|
relay_url: args.relay_url,
|
||||||
agent_command: args.agent_command,
|
agent_command,
|
||||||
agent_args: args.agent_args,
|
agent_args,
|
||||||
mcp_command: args.mcp_command,
|
mcp_command: args.mcp_command,
|
||||||
turn_timeout_secs: args.turn_timeout,
|
turn_timeout_secs: args.turn_timeout,
|
||||||
agents: args.agents,
|
agents: args.agents,
|
||||||
@@ -658,6 +714,82 @@ mod tests {
|
|||||||
assert!(!f.require_mention);
|
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 ────────────────────────────────────
|
// ── resolve_channel_filters: All mode ────────────────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
use nostr::{EventBuilder, Kind, Tag};
|
use nostr::{EventBuilder, Kind, Tag};
|
||||||
use rmcp::{
|
use rmcp::{
|
||||||
handler::server::{router::tool::ToolRouter, wrapper::Parameters},
|
handler::server::{router::tool::ToolRouter, wrapper::Parameters},
|
||||||
@@ -42,6 +44,47 @@ fn validate_uuid(s: &str) -> Result<(), String> {
|
|||||||
Ok(())
|
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).
|
/// Maximum allowed content size for a single message (64 KiB).
|
||||||
const MAX_CONTENT_BYTES: usize = 65_536;
|
const MAX_CONTENT_BYTES: usize = 65_536;
|
||||||
|
|
||||||
@@ -658,10 +701,15 @@ impl SproutMcpServer {
|
|||||||
p.kind
|
p.kind
|
||||||
.unwrap_or(sprout_core::kind::KIND_STREAM_MESSAGE as u16),
|
.unwrap_or(sprout_core::kind::KIND_STREAM_MESSAGE as u16),
|
||||||
);
|
);
|
||||||
let tags = vec![match Tag::parse(&["h", &p.channel_id]) {
|
let sender_pubkey = self.client.keys().public_key().to_hex();
|
||||||
Ok(tag) => tag,
|
let tags = match build_top_level_message_tags(
|
||||||
Err(e) => return format!("Error: failed to build channel tag: {e}"),
|
&p.channel_id,
|
||||||
}];
|
&sender_pubkey,
|
||||||
|
p.mention_pubkeys.as_deref(),
|
||||||
|
) {
|
||||||
|
Ok(tags) => tags,
|
||||||
|
Err(error) => return format!("Error: {error}"),
|
||||||
|
};
|
||||||
|
|
||||||
let event =
|
let event =
|
||||||
match EventBuilder::new(kind, p.content, tags).sign_with_keys(self.client.keys()) {
|
match EventBuilder::new(kind, p.content, tags).sign_with_keys(self.client.keys()) {
|
||||||
@@ -1815,6 +1863,149 @@ mod tests {
|
|||||||
fn max_content_bytes_value() {
|
fn max_content_bytes_value() {
|
||||||
assert_eq!(MAX_CONTENT_BYTES, 65_536);
|
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)]
|
#[cfg(test)]
|
||||||
|
|||||||
Reference in New Issue
Block a user