Revamp new agent dialog (#1201)

This commit is contained in:
klopez4212
2026-06-29 14:35:00 +01:00
committed by GitHub
parent 7d3ee68336
commit 826d735fe6
46 changed files with 3712 additions and 446 deletions

View File

@@ -67,9 +67,11 @@ const overrides = new Map([
// harness-persona-sync feature growth, queued to split in the resolver-unify
// refactor followup. discovery.rs is dominated by the new test module
// (the effective_agent_command / divergent / create-time override matrix);
// alias-preservation coverage extends that matrix so create-time persona
// agents keep an installed runtime alias when the primary command is absent.
// types.rs adds the persona/instance harness fields. Load-bearing, not
// generic debt.
["src-tauri/src/managed_agents/discovery.rs", 1043],
["src-tauri/src/managed_agents/discovery.rs", 1085],
["src-tauri/src/managed_agents/types.rs", 1037],
// migration_tests.rs carries the harness-sync migration coverage plus the
// patch_json_records owner-only writeback regression test (SECURITY.md:90

View File

@@ -1,6 +1,10 @@
use std::collections::HashSet;
use std::{
collections::{BTreeMap, HashSet},
path::PathBuf,
};
use nostr::Keys;
use serde::Deserialize;
use tauri::{AppHandle, State};
use crate::{
@@ -9,9 +13,9 @@ use crate::{
build_managed_agent_summary, current_instance_id, default_agent_workdir,
find_managed_agent_mut, known_acp_runtime, load_managed_agents, load_personas,
managed_agent_avatar_url, missing_command_message, normalize_agent_args, resolve_command,
resolve_effective_prompt_model_provider, save_managed_agents, sync_managed_agent_processes,
try_regenerate_nest, AgentModelInfo, AgentModelsResponse, UpdateManagedAgentRequest,
UpdateManagedAgentResponse,
save_managed_agents, sync_managed_agent_processes, try_regenerate_nest, AgentModelInfo,
AgentModelsResponse, UpdateManagedAgentRequest, UpdateManagedAgentResponse,
DEFAULT_ACP_COMMAND,
},
relay::{relay_ws_url_with_override, sync_managed_agent_profile},
util::now_iso,
@@ -27,7 +31,7 @@ pub async fn get_agent_models(
app: AppHandle,
state: State<'_, AppState>,
) -> Result<AgentModelsResponse, String> {
let (resolved_acp, agent_command, agent_args, persisted_model, merged_env) = {
let (resolved_acp, agent_command, agent_args, persisted_model, effective_provider, merged_env) = {
let _store_guard = state
.managed_agents_store_lock
.lock()
@@ -65,26 +69,542 @@ pub async fn get_agent_models(
.map(|p| p.display().to_string())
.unwrap_or_else(|| effective_command.clone());
// Same env layering as runtime spawn: persona env < agent env.
// Model discovery needs the user's credentials. Fail closed on
// persona-resolution errors so a corrupt personas.json doesn't
// produce a model list as if the persona had no credentials.
let persona_env =
crate::managed_agents::resolve_persona_env(&app, record.persona_id.as_deref())?;
let env = crate::managed_agents::merged_user_env(&persona_env, &record.env_vars);
// ModelPicker can persist a selected model but not rewrite the saved
// provider/env snapshot, and runtime spawn reads that same snapshot.
// Discover models against the record snapshot so an out-of-date persona
// cannot offer models for a provider this agent will not launch with.
let discovery = saved_agent_model_discovery_config(record, &effective_command);
// Resolve the effective model from the linked persona so the ModelPicker
// dropdown shows the current persona model as selected.
let (_prompt, effective_model, _provider) = resolve_effective_prompt_model_provider(
record.persona_id.as_deref(),
&personas,
record.system_prompt.clone(),
record.model.clone(),
);
(resolved, resolved_agent, args, effective_model, env)
(
resolved,
resolved_agent,
args,
discovery.model,
discovery.provider,
discovery.env,
)
}; // store lock released — subprocess runs without holding the lock
if let Some(models) = discover_openai_compatible_models(
&state.http_client,
effective_provider.as_deref(),
&merged_env,
persisted_model.clone(),
)
.await?
{
return Ok(models);
}
if let Some(models) = discover_anthropic_models(
&state.http_client,
effective_provider.as_deref(),
&merged_env,
persisted_model.clone(),
)
.await?
{
return Ok(models);
}
run_agent_models_command(
resolved_acp,
agent_command,
agent_args,
persisted_model,
merged_env,
)
.await
}
#[derive(Debug, PartialEq, Eq)]
struct SavedAgentModelDiscoveryConfig {
model: Option<String>,
provider: Option<String>,
env: BTreeMap<String, String>,
}
fn saved_agent_model_discovery_config(
record: &crate::managed_agents::ManagedAgentRecord,
agent_command: &str,
) -> SavedAgentModelDiscoveryConfig {
let mut derived_env = BTreeMap::new();
if let Some(meta) = known_acp_runtime(agent_command) {
for (key, value) in crate::managed_agents::runtime_metadata_env_vars(
meta.model_env_var,
meta.provider_env_var,
meta.provider_locked,
record.model.as_deref(),
record.provider.as_deref(),
) {
derived_env.insert(key.to_string(), value.to_string());
}
}
SavedAgentModelDiscoveryConfig {
model: record.model.clone(),
provider: record.provider.clone(),
env: crate::managed_agents::merged_user_env(&derived_env, &record.env_vars),
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DiscoverAgentModelsInput {
#[serde(default)]
pub acp_command: Option<String>,
pub agent_command: String,
#[serde(default)]
pub agent_args: Vec<String>,
#[serde(default)]
pub provider: Option<String>,
#[serde(default)]
pub env_vars: BTreeMap<String, String>,
}
/// Query available models from an unsaved agent configuration.
///
/// This powers the new-agent dialog before a persona/agent record exists. It
/// mirrors the saved-agent discovery command, but derives runtime/provider/env
/// from the current form state instead of loading a persisted record.
#[tauri::command]
pub async fn discover_agent_models(
input: DiscoverAgentModelsInput,
state: State<'_, AppState>,
) -> Result<AgentModelsResponse, String> {
crate::managed_agents::validate_user_env_keys(&input.env_vars)?;
let acp_command = input
.acp_command
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(DEFAULT_ACP_COMMAND);
let resolved_acp = resolve_command(acp_command)
.ok_or_else(|| missing_command_message(acp_command, "ACP harness command"))?;
let agent_command = input.agent_command.trim();
if agent_command.is_empty() {
return Err("agent command is required for model discovery".to_string());
}
let agent_args = normalize_agent_args(agent_command, input.agent_args);
let resolved_agent = resolve_command(agent_command)
.map(|p| p.display().to_string())
.unwrap_or_else(|| agent_command.to_string());
let mut derived_env = BTreeMap::new();
if let Some(meta) = known_acp_runtime(agent_command) {
let provider = input
.provider
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
if !meta.provider_locked {
if let (Some(env_key), Some(provider)) = (meta.provider_env_var, provider) {
derived_env.insert(env_key.to_string(), provider.to_string());
}
}
}
let merged_env = crate::managed_agents::merged_user_env(&derived_env, &input.env_vars);
if let Some(models) = discover_openai_compatible_models(
&state.http_client,
input.provider.as_deref(),
&merged_env,
None,
)
.await?
{
return Ok(models);
}
if let Some(models) = discover_anthropic_models(
&state.http_client,
input.provider.as_deref(),
&merged_env,
None,
)
.await?
{
return Ok(models);
}
run_agent_models_command(resolved_acp, resolved_agent, agent_args, None, merged_env).await
}
#[derive(Debug, Deserialize)]
struct OpenAiModelListResponse {
data: Vec<OpenAiModelListItem>,
}
#[derive(Debug, Deserialize)]
struct OpenAiModelListItem {
id: String,
#[serde(default)]
created: Option<i64>,
}
fn is_openai_compatible_provider(provider: Option<&str>) -> bool {
matches!(
provider
.map(str::trim)
.map(str::to_ascii_lowercase)
.as_deref(),
Some("openai" | "openai-compat")
)
}
#[cfg(test)]
fn openai_compatible_models_url(env: &BTreeMap<String, String>) -> String {
let base_url = env_value(env, "OPENAI_COMPAT_BASE_URL")
.unwrap_or_else(|| "https://api.openai.com/v1".to_string());
format!("{}/models", base_url.trim_end_matches('/'))
}
fn openai_compatible_models_url_for_discovery(env: &BTreeMap<String, String>) -> String {
let base_url = env_or_process_value(env, "OPENAI_COMPAT_BASE_URL")
.unwrap_or_else(|| "https://api.openai.com/v1".to_string());
format!("{}/models", base_url.trim_end_matches('/'))
}
fn env_value(env: &BTreeMap<String, String>, key: &str) -> Option<String> {
env.get(key)
.map(String::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
fn env_or_process_value(env: &BTreeMap<String, String>, key: &str) -> Option<String> {
env_value(env, key).or_else(|| {
std::env::var(key)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
})
}
fn redaction_env_with_value(
env: &BTreeMap<String, String>,
key: &str,
value: &str,
) -> BTreeMap<String, String> {
let mut redaction_env = env.clone();
redaction_env.insert(key.to_string(), value.to_string());
redaction_env
}
fn is_agent_text_model_id(id: &str) -> bool {
let lower = id.to_ascii_lowercase();
if [
"audio",
"dall-e",
"embedding",
"image",
"moderation",
"realtime",
"speech",
"transcribe",
"tts",
"whisper",
]
.iter()
.any(|needle| lower.contains(needle))
{
return false;
}
lower.starts_with("gpt-") || lower.starts_with('o') || lower.starts_with("chatgpt-")
}
fn openai_dated_snapshot_alias(id: &str) -> Option<String> {
let (base, date) = id.rsplit_once('-')?;
if date.len() != 2 || !date.chars().all(|character| character.is_ascii_digit()) {
return None;
}
let (base, month) = base.rsplit_once('-')?;
if month.len() != 2 || !month.chars().all(|character| character.is_ascii_digit()) {
return None;
}
let (base, year) = base.rsplit_once('-')?;
if year.len() != 4 || !year.chars().all(|character| character.is_ascii_digit()) {
return None;
}
Some(base.to_string())
}
fn openai_model_display_name(id: &str) -> String {
let canonical = openai_dated_snapshot_alias(id).unwrap_or_else(|| id.to_string());
if let Some(rest) = canonical.strip_prefix("chatgpt-") {
return format!("ChatGPT {}", title_case_model_suffix(rest, false));
}
if let Some(rest) = canonical.strip_prefix("gpt-") {
return format!("GPT-{}", title_case_model_suffix(rest, true));
}
canonical
}
fn title_case_model_suffix(value: &str, preserve_first_separator: bool) -> String {
value
.split('-')
.enumerate()
.map(|(index, part)| {
let part = if part.eq_ignore_ascii_case("pro") {
"Pro".to_string()
} else if part.eq_ignore_ascii_case("mini") {
"mini".to_string()
} else if part.eq_ignore_ascii_case("nano") {
"nano".to_string()
} else {
part.to_string()
};
if preserve_first_separator && index == 0 {
part
} else if index == 0 {
part
} else {
format!(" {part}")
}
})
.collect::<String>()
}
fn normalize_openai_compatible_models(
response: OpenAiModelListResponse,
provider: Option<&str>,
) -> Vec<AgentModelInfo> {
let mut seen = HashSet::new();
let mut items = response.data;
let filter_to_openai_text_models = matches!(
provider
.map(str::trim)
.map(str::to_ascii_lowercase)
.as_deref(),
Some("openai")
);
let all_ids = items
.iter()
.map(|item| item.id.clone())
.collect::<HashSet<String>>();
items.sort_by(|left, right| {
right
.created
.cmp(&left.created)
.then_with(|| left.id.cmp(&right.id))
});
items
.into_iter()
.filter(|item| !filter_to_openai_text_models || is_agent_text_model_id(&item.id))
.filter(|item| match openai_dated_snapshot_alias(&item.id) {
Some(alias) if filter_to_openai_text_models => !all_ids.contains(&alias),
Some(_) | None => true,
})
.filter(|item| seen.insert(item.id.clone()))
.map(|item| AgentModelInfo {
name: Some(openai_model_display_name(&item.id)),
id: item.id,
description: None,
})
.collect()
}
async fn discover_openai_compatible_models(
client: &reqwest::Client,
provider: Option<&str>,
env: &BTreeMap<String, String>,
selected_model: Option<String>,
) -> Result<Option<AgentModelsResponse>, String> {
if !is_openai_compatible_provider(provider) {
return Ok(None);
}
let api_key = env_or_process_value(env, "OPENAI_COMPAT_API_KEY")
.ok_or_else(|| "config: OPENAI_COMPAT_API_KEY required".to_string())?;
let redaction_env = redaction_env_with_value(env, "OPENAI_COMPAT_API_KEY", &api_key);
let url = openai_compatible_models_url_for_discovery(env);
let response = client
.get(&url)
.bearer_auth(&api_key)
.send()
.await
.map_err(|error| format!("OpenAI model discovery request failed: {error}"))?;
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
let body = crate::managed_agents::redact_env_values_in(&body, &redaction_env);
return Err(format!("OpenAI model discovery HTTP {status}: {body}"));
}
let response = response
.json::<OpenAiModelListResponse>()
.await
.map_err(|error| format!("OpenAI model discovery response parse failed: {error}"))?;
let models = normalize_openai_compatible_models(response, provider);
if models.is_empty() {
return Err("OpenAI model discovery returned no compatible text models".to_string());
}
Ok(Some(AgentModelsResponse {
agent_name: provider.unwrap_or("openai").trim().to_string(),
agent_version: "models-api".to_string(),
models,
agent_default_model: None,
selected_model,
supports_switching: true,
}))
}
#[derive(Debug, Deserialize)]
struct AnthropicModelListResponse {
data: Vec<AnthropicModelListItem>,
#[serde(default)]
has_more: bool,
#[serde(default)]
last_id: Option<String>,
}
#[derive(Debug, Deserialize)]
struct AnthropicModelListItem {
id: String,
#[serde(default)]
display_name: Option<String>,
}
fn is_anthropic_provider(provider: Option<&str>) -> bool {
matches!(
provider
.map(str::trim)
.map(str::to_ascii_lowercase)
.as_deref(),
Some("anthropic")
)
}
#[cfg(test)]
fn anthropic_models_url(env: &BTreeMap<String, String>) -> String {
let base_url = env_value(env, "ANTHROPIC_BASE_URL")
.unwrap_or_else(|| "https://api.anthropic.com".to_string());
anthropic_models_url_from_base(&base_url)
}
fn anthropic_models_url_for_discovery(env: &BTreeMap<String, String>) -> String {
let base_url = env_or_process_value(env, "ANTHROPIC_BASE_URL")
.unwrap_or_else(|| "https://api.anthropic.com".to_string());
anthropic_models_url_from_base(&base_url)
}
fn anthropic_models_url_from_base(base_url: &str) -> String {
let base_url = base_url.trim_end_matches('/');
if base_url.ends_with("/v1") {
format!("{base_url}/models")
} else {
format!("{base_url}/v1/models")
}
}
fn normalize_anthropic_models(response: AnthropicModelListResponse) -> Vec<AgentModelInfo> {
let mut seen = HashSet::new();
response
.data
.into_iter()
.filter(|item| seen.insert(item.id.clone()))
.map(|item| AgentModelInfo {
id: item.id,
name: item.display_name,
description: None,
})
.collect()
}
async fn fetch_anthropic_model_page(
client: &reqwest::Client,
url: &str,
api_key: &str,
after_id: Option<&str>,
env: &BTreeMap<String, String>,
) -> Result<AnthropicModelListResponse, String> {
let mut request = client
.get(url)
.header("x-api-key", api_key)
.header("anthropic-version", "2023-06-01");
if let Some(after_id) = after_id {
request = request.query(&[("after_id", after_id)]);
}
let response = request
.send()
.await
.map_err(|error| format!("Anthropic model discovery request failed: {error}"))?;
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
let body = crate::managed_agents::redact_env_values_in(&body, env);
return Err(format!("Anthropic model discovery HTTP {status}: {body}"));
}
response
.json::<AnthropicModelListResponse>()
.await
.map_err(|error| format!("Anthropic model discovery response parse failed: {error}"))
}
async fn discover_anthropic_models(
client: &reqwest::Client,
provider: Option<&str>,
env: &BTreeMap<String, String>,
selected_model: Option<String>,
) -> Result<Option<AgentModelsResponse>, String> {
if !is_anthropic_provider(provider) {
return Ok(None);
}
let api_key = env_or_process_value(env, "ANTHROPIC_API_KEY")
.ok_or_else(|| "config: ANTHROPIC_API_KEY required".to_string())?;
let redaction_env = redaction_env_with_value(env, "ANTHROPIC_API_KEY", &api_key);
let url = anthropic_models_url_for_discovery(env);
let mut models = Vec::new();
let mut after_id: Option<String> = None;
for _ in 0..20 {
let response =
fetch_anthropic_model_page(client, &url, &api_key, after_id.as_deref(), &redaction_env)
.await?;
let has_more = response.has_more;
after_id = response.last_id.clone();
models.extend(normalize_anthropic_models(response));
if !has_more {
break;
}
if after_id.as_deref().unwrap_or_default().is_empty() {
return Err("Anthropic model discovery pagination did not return last_id".to_string());
}
}
let mut seen = HashSet::new();
models.retain(|model| seen.insert(model.id.clone()));
if models.is_empty() {
return Err("Anthropic model discovery returned no models".to_string());
}
Ok(Some(AgentModelsResponse {
agent_name: provider.unwrap_or("anthropic").trim().to_string(),
agent_version: "models-api".to_string(),
models,
agent_default_model: None,
selected_model,
supports_switching: true,
}))
}
async fn run_agent_models_command(
resolved_acp: PathBuf,
agent_command: String,
agent_args: Vec<String>,
persisted_model: Option<String>,
merged_env: BTreeMap<String, String>,
) -> Result<AgentModelsResponse, String> {
// Clone the env map for redaction below — `merged_env` is moved
// into the spawn_blocking closure and we still need the values to
// scrub any user-supplied secrets that the child surfaces in stderr.
@@ -112,6 +632,11 @@ pub async fn get_agent_models(
}
}
}
// Mirror runtime spawn: internal builds may bake Databricks host/model
// defaults. User-provided env below still wins.
for (key, value) in crate::managed_agents::build_databricks_defaults() {
cmd.env(key, value);
}
// User env layering — written LAST so it overrides any Buzz-set env above.
for (k, v) in &merged_env {
cmd.env(k, v);
@@ -415,3 +940,7 @@ fn normalize_agent_models(
supports_switching,
}
}
#[cfg(test)]
#[path = "agent_models_tests.rs"]
mod tests;

View File

@@ -0,0 +1,213 @@
use super::*;
#[test]
fn openai_model_normalization_keeps_agent_text_models() {
let models = normalize_openai_compatible_models(
OpenAiModelListResponse {
data: vec![
OpenAiModelListItem {
id: "text-embedding-3-large".to_string(),
created: Some(4),
},
OpenAiModelListItem {
id: "gpt-image-2".to_string(),
created: Some(5),
},
OpenAiModelListItem {
id: "chatgpt-5.5-pro-2026-04-23".to_string(),
created: Some(7),
},
OpenAiModelListItem {
id: "chatgpt-5.5-pro".to_string(),
created: Some(6),
},
OpenAiModelListItem {
id: "gpt-5.4-mini".to_string(),
created: Some(2),
},
OpenAiModelListItem {
id: "o4-mini".to_string(),
created: Some(3),
},
OpenAiModelListItem {
id: "gpt-5.4-mini".to_string(),
created: Some(1),
},
],
},
Some("openai"),
);
let ids_and_names = models
.into_iter()
.map(|model| (model.id, model.name))
.collect::<Vec<_>>();
assert_eq!(
ids_and_names,
vec![
(
"chatgpt-5.5-pro".to_string(),
Some("ChatGPT 5.5 Pro".to_string()),
),
("o4-mini".to_string(), Some("o4-mini".to_string())),
("gpt-5.4-mini".to_string(), Some("GPT-5.4 mini".to_string()),),
]
);
}
#[test]
fn openai_compat_model_normalization_preserves_provider_specific_ids() {
let models = normalize_openai_compatible_models(
OpenAiModelListResponse {
data: vec![
OpenAiModelListItem {
id: "meta-llama/Llama-3.3-70B-Instruct".to_string(),
created: Some(5),
},
OpenAiModelListItem {
id: "mistral-large-latest".to_string(),
created: Some(4),
},
OpenAiModelListItem {
id: "anthropic/claude-sonnet-4-6".to_string(),
created: Some(3),
},
OpenAiModelListItem {
id: "text-embedding-compatible".to_string(),
created: Some(2),
},
OpenAiModelListItem {
id: "meta-llama/Llama-3.3-70B-Instruct".to_string(),
created: Some(1),
},
],
},
Some("openai-compat"),
);
let ids = models.into_iter().map(|model| model.id).collect::<Vec<_>>();
assert_eq!(
ids,
vec![
"meta-llama/Llama-3.3-70B-Instruct".to_string(),
"mistral-large-latest".to_string(),
"anthropic/claude-sonnet-4-6".to_string(),
"text-embedding-compatible".to_string(),
]
);
}
#[test]
fn openai_models_url_uses_openai_default_base_url() {
assert_eq!(
openai_compatible_models_url(&BTreeMap::new()),
"https://api.openai.com/v1/models"
);
}
#[test]
fn anthropic_models_url_uses_anthropic_default_base_url() {
assert_eq!(
anthropic_models_url(&BTreeMap::new()),
"https://api.anthropic.com/v1/models"
);
}
#[test]
fn anthropic_models_url_accepts_versioned_base_url() {
let env = BTreeMap::from([(
"ANTHROPIC_BASE_URL".to_string(),
"https://proxy.example/v1/".to_string(),
)]);
assert_eq!(
anthropic_models_url(&env),
"https://proxy.example/v1/models"
);
}
#[test]
fn anthropic_model_normalization_uses_display_names() {
let models = normalize_anthropic_models(AnthropicModelListResponse {
data: vec![
AnthropicModelListItem {
id: "claude-opus-4-6".to_string(),
display_name: Some("Claude Opus 4.6".to_string()),
},
AnthropicModelListItem {
id: "claude-opus-4-6".to_string(),
display_name: Some("Duplicate".to_string()),
},
],
has_more: false,
last_id: None,
});
assert_eq!(models.len(), 1);
assert_eq!(models[0].id, "claude-opus-4-6");
assert_eq!(models[0].name.as_deref(), Some("Claude Opus 4.6"));
}
#[test]
fn redaction_env_records_value_used_for_request() {
let env = BTreeMap::from([("OPENAI_COMPAT_API_KEY".to_string(), " ".to_string())]);
let redaction_env =
redaction_env_with_value(&env, "OPENAI_COMPAT_API_KEY", "inherited-process-key");
assert_eq!(
redaction_env
.get("OPENAI_COMPAT_API_KEY")
.map(String::as_str),
Some("inherited-process-key")
);
}
#[test]
fn saved_agent_model_discovery_uses_record_snapshot() {
let record: crate::managed_agents::ManagedAgentRecord = serde_json::from_str(
r#"{
"pubkey": "abcd1234",
"name": "test-agent",
"private_key_nsec": "nsec1fake",
"relay_url": "wss://localhost:3000",
"acp_command": "buzz-acp",
"agent_command": "goose",
"agent_args": [],
"mcp_command": "",
"turn_timeout_seconds": 320,
"system_prompt": null,
"model": "record-model",
"provider": "databricks",
"env_vars": {
"OPENAI_API_KEY": "record-key",
"BUZZ_PRIVATE_KEY": "must-not-leak"
},
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z",
"last_started_at": null,
"last_stopped_at": null,
"last_exit_code": null,
"last_error": null
}"#,
)
.expect("sample managed agent record");
let config = saved_agent_model_discovery_config(&record, "goose");
assert_eq!(config.model.as_deref(), Some("record-model"));
assert_eq!(config.provider.as_deref(), Some("databricks"));
assert_eq!(
config.env.get("GOOSE_MODEL").map(String::as_str),
Some("record-model")
);
assert_eq!(
config.env.get("GOOSE_PROVIDER").map(String::as_str),
Some("databricks")
);
assert_eq!(
config.env.get("OPENAI_API_KEY").map(String::as_str),
Some("record-key")
);
assert!(!config.env.contains_key("BUZZ_PRIVATE_KEY"));
}

View File

@@ -508,6 +508,7 @@ pub fn run() {
delete_managed_agent,
get_managed_agent_log,
get_agent_models,
discover_agent_models,
mesh_availability,
mesh_start_node,
mesh_ensure_client_node,

View File

@@ -243,11 +243,9 @@ pub fn default_agent_command() -> String {
.to_string()
}
/// Resolve the agent command (harness) for a spawn/deploy/summary. Mirrors the
/// model resolution in `resolve_effective_prompt_model_provider`: the linked
/// Resolve the agent command (harness) for a spawn/deploy/summary. The linked
/// persona wins so persona harness edits propagate on the next spawn. An
/// explicit per-instance override (`agent_command_override`) takes precedence,
/// matching the opt-in `record.model` override pattern.
/// explicit per-instance override (`agent_command_override`) takes precedence.
///
/// Resolution order:
/// 1. explicit override (non-empty) — a deliberate per-instance pin;
@@ -315,9 +313,9 @@ pub fn divergent_agent_command_override(
/// distinct cases that the backend MUST tell apart:
///
/// - DELIBERATE OVERRIDE (`harness_override` true): the user explicitly picked a
/// non-persona runtime in a deploy dialog that exposes a runtime selector (e.g.
/// `AddChannelBotDialog`, "overriding persona preferences"). This is a real pin
/// and is preserved via `divergent_agent_command_override`.
/// runtime command in UI that exposes a runtime selector. This is a real pin
/// and is preserved when it differs from the command inheritance would spawn,
/// including installed aliases such as `claude-code-acp`.
/// - MISSING-RUNTIME FALLBACK (`harness_override` false): the persona's runtime
/// isn't installed locally, so `resolvePersonaRuntime` substitutes a fallback
/// default. This is NOT a pin — baking it would freeze the agent on the fallback
@@ -341,6 +339,15 @@ pub fn create_time_agent_command_override(
if persona_id.is_some() && !harness_override {
return None;
}
if persona_id.is_some() && harness_override {
let picked = picked_command
.map(str::trim)
.filter(|value| !value.is_empty())?;
let inherited_command = effective_agent_command(persona_id, personas, None);
return (picked != inherited_command).then(|| picked.to_string());
}
divergent_agent_command_override(persona_id, personas, picked_command)
}
@@ -1027,6 +1034,38 @@ mod tests {
);
}
#[test]
fn create_time_override_preserves_selected_runtime_alias() {
// A `claude` persona inherits the primary command `claude-agent-acp`,
// but discovery may select an installed alias such as `claude-code-acp`.
// When UI marks that create-time selection as explicit, preserve the
// alias so the first spawn uses a command known to be installed.
let personas = vec![persona_with_runtime("p1", Some("claude"))];
assert_eq!(
create_time_agent_command_override(
Some("p1"),
&personas,
Some("claude-code-acp"),
true
),
Some("claude-code-acp".to_string())
);
}
#[test]
fn create_time_override_inherits_exact_persona_command() {
let personas = vec![persona_with_runtime("p1", Some("claude"))];
assert_eq!(
create_time_agent_command_override(
Some("p1"),
&personas,
Some("claude-agent-acp"),
true
),
None
);
}
#[test]
fn create_time_override_preserves_pin_for_persona_less_create() {
// The standalone CreateAgentDialog creates persona-LESS agents. With no

View File

@@ -296,36 +296,5 @@ pub(crate) fn merged_user_env(
merged
}
/// Resolve a managed-agent record's persona env_vars, failing closed.
///
/// Three outcomes:
/// - `persona_id` is `None` → returns `Ok({})` (agent has no persona, no env to inherit).
/// - `persona_id = Some(id)` and the persona exists → returns `Ok(persona.env_vars)`.
/// - `persona_id = Some(id)` and either personas.json fails to load OR no persona
/// with that id exists → returns `Err(...)`.
///
/// Why fail closed: persona env_vars frequently carry API credentials
/// (ANTHROPIC_API_KEY, OPENAI_API_KEY, …). If we swallow load errors and
/// quietly spawn with an empty env, the agent comes up unauthenticated and
/// the user sees a downstream "401 from provider" with no obvious cause.
/// Better to surface the persona load failure at spawn/deploy/discovery
/// time.
pub(crate) fn resolve_persona_env(
app: &tauri::AppHandle,
persona_id: Option<&str>,
) -> Result<BTreeMap<String, String>, String> {
let Some(pid) = persona_id else {
return Ok(BTreeMap::new());
};
let personas = super::load_personas(app).map_err(|e| {
format!("failed to load personas while resolving env for persona `{pid}`: {e}")
})?;
let persona = personas
.into_iter()
.find(|p| p.id == pid)
.ok_or_else(|| format!("persona `{pid}` not found while resolving env"))?;
Ok(persona.env_vars)
}
#[cfg(test)]
mod tests;

View File

@@ -9,6 +9,7 @@ pub struct ParsedPersonaPreview {
pub display_name: String,
pub system_prompt: String,
pub avatar_data_url: Option<String>,
pub avatar_ref: Option<String>,
pub runtime: Option<String>,
pub model: Option<String>,
pub provider: Option<String>,
@@ -69,6 +70,7 @@ pub fn parse_png_persona(png_bytes: &[u8]) -> Result<ParsedPersonaPreview, Strin
display_name: fields.display_name,
system_prompt: fields.system_prompt,
avatar_data_url,
avatar_ref: None,
runtime: fields.runtime,
model: fields.model,
provider: fields.provider,
@@ -224,6 +226,7 @@ pub fn parse_json_persona(json_bytes: &[u8]) -> Result<ParsedPersonaPreview, Str
display_name: fields.display_name,
system_prompt: fields.system_prompt,
avatar_data_url: fields.avatar_url,
avatar_ref: None,
runtime: fields.runtime,
model: fields.model,
provider: fields.provider,
@@ -284,6 +287,7 @@ pub fn parse_md_persona(md_bytes: &[u8]) -> Result<ParsedPersonaPreview, String>
display_name: config.display_name,
system_prompt: config.prompt,
avatar_data_url: None, // .persona.md avatars are paths, not data URIs
avatar_ref: config.avatar,
runtime: config.runtime,
model,
provider: None, // .persona.md format does not carry llmProvider
@@ -385,6 +389,7 @@ pub fn parse_zip_pack(zip_bytes: &[u8]) -> Result<ParsePersonaFilesResult, Strin
display_name: p.display_name.clone(),
system_prompt: p.system_prompt.clone(),
avatar_data_url: None,
avatar_ref: p.avatar.clone(),
runtime: p.runtime.clone(),
model: p.model.clone(),
provider: None, // persona packs do not carry llmProvider
@@ -806,6 +811,16 @@ mod tests {
assert!(result.source_file.is_empty());
}
#[test]
fn parse_md_carries_avatar_ref() {
let md = b"---\nname: goosey\ndisplay_name: Goosey\navatar: app-avatar:gloopies-19\ndescription: A goose persona.\n---\nYou are Goosey.\n";
let result = parse_md_persona(md).unwrap();
assert_eq!(result.display_name, "Goosey");
assert!(result.avatar_data_url.is_none());
assert_eq!(result.avatar_ref.as_deref(), Some("app-avatar:gloopies-19"));
}
#[test]
fn parse_json_round_trip_no_avatar() {
let bytes =

View File

@@ -1522,28 +1522,6 @@ pub(crate) fn build_respond_to_env(
Ok((set, remove))
}
/// Resolve the effective system prompt, model, and provider from the *live*
/// persona for **display and model-discovery only** — the ModelPicker shows the
/// current persona model as selected. The spawn and deploy paths deliberately
/// do NOT use this; they read the pinned record snapshot so a running agent
/// stays on the config it was created with. The linked persona wins here; the
/// record values are the fallback when no persona is linked or it was deleted.
pub(crate) fn resolve_effective_prompt_model_provider(
persona_id: Option<&str>,
personas: &[crate::managed_agents::types::PersonaRecord],
record_prompt: Option<String>,
record_model: Option<String>,
) -> (Option<String>, Option<String>, Option<String>) {
match persona_id.and_then(|pid| personas.iter().find(|p| p.id == pid)) {
Some(p) => (
Some(p.system_prompt.clone()),
p.model.clone(),
p.provider.clone(),
),
None => (record_prompt, record_model, None),
}
}
/// Spawn an agent process without holding any locks on records or runtimes.
/// Returns the child process and log path on success. The caller is responsible
/// for updating `ManagedAgentRecord` fields and inserting into the runtimes map.
@@ -1872,7 +1850,7 @@ fn child_rust_log_filter() -> String {
/// Databricks host/model baked in at compile time for internal builds. Empty
/// in OSS builds, where the `BUZZ_BUILD_DATABRICKS_*` env is unset.
fn build_databricks_defaults() -> Vec<(&'static str, &'static str)> {
pub(crate) fn build_databricks_defaults() -> Vec<(&'static str, &'static str)> {
let mut defaults = Vec::new();
if let Some(host) = option_env!("BUZZ_DESKTOP_BUILD_DATABRICKS_HOST") {
if !host.is_empty() {
@@ -2007,7 +1985,7 @@ pub fn stop_managed_agent_process(
/// switching need the initial bootstrap value. Provider injection is skipped
/// when `provider_locked` is true (e.g. Claude runtimes that only work with
/// Anthropic).
fn runtime_metadata_env_vars<'a>(
pub(crate) fn runtime_metadata_env_vars<'a>(
model_env_var: Option<&'a str>,
provider_env_var: Option<&'a str>,
provider_locked: bool,

View File

@@ -253,11 +253,7 @@ fn build_env_rejects_empty_allowlist_in_allowlist_mode() {
assert!(err.contains("at least one pubkey"));
}
// ── resolve_effective_prompt_model_provider tests ───────────────────
fn persona(id: &str, prompt: &str, model: Option<&str>) -> crate::managed_agents::PersonaRecord {
persona_with_provider(id, prompt, model, None)
}
// ── persona fixture helpers ─────────────────────────────────────────
fn persona_with_provider(
id: &str,
@@ -284,66 +280,6 @@ fn persona_with_provider(
}
}
#[test]
fn linked_persona_wins_over_record_snapshot() {
let personas = vec![persona_with_provider(
"p1",
"fresh",
Some("m-fresh"),
Some("anthropic"),
)];
let (prompt, model, provider) = super::resolve_effective_prompt_model_provider(
Some("p1"),
&personas,
Some("stale".into()),
Some("m-stale".into()),
);
assert_eq!(prompt.as_deref(), Some("fresh"));
assert_eq!(model.as_deref(), Some("m-fresh"));
assert_eq!(provider.as_deref(), Some("anthropic"));
}
#[test]
fn no_persona_id_falls_back_to_record() {
let personas = vec![persona("p1", "fresh", Some("m-fresh"))];
let (prompt, model, provider) = super::resolve_effective_prompt_model_provider(
None,
&personas,
Some("record".into()),
Some("m-record".into()),
);
assert_eq!(prompt.as_deref(), Some("record"));
assert_eq!(model.as_deref(), Some("m-record"));
assert_eq!(provider, None);
}
#[test]
fn deleted_persona_falls_back_to_record() {
let personas = vec![persona("p1", "fresh", None)];
let (prompt, model, provider) = super::resolve_effective_prompt_model_provider(
Some("gone"),
&personas,
Some("record".into()),
Some("m-record".into()),
);
assert_eq!(prompt.as_deref(), Some("record"));
assert_eq!(model.as_deref(), Some("m-record"));
assert_eq!(provider, None);
}
#[test]
fn persona_with_no_model_clears_stale_record_model() {
let personas = vec![persona("p1", "fresh", None)];
let (prompt, model, _provider) = super::resolve_effective_prompt_model_provider(
Some("p1"),
&personas,
Some("stale".into()),
Some("m-stale".into()),
);
assert_eq!(prompt.as_deref(), Some("fresh"));
assert_eq!(model, None);
}
// ── persona pin/refresh acceptance (Phase 4) ────────────────────────────
//
// The full lifecycle Will specified: create from P0, edit P0→P1 (env_vars

View File

@@ -310,11 +310,10 @@ pub struct CreateManagedAgentRequest {
pub relay_url: Option<String>,
pub acp_command: Option<String>,
pub agent_command: Option<String>,
/// True when `agent_command` is a runtime the user deliberately picked to
/// override the linked persona (a deploy-dialog runtime selector). Distinguishes
/// a real pin from a missing-runtime fallback so a persona-backed create only
/// stores an `agent_command_override` for the former. Defaults `false`: callers
/// that don't set it (persona-less creates, fallback divergence) inherit.
/// True when `agent_command` is a runtime command the user deliberately
/// picked for a linked persona. Distinguishes a real selection, including an
/// installed alias, from a missing-runtime fallback so a persona-backed
/// create only stores an `agent_command_override` for the former.
#[serde(default)]
pub harness_override: bool,
#[serde(default)]

View File

@@ -0,0 +1,358 @@
import * as React from "react";
import { Pencil, Plus } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import { useAvatarUpload } from "@/features/profile/useAvatarUpload";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import { Spinner } from "@/shared/ui/spinner";
function isAvatarFileDrag(event: React.DragEvent<HTMLElement>) {
return Array.from(event.dataTransfer.types).includes("Files");
}
const AVATAR_APPLY_MOTION_TRANSITION = {
duration: 0.14,
ease: [0.23, 1, 0.32, 1],
} as const;
export function AgentCreationPreview({
avatarUrl,
disabled = false,
label,
onClearAvatar,
onUploadPendingChange,
onSelectAvatar,
}: {
avatarUrl: string | null;
disabled?: boolean;
label: string;
onClearAvatar?: () => void;
onUploadPendingChange?: (isPending: boolean) => void;
onSelectAvatar: (avatarUrl: string) => void;
}) {
const avatarEditClipId = React.useId().replace(/:/g, "");
const [isDragOverAvatar, setIsDragOverAvatar] = React.useState(false);
const [isAvatarMenuOpen, setIsAvatarMenuOpen] = React.useState(false);
const [avatarUrlDraft, setAvatarUrlDraft] = React.useState("");
const [isAvatarUrlInputFocused, setIsAvatarUrlInputFocused] =
React.useState(false);
const avatarDragDepthRef = React.useRef(0);
const shouldReduceMotion = useReducedMotion();
const {
inputRef: avatarUploadInputRef,
isUploading,
errorMessage: uploadErrorMessage,
clearError: clearUploadError,
openPicker: openUploadPicker,
uploadFile: uploadAvatarFile,
handleFileChange: handleAvatarUploadFileChange,
} = useAvatarUpload({
onUploadSuccess: onSelectAvatar,
});
React.useEffect(() => {
onUploadPendingChange?.(isUploading);
return () => {
onUploadPendingChange?.(false);
};
}, [isUploading, onUploadPendingChange]);
React.useEffect(() => {
if (isAvatarMenuOpen) {
setAvatarUrlDraft("");
setIsAvatarUrlInputFocused(false);
}
}, [isAvatarMenuOpen]);
function applyAvatarUrl() {
const nextUrl = avatarUrlDraft.trim();
if (nextUrl.length === 0) {
return;
}
clearUploadError();
onSelectAvatar(nextUrl);
setIsAvatarMenuOpen(false);
}
const avatarClipStyle = React.useMemo<React.CSSProperties>(
() => ({
clipPath: `url(#${avatarEditClipId})`,
transform: "translateZ(0)",
}),
[avatarEditClipId],
);
const hasAvatarUrlDraft = avatarUrlDraft.trim().length > 0;
const hasAvatar = (avatarUrl?.trim().length ?? 0) > 0;
const applyButtonTransition = shouldReduceMotion
? { duration: 0 }
: AVATAR_APPLY_MOTION_TRANSITION;
const handleAvatarDragEnter = React.useCallback(
(event: React.DragEvent<HTMLFieldSetElement>) => {
if (disabled || !isAvatarFileDrag(event)) {
return;
}
event.preventDefault();
event.stopPropagation();
avatarDragDepthRef.current += 1;
event.dataTransfer.dropEffect = "copy";
setIsDragOverAvatar(true);
},
[disabled],
);
const handleAvatarDragOver = React.useCallback(
(event: React.DragEvent<HTMLFieldSetElement>) => {
if (disabled || !isAvatarFileDrag(event)) {
return;
}
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = "copy";
setIsDragOverAvatar(true);
},
[disabled],
);
const handleAvatarDragLeave = React.useCallback(
(event: React.DragEvent<HTMLFieldSetElement>) => {
if (!isAvatarFileDrag(event)) {
return;
}
event.preventDefault();
event.stopPropagation();
avatarDragDepthRef.current = Math.max(0, avatarDragDepthRef.current - 1);
if (avatarDragDepthRef.current === 0) {
setIsDragOverAvatar(false);
}
},
[],
);
const handleAvatarDrop = React.useCallback(
(event: React.DragEvent<HTMLFieldSetElement>) => {
if (!isAvatarFileDrag(event)) {
return;
}
event.preventDefault();
event.stopPropagation();
avatarDragDepthRef.current = 0;
setIsDragOverAvatar(false);
const file = event.dataTransfer.files[0];
if (!file || disabled || isUploading) {
return;
}
clearUploadError();
void uploadAvatarFile(file);
},
[clearUploadError, disabled, isUploading, uploadAvatarFile],
);
const avatarMenuContent = (
<PopoverContent align="center" className="w-72 space-y-1 p-1">
<button
className="flex min-h-9 w-full items-center rounded-lg px-2.5 text-left text-sm outline-hidden transition-colors duration-150 ease-out hover:bg-muted/50 focus-visible:bg-muted/50 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50"
disabled={disabled || isUploading}
onClick={() => {
clearUploadError();
openUploadPicker();
setIsAvatarMenuOpen(false);
}}
type="button"
>
Upload an image
</button>
<form
className="flex min-h-9 items-center gap-2 rounded-lg px-2.5 py-1.5 transition-colors duration-150 ease-out focus-within:bg-muted/50"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
applyAvatarUrl();
}}
>
<label className="sr-only" htmlFor="agent-avatar-url">
Use a URL
</label>
<Input
autoCapitalize="none"
autoComplete="off"
autoCorrect="off"
className={cn(
"h-7 min-w-0 flex-1 border-0 bg-transparent px-0 py-0 text-sm shadow-none outline-none focus-visible:ring-0",
isAvatarUrlInputFocused
? "placeholder:text-muted-foreground/55"
: "placeholder:text-popover-foreground",
)}
disabled={disabled || isUploading}
id="agent-avatar-url"
onBlur={() => setIsAvatarUrlInputFocused(false)}
onChange={(event) => setAvatarUrlDraft(event.target.value)}
onFocus={() => setIsAvatarUrlInputFocused(true)}
placeholder={isAvatarUrlInputFocused ? "https://..." : "Use a URL"}
spellCheck={false}
value={avatarUrlDraft}
/>
<AnimatePresence initial={false}>
{hasAvatarUrlDraft ? (
<motion.div
animate={{ opacity: 1, scale: 1, width: "auto" }}
className="overflow-hidden"
exit={{ opacity: 0, scale: 0.96, width: 0 }}
initial={{ opacity: 0, scale: 0.96, width: 0 }}
key="apply-avatar-url"
transition={applyButtonTransition}
>
<Button
className="h-7 px-2 text-xs"
disabled={disabled || isUploading}
size="xs"
type="submit"
>
Apply
</Button>
</motion.div>
) : null}
</AnimatePresence>
</form>
{hasAvatar && onClearAvatar ? (
<button
className="flex min-h-9 w-full items-center rounded-lg px-2.5 text-left text-sm text-destructive outline-hidden transition-colors duration-150 ease-out hover:bg-destructive/10 focus-visible:bg-destructive/10 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50"
disabled={disabled || isUploading}
onClick={() => {
onClearAvatar();
setIsAvatarMenuOpen(false);
}}
type="button"
>
Remove avatar
</button>
) : null}
</PopoverContent>
);
return (
<div className="mx-auto w-full max-w-[220px] lg:sticky lg:top-0">
<fieldset
aria-label="Agent avatar preview"
className={cn(
"group/avatar-preview relative m-0 flex min-h-[190px] min-w-0 flex-col items-center justify-center gap-3 rounded-xl border border-transparent p-0 transition-[background-color,border-color,box-shadow] duration-150",
isDragOverAvatar &&
"border-dashed border-primary/70 bg-primary/5 ring-2 ring-primary/15",
)}
onDragEnter={handleAvatarDragEnter}
onDragLeave={handleAvatarDragLeave}
onDragOver={handleAvatarDragOver}
onDrop={handleAvatarDrop}
>
<input
accept="image/gif,image/jpeg,image/png,image/webp"
className="hidden"
onChange={handleAvatarUploadFileChange}
ref={avatarUploadInputRef}
type="file"
/>
<div className="relative h-36 w-36">
{hasAvatar ? (
<>
<svg
aria-hidden="true"
className="pointer-events-none absolute inset-0 h-full w-full"
fill="none"
height="144"
viewBox="0 0 144 144"
width="144"
xmlns="http://www.w3.org/2000/svg"
>
<clipPath clipPathUnits="userSpaceOnUse" id={avatarEditClipId}>
<path
clipRule="evenodd"
d="M100.734 83.3298C102.415 84.1574 104.616 83.8757 105.495 82.2207C109.647 74.3981 112 65.4738 112 56C112 25.0721 86.9279 0 56 0C25.0721 0 0 25.0721 0 56C0 86.9279 25.0721 112 56 112C65.4738 112 74.3981 109.647 82.2207 105.495C83.8757 104.616 84.1574 102.415 83.3298 100.734C82.4783 99.0047 82 97.0582 82 95C82 87.8203 87.8203 82 95 82C97.0582 82 99.0047 82.4783 100.734 83.3298Z"
fillRule="evenodd"
transform="translate(-25.875 -25.875) scale(1.575)"
/>
</clipPath>
</svg>
<div className="relative h-full w-full" style={avatarClipStyle}>
<ProfileAvatar
avatarUrl={avatarUrl}
className={cn(
"h-full w-full text-4xl transition-shadow duration-150",
isDragOverAvatar && "ring-2 ring-primary/30",
)}
label={label}
/>
</div>
<div className="absolute bottom-0 right-0 z-10 flex h-[42px] w-[42px] items-center justify-center rounded-full bg-background">
<Popover
open={isAvatarMenuOpen}
onOpenChange={setIsAvatarMenuOpen}
>
<PopoverTrigger asChild>
<button
aria-label="Edit avatar"
className="flex h-9 w-9 items-center justify-center rounded-full bg-sidebar-active text-sidebar-active-foreground shadow-lg transition-[background-color,scale] duration-150 ease-out hover:scale-[1.04] hover:bg-sidebar-active focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-default disabled:opacity-90 disabled:hover:scale-100"
disabled={disabled || isUploading}
title="Edit avatar"
type="button"
>
{isUploading ? (
<Spinner
aria-label="Uploading avatar"
className="h-4 w-4 border-2"
/>
) : (
<Pencil className="h-4 w-4" />
)}
</button>
</PopoverTrigger>
{avatarMenuContent}
</Popover>
</div>
</>
) : (
<Popover open={isAvatarMenuOpen} onOpenChange={setIsAvatarMenuOpen}>
<PopoverTrigger asChild>
<button
aria-label="Add avatar"
className={cn(
"flex h-full w-full items-center justify-center rounded-full border-2 border-dashed border-border bg-background text-primary shadow-xs transition-[background-color,border-color,color,box-shadow] duration-150 ease-out hover:border-primary/50 hover:bg-primary/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-default disabled:opacity-70",
isDragOverAvatar &&
"border-primary/70 bg-primary/5 ring-2 ring-primary/15",
)}
disabled={disabled || isUploading}
title="Add avatar"
type="button"
>
{isUploading ? (
<Spinner
aria-label="Uploading avatar"
className="h-4 w-4 border-2"
/>
) : (
<Plus aria-hidden="true" className="h-14 w-14" />
)}
</button>
</PopoverTrigger>
{avatarMenuContent}
</Popover>
)}
</div>
{uploadErrorMessage ? (
<p className="max-w-full rounded-md bg-background/95 px-2 py-1 text-center text-xs text-destructive shadow-xs">
{uploadErrorMessage}
</p>
) : null}
</fieldset>
</div>
);
}

View File

@@ -192,6 +192,16 @@ export function AgentsView() {
}}
/>
) : null}
{personas.createdAgent ? (
<SecretRevealDialog
created={personas.createdAgent}
onOpenChange={(open) => {
if (!open) {
personas.setCreatedAgent(null);
}
}}
/>
) : null}
{personas.personaDialogState ? (
<PersonaDialog
description={personas.personaDialogState.description}
@@ -206,10 +216,7 @@ export function AgentsView() {
isImportPending={
personas.personaImportActions.isApplyingPersonaImportUpdate
}
isPending={
personas.createPersonaMutation.isPending ||
personas.updatePersonaMutation.isPending
}
isPending={personas.isPending}
runtimes={personas.acpRuntimesQuery.data ?? []}
runtimesLoading={personas.acpRuntimesQuery.isLoading}
onImportUpdateFile={

View File

@@ -19,6 +19,8 @@ import {
DialogTitle,
} from "@/shared/ui/dialog";
import { buildBatchImportPersonaInput } from "./batchImportPersonaInput";
import { resolveManagedAgentAvatarUrl } from "./managedAgentAvatar";
import { importedAvatarUrl } from "./personaDialogState";
type BatchImportDialogProps = {
fileName: string;
@@ -92,7 +94,9 @@ export function BatchImportDialog({
});
try {
await createPersona(buildBatchImportPersonaInput(persona));
const input = buildBatchImportPersonaInput(persona);
const avatarUrl = await resolveManagedAgentAvatarUrl(input.avatarUrl);
await createPersona({ ...input, avatarUrl });
completed += 1;
setImportedCount(completed);
setItemStatuses((prev) => {
@@ -143,6 +147,7 @@ export function BatchImportDialog({
.trim()
.split("\n")
.find((line) => line.trim().length > 0);
const avatarUrl = importedAvatarUrl(persona);
return (
<div
@@ -173,7 +178,7 @@ export function BatchImportDialog({
onClick={(e: React.MouseEvent) => e.stopPropagation()}
/>
<ProfileAvatar
avatarUrl={persona.avatarDataUrl}
avatarUrl={avatarUrl}
className="h-8 w-8 rounded-lg text-xs"
label={persona.displayName}
/>

View File

@@ -3,6 +3,11 @@ import * as React from "react";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
import { cn } from "@/shared/lib/cn";
import {
PERSONA_FIELD_CONTROL_CLASS,
PERSONA_FIELD_SHELL_CLASS,
} from "./personaDialogPickers";
export type EnvVarsValue = Record<string, string>;
@@ -98,28 +103,48 @@ export function EnvVarsEditor({
return (
<div key={row.id} className="space-y-1">
<div className="flex items-center gap-2">
<Input
aria-label="Variable name"
className="flex-1 font-mono text-xs"
data-testid="env-vars-key"
disabled={disabled}
onChange={(event) =>
updateRow(row.id, { key: event.target.value })
}
placeholder="ANTHROPIC_API_KEY"
value={row.key}
/>
<Input
aria-label="Variable value"
className="flex-[2] font-mono text-xs"
data-testid="env-vars-value"
disabled={disabled}
onChange={(event) =>
updateRow(row.id, { value: event.target.value })
}
placeholder="sk-ant-..."
value={row.value}
/>
<div
className={cn(
"flex min-h-11 flex-1 items-center px-3",
PERSONA_FIELD_SHELL_CLASS,
)}
>
<Input
aria-label="Variable name"
className={cn(
"h-8 px-0 py-0 font-mono leading-6",
PERSONA_FIELD_CONTROL_CLASS,
)}
data-testid="env-vars-key"
disabled={disabled}
onChange={(event) =>
updateRow(row.id, { key: event.target.value })
}
placeholder="VARIABLE_NAME"
value={row.key}
/>
</div>
<div
className={cn(
"flex min-h-11 flex-[2] items-center px-3",
PERSONA_FIELD_SHELL_CLASS,
)}
>
<Input
aria-label="Variable value"
className={cn(
"h-8 px-0 py-0 font-mono leading-6",
PERSONA_FIELD_CONTROL_CLASS,
)}
data-testid="env-vars-value"
disabled={disabled}
onChange={(event) =>
updateRow(row.id, { value: event.target.value })
}
placeholder="value"
value={row.value}
/>
</div>
<Button
aria-label="Remove variable"
data-testid="env-vars-remove"

View File

@@ -0,0 +1,65 @@
import { Input } from "@/shared/ui/input";
import { cn } from "@/shared/lib/cn";
import { EnvVarsEditor, type EnvVarsValue } from "./EnvVarsEditor";
import {
PERSONA_FIELD_CONTROL_CLASS,
PERSONA_FIELD_SHELL_CLASS,
} from "./personaDialogPickers";
const PERSONA_LABEL_OPTIONAL_CLASS =
"ml-1 text-xs font-normal text-muted-foreground/50";
export function PersonaAdvancedFields({
disabled,
envVars,
namePoolText,
onEnvVarsChange,
onNamePoolTextChange,
}: {
disabled: boolean;
envVars: EnvVarsValue;
namePoolText: string;
onEnvVarsChange: (value: EnvVarsValue) => void;
onNamePoolTextChange: (value: string) => void;
}) {
return (
<div className="space-y-5 pt-2">
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="persona-name-pool"
>
Instance name pool
<span className={PERSONA_LABEL_OPTIONAL_CLASS}>Optional</span>
</label>
<div
className={cn(
"flex min-h-11 items-center px-3",
PERSONA_FIELD_SHELL_CLASS,
)}
>
<Input
autoCapitalize="words"
autoCorrect="off"
className={cn(
"h-8 px-0 py-0 leading-6",
PERSONA_FIELD_CONTROL_CLASS,
)}
disabled={disabled}
id="persona-name-pool"
onChange={(event) => onNamePoolTextChange(event.target.value)}
placeholder="Birch, Compass, Ridge, Thistle"
spellCheck={false}
value={namePoolText}
/>
</div>
</div>
<EnvVarsEditor
disabled={disabled}
onChange={onEnvVarsChange}
value={envVars}
/>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,100 @@
import * as React from "react";
import { ChevronDown } from "lucide-react";
import { cn } from "@/shared/lib/cn";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import {
type PersonaDropdownOption,
PERSONA_FIELD_CONTROL_CLASS,
PERSONA_FIELD_SHELL_CLASS,
} from "./personaDialogPickers";
export function PersonaDropdownField({
contentClassName,
disabled,
id,
onValueChange,
options,
placeholder,
value,
}: {
contentClassName?: string;
disabled?: boolean;
id: string;
onValueChange: (value: string) => void;
options: readonly PersonaDropdownOption[];
placeholder: string;
value: string;
}) {
const [open, setOpen] = React.useState(false);
const selectedOption = options.find((option) => option.value === value);
return (
<div className={PERSONA_FIELD_SHELL_CLASS}>
<DropdownMenu modal={false} onOpenChange={setOpen} open={open}>
<DropdownMenuTrigger asChild>
<button
className={cn(
"flex h-11 w-full items-center justify-between gap-3 px-3 py-2 text-left text-sm leading-6",
PERSONA_FIELD_CONTROL_CLASS,
disabled && "cursor-default opacity-60",
)}
disabled={disabled}
id={id}
type="button"
>
<span
className={cn(
"min-w-0 flex-1 truncate",
!selectedOption && "text-muted-foreground/55",
)}
>
{selectedOption?.label ?? placeholder}
</span>
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground/60" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className={cn("overflow-hidden", contentClassName)}
onCloseAutoFocus={(event) => event.preventDefault()}
sideOffset={5}
style={{
minWidth: "var(--radix-dropdown-menu-trigger-width)",
width: "var(--radix-dropdown-menu-trigger-width)",
}}
>
<div
className="max-h-[min(16rem,var(--radix-dropdown-menu-content-available-height))] overflow-y-auto overscroll-contain"
onTouchMoveCapture={(event) => event.stopPropagation()}
onWheelCapture={(event) => event.stopPropagation()}
>
<DropdownMenuRadioGroup
onValueChange={(nextValue) => {
onValueChange(nextValue);
setOpen(false);
}}
value={value}
>
{options.map((option) => (
<DropdownMenuRadioItem
disabled={option.disabled}
key={option.value}
value={option.value}
>
<span className="truncate">{option.label}</span>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</div>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}

View File

@@ -0,0 +1,108 @@
import type * as React from "react";
import { motion } from "motion/react";
import { cn } from "@/shared/lib/cn";
import { Input } from "@/shared/ui/input";
import { PersonaDropdownField } from "./PersonaDropdownField";
import type { PersonaModelDiscoveryStatus } from "./personaModelDiscoveryStatus";
import {
type PersonaDropdownOption,
PERSONA_FIELD_CONTROL_CLASS,
PERSONA_FIELD_SHELL_CLASS,
PERSONA_LABEL_OPTIONAL_CLASS,
} from "./personaDialogPickers";
type PersonaModelFieldProps = {
disabled: boolean;
isExplicitModelRequired: boolean;
model: string;
modelDiscoveryStatus: PersonaModelDiscoveryStatus | null;
modelDropdownOptions: readonly PersonaDropdownOption[];
modelSelectValue: string;
onCustomModelChange: (value: string) => void;
onModelValueChange: (value: string) => void;
showCustomModelInput: boolean;
transition: React.ComponentProps<typeof motion.div>["transition"];
};
export function PersonaModelField({
disabled,
isExplicitModelRequired,
model,
modelDiscoveryStatus,
modelDropdownOptions,
modelSelectValue,
onCustomModelChange,
onModelValueChange,
showCustomModelInput,
transition,
}: PersonaModelFieldProps) {
return (
<motion.div
animate={{ height: "auto", opacity: 1, scale: 1 }}
className="origin-top overflow-hidden"
exit={{ height: 0, opacity: 0, scale: 0.98 }}
initial={{ height: 0, opacity: 0, scale: 0.98 }}
key="persona-model-field"
transition={transition}
>
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="persona-model"
>
Model
{!isExplicitModelRequired ? (
<span className={PERSONA_LABEL_OPTIONAL_CLASS}>Optional</span>
) : null}
</label>
<PersonaDropdownField
disabled={disabled}
id="persona-model"
onValueChange={onModelValueChange}
options={modelDropdownOptions}
placeholder={
isExplicitModelRequired ? "Choose a model" : "Default model"
}
value={modelSelectValue}
/>
{showCustomModelInput ? (
<div
className={cn(
"mt-2 flex min-h-11 items-center px-3",
PERSONA_FIELD_SHELL_CLASS,
)}
>
<Input
aria-label="Custom model ID"
autoCorrect="off"
className={cn(
"h-8 px-0 py-0 leading-6",
PERSONA_FIELD_CONTROL_CLASS,
)}
disabled={disabled}
id="persona-custom-model"
onChange={(event) => onCustomModelChange(event.target.value)}
placeholder="Custom model ID"
value={model}
/>
</div>
) : null}
{modelDiscoveryStatus ? (
<p
aria-live="polite"
className={cn(
"text-xs",
modelDiscoveryStatus.tone === "warning"
? "text-warning"
: "text-muted-foreground",
)}
data-testid="persona-model-discovery-status"
>
{modelDiscoveryStatus.message}
</p>
) : null}
</div>
</motion.div>
);
}

View File

@@ -0,0 +1,47 @@
import { Input } from "@/shared/ui/input";
import { cn } from "@/shared/lib/cn";
import {
type ProviderApiKeyConfig,
PERSONA_FIELD_CONTROL_CLASS,
PERSONA_FIELD_SHELL_CLASS,
} from "./personaDialogPickers";
export function PersonaProviderApiKeyField({
config,
disabled,
onChange,
value,
}: {
config: ProviderApiKeyConfig;
disabled: boolean;
onChange: (value: string) => void;
value: string;
}) {
return (
<div className="mt-2 space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="persona-provider-api-key"
>
{config.label}
</label>
<div
className={cn(
"flex min-h-11 items-center px-3",
PERSONA_FIELD_SHELL_CLASS,
)}
>
<Input
autoCorrect="off"
className={cn("h-8 px-0 py-0 leading-6", PERSONA_FIELD_CONTROL_CLASS)}
data-testid="persona-provider-api-key"
disabled={disabled}
id="persona-provider-api-key"
onChange={(event) => onChange(event.target.value)}
placeholder={config.placeholder}
value={value}
/>
</div>
</div>
);
}

View File

@@ -221,7 +221,7 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
agents={ungrouped}
collapsed={collapsed}
groupKey="__ungrouped__"
label="Custom Agents"
label="Custom agents"
presenceLoaded={presenceLoaded}
presenceLookup={presenceLookup}
onToggle={toggle}
@@ -499,19 +499,19 @@ function NewAgentCard({
disabled={isPersonasPending}
onClick={onCreatePersona}
>
Persona
New agent
</DropdownMenuItem>
{canChooseCatalog ? (
<DropdownMenuItem
disabled={isPersonasPending}
onClick={onChooseCatalog}
>
Choose from Catalog...
Choose from catalog
</DropdownMenuItem>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onCreateAgent}>
Custom Agent
Custom agent
</DropdownMenuItem>
<DropdownMenuItem onClick={openFilePicker}>
Import persona file

View File

@@ -1,12 +1,13 @@
import type { ParsedPersonaPreview } from "@/shared/api/tauriPersonas";
import type { CreatePersonaInput } from "@/shared/api/types";
import { importedAvatarUrl } from "./personaDialogState";
export function buildBatchImportPersonaInput(
persona: ParsedPersonaPreview,
): CreatePersonaInput {
return {
displayName: persona.displayName,
avatarUrl: persona.avatarDataUrl ?? undefined,
avatarUrl: importedAvatarUrl(persona) || undefined,
systemPrompt: persona.systemPrompt,
runtime: persona.runtime ?? undefined,
model: persona.model ?? undefined,

View File

@@ -0,0 +1,68 @@
import assert from "node:assert/strict";
import test from "node:test";
import { resolveManagedAgentAvatarUrl } from "./managedAgentAvatar.ts";
test("resolveManagedAgentAvatarUrl uploads data image URIs", async () => {
const uploaded = await resolveManagedAgentAvatarUrl(
"data:image/png;base64,aGVsbG8=",
async (bytes) => {
assert.deepEqual(bytes, [104, 101, 108, 108, 111]);
return {
url: "https://relay.example/avatar.png",
sha256: "hash",
size: bytes.length,
type: "image/png",
uploaded: 1,
};
},
);
assert.equal(uploaded, "https://relay.example/avatar.png");
});
test("resolveManagedAgentAvatarUrl passes non-data URLs through", async () => {
const uploaded = await resolveManagedAgentAvatarUrl(
" https://relay.example/already-hosted.png ",
async () => {
throw new Error("should not upload hosted avatars");
},
);
assert.equal(uploaded, "https://relay.example/already-hosted.png");
});
test("resolveManagedAgentAvatarUrl omits invalid data image URIs", async () => {
const uploaded = await resolveManagedAgentAvatarUrl(
"data:image/png;base64,",
async () => {
throw new Error("should not upload invalid data URIs");
},
);
assert.equal(uploaded, undefined);
});
test("resolveManagedAgentAvatarUrl uses safe fallback when data image upload fails", async () => {
const uploaded = await resolveManagedAgentAvatarUrl(
"data:image/png;base64,YQ==",
async () => {
throw new Error("upload failed");
},
"app-avatar://goose",
);
assert.equal(uploaded, "app-avatar://goose");
});
test("resolveManagedAgentAvatarUrl ignores data URI fallbacks", async () => {
const uploaded = await resolveManagedAgentAvatarUrl(
"data:image/png;base64,YQ==",
async () => {
throw new Error("upload failed");
},
"data:image/png;base64,Yg==",
);
assert.equal(uploaded, undefined);
});

View File

@@ -0,0 +1,45 @@
type BlobDescriptor = {
url: string;
sha256: string;
size: number;
type: string;
uploaded: number;
};
type UploadMediaBytes = (
data: number[],
filename?: string,
) => Promise<BlobDescriptor>;
export async function resolveManagedAgentAvatarUrl(
avatarUrl: string | null | undefined,
upload: UploadMediaBytes = defaultUploadMediaBytes,
fallbackAvatarUrl?: string | null,
): Promise<string | undefined> {
const resolvedAvatarUrl = avatarUrl?.trim() || undefined;
if (!resolvedAvatarUrl?.startsWith("data:image/")) {
return resolvedAvatarUrl;
}
try {
const [, b64] = resolvedAvatarUrl.split(",", 2);
if (!b64) {
throw new Error("empty data URI payload");
}
const bytes = Array.from(atob(b64), (char) => char.charCodeAt(0));
const blob = await upload(bytes);
return blob.url;
} catch {
return safeFallbackAvatarUrl(fallbackAvatarUrl);
}
}
async function defaultUploadMediaBytes(data: number[], filename?: string) {
const { uploadMediaBytes } = await import("@/shared/api/tauri");
return uploadMediaBytes(data, filename);
}
function safeFallbackAvatarUrl(avatarUrl: string | null | undefined) {
const trimmed = avatarUrl?.trim() || undefined;
return trimmed?.startsWith("data:image/") ? undefined : trimmed;
}

View File

@@ -0,0 +1,25 @@
import type { EnvVarsValue } from "./EnvVarsEditor";
export function hasText(value: string | null | undefined): boolean {
return (value?.trim().length ?? 0) > 0;
}
export function hasAdvancedEnvVars(
value: EnvVarsValue,
managedKey: string | null,
): boolean {
return Object.keys(value).some((key) => key !== managedKey);
}
export function getAdvancedEnvVars(
value: EnvVarsValue,
managedKey: string | null,
): EnvVarsValue {
if (!managedKey || !(managedKey in value)) {
return value;
}
const next = { ...value };
delete next[managedKey];
return next;
}

View File

@@ -0,0 +1,453 @@
import type { AcpRuntimeCatalogEntry } from "@/shared/api/types";
export const PERSONA_FIELD_SHELL_CLASS =
"rounded-xl border border-input bg-muted/40 transition-colors duration-150 ease-out hover:border-muted-foreground/40 focus-within:border-muted-foreground/50";
export const PERSONA_FIELD_CONTROL_CLASS =
"border-0 bg-transparent text-muted-foreground shadow-none outline-none ring-0 transition-colors duration-150 ease-out placeholder:text-muted-foreground/55 focus:bg-transparent focus:text-muted-foreground focus:outline-hidden focus-visible:ring-0";
export const PERSONA_LABEL_OPTIONAL_CLASS =
"ml-1 text-xs font-normal text-muted-foreground/50";
export const AUTO_MODEL_DROPDOWN_VALUE = "__auto_model__";
export const CUSTOM_MODEL_DROPDOWN_VALUE = "__custom_model__";
export const AUTO_PROVIDER_DROPDOWN_VALUE = "__auto_provider__";
export const CUSTOM_PROVIDER_DROPDOWN_VALUE = "__custom_provider__";
export const NO_RUNTIME_DROPDOWN_VALUE = "__no_runtime__";
const KNOWN_LLM_PROVIDER_IDS = [
"anthropic",
"databricks",
"openai",
"openai-compat",
] as const;
type PersonaLlmProviderId = (typeof KNOWN_LLM_PROVIDER_IDS)[number];
export type PersonaModelOption = {
id: string;
label: string;
providers?: readonly PersonaLlmProviderId[];
};
export type PersonaDropdownOption = {
disabled?: boolean;
label: string;
value: string;
};
export type ProviderApiKeyConfig = {
envVar: string;
label: string;
placeholder: string;
};
const DEFAULT_MODEL_OPTION: PersonaModelOption = {
id: "",
label: "Default model",
};
const DATABRICKS_DEFAULT_MODEL_ID = "goose-claude-4-8-opus";
const DATABRICKS_DEFAULT_MODEL_LABEL = "Claude Opus 4.8";
const DATABRICKS_DEFAULT_MODEL_OPTION: PersonaModelOption = {
id: "",
label: `${DATABRICKS_DEFAULT_MODEL_LABEL} (default)`,
};
// Databricks IDs are sourced from squareup/goose-releases goose_models.json.
// `goose-claude-4-8-opus` is also the current Buzz internal build default in
// squareup/buzz-releases, though it is ahead of that registry today.
const BUZZ_AGENT_MODEL_OPTIONS: readonly PersonaModelOption[] = [
DATABRICKS_DEFAULT_MODEL_OPTION,
{
id: "goose-claude-4-8-opus",
label: "Claude Opus 4.8",
providers: ["databricks"],
},
{
id: "goose-claude-4-7-opus",
label: "Claude Opus 4.7",
providers: ["databricks"],
},
{
id: "goose-claude-4-6-opus",
label: "Claude Opus 4.6",
providers: ["anthropic", "databricks"],
},
{
id: "goose-claude-4-6-sonnet",
label: "Claude Sonnet 4.6",
providers: ["anthropic", "databricks"],
},
{
id: "goose-claude-4-5-opus",
label: "Claude Opus 4.5",
providers: ["databricks"],
},
{
id: "goose-claude-4-5-sonnet",
label: "Claude Sonnet 4.5",
providers: ["databricks"],
},
{
id: "goose-claude-4-5-haiku",
label: "Claude Haiku 4.5",
providers: ["databricks"],
},
{
id: "goose-gpt-5-2",
label: "GPT-5.2",
providers: ["databricks"],
},
{
id: "databricks-gpt-5-5-pro",
label: "GPT-5.5 Pro",
providers: ["databricks"],
},
{
id: "databricks-gpt-5-5",
label: "GPT-5.5",
providers: ["databricks"],
},
{
id: "gpt-5.5",
label: "GPT-5.5",
providers: ["openai", "openai-compat"],
},
{
id: "gpt-5.4",
label: "GPT-5.4",
providers: ["openai", "openai-compat"],
},
{
id: "gpt-5.4-mini",
label: "GPT-5.4 mini",
providers: ["openai", "openai-compat"],
},
{
id: "gpt-5.4-nano",
label: "GPT-5.4 nano",
providers: ["openai", "openai-compat"],
},
{
id: "gpt-5",
label: "GPT-5",
providers: ["openai", "openai-compat"],
},
{
id: "gpt-5-mini",
label: "GPT-5 mini",
providers: ["openai", "openai-compat"],
},
{
id: "goose-gemini-3-5-flash",
label: "Gemini 3.5 Flash",
providers: ["databricks"],
},
{
id: "goose-gemini-3-1-pro",
label: "Gemini 3.1 Pro",
providers: ["databricks"],
},
{
id: "goose-gemini-3-1-flash-lite",
label: "Gemini 3.1 Flash Lite",
providers: ["databricks"],
},
{
id: "goose-gemini-2-5-pro",
label: "Gemini 2.5 Pro",
providers: ["databricks"],
},
{
id: "gemini-2.5-pro",
label: "Gemini 2.5 Pro",
providers: ["openai-compat"],
},
{
id: "gemini-2.5-flash",
label: "Gemini 2.5 Flash",
providers: ["openai-compat"],
},
];
const PERSONA_LLM_PROVIDER_OPTIONS: readonly PersonaModelOption[] = [
{ id: "anthropic", label: "Anthropic" },
{ id: "openai", label: "OpenAI" },
{ id: "openai-compat", label: "OpenAI-compatible" },
{ id: "databricks", label: "Databricks" },
];
const PERSONA_MODEL_OPTIONS_BY_RUNTIME: Record<
string,
readonly PersonaModelOption[]
> = {
goose: BUZZ_AGENT_MODEL_OPTIONS,
"buzz-agent": BUZZ_AGENT_MODEL_OPTIONS,
claude: [DEFAULT_MODEL_OPTION],
codex: [DEFAULT_MODEL_OPTION],
};
export function getRuntimePersonaModelOptions(
runtimeId: string,
): readonly PersonaModelOption[] {
return PERSONA_MODEL_OPTIONS_BY_RUNTIME[runtimeId] ?? [DEFAULT_MODEL_OPTION];
}
function isKnownLlmProvider(
providerId: string,
): providerId is PersonaLlmProviderId {
return (KNOWN_LLM_PROVIDER_IDS as readonly string[]).includes(providerId);
}
function runtimeDefaultsToDatabricks(runtimeId: string) {
return runtimeId === "buzz-agent" || runtimeId === "goose";
}
export function runtimeSupportsLlmProviderSelection(runtimeId: string) {
return runtimeDefaultsToDatabricks(runtimeId);
}
function effectiveModelProviderForOptions(
runtimeId: string,
providerId: string | null | undefined,
) {
if (
runtimeId.trim().length > 0 &&
!runtimeSupportsLlmProviderSelection(runtimeId)
) {
return "";
}
const trimmedProvider = providerId?.trim() ?? "";
if (trimmedProvider.length === 0 && runtimeDefaultsToDatabricks(runtimeId)) {
return "databricks";
}
return trimmedProvider;
}
export function getPersonaModelOptions(
runtimeId: string,
providerId: string | null | undefined,
): readonly PersonaModelOption[] {
const options = getRuntimePersonaModelOptions(runtimeId);
const trimmedProvider = effectiveModelProviderForOptions(
runtimeId,
providerId,
);
if (trimmedProvider.length === 0) {
return options.filter((option) => option.id.length === 0);
}
if (!isKnownLlmProvider(trimmedProvider)) {
return options;
}
return options.filter(
(option) =>
(option.id.length === 0 &&
!providerRequiresExplicitModel(trimmedProvider)) ||
(option.id !== DATABRICKS_DEFAULT_MODEL_ID &&
option.providers?.includes(trimmedProvider)),
);
}
function hasExactPersonaModelOption(
options: readonly PersonaModelOption[],
modelId: string,
) {
const trimmedModel = modelId.trim();
return (
trimmedModel.length > 0 &&
options.some((option) => option.id === trimmedModel)
);
}
export function hasPersonaModelOption(
options: readonly PersonaModelOption[],
modelId: string,
) {
const trimmedModel = modelId.trim();
return (
trimmedModel.length === 0 ||
options.some((option) => option.id === trimmedModel)
);
}
export function getModelSelectValue({
isCustomModelEditing,
isModelCustom,
model,
}: {
isCustomModelEditing: boolean;
isModelCustom: boolean;
model: string;
}) {
if (isCustomModelEditing || isModelCustom) {
return CUSTOM_MODEL_DROPDOWN_VALUE;
}
return model.trim() || AUTO_MODEL_DROPDOWN_VALUE;
}
export function providerRequiresExplicitModel(
providerId: string | null | undefined,
) {
const trimmedProvider = providerId?.trim() ?? "";
return (
trimmedProvider === "anthropic" ||
trimmedProvider === "openai" ||
trimmedProvider === "openai-compat"
);
}
export function getDefaultLlmProviderLabel(runtimeId: string) {
return runtimeDefaultsToDatabricks(runtimeId)
? "Databricks (default)"
: "Default";
}
export function getPersonaProviderOptions(
currentProvider: string,
runtimeId: string,
): readonly PersonaModelOption[] {
const trimmedProvider = currentProvider.trim();
const providerOptions = runtimeDefaultsToDatabricks(runtimeId)
? PERSONA_LLM_PROVIDER_OPTIONS.filter((option) =>
trimmedProvider === "databricks" ? true : option.id !== "databricks",
)
: PERSONA_LLM_PROVIDER_OPTIONS;
const defaultProviderOptions =
runtimeDefaultsToDatabricks(runtimeId) && trimmedProvider === "databricks"
? []
: [{ id: "", label: getDefaultLlmProviderLabel(runtimeId) }];
const options = [...defaultProviderOptions, ...providerOptions];
if (
trimmedProvider.length === 0 ||
options.some((option) => option.id === trimmedProvider)
) {
return options;
}
return [
...options,
{ id: trimmedProvider, label: `${trimmedProvider} (current)` },
];
}
export function getProviderApiKeyConfig(
providerId: string,
): ProviderApiKeyConfig | null {
switch (providerId.trim()) {
case "anthropic":
return {
envVar: "ANTHROPIC_API_KEY",
label: "Anthropic API key",
placeholder: "sk-ant-...",
};
case "openai":
return {
envVar: "OPENAI_COMPAT_API_KEY",
label: "OpenAI API key",
placeholder: "sk-...",
};
case "openai-compat":
return {
envVar: "OPENAI_COMPAT_API_KEY",
label: "OpenAI-compatible API key",
placeholder: "sk-...",
};
default:
return null;
}
}
export function getProviderApiKeyEnvVar(providerId: string): string | null {
return getProviderApiKeyConfig(providerId)?.envVar ?? null;
}
export function shouldClearKnownModelForSelectionScope({
model,
provider,
runtime,
}: {
model: string;
provider: string | null | undefined;
runtime: string;
}) {
const runtimeOptions = getRuntimePersonaModelOptions(runtime);
const scopedOptions = getPersonaModelOptions(runtime, provider);
return (
hasExactPersonaModelOption(runtimeOptions, model) &&
!hasExactPersonaModelOption(scopedOptions, model)
);
}
export function formatRuntimeOptionLabel(runtime: AcpRuntimeCatalogEntry) {
const suffix =
runtime.availability === "adapter_missing"
? " (adapter missing)"
: runtime.availability === "cli_missing"
? " (CLI missing)"
: runtime.availability === "not_installed"
? " (not installed)"
: "";
return `${runtime.label}${suffix}`;
}
function runtimeAvailabilitySortRank(
availability: AcpRuntimeCatalogEntry["availability"],
) {
switch (availability) {
case "available":
return 0;
case "cli_missing":
return 1;
case "not_installed":
return 2;
case "adapter_missing":
return 3;
}
}
function runtimePreferenceSortRank(runtimeId: string) {
switch (runtimeId) {
case "buzz-agent":
return 0;
case "goose":
return 1;
default:
return 2;
}
}
export function sortPersonaRuntimes(
runtimes: readonly AcpRuntimeCatalogEntry[],
) {
return [...runtimes].sort((left, right) => {
const availabilityDelta =
runtimeAvailabilitySortRank(left.availability) -
runtimeAvailabilitySortRank(right.availability);
if (availabilityDelta !== 0) {
return availabilityDelta;
}
const preferenceDelta =
runtimePreferenceSortRank(left.id) - runtimePreferenceSortRank(right.id);
if (preferenceDelta !== 0) {
return preferenceDelta;
}
return left.label.localeCompare(right.label);
});
}
export function getDefaultPersonaRuntime(runtimes: AcpRuntimeCatalogEntry[]) {
const available = runtimes.filter(
(runtime) => runtime.availability === "available",
);
return (
available.find((runtime) => runtime.id === "buzz-agent") ??
available.find((runtime) => runtime.id === "goose") ??
available[0] ??
null
);
}

View File

@@ -6,7 +6,9 @@ import {
createPersonaDialogState,
duplicatePersonaDialogState,
editPersonaDialogState,
formatPersonaNamePoolText,
importPersonaDialogState,
parsePersonaNamePoolText,
} from "./personaDialogState.ts";
test("canSubmitPersonaDialog requires a display name but not a system prompt", () => {
@@ -40,11 +42,25 @@ test("canSubmitPersonaDialog blocks while a save is pending", () => {
);
});
test("persona name pool helpers parse, format, and clear values", () => {
assert.deepEqual(parsePersonaNamePoolText("Birch, Compass, , Ridge "), [
"Birch",
"Compass",
"Ridge",
]);
assert.deepEqual(parsePersonaNamePoolText(" "), []);
assert.equal(
formatPersonaNamePoolText(["Birch", "Compass", "Ridge"]),
"Birch, Compass, Ridge",
);
assert.equal(formatPersonaNamePoolText(undefined), "");
});
test("createPersonaDialogState returns a fresh empty draft", () => {
const first = createPersonaDialogState();
const second = createPersonaDialogState();
assert.equal(first.title, "Create persona");
assert.equal(first.title, "Create agent");
assert.deepEqual(first.initialValues, {
displayName: "",
avatarUrl: "",
@@ -123,7 +139,7 @@ test("editPersonaDialogState preserves the persona id for updates", () => {
updatedAt: "2025-01-02T00:00:00Z",
});
assert.equal(state.title, "Edit persona");
assert.equal(state.title, "Edit agent");
assert.equal(state.description, "");
assert.equal(state.submitLabel, "Save changes");
assert.deepEqual(state.initialValues, {
@@ -165,6 +181,7 @@ test("importPersonaDialogState maps parsed persona previews into create drafts",
const state = importPersonaDialogState({
displayName: "Imported",
avatarDataUrl: null,
avatarRef: null,
systemPrompt: "Imported prompt",
runtime: null,
model: "model-b",
@@ -184,6 +201,94 @@ test("importPersonaDialogState maps parsed persona previews into create drafts",
});
});
test("importPersonaDialogState preserves imported name pools", () => {
const state = importPersonaDialogState({
displayName: "Named imports",
avatarDataUrl: null,
avatarRef: null,
systemPrompt: "Imported prompt",
runtime: null,
model: null,
provider: null,
namePool: ["Birch", "Compass"],
sourceFile: "named.persona.json",
});
assert.deepEqual(state.initialValues.namePool, ["Birch", "Compass"]);
});
test("importPersonaDialogState filters unresolved Goose app-avatar refs", () => {
const state = importPersonaDialogState({
displayName: "Goosey",
avatarDataUrl: null,
avatarRef: "app-avatar:gloopies-19",
systemPrompt: "Imported prompt",
runtime: null,
model: null,
provider: null,
namePool: [],
sourceFile: "goosey.persona.md",
});
assert.equal(state.initialValues.avatarUrl, "");
});
test("importPersonaDialogState filters nonpersistent imported avatar refs", () => {
for (const avatarRef of [
"blob:https://buzz.example/temporary-avatar",
"ipfs://bafybeigdyrzt",
]) {
const state = importPersonaDialogState({
displayName: "Packed avatar",
avatarDataUrl: null,
avatarRef,
systemPrompt: "Imported prompt",
runtime: null,
model: null,
provider: null,
namePool: [],
sourceFile: "pack.persona.zip",
});
assert.equal(state.initialValues.avatarUrl, "");
}
});
test("importPersonaDialogState preserves URL-like avatar refs", () => {
const state = importPersonaDialogState({
displayName: "Hosted avatar",
avatarDataUrl: null,
avatarRef: "https://relay.example/avatar.png",
systemPrompt: "Imported prompt",
runtime: null,
model: null,
provider: null,
namePool: [],
sourceFile: "hosted.persona.md",
});
assert.equal(
state.initialValues.avatarUrl,
"https://relay.example/avatar.png",
);
});
test("importPersonaDialogState filters relative avatar refs from packs", () => {
const state = importPersonaDialogState({
displayName: "Packed avatar",
avatarDataUrl: null,
avatarRef: "./avatars/lep.png",
systemPrompt: "Imported prompt",
runtime: null,
model: null,
provider: null,
namePool: [],
sourceFile: "pack.persona.zip",
});
assert.equal(state.initialValues.avatarUrl, "");
});
test("editPersonaDialogState preserves provider=databricks", () => {
const state = editPersonaDialogState({
id: "persona-provider",
@@ -248,6 +353,7 @@ test("importPersonaDialogState preserves provider=anthropic", () => {
const state = importPersonaDialogState({
displayName: "Imported With Provider",
avatarDataUrl: null,
avatarRef: null,
systemPrompt: "Anthropic agent.",
runtime: "goose",
model: "claude-sonnet",

View File

@@ -1,4 +1,4 @@
import type { ParsePersonaFilesResult } from "@/shared/api/tauriPersonas";
import type { ParsedPersonaPreview } from "@/shared/api/tauriPersonas";
import type {
AgentPersona,
CreatePersonaInput,
@@ -12,7 +12,10 @@ export type PersonaDialogState = {
title: string;
};
type ParsedPersonaDraft = ParsePersonaFilesResult["personas"][number];
type ImportedPersonaAvatarPreview = Pick<
ParsedPersonaPreview,
"avatarDataUrl" | "avatarRef"
>;
/**
* Whether the persona dialog's save action should be enabled.
@@ -28,12 +31,46 @@ export function canSubmitPersonaDialog(args: {
return args.displayName.trim().length > 0 && !args.isPending;
}
function isSafeImportedAvatarRef(
ref: string | null | undefined,
): ref is string {
const trimmed = ref?.trim();
if (!trimmed) return false;
try {
const parsed = new URL(trimmed);
return (
parsed.protocol === "http:" ||
parsed.protocol === "https:" ||
(parsed.protocol === "data:" && trimmed.startsWith("data:image/"))
);
} catch {
return false;
}
}
export function importedAvatarUrl(persona: ImportedPersonaAvatarPreview) {
if (persona.avatarDataUrl) return persona.avatarDataUrl;
return isSafeImportedAvatarRef(persona.avatarRef) ? persona.avatarRef : "";
}
export function formatPersonaNamePoolText(namePool: string[] | undefined) {
return namePool?.join(", ") ?? "";
}
export function parsePersonaNamePoolText(text: string): string[] {
return text
.split(",")
.map((value) => value.trim())
.filter((value) => value.length > 0);
}
export function createPersonaDialogState(): PersonaDialogState {
return {
title: "Create persona",
title: "Create agent",
description:
"Save a reusable role, prompt, and optional avatar for future agent deployments.",
submitLabel: "Create persona",
"Create an agent profile and start its managed agent instance.",
submitLabel: "Create agent",
initialValues: {
displayName: "",
avatarUrl: "",
@@ -50,8 +87,8 @@ export function duplicatePersonaDialogState(
return {
title: `Duplicate ${persona.displayName}`,
description:
"Create a new persona by copying this template and adjusting it as needed.",
submitLabel: "Create persona",
"Create a new agent by copying this profile and adjusting it as needed.",
submitLabel: "Create agent",
initialValues: {
displayName: `${persona.displayName} copy`,
avatarUrl: persona.avatarUrl ?? "",
@@ -74,7 +111,7 @@ export function editPersonaDialogState(
persona: AgentPersona,
): PersonaDialogState {
return {
title: "Edit persona",
title: "Edit agent",
description: "",
submitLabel: "Save changes",
initialValues: {
@@ -96,19 +133,20 @@ export function editPersonaDialogState(
}
export function importPersonaDialogState(
persona: ParsedPersonaDraft,
persona: ParsedPersonaPreview,
): PersonaDialogState {
return {
title: `Import ${persona.displayName}`,
description: "Review and save this imported persona.",
submitLabel: "Create persona",
description: "Review and create this imported agent.",
submitLabel: "Create agent",
initialValues: {
displayName: persona.displayName,
avatarUrl: persona.avatarDataUrl ?? "",
avatarUrl: importedAvatarUrl(persona),
systemPrompt: persona.systemPrompt,
runtime: persona.runtime ?? undefined,
model: persona.model ?? undefined,
provider: persona.provider ?? undefined,
...(persona.namePool.length > 0 ? { namePool: persona.namePool } : {}),
},
};
}

View File

@@ -87,6 +87,58 @@ test("buildPersonaImportPlan detects avatar change", () => {
assert.equal(plan.fields[0]?.field, "avatarUrl");
});
test("buildPersonaImportPlan detects hosted avatar ref changes", () => {
const plan = buildPersonaImportPlan({
persona: createPersona({ avatarUrl: null }),
preview: createPreview({ avatarRef: "https://new.example/avatar.png" }),
});
assert.equal(plan.fields.length, 1);
assert.equal(plan.fields[0]?.field, "avatarUrl");
assert.equal(plan.fields[0]?.importedValue, "https://new.example/avatar.png");
});
test("buildPersonaImportPlan ignores unresolved app-avatar refs", () => {
const plan = buildPersonaImportPlan({
persona: createPersona({ avatarUrl: "https://old.example/avatar.png" }),
preview: createPreview({ avatarRef: "app-avatar:gloopies-19" }),
});
assert.equal(
plan.fields.some((field) => field.field === "avatarUrl"),
false,
);
});
test("buildPersonaImportPlan ignores raw relative avatar refs", () => {
const plan = buildPersonaImportPlan({
persona: createPersona({ avatarUrl: "https://old.example/avatar.png" }),
preview: createPreview({ avatarRef: "./avatars/lep.png" }),
});
assert.equal(
plan.fields.some((field) => field.field === "avatarUrl"),
false,
);
});
test("buildPersonaImportPlan ignores nonpersistent imported avatar refs", () => {
for (const avatarRef of [
"blob:https://buzz.example/temporary-avatar",
"ipfs://bafybeigdyrzt",
]) {
const plan = buildPersonaImportPlan({
persona: createPersona({ avatarUrl: "https://old.example/avatar.png" }),
preview: createPreview({ avatarRef }),
});
assert.equal(
plan.fields.some((field) => field.field === "avatarUrl"),
false,
);
}
});
test("buildPersonaImportPlan detects runtime change", () => {
const plan = buildPersonaImportPlan({
persona: createPersona({ runtime: "goose" }),

View File

@@ -1,5 +1,6 @@
import type { ParsedPersonaPreview } from "@/shared/api/tauriPersonas";
import type { AgentPersona } from "@/shared/api/types";
import { importedAvatarUrl } from "./personaDialogState";
type LineChangeCounts = {
addedLines: number;
@@ -124,8 +125,12 @@ export function buildPersonaImportPlan({
}
const existingAvatar = normalizeOptionalText(persona.avatarUrl);
const importedAvatar = normalizeOptionalText(preview.avatarDataUrl);
if (existingAvatar !== importedAvatar) {
const importedAvatar = normalizeOptionalText(importedAvatarUrl(preview));
const hasUnusableAvatarRef =
!preview.avatarDataUrl &&
Boolean(preview.avatarRef?.trim()) &&
!importedAvatar;
if (!hasUnusableAvatarRef && existingAvatar !== importedAvatar) {
fields.push({
field: "avatarUrl",
label: "Avatar",

View File

@@ -1,5 +1,6 @@
import type { ParsedPersonaPreview } from "@/shared/api/tauriPersonas";
import type { AgentPersona, UpdatePersonaInput } from "@/shared/api/types";
import { importedAvatarUrl } from "./personaDialogState";
type BuildPersonaImportUpdateInputArgs = {
existing: AgentPersona;
@@ -23,7 +24,7 @@ export function buildPersonaImportUpdateInput({
? preview.systemPrompt
: existing.systemPrompt,
avatarUrl: selectedFieldSet.has("avatarUrl")
? (preview.avatarDataUrl ?? undefined)
? importedAvatarUrl(preview) || undefined
: (existing.avatarUrl ?? undefined),
runtime: selectedFieldSet.has("runtime")
? (preview.runtime ?? undefined)

View File

@@ -2,12 +2,13 @@ export const personaLibraryCopy = {
title: "My agents",
description:
"The personas you have chosen for this app. Use them to create teams and launch agents.",
chooseFromCatalog: "Choose...",
createNew: "Persona",
chooseFromCatalog: "Choose from catalog",
createNew: "New agent",
customAgent: "Custom agent",
import: "Import",
emptyTitle: "No agents yet",
emptyDescription:
"Choose one from Persona Catalog, add your own persona, or import one to get started.",
"Create a new agent, choose one from the catalog, or import one to get started.",
emptyImportHint:
"Or drop a .persona.md, .persona.json, .persona.png, or .zip file here to import.",
} as const;

View File

@@ -0,0 +1,35 @@
import assert from "node:assert/strict";
import test from "node:test";
import { formatModelDiscoveryErrorStatus } from "./personaModelDiscoveryStatus.ts";
test("model discovery status names missing Anthropic credentials", () => {
const status = formatModelDiscoveryErrorStatus(
new Error("config: ANTHROPIC_API_KEY required"),
"anthropic",
);
assert.equal(status?.tone, "warning");
assert.match(status?.message ?? "", /Anthropic API key/);
assert.match(status?.message ?? "", /Anthropic models/);
});
test("model discovery status names missing OpenAI-compatible credentials", () => {
const status = formatModelDiscoveryErrorStatus(
new Error("config: OPENAI_COMPAT_API_KEY required"),
"openai-compat",
);
assert.equal(status?.tone, "warning");
assert.match(status?.message ?? "", /OpenAI API key/);
assert.match(status?.message ?? "", /OpenAI models/);
});
test("model discovery status stays quiet for missing Databricks defaults", () => {
const status = formatModelDiscoveryErrorStatus(
new Error("config: DATABRICKS_HOST required"),
"databricks",
);
assert.equal(status, null);
});

View File

@@ -0,0 +1,67 @@
export type PersonaModelDiscoveryStatus = {
message: string;
tone: "muted" | "warning";
};
function errorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
if (typeof error === "string") {
return error;
}
try {
return JSON.stringify(error);
} catch {
return "Unknown model discovery error";
}
}
function providerObjectLabel(provider: string): string {
switch (provider.trim()) {
case "anthropic":
return "Anthropic";
case "openai":
return "OpenAI";
case "openai-compat":
return "OpenAI-compatible";
default:
return "the selected provider";
}
}
export function formatModelDiscoveryErrorStatus(
error: unknown,
provider: string,
): PersonaModelDiscoveryStatus | null {
const message = errorMessage(error);
if (message.includes("ANTHROPIC_API_KEY required")) {
return {
message: "Enter an Anthropic API key to load Anthropic models.",
tone: "warning",
};
}
if (message.includes("OPENAI_COMPAT_API_KEY required")) {
return {
message: "Enter an OpenAI API key to load OpenAI models.",
tone: "warning",
};
}
if (
message.includes("DATABRICKS_HOST required") ||
message.includes("DATABRICKS_MODEL required") ||
message.includes("BUZZ_AGENT_PROVIDER required")
) {
return null;
}
return {
message: `Using built-in model options. Could not load live models for ${providerObjectLabel(
provider,
)}.`,
tone: "warning",
};
}

View File

@@ -0,0 +1,20 @@
import assert from "node:assert/strict";
import test from "node:test";
import { shouldClearModelForRuntimeChange } from "./personaRuntimeModel.ts";
test("shouldClearModelForRuntimeChange preserves model for first runtime selection", () => {
assert.equal(shouldClearModelForRuntimeChange("", "goose"), false);
});
test("shouldClearModelForRuntimeChange clears model when switching runtimes", () => {
assert.equal(shouldClearModelForRuntimeChange("goose", "claude"), true);
});
test("shouldClearModelForRuntimeChange clears model when runtime is removed", () => {
assert.equal(shouldClearModelForRuntimeChange("goose", ""), true);
});
test("shouldClearModelForRuntimeChange keeps model for unchanged runtime", () => {
assert.equal(shouldClearModelForRuntimeChange("goose", "goose"), false);
});

View File

@@ -0,0 +1,9 @@
export function shouldClearModelForRuntimeChange(
previousRuntime: string,
nextRuntime: string,
): boolean {
const previous = previousRuntime.trim();
const next = nextRuntime.trim();
return previous.length > 0 && previous !== next;
}

View File

@@ -4,6 +4,7 @@ import { useQueryClient } from "@tanstack/react-query";
import {
personasQueryKey,
useAcpRuntimesQuery,
useCreateManagedAgentMutation,
useCreatePersonaMutation,
useDeletePersonaMutation,
useExportPersonaJsonMutation,
@@ -18,7 +19,10 @@ import {
} from "@/shared/api/tauriPersonas";
import { isSingleItemFile } from "@/shared/lib/fileMagic";
import type {
AcpRuntime,
AgentPersona,
CreateManagedAgentInput,
CreateManagedAgentResponse,
CreatePersonaInput,
UpdatePersonaInput,
} from "@/shared/api/types";
@@ -29,6 +33,7 @@ import {
importPersonaDialogState,
type PersonaDialogState,
} from "./personaDialogState";
import { resolveManagedAgentAvatarUrl } from "./managedAgentAvatar";
import { usePersonaImportActions } from "./usePersonaImportActions";
type PersonaFeedbackSurface = "catalog" | "library";
@@ -41,6 +46,7 @@ export function usePersonaActions() {
const acpRuntimesQuery = useAcpRuntimesQuery({
enabled: shouldLoadAcpRuntimes,
});
const createAgentMutation = useCreateManagedAgentMutation();
const createPersonaMutation = useCreatePersonaMutation();
const updatePersonaMutation = useUpdatePersonaMutation();
const deletePersonaMutation = useDeletePersonaMutation();
@@ -63,8 +69,20 @@ export function usePersonaActions() {
>(null);
const [personaFeedbackSurface, setPersonaFeedbackSurface] =
React.useState<PersonaFeedbackSurface>("library");
const [createdAgent, setCreatedAgent] =
React.useState<CreateManagedAgentResponse | null>(null);
const [isPersonaSubmitPending, setIsPersonaSubmitPending] =
React.useState(false);
const personas = personasQuery.data ?? [];
const availableRuntimes = React.useMemo(
() =>
(acpRuntimesQuery.data ?? []).filter(
(runtime): runtime is AcpRuntime =>
runtime.availability === "available",
),
[acpRuntimesQuery.data],
);
const { catalogPersonas, libraryPersonas, personaLabelsById } = React.useMemo(
() => getPersonaLibraryState(personas),
[personas],
@@ -86,20 +104,84 @@ export function usePersonaActions() {
}
async function handleSubmit(input: CreatePersonaInput | UpdatePersonaInput) {
if (isPersonaSubmitPending) {
return;
}
clearFeedback("library");
setIsPersonaSubmitPending(true);
try {
if ("id" in input) {
await updatePersonaMutation.mutateAsync(input);
setPersonaNoticeMessage(`Updated ${input.displayName}.`);
} else {
await createPersonaMutation.mutateAsync(input);
setPersonaNoticeMessage(`Created ${input.displayName}.`);
const runtime = availableRuntimes.find(
(candidate) => candidate.id === input.runtime,
);
if (!runtime) {
setPersonaErrorMessage(
"Choose an available provider for this agent.",
);
return;
}
const avatarUrl = await resolveManagedAgentAvatarUrl(
input.avatarUrl,
undefined,
runtime.avatarUrl,
);
const persona = await createPersonaMutation.mutateAsync({
...input,
avatarUrl,
});
const agentInput: CreateManagedAgentInput = {
name: persona.displayName,
acpCommand: "buzz-acp",
agentCommand: runtime.command,
agentArgs: runtime.defaultArgs,
mcpCommand: runtime.mcpCommand ?? "",
personaId: persona.id,
harnessOverride: true,
systemPrompt: persona.systemPrompt,
avatarUrl: persona.avatarUrl ?? avatarUrl,
model: persona.model ?? undefined,
spawnAfterCreate: true,
startOnAppLaunch: true,
backend: { type: "local" },
};
try {
const created = await createAgentMutation.mutateAsync(agentInput);
setCreatedAgent(created);
if (created.spawnError) {
setPersonaErrorMessage(
`${persona.displayName} was created, but it did not start: ${created.spawnError}`,
);
} else {
setPersonaNoticeMessage(
`Created and started ${created.agent.name}.`,
);
}
if (created.profileSyncError) {
setPersonaErrorMessage(
`${created.agent.name} was created, but profile sync failed: ${created.profileSyncError}`,
);
}
} catch (error) {
setPersonaErrorMessage(
error instanceof Error
? `${persona.displayName} was created, but the agent instance could not be created: ${error.message}`
: `${persona.displayName} was created, but the agent instance could not be created.`,
);
}
}
setPersonaDialogState(null);
} catch (error) {
setPersonaErrorMessage(
error instanceof Error ? error.message : "Failed to save persona.",
);
} finally {
setIsPersonaSubmitPending(false);
}
}
@@ -217,7 +299,9 @@ export function usePersonaActions() {
}
const isPending =
isPersonaSubmitPending ||
createPersonaMutation.isPending ||
createAgentMutation.isPending ||
updatePersonaMutation.isPending ||
deletePersonaMutation.isPending ||
setPersonaActiveMutation.isPending ||
@@ -245,6 +329,8 @@ export function usePersonaActions() {
personaNoticeMessage,
personaErrorMessage,
personaFeedbackSurface,
createdAgent,
setCreatedAgent,
personaImportActions,
handleSubmit,
handleDelete,

View File

@@ -8,6 +8,7 @@ import {
type ParsedPersonaPreview,
} from "@/shared/api/tauriPersonas";
import type { AgentPersona } from "@/shared/api/types";
import { resolveManagedAgentAvatarUrl } from "./managedAgentAvatar";
import { buildPersonaImportPlan } from "./personaImportPlan";
import { buildPersonaImportUpdateInput } from "./personaImportUpdateInput";
import {
@@ -115,6 +116,13 @@ export function usePersonaImportActions(
preview,
selectedFields,
});
if (selectedFields.includes("avatarUrl")) {
updateInput.avatarUrl = await resolveManagedAgentAvatarUrl(
updateInput.avatarUrl,
undefined,
existing.avatarUrl,
);
}
await updatePersonaApi(updateInput);

View File

@@ -0,0 +1,214 @@
import * as React from "react";
import { discoverAgentModels } from "@/shared/api/agentModels";
import type {
AcpRuntimeCatalogEntry,
AgentModelsResponse,
} from "@/shared/api/types";
import type { EnvVarsValue } from "./EnvVarsEditor";
import {
formatModelDiscoveryErrorStatus,
type PersonaModelDiscoveryStatus,
} from "./personaModelDiscoveryStatus";
import type { PersonaModelOption } from "./personaDialogPickers";
import { providerRequiresExplicitModel } from "./personaDialogPickers";
export const MODEL_DISCOVERY_LOADING_VALUE = "__model_discovery_loading__";
const MODEL_DISCOVERY_CREDENTIAL_DEBOUNCE_MS = 250;
function stableModelDiscoveryEnvKey(envVars: EnvVarsValue): string {
return JSON.stringify(
Object.entries(envVars).sort(([left], [right]) =>
left.localeCompare(right),
),
);
}
function getDiscoveredPersonaModelOptions(
response: AgentModelsResponse | null,
provider: string,
): readonly PersonaModelOption[] | null {
if (!response?.supportsSwitching || response.models.length === 0) {
return null;
}
const defaultModelOption = providerRequiresExplicitModel(provider)
? []
: [
{
id: "",
label: response.agentDefaultModel?.trim()
? `Default model (${response.agentDefaultModel})`
: "Default model",
},
];
return [
...defaultModelOption,
...response.models.map((model) => ({
id: model.id,
label: model.name?.trim() || model.id,
})),
];
}
export function usePersonaModelDiscovery({
envVars,
isCustomProviderEditing,
modelFieldVisible,
open,
provider,
selectedRuntime,
}: {
envVars: EnvVarsValue;
isCustomProviderEditing: boolean;
modelFieldVisible: boolean;
open: boolean;
provider: string;
selectedRuntime: AcpRuntimeCatalogEntry | undefined;
}) {
const [modelDiscoveryData, setModelDiscoveryData] =
React.useState<AgentModelsResponse | null>(null);
const [modelDiscoveryStatus, setModelDiscoveryStatus] =
React.useState<PersonaModelDiscoveryStatus | null>(null);
const [modelDiscoveryLoading, setModelDiscoveryLoading] =
React.useState(false);
const modelDiscoveryCacheRef = React.useRef(
new Map<string, AgentModelsResponse>(),
);
const modelDiscoveryRequestRef = React.useRef(0);
const trimmedProvider = provider.trim();
const shouldDebounceModelDiscovery =
providerRequiresExplicitModel(trimmedProvider);
const discoveryAgentCommand = selectedRuntime?.command?.trim()
? selectedRuntime.command
: null;
const canDiscoverModelOptions =
open &&
modelFieldVisible &&
selectedRuntime?.availability === "available" &&
discoveryAgentCommand !== null &&
(!isCustomProviderEditing || trimmedProvider.length > 0);
const modelDiscoveryEnvKey = React.useMemo(
() => stableModelDiscoveryEnvKey(envVars),
[envVars],
);
const modelDiscoveryArgsKey = JSON.stringify(
selectedRuntime?.defaultArgs ?? [],
);
const modelDiscoveryKey = React.useMemo(() => {
if (!canDiscoverModelOptions || discoveryAgentCommand === null) {
return null;
}
return JSON.stringify({
agentCommand: discoveryAgentCommand,
agentArgs: modelDiscoveryArgsKey,
provider: trimmedProvider,
envVars: modelDiscoveryEnvKey,
});
}, [
canDiscoverModelOptions,
discoveryAgentCommand,
modelDiscoveryArgsKey,
modelDiscoveryEnvKey,
trimmedProvider,
]);
React.useEffect(() => {
if (modelDiscoveryKey === null || discoveryAgentCommand === null) {
modelDiscoveryRequestRef.current += 1;
setModelDiscoveryData(null);
setModelDiscoveryStatus(null);
setModelDiscoveryLoading(false);
return;
}
const requestId = modelDiscoveryRequestRef.current + 1;
modelDiscoveryRequestRef.current = requestId;
const activeAgentCommand = discoveryAgentCommand;
const activeModelDiscoveryKey = modelDiscoveryKey;
const cached = modelDiscoveryCacheRef.current.get(activeModelDiscoveryKey);
if (cached) {
setModelDiscoveryData(cached);
setModelDiscoveryStatus(null);
setModelDiscoveryLoading(false);
return;
}
setModelDiscoveryData(null);
setModelDiscoveryStatus(null);
setModelDiscoveryLoading(true);
function runModelDiscovery() {
void discoverAgentModels({
agentCommand: activeAgentCommand,
agentArgs: selectedRuntime?.defaultArgs ?? [],
provider: trimmedProvider || undefined,
envVars,
})
.then((response) => {
if (modelDiscoveryRequestRef.current !== requestId) {
return;
}
modelDiscoveryCacheRef.current.set(activeModelDiscoveryKey, response);
setModelDiscoveryData(response);
setModelDiscoveryStatus(null);
})
.catch((error) => {
if (modelDiscoveryRequestRef.current !== requestId) {
return;
}
setModelDiscoveryData(null);
setModelDiscoveryStatus(
formatModelDiscoveryErrorStatus(error, trimmedProvider),
);
})
.finally(() => {
if (modelDiscoveryRequestRef.current === requestId) {
setModelDiscoveryLoading(false);
}
});
}
if (!shouldDebounceModelDiscovery) {
runModelDiscovery();
return;
}
const timeout = window.setTimeout(
runModelDiscovery,
MODEL_DISCOVERY_CREDENTIAL_DEBOUNCE_MS,
);
return () => {
window.clearTimeout(timeout);
if (modelDiscoveryRequestRef.current === requestId) {
modelDiscoveryRequestRef.current += 1;
setModelDiscoveryLoading(false);
}
};
}, [
discoveryAgentCommand,
envVars,
modelDiscoveryKey,
selectedRuntime?.defaultArgs,
shouldDebounceModelDiscovery,
trimmedProvider,
]);
const discoveredModelOptions = React.useMemo(
() => getDiscoveredPersonaModelOptions(modelDiscoveryData, trimmedProvider),
[modelDiscoveryData, trimmedProvider],
);
return {
discoveredModelOptions,
modelDiscoveryLoading,
modelDiscoveryStatus:
modelDiscoveryLoading || discoveredModelOptions !== null
? null
: modelDiscoveryStatus,
};
}

View File

@@ -0,0 +1,14 @@
import type { AgentModelsResponse } from "@/shared/api/types";
import { invokeTauri } from "@/shared/api/tauri";
export type DiscoverAgentModelsInput = {
acpCommand?: string;
agentCommand: string;
agentArgs?: string[];
provider?: string;
envVars?: Record<string, string>;
};
export async function discoverAgentModels(input: DiscoverAgentModelsInput) {
return invokeTauri<AgentModelsResponse>("discover_agent_models", { input });
}

View File

@@ -10,6 +10,7 @@ type RawParsedPersonaPreview = {
display_name: string;
system_prompt: string;
avatar_data_url: string | null;
avatar_ref: string | null;
runtime: string | null;
model: string | null;
provider: string | null;
@@ -32,6 +33,7 @@ export type ParsedPersonaPreview = {
displayName: string;
systemPrompt: string;
avatarDataUrl: string | null;
avatarRef: string | null;
runtime: string | null;
model: string | null;
provider: string | null;
@@ -154,7 +156,8 @@ export async function parsePersonaFiles(
personas: raw.personas.map((p) => ({
displayName: p.display_name,
systemPrompt: p.system_prompt,
avatarDataUrl: p.avatar_data_url,
avatarDataUrl: p.avatar_data_url ?? null,
avatarRef: p.avatar_ref ?? null,
runtime: p.runtime,
model: p.model,
provider: p.provider,

View File

@@ -364,10 +364,9 @@ export type CreateManagedAgentInput = {
acpCommand?: string;
agentCommand?: string;
/**
* True when `agentCommand` is a runtime the user deliberately picked to
* override the linked persona (a deploy-dialog runtime selector). Lets the
* backend distinguish a real pin from a missing-runtime fallback. Omit/false
* for persona-less creates and fallback divergence — both inherit.
* True when `agentCommand` is a runtime command the user deliberately picked
* for a linked persona. Lets the backend preserve real selections, including
* installed aliases, while still ignoring missing-runtime fallbacks.
*/
harnessOverride?: boolean;
agentArgs?: string[];

View File

@@ -5084,6 +5084,7 @@ async function handleParsePersonaFiles(args: {
display_name: string;
system_prompt: string;
avatar_data_url: string | null;
avatar_ref: string | null;
source_file: string;
}[];
skipped: { source_file: string; reason: string }[];
@@ -5095,6 +5096,7 @@ async function handleParsePersonaFiles(args: {
display_name: "Imported Persona",
system_prompt: "You are an imported test persona.",
avatar_data_url: null,
avatar_ref: null,
source_file: args.fileName,
},
],
@@ -6830,6 +6832,43 @@ export function maybeInstallE2eTauriMocks() {
selectedModel: null,
supportsSwitching: false,
};
case "discover_agent_models": {
const input = (payload as { input?: { provider?: string } } | null)
?.input;
const provider = input?.provider?.trim() ?? "";
const openAiModels = [
{ id: "gpt-5.5", name: "GPT-5.5", description: null },
{ id: "gpt-5.4", name: "GPT-5.4", description: null },
{ id: "gpt-5.4-mini", name: "GPT-5.4 mini", description: null },
{ id: "gpt-5.4-nano", name: "GPT-5.4 nano", description: null },
];
const anthropicModels = [
{
id: "goose-claude-4-6-opus",
name: "Claude Opus 4.6",
description: null,
},
{
id: "goose-claude-4-6-sonnet",
name: "Claude Sonnet 4.6",
description: null,
},
];
const models =
provider === "openai"
? openAiModels
: provider === "anthropic"
? anthropicModels
: [...anthropicModels, ...openAiModels];
return {
agentName: "mock-agent",
agentVersion: "0.0.0",
models,
agentDefaultModel: null,
selectedModel: null,
supportsSwitching: true,
};
}
case "update_managed_agent":
return handleUpdateManagedAgent(
payload as Parameters<typeof handleUpdateManagedAgent>[0],

View File

@@ -31,7 +31,7 @@ async function gotoApp(page: import("@playwright/test").Page) {
async function openPersonaCatalog(page: import("@playwright/test").Page) {
await page.getByTestId("new-agent-card").click();
await page.getByText("Choose from Catalog...").click();
await page.getByRole("menuitem", { name: "Choose from catalog" }).click();
}
async function getCatalogOrder(page: import("@playwright/test").Page) {

View File

@@ -157,7 +157,7 @@ test("Run-on-relay-mesh ensures the client node BEFORE spawning the agent", asyn
await gotoApp(page);
await page.getByTestId("open-agents-view").click();
await openNewAgentMenu(page);
await page.getByText("Custom Agent").click();
await page.getByText("Custom agent").click();
// Member is admitted -> the relay-mesh toggle becomes enabled (availability
// resolves to available). Wait for that before driving the flow.
@@ -201,7 +201,7 @@ test("Run-on-relay-mesh skips connect signaling for own serve target", async ({
await gotoApp(page);
await page.getByTestId("open-agents-view").click();
await openNewAgentMenu(page);
await page.getByText("Custom Agent").click();
await page.getByText("Custom agent").click();
await page.getByTestId("agent-name-input").fill("Own Mesh Agent");
const toggle = page.getByTestId("agent-relay-mesh-toggle");
@@ -241,7 +241,7 @@ test("Run-on-relay-mesh canonicalizes the mesh connect #p target", async ({
await gotoApp(page);
await page.getByTestId("open-agents-view").click();
await openNewAgentMenu(page);
await page.getByText("Custom Agent").click();
await page.getByText("Custom agent").click();
await page.getByTestId("agent-name-input").fill("Mesh Agent");
const toggle = page.getByTestId("agent-relay-mesh-toggle");
@@ -279,7 +279,7 @@ test("a non-member cannot enable relay-mesh — membership is the gate", async (
await page.getByTestId("open-agents-view").click();
await openNewAgentMenu(page);
await page.getByText("Custom Agent").click();
await page.getByText("Custom agent").click();
// The relay-mesh toggle stays disabled — a non-member cannot even opt into
// running on the mesh, let alone spawn an agent against it.
@@ -298,7 +298,7 @@ test("saved relay-mesh agents restart via the backend serve-target preflight", a
await gotoApp(page);
await page.getByTestId("open-agents-view").click();
await openNewAgentMenu(page);
await page.getByText("Custom Agent").click();
await page.getByText("Custom agent").click();
await page.getByTestId("agent-name-input").fill("Saved relay mesh agent");
const toggle = page.getByTestId("agent-relay-mesh-toggle");

View File

@@ -1,5 +1,6 @@
import { expect, test } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge } from "../helpers/bridge";
test.beforeEach(async ({ page }) => {
@@ -57,6 +58,24 @@ async function invokeTauri<T>(
);
}
async function openModelMenu(
page: import("@playwright/test").Page,
model: import("@playwright/test").Locator,
) {
await model.click();
const menu = page
.getByRole("menu")
.filter({
has: page.getByRole("menuitemradio", {
name: "Custom model...",
exact: true,
}),
})
.last();
await expect(menu).toBeVisible();
return menu;
}
test("persona env_vars round-trip through create_persona + update_persona", async ({
page,
}) => {
@@ -221,12 +240,15 @@ test("env vars editor renders in PersonaDialog new-persona form", async ({
}) => {
await gotoApp(page);
// Open the Agents view, click New > Persona to open the persona dialog.
// Open the Agents view, click New > New agent to open the persona dialog.
await page.getByTestId("open-agents-view").click();
await page.getByTestId("new-agent-card").click();
await page.getByRole("menuitem", { name: /^Persona$/ }).click();
await page.getByRole("menuitem", { name: /^New agent$/ }).click();
// The env vars editor should be present.
await expect(page.getByTestId("env-vars-editor")).toHaveCount(0);
await page.getByRole("button", { name: "Advanced", exact: true }).click();
// The env vars editor should be present after opening Advanced.
await expect(page.getByTestId("env-vars-editor")).toBeVisible();
// Initially empty (no rows).
await expect(page.getByTestId("env-vars-key")).toHaveCount(0);
@@ -247,7 +269,110 @@ test("env vars editor renders in PersonaDialog new-persona form", async ({
await keys.nth(2).fill("OPENAI_BASE_URL");
await values.nth(2).fill("https://api.openai.com/v1");
// Capture a screenshot of the dialog with three env vars filled. Helps
// reviewers see the UI at a glance.
await waitForAnimations(page);
await page
.getByRole("dialog")
.screenshot({ path: "test-results/persona-env-dialog.png" });
// Remove the first row to verify per-row removal still works.
await page.getByTestId("env-vars-remove").first().click();
await expect(keys).toHaveCount(2);
});
test("persona model options follow the selected LLM provider", async ({
page,
}) => {
await gotoApp(page);
await page.getByTestId("open-agents-view").click();
await page.getByTestId("new-agent-card").click();
await page.getByRole("menuitem", { name: /^New agent$/ }).click();
const provider = page.locator("#persona-runtime");
const llmProvider = page.locator("#persona-llm-provider");
const model = page.locator("#persona-model");
await expect(provider).toContainText("Buzz Agent (default)");
await expect(llmProvider).toBeVisible();
await expect(model).toBeVisible();
await expect(model).toContainText("Default model");
await llmProvider.click();
await page
.getByRole("menuitemradio", { name: "OpenAI", exact: true })
.click();
const providerApiKey = page.getByTestId("persona-provider-api-key");
await expect(page.getByText("OpenAI API key")).toBeVisible();
await expect(providerApiKey).toBeVisible();
await expect(page.getByTestId("env-vars-editor")).toHaveCount(0);
await expect(model).toBeVisible();
await providerApiKey.fill("sk-openai-test");
const openAiModelMenu = await openModelMenu(page, model);
await expect(
openAiModelMenu.getByRole("menuitemradio", {
name: "GPT-5.5",
exact: true,
}),
).toBeVisible();
await expect(
openAiModelMenu.getByRole("menuitemradio", { name: "GPT-5", exact: true }),
).toHaveCount(0);
await expect(
openAiModelMenu.getByRole("menuitemradio", { name: /Claude/ }),
).toHaveCount(0);
await openAiModelMenu
.getByRole("menuitemradio", { name: "GPT-5.5", exact: true })
.click();
await expect(model).toContainText("GPT-5.5");
await llmProvider.click();
await page
.getByRole("menuitemradio", { name: "Anthropic", exact: true })
.click();
await expect(page.getByText("Anthropic API key")).toBeVisible();
await expect(providerApiKey).toHaveValue("");
await expect(model).toBeVisible();
await providerApiKey.fill("sk-ant-test");
const anthropicModelMenu = await openModelMenu(page, model);
await expect(
anthropicModelMenu.getByRole("menuitemradio", {
name: "Claude Sonnet 4.6",
}),
).toBeVisible();
await expect(
anthropicModelMenu.getByRole("menuitemradio", {
name: "GPT-5.5",
exact: true,
}),
).toHaveCount(0);
await anthropicModelMenu
.getByRole("menuitemradio", { name: "Claude Sonnet 4.6" })
.click();
await expect(model).toContainText("Claude Sonnet 4.6");
await llmProvider.click();
const llmProviderMenu = page.getByRole("menu").filter({
has: page.getByRole("menuitemradio", { name: "OpenAI", exact: true }),
});
await llmProviderMenu
.last()
.getByRole("menuitemradio", { name: "Databricks (default)", exact: true })
.click();
await expect(model).toBeVisible();
await expect(model).toContainText("Claude Sonnet 4.6");
const defaultModelMenu = await openModelMenu(page, model);
await expect(
defaultModelMenu.getByRole("menuitemradio", { name: "Claude Sonnet 4.6" }),
).toBeVisible();
await expect(
defaultModelMenu.getByRole("menuitemradio", {
name: "GPT-5.5",
exact: true,
}),
).toBeVisible();
});

View File

@@ -116,7 +116,7 @@ test("create agent supports parallelism and system prompt overrides", async ({
await page.goto("/");
await page.getByTestId("open-agents-view").click();
await page.getByTestId("new-agent-card").click();
await page.getByText("Custom Agent").click();
await page.getByText("Custom agent").click();
await page.getByTestId("agent-name-input").fill(agentName);
await page.getByRole("button", { name: "Advanced setup" }).click();