Fix Codex ACP startup and bot-to-bot mentions (#95)

Co-authored-by: Tyler Longwell <tlongwell@squareup.com>
This commit is contained in:
Wes
2026-03-17 17:44:19 -07:00
committed by GitHub
co-authored by Tyler Longwell
parent 7fd9f7ae78
commit 60d1d2334d
2 changed files with 329 additions and 6 deletions
+134 -2
View File
@@ -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]
+195 -4
View File
@@ -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)]