Add managed agent avatars (#94)

This commit is contained in:
Wes
2026-03-17 14:44:49 -07:00
committed by GitHub
parent 0d73fe7632
commit 9728fa0c12
3 changed files with 107 additions and 14 deletions
+12 -9
View File
@@ -7,17 +7,18 @@ use crate::{
managed_agents::{
admin_command, build_managed_agent_summary, command_availability, default_token_scopes,
discover_local_acp_providers, find_managed_agent_mut, load_managed_agents,
managed_agent_log_path, read_log_tail, run_sprout_admin_mint_token, save_managed_agents,
start_managed_agent_process, stop_managed_agent_process, sync_managed_agent_processes,
AcpProviderInfo, CreateManagedAgentRequest, CreateManagedAgentResponse,
DiscoverManagedAgentPrereqsRequest, ManagedAgentLogResponse, ManagedAgentPrereqsInfo,
ManagedAgentSummary, MintManagedAgentTokenRequest, MintManagedAgentTokenResponse,
RelayAgentInfo, DEFAULT_ACP_COMMAND, DEFAULT_AGENT_ARG, DEFAULT_AGENT_COMMAND,
DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, DEFAULT_MCP_COMMAND,
managed_agent_avatar_url, managed_agent_log_path, read_log_tail,
run_sprout_admin_mint_token, save_managed_agents, start_managed_agent_process,
stop_managed_agent_process, sync_managed_agent_processes, AcpProviderInfo,
CreateManagedAgentRequest, CreateManagedAgentResponse, DiscoverManagedAgentPrereqsRequest,
ManagedAgentLogResponse, ManagedAgentPrereqsInfo, ManagedAgentSummary,
MintManagedAgentTokenRequest, MintManagedAgentTokenResponse, RelayAgentInfo,
DEFAULT_ACP_COMMAND, DEFAULT_AGENT_ARG, DEFAULT_AGENT_COMMAND, DEFAULT_AGENT_PARALLELISM,
DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, DEFAULT_MCP_COMMAND,
},
relay::{
build_authed_request, managed_agent_owner_pubkey, relay_ws_url, send_json_request,
sync_managed_agent_profile_display_name,
sync_managed_agent_profile,
},
util::now_iso,
};
@@ -269,13 +270,15 @@ pub async fn create_managed_agent(
))
}?;
let profile_sync_error = match sync_managed_agent_profile_display_name(
let avatar_url = managed_agent_avatar_url(agent.agent_command.as_str());
let profile_sync_error = match sync_managed_agent_profile(
&state,
&resolved_relay_url,
&pubkey,
api_token.as_deref(),
&token_scopes,
name,
avatar_url.as_deref(),
)
.await
{
@@ -13,9 +13,15 @@ struct KnownAcpProvider {
id: &'static str,
label: &'static str,
command: &'static str,
aliases: &'static [&'static str],
default_args: &'static [&'static str],
avatar_url: &'static str,
}
const GOOSE_AVATAR_URL: &str = "https://block.github.io/goose/img/logo_dark.png";
const CLAUDE_CODE_AVATAR_URL: &str = "https://anthropic.gallerycdn.vsassets.io/extensions/anthropic/claude-code/2.1.77/1773707456892/Microsoft.VisualStudio.Services.Icons.Default";
const CODEX_AVATAR_URL: &str = "https://openai.gallerycdn.vsassets.io/extensions/openai/chatgpt/26.5313.41514/1773706730621/Microsoft.VisualStudio.Services.Icons.Default";
const COMMON_BINARY_PATHS: &[&str] = &[
"/opt/homebrew/bin",
"/usr/local/bin",
@@ -28,19 +34,25 @@ const KNOWN_ACP_PROVIDERS: &[KnownAcpProvider] = &[
id: "goose",
label: "Goose",
command: "goose",
aliases: &[],
default_args: &[DEFAULT_AGENT_ARG],
avatar_url: GOOSE_AVATAR_URL,
},
KnownAcpProvider {
id: "claude",
label: "Claude Code",
command: "claude-agent-acp",
aliases: &["claude-code", "claudecode"],
default_args: &[],
avatar_url: CLAUDE_CODE_AVATAR_URL,
},
KnownAcpProvider {
id: "codex",
label: "Codex",
command: "codex-acp",
aliases: &[],
default_args: &[],
avatar_url: CODEX_AVATAR_URL,
},
];
@@ -62,6 +74,42 @@ fn executable_basename(command: &str) -> String {
}
}
fn normalize_command_identity(command: &str) -> String {
let normalized = command.trim().replace('\\', "/");
let basename = normalized.rsplit('/').next().unwrap_or(normalized.as_str());
let lower = basename
.chars()
.map(|character| match character {
' ' | '_' => '-',
_ => character.to_ascii_lowercase(),
})
.collect::<String>();
let lower = lower.strip_suffix(".exe").unwrap_or(&lower).to_string();
if let Some(suffix) = std::env::consts::EXE_SUFFIX.strip_prefix('.') {
return lower.strip_suffix(&format!(".{suffix}")).unwrap_or(&lower).to_string();
}
if !std::env::consts::EXE_SUFFIX.is_empty() {
return lower
.strip_suffix(std::env::consts::EXE_SUFFIX)
.unwrap_or(&lower)
.to_string();
}
lower
}
fn known_acp_provider(command: &str) -> Option<&'static KnownAcpProvider> {
let normalized = normalize_command_identity(command);
KNOWN_ACP_PROVIDERS.iter().find(|provider| {
normalized == provider.id
|| normalized == normalize_command_identity(provider.command)
|| provider.aliases.iter().any(|alias| normalized == *alias)
})
}
fn command_search_dirs(app: Option<&AppHandle>) -> Vec<PathBuf> {
let mut dirs = vec![
workspace_root_dir().join("target/release"),
@@ -212,6 +260,11 @@ pub fn discover_local_acp_providers() -> Vec<AcpProviderInfo> {
.collect()
}
pub fn managed_agent_avatar_url(command: &str) -> Option<String> {
let provider = known_acp_provider(command)?;
Some(provider.avatar_url.to_string())
}
pub fn admin_command() -> String {
std::env::var("SPROUT_ADMIN_COMMAND").unwrap_or_else(|_| "sprout-admin".to_string())
}
@@ -224,6 +277,42 @@ pub fn default_token_scopes() -> Vec<String> {
]
}
#[cfg(test)]
mod tests {
use super::{
managed_agent_avatar_url, CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, GOOSE_AVATAR_URL,
};
#[test]
fn resolves_known_avatar_for_bare_command() {
let avatar_url =
managed_agent_avatar_url("goose").expect("goose avatar should resolve");
assert_eq!(avatar_url, GOOSE_AVATAR_URL);
}
#[test]
fn resolves_known_avatar_for_command_paths_and_aliases() {
assert_eq!(
managed_agent_avatar_url("/usr/local/bin/codex-acp"),
Some(CODEX_AVATAR_URL.to_string())
);
assert_eq!(
managed_agent_avatar_url("Claude Code"),
Some(CLAUDE_CODE_AVATAR_URL.to_string())
);
assert_eq!(
managed_agent_avatar_url(r"C:\Tools\claude-agent-acp.exe"),
Some(CLAUDE_CODE_AVATAR_URL.to_string())
);
}
#[test]
fn returns_none_for_unknown_commands() {
assert!(managed_agent_avatar_url("custom-agent").is_none());
}
}
fn run_sprout_admin_command(
program: &Path,
pubkey: &str,
+6 -5
View File
@@ -81,13 +81,14 @@ fn token_supports_scope(scopes: &[String], required_scope: &str) -> bool {
scopes.iter().any(|scope| scope == required_scope)
}
pub async fn sync_managed_agent_profile_display_name(
pub async fn sync_managed_agent_profile(
state: &AppState,
relay_url: &str,
pubkey: &str,
api_token: Option<&str>,
token_scopes: &[String],
display_name: &str,
avatar_url: Option<&str>,
) -> Result<(), String> {
let url = format!(
"{}{}",
@@ -105,7 +106,7 @@ pub async fn sync_managed_agent_profile_display_name(
let request = request.json(&UpdateProfileBody {
display_name: Some(display_name),
avatar_url: None,
avatar_url,
about: None,
nip05_handle: None,
});
@@ -113,13 +114,13 @@ pub async fn sync_managed_agent_profile_display_name(
send_empty_request(request).await.map_err(|error| {
if api_token.is_some() && !use_bearer_token {
format!(
"Created the agent, but could not sync its profile display name. The minted token does not include `users:write`, and the relay rejected dev-mode pubkey auth: {error}"
"Created the agent, but could not sync its profile metadata. The minted token does not include `users:write`, and the relay rejected dev-mode pubkey auth: {error}"
)
} else if api_token.is_some() {
format!("Created the agent, but could not sync its profile display name: {error}")
format!("Created the agent, but could not sync its profile metadata: {error}")
} else {
format!(
"Created the agent, but could not sync its profile display name without a token: {error}"
"Created the agent, but could not sync its profile metadata without a token: {error}"
)
}
})