diff --git a/desktop/package.json b/desktop/package.json index 69787dd02..52b2737c7 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -9,6 +9,7 @@ "typecheck": "tsc --noEmit", "check:file-sizes": "node ./scripts/check-file-sizes.mjs", "check:px-text": "node ./scripts/check-px-text.mjs", + "sync:goose-avatars": "node ./scripts/sync-goose-avatars.mjs", "lint": "biome lint .", "check": "biome check . && pnpm check:file-sizes && pnpm check:px-text", "format": "biome format --write .", @@ -37,6 +38,7 @@ "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-focus-scope": "^1.1.8", "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-select": "^2.3.1", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-switch": "^1.2.6", diff --git a/desktop/scripts/sync-goose-avatars.mjs b/desktop/scripts/sync-goose-avatars.mjs new file mode 100644 index 000000000..05afd9299 --- /dev/null +++ b/desktop/scripts/sync-goose-avatars.mjs @@ -0,0 +1,175 @@ +#!/usr/bin/env node +import { spawn } from "node:child_process"; +import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +const ARTIFACTORY_BASE = + "https://global.block-artifacts.com/artifactory/goose-internal/avatars"; +const LATEST_URL = `${ARTIFACTORY_BASE}/latest.json`; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const desktopRoot = dirname(scriptDir); +const outputRoot = join(desktopRoot, "src/shared/assets/goose-avatars"); +const catalogPath = join(outputRoot, "catalog.json"); + +const FORMATS = ["webm", "hevc"]; + +function variantOutputPath(asset, format) { + const extension = format === "hevc" ? "mp4" : "webm"; + return join( + outputRoot, + format, + asset.collectionId, + `${asset.id}.${extension}`, + ); +} + +function posterOutputPath(asset) { + return join(outputRoot, "posters", asset.collectionId, `${asset.id}.png`); +} + +async function fetchJson(url) { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch ${url}: ${response.status}`); + } + return response.json(); +} + +async function fileExistsWithSize(path, byteSize) { + try { + const info = await stat(path); + return info.size === byteSize; + } catch { + return false; + } +} + +async function downloadFile(url, path, byteSize) { + if (await fileExistsWithSize(path, byteSize)) { + return "skipped"; + } + + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to download ${url}: ${response.status}`); + } + + const bytes = new Uint8Array(await response.arrayBuffer()); + if (bytes.byteLength !== byteSize) { + throw new Error( + `Downloaded ${url} with ${bytes.byteLength} bytes, expected ${byteSize}.`, + ); + } + + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, bytes); + return "downloaded"; +} + +async function runFfmpeg(args) { + await new Promise((resolve, reject) => { + const child = spawn("ffmpeg", args, { + stdio: ["ignore", "ignore", "pipe"], + }); + let stderr = ""; + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + child.on("error", reject); + child.on("close", (code) => { + if (code === 0) { + resolve(); + } else { + reject( + new Error( + `ffmpeg exited with ${code}: ${stderr.trim() || "no stderr"}`, + ), + ); + } + }); + }); +} + +async function ensurePoster(asset) { + const posterPath = posterOutputPath(asset); + try { + await stat(posterPath); + return "skipped"; + } catch { + // Generate it below. + } + + await mkdir(dirname(posterPath), { recursive: true }); + await runFfmpeg([ + "-hide_banner", + "-loglevel", + "error", + "-y", + "-i", + variantOutputPath(asset, "webm"), + "-frames:v", + "1", + posterPath, + ]); + return "generated"; +} + +async function main() { + const latest = await fetchJson(LATEST_URL); + const manifest = await fetchJson( + `${ARTIFACTORY_BASE}/${latest.manifestPath}`, + ); + const versionRoot = `${ARTIFACTORY_BASE}/${manifest.catalogVersion}`; + + await mkdir(outputRoot, { recursive: true }); + await writeFile(catalogPath, `${JSON.stringify(manifest, null, 2)}\n`); + + const totals = { + downloaded: 0, + skipped: 0, + postersGenerated: 0, + postersSkipped: 0, + }; + + for (const [index, asset] of manifest.assets.entries()) { + for (const format of FORMATS) { + const variant = asset.variants[format]; + const sourceUrl = `${versionRoot}/${variant.path}`; + const result = await downloadFile( + sourceUrl, + variantOutputPath(asset, format), + variant.byteSize, + ); + totals[result] += 1; + } + + const posterResult = await ensurePoster(asset); + if (posterResult === "generated") { + totals.postersGenerated += 1; + } else { + totals.postersSkipped += 1; + } + + const completed = index + 1; + if (completed % 5 === 0 || completed === manifest.assets.length) { + console.log( + `Synced ${completed}/${manifest.assets.length} Goose avatars...`, + ); + } + } + + const catalogBytes = await readFile(catalogPath, "utf8"); + JSON.parse(catalogBytes); + + console.log( + `Done. Downloaded ${totals.downloaded}, skipped ${totals.skipped}, generated ${totals.postersGenerated} posters, skipped ${totals.postersSkipped} posters.`, + ); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index 1c8b9925f..261f501a6 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -10,13 +10,40 @@ use crate::{ 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, + try_regenerate_nest, AgentModelInfo, AgentModelsResponse, ManagedAgentRecord, + UpdateManagedAgentRequest, UpdateManagedAgentResponse, }, relay::{relay_ws_url_with_override, sync_managed_agent_profile}, util::now_iso, }; +fn trim_optional(value: Option) -> Option { + value + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) +} + +fn is_persona_runtime_avatar(record: &ManagedAgentRecord, avatar_url: &str) -> bool { + record.persona_id.is_some() + && managed_agent_avatar_url(&record.agent_command) + .as_deref() + .is_some_and(|runtime_avatar_url| runtime_avatar_url == avatar_url.trim()) +} + +fn profile_sync_avatar_url(record: &ManagedAgentRecord) -> Option { + record + .avatar_url + .clone() + .filter(|avatar_url| !is_persona_runtime_avatar(record, avatar_url)) + .or_else(|| { + if record.persona_id.is_none() { + managed_agent_avatar_url(&record.agent_command) + } else { + None + } + }) +} + /// Query available models from an agent via `buzz-acp models --json`. /// /// Spawns a short-lived subprocess (no relay connection needed). The subprocess @@ -139,7 +166,8 @@ pub async fn get_agent_models( /// /// Does NOT auto-restart the agent. Runtime config changes (system prompt, /// parallelism, commands, toolsets) take effect on the next agent spawn. -/// Name changes are synced to the relay immediately via a kind:0 re-publish. +/// Name and avatar changes are synced to the relay immediately via a kind:0 +/// re-publish. #[tauri::command] pub async fn update_managed_agent( input: UpdateManagedAgentRequest, @@ -162,6 +190,7 @@ pub async fn update_managed_agent( let record = find_managed_agent_mut(&mut records, &input.pubkey)?; let mut name_changed = false; + let mut avatar_changed = false; if let Some(name_update) = input.name { let trimmed = name_update.trim().to_string(); if !trimmed.is_empty() && trimmed != record.name { @@ -169,6 +198,15 @@ pub async fn update_managed_agent( name_changed = true; } } + if let Some(avatar_update) = input.avatar_url { + let normalized = trim_optional(avatar_update); + let avatar_url_cleared = normalized.is_none(); + if normalized != record.avatar_url || avatar_url_cleared != record.avatar_url_cleared { + record.avatar_url = normalized; + record.avatar_url_cleared = avatar_url_cleared; + avatar_changed = true; + } + } if let Some(model_update) = input.model { record.model = model_update; } @@ -245,15 +283,16 @@ pub async fn update_managed_agent( .find(|r| r.pubkey == input.pubkey) .ok_or_else(|| format!("agent {} not found", input.pubkey))?; - let sync_params = if name_changed { + let sync_params = if name_changed || avatar_changed { let agent_keys = Keys::parse(&record.private_key_nsec) .map_err(|e| format!("failed to parse agent keys: {e}"))?; let relay_url = record.relay_url.clone(); let display_name = record.name.clone(); - let avatar_url = record - .avatar_url - .clone() - .or_else(|| managed_agent_avatar_url(&record.agent_command)); + let avatar_url = if avatar_changed || record.avatar_url_cleared { + record.avatar_url.clone() + } else { + profile_sync_avatar_url(record) + }; let auth_tag = record.auth_tag.clone(); Some((agent_keys, relay_url, display_name, avatar_url, auth_tag)) } else { diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index dc64dc826..8d65b3ea8 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -60,6 +60,7 @@ fn resolve_created_avatar_url( requested_avatar_url: Option<&str>, persona_avatar_url: Option, agent_command: &str, + use_command_fallback: bool, ) -> Option { requested_avatar_url .and_then(trim_to_optional_string) @@ -68,7 +69,35 @@ fn resolve_created_avatar_url( .as_deref() .and_then(trim_to_optional_string) }) - .or_else(|| managed_agent_avatar_url(agent_command)) + .or_else(|| { + if use_command_fallback { + managed_agent_avatar_url(agent_command) + } else { + None + } + }) +} + +fn is_retired_fizz_data_url(persona_id: Option<&str>, avatar_url: &str) -> bool { + persona_id == Some("builtin:fizz") && avatar_url.trim_start().starts_with("data:image/") +} + +fn is_command_avatar_for_persona( + persona_id: Option<&str>, + agent_command: &str, + avatar_url: &str, +) -> bool { + persona_id.is_some() + && managed_agent_avatar_url(agent_command) + .as_deref() + .is_some_and(|command_avatar_url| command_avatar_url == avatar_url.trim()) +} + +fn filter_retired_fizz_avatar( + persona_id: Option<&str>, + avatar_url: Option, +) -> Option { + avatar_url.filter(|url| !is_retired_fizz_data_url(persona_id, url)) } #[cfg(feature = "mesh-llm")] @@ -511,6 +540,7 @@ pub async fn create_managed_agent( input.avatar_url.as_deref(), persona_avatar_url, &agent_command, + requested_persona_id.is_none(), ); let record = crate::managed_agents::ManagedAgentRecord { @@ -521,6 +551,7 @@ pub async fn create_managed_agent( auth_tag: auth_tag.clone(), relay_url: resolved_relay_url.clone(), avatar_url: resolved_avatar_url.clone(), + avatar_url_cleared: false, acp_command: input .acp_command .as_deref() @@ -732,10 +763,11 @@ pub(crate) struct ProfileReconcileData { pub(crate) private_key_nsec: String, pub(crate) name: String, pub(crate) relay_url: String, - /// Expected avatar URL for the published profile. `None` for legacy records - /// that predate the `avatar_url` field — these will be backfilled from the - /// relay's existing kind:0 profile on first reconciliation. + /// Expected avatar URL for the published profile. `None` can mean either a + /// legacy missing value or an explicit clear; `avatar_url_cleared` + /// disambiguates those cases. pub(crate) avatar_url: Option, + pub(crate) avatar_url_cleared: bool, pub(crate) auth_tag: Option, /// The agent's pubkey (hex). Needed to update the persisted record during /// avatar backfill migration. @@ -791,6 +823,7 @@ pub async fn start_managed_agent( name: record.name.clone(), relay_url: record.relay_url.clone(), avatar_url: record.avatar_url.clone(), + avatar_url_cleared: record.avatar_url_cleared, auth_tag: record.auth_tag.clone(), pubkey: record.pubkey.clone(), agent_command: record.agent_command.clone(), @@ -889,25 +922,41 @@ fn resolve_legacy_avatar( persona_avatar: Option, relay_picture: Option, agent_command: &str, + use_command_fallback: bool, ) -> String { persona_avatar .or(relay_picture) - .or_else(|| managed_agent_avatar_url(agent_command)) + .or_else(|| { + if use_command_fallback { + managed_agent_avatar_url(agent_command) + } else { + None + } + }) .unwrap_or_default() } +fn should_skip_legacy_command_avatar( + stored_avatar_was_retired_fizz: bool, + relay_picture_was_retired_fizz: bool, + persona_avatar: Option<&str>, + relay_picture: Option<&str>, +) -> bool { + (stored_avatar_was_retired_fizz || relay_picture_was_retired_fizz) + && persona_avatar.is_none() + && relay_picture.is_none() +} + /// Reconcile an agent's kind:0 profile on the relay. /// /// Queries the relay for the agent's existing profile and re-publishes if missing -/// or stale (display_name or picture mismatch). This is fire-and-forget — errors -/// are returned to the caller for logging but never block agent startup. +/// or stale. This is fire-and-forget — errors are returned to the caller for +/// logging but never block agent startup. /// /// For legacy records (pre-PR-921) where `avatar_url` is `None`, this function /// backfills via `resolve_legacy_avatar` — preferring the persona record's avatar /// over the relay's `picture`, since the old code may have corrupted the relay /// profile — and persists the updated record. After backfill, normal -/// reconciliation proceeds. -/// /// Query and publish both target the agent's stored `relay_url` so that, under /// an active workspace relay override, reconciliation reads and writes the same /// relay the agent's profile actually lives on. @@ -922,50 +971,116 @@ pub(crate) async fn reconcile_agent_profile( // Query the relay for the agent's existing kind:0 profile. let existing = query_agent_profile(state, &data.relay_url, agent_pubkey).await?; - // Resolve the expected avatar — backfilling for legacy records that have no - // stored avatar_url yet. - let expected_avatar = match data.avatar_url.as_deref() { - Some(url) => url.to_string(), - None => { - // Legacy record: the relay profile may have been corrupted by the - // old reconciliation code (it overwrote the persona avatar with the - // command default), so the persona record is the authoritative source. - let persona_avatar = data.persona_id.as_ref().and_then(|pid| { - load_personas(app) - .ok()? - .into_iter() - .find(|p| p.id == *pid)? - .avatar_url - }); - - let backfilled = resolve_legacy_avatar( - persona_avatar, - existing.as_ref().and_then(|info| info.picture.clone()), - &data.agent_command, - ); + // Resolve the expected avatar. A user-initiated clear is intentionally + // `None` and must not be backfilled from persona/relay/runtime defaults. + // For legacy records that have no stored avatar_url yet, `None` still means + // backfill from the best available historical source. + let stored_avatar = + filter_retired_fizz_avatar(data.persona_id.as_deref(), data.avatar_url.clone()); + let stored_avatar_was_retired_fizz = data + .avatar_url + .as_deref() + .is_some_and(|url| is_retired_fizz_data_url(data.persona_id.as_deref(), url)); + let stored_avatar_was_command_fallback = stored_avatar.as_deref().is_some_and(|url| { + is_command_avatar_for_persona(data.persona_id.as_deref(), &data.agent_command, url) + }); + let stored_avatar = stored_avatar.filter(|url| { + !is_command_avatar_for_persona(data.persona_id.as_deref(), &data.agent_command, url) + }); + let expected_avatar = if data.avatar_url_cleared && stored_avatar.is_none() { + None + } else { + match stored_avatar { + Some(url) => Some(url.to_string()), + None => { + // Legacy record: the relay profile may have been corrupted by the + // old reconciliation code (it overwrote the persona avatar with the + // command default), so the persona record is the authoritative source. + let persona_avatar = filter_retired_fizz_avatar( + data.persona_id.as_deref(), + data.persona_id.as_ref().and_then(|pid| { + load_personas(app) + .ok()? + .into_iter() + .find(|p| p.id == *pid)? + .avatar_url + }), + ); + let relay_picture_raw = existing.as_ref().and_then(|info| info.picture.clone()); + let relay_picture_was_retired_fizz = relay_picture_raw + .as_deref() + .is_some_and(|url| is_retired_fizz_data_url(data.persona_id.as_deref(), url)); + let relay_picture = + filter_retired_fizz_avatar(data.persona_id.as_deref(), relay_picture_raw); + let relay_picture_was_command_fallback = + relay_picture.as_deref().is_some_and(|url| { + is_command_avatar_for_persona( + data.persona_id.as_deref(), + &data.agent_command, + url, + ) + }); + let relay_picture = relay_picture.filter(|url| { + !is_command_avatar_for_persona( + data.persona_id.as_deref(), + &data.agent_command, + url, + ) + }); + + let skip_command_fallback = should_skip_legacy_command_avatar( + stored_avatar_was_retired_fizz, + relay_picture_was_retired_fizz, + persona_avatar.as_deref(), + relay_picture.as_deref(), + ); + let backfilled = if skip_command_fallback { + String::new() + } else { + resolve_legacy_avatar( + persona_avatar, + relay_picture, + &data.agent_command, + data.persona_id.is_none(), + ) + }; + + // Persist the backfilled avatar so this migration only runs once, + // or clear the retired built-in Fizz data URL if there is no + // current profile image to backfill. + let should_persist_avatar = stored_avatar_was_retired_fizz + || relay_picture_was_retired_fizz + || stored_avatar_was_command_fallback + || relay_picture_was_command_fallback + || (!backfilled.is_empty() + && data.avatar_url.as_deref() != Some(backfilled.as_str())); + if should_persist_avatar { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let mut records = load_managed_agents(app)?; + if let Some(record) = records.iter_mut().find(|r| r.pubkey == data.pubkey) { + record.avatar_url = if backfilled.is_empty() { + None + } else { + Some(backfilled.clone()) + }; + record.avatar_url_cleared = backfilled.is_empty(); + save_managed_agents(app, &records)?; + } + } - // Persist the backfilled avatar so this migration only runs once. - if !backfilled.is_empty() { - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|e| e.to_string())?; - let mut records = load_managed_agents(app)?; - if let Some(record) = records.iter_mut().find(|r| r.pubkey == data.pubkey) { - record.avatar_url = Some(backfilled.clone()); - save_managed_agents(app, &records)?; + if backfilled.is_empty() { + None + } else { + Some(backfilled) } } - - backfilled } }; - if expected_avatar.is_empty() { - return Ok(()); - } - - if !profile_needs_sync(existing.as_ref(), &data.name, Some(&expected_avatar)) { + if !profile_needs_sync(existing.as_ref(), &data.name, expected_avatar.as_deref()) { return Ok(()); } @@ -977,7 +1092,7 @@ pub(crate) async fn reconcile_agent_profile( &data.relay_url, &agent_keys, &data.name, - Some(&expected_avatar), + expected_avatar.as_deref(), data.auth_tag.as_deref(), ) .await diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index f141c6ae5..a13be10ff 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -48,6 +48,7 @@ fn created_avatar_prefers_explicit_input() { Some(" https://x/input.png "), Some("https://x/persona.png".to_string()), "goose", + true, ); assert_eq!(resolved.as_deref(), Some("https://x/input.png")); @@ -55,8 +56,12 @@ fn created_avatar_prefers_explicit_input() { #[test] fn created_avatar_uses_persona_before_command_fallback() { - let resolved = - resolve_created_avatar_url(None, Some(" https://x/persona.png ".to_string()), "goose"); + let resolved = resolve_created_avatar_url( + None, + Some(" https://x/persona.png ".to_string()), + "goose", + true, + ); assert_eq!(resolved.as_deref(), Some("https://x/persona.png")); } @@ -65,11 +70,45 @@ fn created_avatar_uses_persona_before_command_fallback() { fn created_avatar_uses_command_fallback_without_input_or_persona() { use crate::managed_agents::managed_agent_avatar_url; - let resolved = resolve_created_avatar_url(None, None, "goose"); + let resolved = resolve_created_avatar_url(None, None, "goose", true); assert_eq!(resolved, managed_agent_avatar_url("goose")); } +#[test] +fn created_persona_avatar_does_not_use_command_fallback() { + let resolved = resolve_created_avatar_url(None, None, "goose", false); + + assert_eq!(resolved, None); +} + +#[test] +fn retired_fizz_data_url_is_treated_as_absent() { + assert_eq!( + filter_retired_fizz_avatar( + Some("builtin:fizz"), + Some("data:image/png;base64,old-demo".to_string()), + ), + None, + ); + assert_eq!( + filter_retired_fizz_avatar( + Some("custom:fizz"), + Some("data:image/png;base64,user-avatar".to_string()), + ) + .as_deref(), + Some("data:image/png;base64,user-avatar"), + ); + assert_eq!( + filter_retired_fizz_avatar( + Some("builtin:fizz"), + Some("https://relay.example/avatar.png".to_string()), + ) + .as_deref(), + Some("https://relay.example/avatar.png"), + ); +} + fn profile(name: Option<&str>, picture: Option<&str>) -> crate::relay::AgentProfileInfo { crate::relay::AgentProfileInfo { display_name: name.map(str::to_string), @@ -142,6 +181,7 @@ fn legacy_avatar_prefers_persona_over_corrupted_relay_picture() { Some("https://x/persona.png".to_string()), Some("https://x/default-icon.png".to_string()), "goose", + false, ); assert_eq!(resolved, "https://x/persona.png"); @@ -149,7 +189,12 @@ fn legacy_avatar_prefers_persona_over_corrupted_relay_picture() { #[test] fn legacy_avatar_falls_back_to_relay_picture_without_persona() { - let resolved = resolve_legacy_avatar(None, Some("https://x/relay.png".to_string()), "goose"); + let resolved = resolve_legacy_avatar( + None, + Some("https://x/relay.png".to_string()), + "goose", + false, + ); assert_eq!(resolved, "https://x/relay.png"); } @@ -158,14 +203,69 @@ fn legacy_avatar_falls_back_to_relay_picture_without_persona() { fn legacy_avatar_falls_back_to_command_icon_when_no_persona_or_relay() { use crate::managed_agents::managed_agent_avatar_url; - let resolved = resolve_legacy_avatar(None, None, "goose"); + let resolved = resolve_legacy_avatar(None, None, "goose", true); assert_eq!(resolved, managed_agent_avatar_url("goose").unwrap()); } #[test] fn legacy_avatar_empty_when_nothing_resolves() { - let resolved = resolve_legacy_avatar(None, None, "totally-unknown-command"); + let resolved = resolve_legacy_avatar(None, None, "totally-unknown-command", true); assert!(resolved.is_empty()); } + +#[test] +fn legacy_persona_avatar_does_not_use_command_fallback() { + let resolved = resolve_legacy_avatar(None, None, "goose", false); + + assert!(resolved.is_empty()); +} + +#[test] +fn detects_command_avatar_for_persona_agents() { + let command_avatar = crate::managed_agents::managed_agent_avatar_url("goose") + .expect("goose avatar should resolve"); + + assert!(is_command_avatar_for_persona( + Some("builtin:fizz"), + "goose", + &command_avatar, + )); + assert!(!is_command_avatar_for_persona( + None, + "goose", + &command_avatar, + )); + assert!(!is_command_avatar_for_persona( + Some("builtin:fizz"), + "goose", + "https://x/fizz.png", + )); +} + +#[test] +fn legacy_avatar_skips_command_icon_for_retired_stored_fizz_avatar() { + assert!(should_skip_legacy_command_avatar(true, false, None, None)); +} + +#[test] +fn legacy_avatar_skips_command_icon_for_retired_relay_fizz_avatar() { + assert!(should_skip_legacy_command_avatar(false, true, None, None)); +} + +#[test] +fn legacy_avatar_keeps_command_icon_when_retired_fizz_has_current_avatar_source() { + assert!(!should_skip_legacy_command_avatar( + false, + true, + Some("https://x/persona.png"), + None, + )); + assert!(!should_skip_legacy_command_avatar( + false, + true, + None, + Some("https://x/relay.png"), + )); +} diff --git a/desktop/src-tauri/src/commands/personas.rs b/desktop/src-tauri/src/commands/personas.rs index 56f6742f4..db8df524e 100644 --- a/desktop/src-tauri/src/commands/personas.rs +++ b/desktop/src-tauri/src/commands/personas.rs @@ -298,9 +298,11 @@ pub fn parse_persona_files( }); } - // .persona.md: YAML frontmatter starts with "---" + // Persona markdown: YAML frontmatter starts with "---". + // Goose Internal exports plain .md agent files, while Buzz historically + // used .persona.md; parse both through the same validated importer. let lower_name = file_name.to_ascii_lowercase(); - if lower_name.ends_with(".persona.md") { + if lower_name.ends_with(".md") { if file_bytes.len() > MAX_JSON_BYTES { return Err("Markdown file is too large (max 5 MB).".to_string()); } @@ -312,17 +314,7 @@ pub fn parse_persona_files( }); } - // If it's a .md file but not .persona.md, give a specific hint. - if lower_name.ends_with(".md") { - return Err( - "Only .persona.md files are supported. Rename to .persona.md".to_string(), - ); - } - - Err( - "Unsupported file format. Expected .persona.md, .persona.png, .persona.json, or .zip" - .to_string(), - ) + Err("Unsupported file format. Expected .md, .persona.png, .persona.json, or .zip".to_string()) } #[tauri::command] @@ -373,3 +365,31 @@ pub async fn export_persona_to_json( let filename = format!("{slug}.persona.json"); save_json_with_dialog(&app, &filename, &json_bytes).await } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_persona_files_accepts_plain_md_with_avatar_ref() { + let md = br#"--- +name: fizz +display_name: Fizz +avatar: app-avatar:pollies-12 +runtime: goose +--- +You are Fizz. +"#; + + let result = parse_persona_files(md.to_vec(), "fizz.md".to_string()).unwrap(); + + assert_eq!(result.personas.len(), 1); + assert!(result.skipped.is_empty()); + assert_eq!(result.personas[0].display_name, "Fizz"); + assert_eq!( + result.personas[0].avatar_ref.as_deref(), + Some("app-avatar:pollies-12") + ); + assert_eq!(result.personas[0].source_file, "fizz.md"); + } +} diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index 8b1f3c62d..b21d965c8 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -964,6 +964,7 @@ mod tests { auth_tag: None, relay_url: String::new(), avatar_url: None, + avatar_url_cleared: false, acp_command: String::new(), agent_command: String::new(), agent_args: vec![], diff --git a/desktop/src-tauri/src/managed_agents/persona_card.rs b/desktop/src-tauri/src/managed_agents/persona_card.rs index 68d9b30a4..abb5803b6 100644 --- a/desktop/src-tauri/src/managed_agents/persona_card.rs +++ b/desktop/src-tauri/src/managed_agents/persona_card.rs @@ -1,6 +1,6 @@ use base64::{engine::general_purpose::STANDARD, Engine as _}; use png::Decoder; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use serde_json::Value; use std::io::{Cursor, Read}; @@ -13,6 +13,7 @@ pub struct ParsedPersonaPreview { pub display_name: String, pub system_prompt: String, pub avatar_data_url: Option, + pub avatar_ref: Option, pub runtime: Option, pub model: Option, pub provider: Option, @@ -81,6 +82,7 @@ pub fn parse_png_persona(png_bytes: &[u8]) -> Result Result Result { let content = - std::str::from_utf8(md_bytes).map_err(|e| format!("Invalid UTF-8 in .persona.md: {e}"))?; - let config = buzz_persona_pkg::persona::parse_persona_md(content) - .map_err(|e| format!("Failed to parse .persona.md: {e}"))?; - - // Split "provider:model" into separate fields for the preview. - let model = match config.model.as_deref() { - Some(s) if !s.is_empty() => { - let (_prov, id) = buzz_persona_pkg::persona::split_model(s); - Some(id.to_owned()) - } - _ => None, - }; + std::str::from_utf8(md_bytes).map_err(|e| format!("Invalid UTF-8 in Markdown: {e}"))?; + match buzz_persona_pkg::persona::parse_persona_md(content) { + Ok(config) => Ok(parsed_preview_from_md_config(config)), + Err(strict_err) => parse_lenient_md_persona(content) + .map_err(|_| format!("Failed to parse persona Markdown: {strict_err}")), + } +} - Ok(ParsedPersonaPreview { +fn parsed_preview_from_md_config( + config: buzz_persona_pkg::persona::PersonaConfig, +) -> ParsedPersonaPreview { + let (provider, model) = split_preview_model(config.model.as_deref()); + + ParsedPersonaPreview { display_name: config.display_name, system_prompt: config.prompt, - avatar_data_url: None, // .persona.md avatars are paths, not data URIs + avatar_data_url: None, // Markdown avatars are paths, not data URIs + avatar_ref: config.avatar, runtime: config.runtime, model, - provider: None, // .persona.md format does not carry llmProvider + provider, + name_pool: Vec::new(), + source_file: String::new(), + } +} + +fn split_preview_model(model: Option<&str>) -> (Option, Option) { + match model.map(str::trim).filter(|s| !s.is_empty()) { + Some(raw_model) => { + let (provider, id) = buzz_persona_pkg::persona::split_model(raw_model); + (provider.map(str::to_owned), Some(id.to_owned())) + } + None => (None, None), + } +} + +#[derive(Debug, Deserialize)] +struct LenientMdFrontmatter { + name: Option, + display_name: Option, + avatar: Option, + runtime: Option, + model: Option, +} + +fn parse_lenient_md_persona(content: &str) -> Result { + let (frontmatter, body) = buzz_persona_pkg::persona::split_frontmatter(content) + .map_err(|e| format!("Missing frontmatter: {e}"))?; + let fields: LenientMdFrontmatter = + serde_yaml::from_str(frontmatter).map_err(|e| format!("Invalid YAML frontmatter: {e}"))?; + let display_name = fields + .display_name + .or(fields.name) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .ok_or_else(|| "Missing display name".to_string())?; + let (provider, model) = split_preview_model(fields.model.as_deref()); + + Ok(ParsedPersonaPreview { + display_name, + system_prompt: body.to_string(), + avatar_data_url: None, // Markdown avatars are paths, not data URIs + avatar_ref: fields.avatar, + runtime: fields.runtime, + model, + provider, name_pool: Vec::new(), source_file: String::new(), }) @@ -405,6 +454,7 @@ pub fn parse_zip_pack(zip_bytes: &[u8]) -> Result Result Result Result Vec { - let mut buf = Vec::new(); - { - let mut enc = Encoder::new(Cursor::new(&mut buf), 1, 1); - enc.set_color(ColorType::Rgba); - enc.set_depth(BitDepth::Eight); - enc.add_text_chunk(keyword.to_string(), text.to_string()) - .unwrap(); - let mut w = enc.write_header().unwrap(); - w.write_image_data(&[0, 0, 0, 255]).unwrap(); - } - buf - } - - /// Helper: build a PNG with a buzz_persona_pkg tEXt chunk for the given name/prompt. - fn make_test_persona_png(name: &str, prompt: &str) -> Vec { - let payload = serde_json::json!({ - "version": 1, - "displayName": name, - "systemPrompt": prompt, - }); - let b64 = STANDARD.encode(payload.to_string().as_bytes()); - make_png_with_text("buzz_persona_pkg", &b64) - } - - /// Helper: build a plain PNG with no metadata. - fn make_plain_png() -> Vec { - let mut buf = Vec::new(); - { - let mut enc = Encoder::new(Cursor::new(&mut buf), 1, 1); - enc.set_color(ColorType::Rgba); - enc.set_depth(BitDepth::Eight); - let mut w = enc.write_header().unwrap(); - w.write_image_data(&[0, 0, 0, 255]).unwrap(); - } - buf - } - - /// Helper: create a ZIP from name→data pairs. - fn make_test_zip(entries: &[(&str, &[u8])]) -> Vec { - let mut buf = Cursor::new(Vec::new()); - let mut zip = ZipWriter::new(&mut buf); - let options = SimpleFileOptions::default(); - for (name, data) in entries { - zip.start_file(*name, options).unwrap(); - zip.write_all(data).unwrap(); - } - zip.finish().unwrap(); - buf.into_inner() - } - - #[test] - fn parse_png_round_trip() { - let png = make_test_persona_png("George Costanza", "You are George."); - let result = parse_png_persona(&png).unwrap(); - assert_eq!(result.display_name, "George Costanza"); - assert_eq!(result.system_prompt, "You are George."); - assert!(result - .avatar_data_url - .unwrap() - .starts_with("data:image/png;base64,")); - } - - #[test] - fn parse_png_no_metadata() { - let png = make_plain_png(); - let err = parse_png_persona(&png).unwrap_err(); - assert!(err.contains("doesn't contain persona data")); - } - - #[test] - fn parse_png_unknown_version() { - let payload = serde_json::json!({"version": 99, "displayName": "X", "systemPrompt": "Y"}); - let b64 = STANDARD.encode(payload.to_string().as_bytes()); - let png = make_png_with_text("buzz_persona_pkg", &b64); - let err = parse_png_persona(&png).unwrap_err(); - assert!(err.contains("Unsupported persona version")); - } - - #[test] - fn parse_png_malformed_base64() { - let png = make_png_with_text("buzz_persona_pkg", "!!!not-base64!!!"); - let err = parse_png_persona(&png).unwrap_err(); - assert!(err.contains("Invalid base64")); - } - - #[test] - fn parse_png_malformed_json() { - let b64 = STANDARD.encode(b"not json at all"); - let png = make_png_with_text("buzz_persona_pkg", &b64); - let err = parse_png_persona(&png).unwrap_err(); - assert!(err.contains("Invalid JSON")); - } - - #[test] - fn parse_png_empty_fields() { - let payload = serde_json::json!({"version": 1, "displayName": "", "systemPrompt": "Y"}); - let b64 = STANDARD.encode(payload.to_string().as_bytes()); - let png = make_png_with_text("buzz_persona_pkg", &b64); - let err = parse_png_persona(&png).unwrap_err(); - assert!(err.contains("displayName is empty")); - } - - #[test] - fn parse_png_chara_fallback() { - let chara = serde_json::json!({ - "spec": "chara_card_v2", - "spec_version": "2.0", - "data": { - "name": "Kramer", - "system_prompt": "You are Kramer.", - "description": "" - } - }); - let b64 = STANDARD.encode(chara.to_string().as_bytes()); - let png = make_png_with_text("chara", &b64); - let result = parse_png_persona(&png).unwrap(); - assert_eq!(result.display_name, "Kramer"); - assert_eq!(result.system_prompt, "You are Kramer."); - } - - #[test] - fn parse_png_chara_ignored_when_buzz_present() { - // Build a PNG with both buzz_persona_pkg and chara chunks. - let buzz = serde_json::json!({"version": 1, "displayName": "Buzz Name", "systemPrompt": "Buzz prompt"}); - let chara = serde_json::json!({ - "spec": "chara_card_v2", "spec_version": "2.0", - "data": {"name": "Chara Name", "system_prompt": "Chara prompt", "description": ""} - }); - let buzz_b64 = STANDARD.encode(buzz.to_string().as_bytes()); - let chara_b64 = STANDARD.encode(chara.to_string().as_bytes()); - - let mut buf = Vec::new(); - { - let mut enc = Encoder::new(Cursor::new(&mut buf), 1, 1); - enc.set_color(ColorType::Rgba); - enc.set_depth(BitDepth::Eight); - enc.add_text_chunk("buzz_persona_pkg".to_string(), buzz_b64) - .unwrap(); - enc.add_text_chunk("chara".to_string(), chara_b64).unwrap(); - let mut w = enc.write_header().unwrap(); - w.write_image_data(&[0, 0, 0, 255]).unwrap(); - } - - let result = parse_png_persona(&buf).unwrap(); - assert_eq!(result.display_name, "Buzz Name"); - assert_eq!(result.system_prompt, "Buzz prompt"); - } - - #[test] - fn parse_zip_valid_pack() { - let p1 = make_test_persona_png("Alice", "Prompt A"); - let p2 = make_test_persona_png("Bob", "Prompt B"); - let p3 = make_test_persona_png("Carol", "Prompt C"); - let zip = make_test_zip(&[("alice.png", &p1), ("bob.png", &p2), ("carol.png", &p3)]); - let result = parse_zip_personas(&zip).unwrap(); - assert_eq!(result.personas.len(), 3); - assert!(result.skipped.is_empty()); - assert_eq!(result.personas[0].source_file, "alice.png"); - } - - #[test] - fn parse_zip_mixed() { - let valid1 = make_test_persona_png("Alice", "Prompt A"); - let valid2 = make_test_persona_png("Bob", "Prompt B"); - let bad_png = make_plain_png(); // no metadata - let zip = make_test_zip(&[ - ("alice.png", &valid1), - ("bob.png", &valid2), - ("bad.png", &bad_png), - ("readme.txt", b"hello"), - ]); - let result = parse_zip_personas(&zip).unwrap(); - assert_eq!(result.personas.len(), 2); - assert_eq!(result.skipped.len(), 2); - } - - #[test] - fn parse_zip_no_pngs() { - let zip = make_test_zip(&[("readme.txt", b"hello"), ("data.csv", b"a,b")]); - let err = parse_zip_personas(&zip).unwrap_err(); - assert!(err.contains("No persona files found")); - } - - #[test] - fn parse_zip_exceeds_entry_limit() { - let png = make_test_persona_png("X", "Y"); - let entries: Vec<(String, &[u8])> = (0..51) - .map(|i| (format!("{i}.png"), png.as_slice())) - .collect(); - let refs: Vec<(&str, &[u8])> = entries.iter().map(|(n, d)| (n.as_str(), *d)).collect(); - let zip = make_test_zip(&refs); - let err = parse_zip_personas(&zip).unwrap_err(); - assert!(err.contains("too many entries")); - } - - #[test] - fn parse_zip_path_traversal() { - let valid = make_test_persona_png("Safe", "Prompt"); - let evil = make_test_persona_png("Evil", "Prompt"); - let zip = make_test_zip(&[("safe.png", &valid), ("../evil.png", &evil)]); - let result = parse_zip_personas(&zip).unwrap(); - assert_eq!(result.personas.len(), 1); - assert_eq!(result.skipped.len(), 1); - assert!(result.skipped[0].reason.contains("Path traversal")); - } - - #[test] - fn parse_png_duplicate_chunks() { - // Two buzz_persona_pkg chunks — should use the first and ignore the second. - let payload1 = - serde_json::json!({"version": 1, "displayName": "First", "systemPrompt": "Prompt 1"}); - let payload2 = - serde_json::json!({"version": 1, "displayName": "Second", "systemPrompt": "Prompt 2"}); - let b64_1 = STANDARD.encode(payload1.to_string().as_bytes()); - let b64_2 = STANDARD.encode(payload2.to_string().as_bytes()); - - let mut buf = Vec::new(); - { - let mut enc = Encoder::new(Cursor::new(&mut buf), 1, 1); - enc.set_color(ColorType::Rgba); - enc.set_depth(BitDepth::Eight); - enc.add_text_chunk("buzz_persona_pkg".to_string(), b64_1) - .unwrap(); - enc.add_text_chunk("buzz_persona_pkg".to_string(), b64_2) - .unwrap(); - let mut w = enc.write_header().unwrap(); - w.write_image_data(&[0, 0, 0, 255]).unwrap(); - } - - let result = parse_png_persona(&buf).unwrap(); - assert_eq!(result.display_name, "First"); - assert_eq!(result.system_prompt, "Prompt 1"); - } - - #[test] - fn parse_zip_exceeds_size_limit() { - // Create a ZIP with entries whose cumulative decompressed size exceeds 100MB. - let mut zip_buf = Cursor::new(Vec::new()); - { - let mut zip = ZipWriter::new(&mut zip_buf); - let options = SimpleFileOptions::default(); - zip.start_file("big.png", options).unwrap(); - let chunk = vec![0u8; 1024 * 1024]; // 1 MB - for _ in 0..101 { - zip.write_all(&chunk).unwrap(); - } - zip.finish().unwrap(); - } - let zip_bytes = zip_buf.into_inner(); - let err = parse_zip_personas(&zip_bytes).unwrap_err(); - assert!(err.contains("exceeds 100MB")); - } - - #[test] - fn parse_json_round_trip() { - let bytes = encode_persona_json( - "Ada Lovelace", - "You are Ada.", - Some("https://example.com/ada.png"), - None, - None, - None, - &[], - ) - .unwrap(); - let result = parse_json_persona(&bytes).unwrap(); - assert_eq!(result.display_name, "Ada Lovelace"); - assert_eq!(result.system_prompt, "You are Ada."); - assert_eq!( - result.avatar_data_url.as_deref(), - Some("https://example.com/ada.png") - ); - assert!(result.source_file.is_empty()); - } - - #[test] - fn parse_json_round_trip_no_avatar() { - let bytes = - encode_persona_json("Bob", "You are Bob.", None, None, None, None, &[]).unwrap(); - let result = parse_json_persona(&bytes).unwrap(); - assert_eq!(result.display_name, "Bob"); - assert_eq!(result.system_prompt, "You are Bob."); - assert!(result.avatar_data_url.is_none()); - } - - #[test] - fn parse_json_round_trip_data_uri_avatar() { - let data_uri = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg=="; - let bytes = encode_persona_json( - "Carol", - "You are Carol.", - Some(data_uri), - None, - None, - None, - &[], - ) - .unwrap(); - let result = parse_json_persona(&bytes).unwrap(); - assert_eq!(result.display_name, "Carol"); - assert_eq!(result.avatar_data_url.as_deref(), Some(data_uri)); - } - - #[test] - fn parse_json_round_trip_with_runtime_and_model() { - let bytes = encode_persona_json( - "Agent Smith", - "You are an agent.", - None, - Some("goose"), - Some("claude-sonnet-4"), - None, - &[], - ) - .unwrap(); - let result = parse_json_persona(&bytes).unwrap(); - assert_eq!(result.display_name, "Agent Smith"); - assert_eq!(result.system_prompt, "You are an agent."); - assert!(result.avatar_data_url.is_none()); - assert_eq!(result.runtime.as_deref(), Some("goose")); - assert_eq!(result.model.as_deref(), Some("claude-sonnet-4")); - } - - #[test] - fn parse_json_round_trip_without_runtime_and_model() { - let bytes = - encode_persona_json("Bob", "You are Bob.", None, None, None, None, &[]).unwrap(); - let result = parse_json_persona(&bytes).unwrap(); - assert_eq!(result.display_name, "Bob"); - assert!(result.runtime.is_none()); - assert!(result.model.is_none()); - } - - #[test] - fn parse_json_backward_compat_no_runtime_model_fields() { - // Simulate a legacy persona JSON without runtime/model fields - let json = serde_json::json!({ - "version": 1, - "displayName": "Legacy Persona", - "systemPrompt": "Old school prompt" - }); - let bytes = serde_json::to_vec(&json).unwrap(); - let result = parse_json_persona(&bytes).unwrap(); - assert_eq!(result.display_name, "Legacy Persona"); - assert_eq!(result.system_prompt, "Old school prompt"); - assert!(result.runtime.is_none()); - assert!(result.model.is_none()); - } - - #[test] - fn parse_json_backward_compat_legacy_provider_key() { - // A JSON card written with the old "provider" key should still parse. - let json = serde_json::json!({ - "version": 1, - "displayName": "Legacy Agent", - "systemPrompt": "Old prompt", - "provider": "goose" - }); - let bytes = serde_json::to_vec(&json).unwrap(); - let result = parse_json_persona(&bytes).unwrap(); - assert_eq!(result.runtime.as_deref(), Some("goose")); - } - - #[test] - fn parse_json_invalid_version() { - let json = serde_json::json!({ - "version": 99, - "displayName": "X", - "systemPrompt": "Y" - }); - let bytes = serde_json::to_vec(&json).unwrap(); - let err = parse_json_persona(&bytes).unwrap_err(); - assert!(err.contains("Unsupported persona version")); - } - - #[test] - fn parse_json_empty_fields() { - let json_empty_name = serde_json::json!({ - "version": 1, - "displayName": "", - "systemPrompt": "Y" - }); - let err = parse_json_persona(&serde_json::to_vec(&json_empty_name).unwrap()).unwrap_err(); - assert!(err.contains("displayName is empty")); - - let json_empty_prompt = serde_json::json!({ - "version": 1, - "displayName": "X", - "systemPrompt": "" - }); - let err = parse_json_persona(&serde_json::to_vec(&json_empty_prompt).unwrap()).unwrap_err(); - assert!(err.contains("systemPrompt is empty")); - } - - #[test] - fn parse_json_malformed() { - let err = parse_json_persona(b"not json at all").unwrap_err(); - assert!(err.contains("Invalid JSON")); - } - - #[test] - fn parse_zip_with_json() { - let j1 = encode_persona_json("Alice", "Prompt A", None, None, None, None, &[]).unwrap(); - let j2 = encode_persona_json("Bob", "Prompt B", None, None, None, None, &[]).unwrap(); - let zip = make_test_zip(&[("alice.persona.json", &j1), ("bob.persona.json", &j2)]); - let result = parse_zip_personas(&zip).unwrap(); - assert_eq!(result.personas.len(), 2); - assert!(result.skipped.is_empty()); - assert_eq!(result.personas[0].display_name, "Alice"); - assert_eq!(result.personas[1].display_name, "Bob"); - } - - #[test] - fn parse_zip_mixed_png_and_json() { - let png = make_test_persona_png("PngPersona", "PNG prompt"); - let json = - encode_persona_json("JsonPersona", "JSON prompt", None, None, None, None, &[]).unwrap(); - let zip = make_test_zip(&[ - ("persona.png", &png), - ("persona.json", &json), - ("readme.txt", b"hello"), - ]); - let result = parse_zip_personas(&zip).unwrap(); - assert_eq!(result.personas.len(), 2); - // readme.txt should be skipped - assert_eq!(result.skipped.len(), 1); - assert!(result.skipped[0] - .reason - .contains("Not a .png, .json, or .persona.md file")); - } - - #[test] - fn parse_zip_ignores_macos_resource_forks() { - let j1 = - encode_persona_json("Frank", "You are Frank.", None, None, None, None, &[]).unwrap(); - let j2 = - encode_persona_json("Jackie", "You are Jackie.", None, None, None, None, &[]).unwrap(); - let zip = make_test_zip(&[ - ("frank-costanza.persona.json", &j1), - ("jackie-chiles.persona.json", &j2), - ("__MACOSX/._frank-costanza.persona.json", b"\x00\x05\x16"), - ("__MACOSX/._jackie-chiles.persona.json", b"\x00\x05\x16"), - ]); - let result = parse_zip_personas(&zip).unwrap(); - assert_eq!(result.personas.len(), 2); - // macOS resource forks should be silently ignored, not skipped with errors - assert!(result.skipped.is_empty()); - } -} +#[path = "persona_card_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/managed_agents/persona_card_tests.rs b/desktop/src-tauri/src/managed_agents/persona_card_tests.rs new file mode 100644 index 000000000..6bcf92481 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/persona_card_tests.rs @@ -0,0 +1,530 @@ +use super::*; +use png::{BitDepth, ColorType, Encoder}; +use std::io::Write; +use zip::write::{SimpleFileOptions, ZipWriter}; + +/// Helper: build a minimal valid PNG with a custom tEXt chunk. +fn make_png_with_text(keyword: &str, text: &str) -> Vec { + let mut buf = Vec::new(); + { + let mut enc = Encoder::new(Cursor::new(&mut buf), 1, 1); + enc.set_color(ColorType::Rgba); + enc.set_depth(BitDepth::Eight); + enc.add_text_chunk(keyword.to_string(), text.to_string()) + .unwrap(); + let mut w = enc.write_header().unwrap(); + w.write_image_data(&[0, 0, 0, 255]).unwrap(); + } + buf +} + +/// Helper: build a PNG with a buzz_persona_pkg tEXt chunk for the given name/prompt. +fn make_test_persona_png(name: &str, prompt: &str) -> Vec { + let payload = serde_json::json!({ + "version": 1, + "displayName": name, + "systemPrompt": prompt, + }); + let b64 = STANDARD.encode(payload.to_string().as_bytes()); + make_png_with_text("buzz_persona_pkg", &b64) +} + +/// Helper: build a plain PNG with no metadata. +fn make_plain_png() -> Vec { + let mut buf = Vec::new(); + { + let mut enc = Encoder::new(Cursor::new(&mut buf), 1, 1); + enc.set_color(ColorType::Rgba); + enc.set_depth(BitDepth::Eight); + let mut w = enc.write_header().unwrap(); + w.write_image_data(&[0, 0, 0, 255]).unwrap(); + } + buf +} + +/// Helper: create a ZIP from name→data pairs. +fn make_test_zip(entries: &[(&str, &[u8])]) -> Vec { + let mut buf = Cursor::new(Vec::new()); + let mut zip = ZipWriter::new(&mut buf); + let options = SimpleFileOptions::default(); + for (name, data) in entries { + zip.start_file(*name, options).unwrap(); + zip.write_all(data).unwrap(); + } + zip.finish().unwrap(); + buf.into_inner() +} + +#[test] +fn parse_png_round_trip() { + let png = make_test_persona_png("George Costanza", "You are George."); + let result = parse_png_persona(&png).unwrap(); + assert_eq!(result.display_name, "George Costanza"); + assert_eq!(result.system_prompt, "You are George."); + assert!(result + .avatar_data_url + .unwrap() + .starts_with("data:image/png;base64,")); +} + +#[test] +fn parse_png_no_metadata() { + let png = make_plain_png(); + let err = parse_png_persona(&png).unwrap_err(); + assert!(err.contains("doesn't contain persona data")); +} + +#[test] +fn parse_png_unknown_version() { + let payload = serde_json::json!({"version": 99, "displayName": "X", "systemPrompt": "Y"}); + let b64 = STANDARD.encode(payload.to_string().as_bytes()); + let png = make_png_with_text("buzz_persona_pkg", &b64); + let err = parse_png_persona(&png).unwrap_err(); + assert!(err.contains("Unsupported persona version")); +} + +#[test] +fn parse_png_malformed_base64() { + let png = make_png_with_text("buzz_persona_pkg", "!!!not-base64!!!"); + let err = parse_png_persona(&png).unwrap_err(); + assert!(err.contains("Invalid base64")); +} + +#[test] +fn parse_png_malformed_json() { + let b64 = STANDARD.encode(b"not json at all"); + let png = make_png_with_text("buzz_persona_pkg", &b64); + let err = parse_png_persona(&png).unwrap_err(); + assert!(err.contains("Invalid JSON")); +} + +#[test] +fn parse_png_empty_fields() { + let payload = serde_json::json!({"version": 1, "displayName": "", "systemPrompt": "Y"}); + let b64 = STANDARD.encode(payload.to_string().as_bytes()); + let png = make_png_with_text("buzz_persona_pkg", &b64); + let err = parse_png_persona(&png).unwrap_err(); + assert!(err.contains("displayName is empty")); +} + +#[test] +fn parse_png_chara_fallback() { + let chara = serde_json::json!({ + "spec": "chara_card_v2", + "spec_version": "2.0", + "data": { + "name": "Kramer", + "system_prompt": "You are Kramer.", + "description": "" + } + }); + let b64 = STANDARD.encode(chara.to_string().as_bytes()); + let png = make_png_with_text("chara", &b64); + let result = parse_png_persona(&png).unwrap(); + assert_eq!(result.display_name, "Kramer"); + assert_eq!(result.system_prompt, "You are Kramer."); +} + +#[test] +fn parse_png_chara_ignored_when_buzz_present() { + // Build a PNG with both buzz_persona_pkg and chara chunks. + let buzz = serde_json::json!({"version": 1, "displayName": "Buzz Name", "systemPrompt": "Buzz prompt"}); + let chara = serde_json::json!({ + "spec": "chara_card_v2", "spec_version": "2.0", + "data": {"name": "Chara Name", "system_prompt": "Chara prompt", "description": ""} + }); + let buzz_b64 = STANDARD.encode(buzz.to_string().as_bytes()); + let chara_b64 = STANDARD.encode(chara.to_string().as_bytes()); + + let mut buf = Vec::new(); + { + let mut enc = Encoder::new(Cursor::new(&mut buf), 1, 1); + enc.set_color(ColorType::Rgba); + enc.set_depth(BitDepth::Eight); + enc.add_text_chunk("buzz_persona_pkg".to_string(), buzz_b64) + .unwrap(); + enc.add_text_chunk("chara".to_string(), chara_b64).unwrap(); + let mut w = enc.write_header().unwrap(); + w.write_image_data(&[0, 0, 0, 255]).unwrap(); + } + + let result = parse_png_persona(&buf).unwrap(); + assert_eq!(result.display_name, "Buzz Name"); + assert_eq!(result.system_prompt, "Buzz prompt"); +} + +#[test] +fn parse_zip_valid_pack() { + let p1 = make_test_persona_png("Alice", "Prompt A"); + let p2 = make_test_persona_png("Bob", "Prompt B"); + let p3 = make_test_persona_png("Carol", "Prompt C"); + let zip = make_test_zip(&[("alice.png", &p1), ("bob.png", &p2), ("carol.png", &p3)]); + let result = parse_zip_personas(&zip).unwrap(); + assert_eq!(result.personas.len(), 3); + assert!(result.skipped.is_empty()); + assert_eq!(result.personas[0].source_file, "alice.png"); +} + +#[test] +fn parse_zip_mixed() { + let valid1 = make_test_persona_png("Alice", "Prompt A"); + let valid2 = make_test_persona_png("Bob", "Prompt B"); + let bad_png = make_plain_png(); // no metadata + let zip = make_test_zip(&[ + ("alice.png", &valid1), + ("bob.png", &valid2), + ("bad.png", &bad_png), + ("readme.txt", b"hello"), + ]); + let result = parse_zip_personas(&zip).unwrap(); + assert_eq!(result.personas.len(), 2); + assert_eq!(result.skipped.len(), 2); +} + +#[test] +fn parse_zip_no_pngs() { + let zip = make_test_zip(&[("readme.txt", b"hello"), ("data.csv", b"a,b")]); + let err = parse_zip_personas(&zip).unwrap_err(); + assert!(err.contains("No persona files found")); +} + +#[test] +fn parse_zip_exceeds_entry_limit() { + let png = make_test_persona_png("X", "Y"); + let entries: Vec<(String, &[u8])> = (0..51) + .map(|i| (format!("{i}.png"), png.as_slice())) + .collect(); + let refs: Vec<(&str, &[u8])> = entries.iter().map(|(n, d)| (n.as_str(), *d)).collect(); + let zip = make_test_zip(&refs); + let err = parse_zip_personas(&zip).unwrap_err(); + assert!(err.contains("too many entries")); +} + +#[test] +fn parse_zip_path_traversal() { + let valid = make_test_persona_png("Safe", "Prompt"); + let evil = make_test_persona_png("Evil", "Prompt"); + let zip = make_test_zip(&[("safe.png", &valid), ("../evil.png", &evil)]); + let result = parse_zip_personas(&zip).unwrap(); + assert_eq!(result.personas.len(), 1); + assert_eq!(result.skipped.len(), 1); + assert!(result.skipped[0].reason.contains("Path traversal")); +} + +#[test] +fn parse_png_duplicate_chunks() { + // Two buzz_persona_pkg chunks — should use the first and ignore the second. + let payload1 = + serde_json::json!({"version": 1, "displayName": "First", "systemPrompt": "Prompt 1"}); + let payload2 = + serde_json::json!({"version": 1, "displayName": "Second", "systemPrompt": "Prompt 2"}); + let b64_1 = STANDARD.encode(payload1.to_string().as_bytes()); + let b64_2 = STANDARD.encode(payload2.to_string().as_bytes()); + + let mut buf = Vec::new(); + { + let mut enc = Encoder::new(Cursor::new(&mut buf), 1, 1); + enc.set_color(ColorType::Rgba); + enc.set_depth(BitDepth::Eight); + enc.add_text_chunk("buzz_persona_pkg".to_string(), b64_1) + .unwrap(); + enc.add_text_chunk("buzz_persona_pkg".to_string(), b64_2) + .unwrap(); + let mut w = enc.write_header().unwrap(); + w.write_image_data(&[0, 0, 0, 255]).unwrap(); + } + + let result = parse_png_persona(&buf).unwrap(); + assert_eq!(result.display_name, "First"); + assert_eq!(result.system_prompt, "Prompt 1"); +} + +#[test] +fn parse_zip_exceeds_size_limit() { + // Create a ZIP with entries whose cumulative decompressed size exceeds 100MB. + let mut zip_buf = Cursor::new(Vec::new()); + { + let mut zip = ZipWriter::new(&mut zip_buf); + let options = SimpleFileOptions::default(); + zip.start_file("big.png", options).unwrap(); + let chunk = vec![0u8; 1024 * 1024]; // 1 MB + for _ in 0..101 { + zip.write_all(&chunk).unwrap(); + } + zip.finish().unwrap(); + } + let zip_bytes = zip_buf.into_inner(); + let err = parse_zip_personas(&zip_bytes).unwrap_err(); + assert!(err.contains("exceeds 100MB")); +} + +#[test] +fn parse_json_round_trip() { + let bytes = encode_persona_json( + "Ada Lovelace", + "You are Ada.", + Some("https://example.com/ada.png"), + None, + None, + None, + &[], + ) + .unwrap(); + let result = parse_json_persona(&bytes).unwrap(); + assert_eq!(result.display_name, "Ada Lovelace"); + assert_eq!(result.system_prompt, "You are Ada."); + assert_eq!( + result.avatar_data_url.as_deref(), + Some("https://example.com/ada.png") + ); + assert!(result.source_file.is_empty()); +} + +#[test] +fn parse_json_round_trip_no_avatar() { + let bytes = encode_persona_json("Bob", "You are Bob.", None, None, None, None, &[]).unwrap(); + let result = parse_json_persona(&bytes).unwrap(); + assert_eq!(result.display_name, "Bob"); + assert_eq!(result.system_prompt, "You are Bob."); + assert!(result.avatar_data_url.is_none()); +} + +#[test] +fn parse_json_round_trip_data_uri_avatar() { + let data_uri = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg=="; + let bytes = encode_persona_json( + "Carol", + "You are Carol.", + Some(data_uri), + None, + None, + None, + &[], + ) + .unwrap(); + let result = parse_json_persona(&bytes).unwrap(); + assert_eq!(result.display_name, "Carol"); + assert_eq!(result.avatar_data_url.as_deref(), Some(data_uri)); +} + +#[test] +fn parse_json_round_trip_with_runtime_and_model() { + let bytes = encode_persona_json( + "Agent Smith", + "You are an agent.", + None, + Some("goose"), + Some("claude-sonnet-4"), + None, + &[], + ) + .unwrap(); + let result = parse_json_persona(&bytes).unwrap(); + assert_eq!(result.display_name, "Agent Smith"); + assert_eq!(result.system_prompt, "You are an agent."); + assert!(result.avatar_data_url.is_none()); + assert_eq!(result.runtime.as_deref(), Some("goose")); + assert_eq!(result.model.as_deref(), Some("claude-sonnet-4")); +} + +#[test] +fn parse_json_round_trip_without_runtime_and_model() { + let bytes = encode_persona_json("Bob", "You are Bob.", None, None, None, None, &[]).unwrap(); + let result = parse_json_persona(&bytes).unwrap(); + assert_eq!(result.display_name, "Bob"); + assert!(result.runtime.is_none()); + assert!(result.model.is_none()); +} + +#[test] +fn parse_json_backward_compat_no_runtime_model_fields() { + // Simulate a legacy persona JSON without runtime/model fields + let json = serde_json::json!({ + "version": 1, + "displayName": "Legacy Persona", + "systemPrompt": "Old school prompt" + }); + let bytes = serde_json::to_vec(&json).unwrap(); + let result = parse_json_persona(&bytes).unwrap(); + assert_eq!(result.display_name, "Legacy Persona"); + assert_eq!(result.system_prompt, "Old school prompt"); + assert!(result.runtime.is_none()); + assert!(result.model.is_none()); +} + +#[test] +fn parse_json_backward_compat_legacy_provider_key() { + // A JSON card written with the old "provider" key should still parse. + let json = serde_json::json!({ + "version": 1, + "displayName": "Legacy Agent", + "systemPrompt": "Old prompt", + "provider": "goose" + }); + let bytes = serde_json::to_vec(&json).unwrap(); + let result = parse_json_persona(&bytes).unwrap(); + assert_eq!(result.runtime.as_deref(), Some("goose")); +} + +#[test] +fn parse_json_invalid_version() { + let json = serde_json::json!({ + "version": 99, + "displayName": "X", + "systemPrompt": "Y" + }); + let bytes = serde_json::to_vec(&json).unwrap(); + let err = parse_json_persona(&bytes).unwrap_err(); + assert!(err.contains("Unsupported persona version")); +} + +#[test] +fn parse_json_empty_fields() { + let json_empty_name = serde_json::json!({ + "version": 1, + "displayName": "", + "systemPrompt": "Y" + }); + let err = parse_json_persona(&serde_json::to_vec(&json_empty_name).unwrap()).unwrap_err(); + assert!(err.contains("displayName is empty")); + + let json_empty_prompt = serde_json::json!({ + "version": 1, + "displayName": "X", + "systemPrompt": "" + }); + let err = parse_json_persona(&serde_json::to_vec(&json_empty_prompt).unwrap()).unwrap_err(); + assert!(err.contains("systemPrompt is empty")); +} + +#[test] +fn parse_json_malformed() { + let err = parse_json_persona(b"not json at all").unwrap_err(); + assert!(err.contains("Invalid JSON")); +} + +#[test] +fn parse_md_persona_preserves_app_avatar_ref() { + let md = br#"--- +name: goosey +display_name: Goosey +description: Goose internal agent. +avatar: app-avatar:gloopies-19 +model: anthropic:claude-sonnet-4 +runtime: goose +--- +You are Goosey. +"#; + let result = parse_md_persona(md).unwrap(); + assert_eq!(result.display_name, "Goosey"); + assert_eq!(result.avatar_data_url, None); + assert_eq!(result.avatar_ref.as_deref(), Some("app-avatar:gloopies-19")); + assert_eq!(result.model.as_deref(), Some("claude-sonnet-4")); + assert_eq!(result.provider.as_deref(), Some("anthropic")); + assert_eq!(result.runtime.as_deref(), Some("goose")); +} + +#[test] +fn parse_lenient_md_persona_preserves_model_provider_prefix() { + let md = r#"--- +display_name: Lenient Agent +model: databricks:gpt-5 +runtime: goose +--- +You are lenient. +"#; + + let result = parse_lenient_md_persona(md).unwrap(); + assert_eq!(result.display_name, "Lenient Agent"); + assert_eq!(result.model.as_deref(), Some("gpt-5")); + assert_eq!(result.provider.as_deref(), Some("databricks")); + assert_eq!(result.runtime.as_deref(), Some("goose")); +} + +#[test] +fn parse_md_persona_accepts_goose_internal_frontmatter() { + let md = br#"--- +name: block.md +description: Opinionated guide to Block's intelligence operating model. +avatar: app-avatar:gloopies-19 +metadata: + gooseInternalBundled: true +--- +You are block.md. +"#; + let result = parse_md_persona(md).unwrap(); + assert_eq!(result.display_name, "block.md"); + assert_eq!(result.avatar_ref.as_deref(), Some("app-avatar:gloopies-19")); + assert_eq!(result.system_prompt, "You are block.md.\n"); +} + +#[test] +fn parse_zip_with_json() { + let j1 = encode_persona_json("Alice", "Prompt A", None, None, None, None, &[]).unwrap(); + let j2 = encode_persona_json("Bob", "Prompt B", None, None, None, None, &[]).unwrap(); + let zip = make_test_zip(&[("alice.persona.json", &j1), ("bob.persona.json", &j2)]); + let result = parse_zip_personas(&zip).unwrap(); + assert_eq!(result.personas.len(), 2); + assert!(result.skipped.is_empty()); + assert_eq!(result.personas[0].display_name, "Alice"); + assert_eq!(result.personas[1].display_name, "Bob"); +} + +#[test] +fn parse_zip_mixed_png_and_json() { + let png = make_test_persona_png("PngPersona", "PNG prompt"); + let json = + encode_persona_json("JsonPersona", "JSON prompt", None, None, None, None, &[]).unwrap(); + let zip = make_test_zip(&[ + ("persona.png", &png), + ("persona.json", &json), + ("readme.txt", b"hello"), + ]); + let result = parse_zip_personas(&zip).unwrap(); + assert_eq!(result.personas.len(), 2); + // readme.txt should be skipped + assert_eq!(result.skipped.len(), 1); + assert!(result.skipped[0] + .reason + .contains("Not a .png, .json, or .md file")); +} + +#[test] +fn parse_zip_with_plain_md_persona_preserves_avatar_ref() { + let md = br#"--- +name: fizz +display_name: Fizz +description: Engineering agent. +avatar: app-avatar:pollies-12 +runtime: goose +model: anthropic:claude-sonnet-4 +--- +You are Fizz. +"#; + let zip = make_test_zip(&[("fizz.md", md)]); + let result = parse_zip_personas(&zip).unwrap(); + assert_eq!(result.personas.len(), 1); + assert!(result.skipped.is_empty()); + assert_eq!(result.personas[0].display_name, "Fizz"); + assert_eq!( + result.personas[0].avatar_ref.as_deref(), + Some("app-avatar:pollies-12") + ); + assert_eq!(result.personas[0].source_file, "fizz.md"); +} + +#[test] +fn parse_zip_ignores_macos_resource_forks() { + let j1 = encode_persona_json("Frank", "You are Frank.", None, None, None, None, &[]).unwrap(); + let j2 = encode_persona_json("Jackie", "You are Jackie.", None, None, None, None, &[]).unwrap(); + let zip = make_test_zip(&[ + ("frank-costanza.persona.json", &j1), + ("jackie-chiles.persona.json", &j2), + ("__MACOSX/._frank-costanza.persona.json", b"\x00\x05\x16"), + ("__MACOSX/._jackie-chiles.persona.json", b"\x00\x05\x16"), + ]); + let result = parse_zip_personas(&zip).unwrap(); + assert_eq!(result.personas.len(), 2); + // macOS resource forks should be silently ignored, not skipped with errors + assert!(result.skipped.is_empty()); +} diff --git a/desktop/src-tauri/src/managed_agents/relay_mesh.rs b/desktop/src-tauri/src/managed_agents/relay_mesh.rs index 808546aa4..6924e5187 100644 --- a/desktop/src-tauri/src/managed_agents/relay_mesh.rs +++ b/desktop/src-tauri/src/managed_agents/relay_mesh.rs @@ -69,6 +69,7 @@ mod tests { auth_tag: Some("tag".into()), relay_url: "ws://localhost:3000".into(), avatar_url: None, + avatar_url_cleared: false, acp_command: "buzz-acp".into(), agent_command: "goose".into(), agent_args: vec![], diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs index 3469dc80d..410f0ed62 100644 --- a/desktop/src-tauri/src/managed_agents/restore.rs +++ b/desktop/src-tauri/src/managed_agents/restore.rs @@ -210,6 +210,7 @@ pub async fn restore_managed_agents_on_launch( name: record.name.clone(), relay_url: record.relay_url.clone(), avatar_url: record.avatar_url.clone(), + avatar_url_cleared: record.avatar_url_cleared, auth_tag: record.auth_tag.clone(), pubkey: record.pubkey.clone(), agent_command: record.agent_command.clone(), diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 328b888fd..3c6c2fbe5 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -1364,6 +1364,7 @@ pub fn build_managed_agent_summary( max_turn_duration_seconds: record.max_turn_duration_seconds, parallelism: record.parallelism, system_prompt: effective_prompt, + avatar_url: record.avatar_url.clone(), model: effective_model, mcp_toolsets: record.mcp_toolsets.clone(), env_vars: record.env_vars.clone(), diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index f8a922da5..6be96057c 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -130,6 +130,7 @@ fn fixture( auth_tag, relay_url: "ws://localhost:3000".into(), avatar_url: None, + avatar_url_cleared: false, acp_command: "buzz-acp".into(), agent_command: "goose".into(), agent_args: vec![], diff --git a/desktop/src-tauri/src/managed_agents/team_repair.rs b/desktop/src-tauri/src/managed_agents/team_repair.rs index d9e272066..002b0dfd7 100644 --- a/desktop/src-tauri/src/managed_agents/team_repair.rs +++ b/desktop/src-tauri/src/managed_agents/team_repair.rs @@ -279,6 +279,7 @@ mod tests { auth_tag: None, relay_url: String::new(), avatar_url: None, + avatar_url_cleared: false, acp_command: String::new(), agent_command: String::new(), agent_args: vec![], diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 51761a0f9..db6246020 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -104,6 +104,9 @@ pub struct ManagedAgentRecord { /// `#[serde(default)]` so pre-existing records deserialize as `None`. #[serde(default)] pub avatar_url: Option, + /// True when `avatar_url: None` came from an explicit user clear. + #[serde(default)] + pub avatar_url_cleared: bool, pub acp_command: String, pub agent_command: String, pub agent_args: Vec, @@ -230,6 +233,7 @@ pub struct ManagedAgentSummary { pub max_turn_duration_seconds: Option, pub parallelism: u32, pub system_prompt: Option, + pub avatar_url: Option, pub model: Option, pub mcp_toolsets: Option, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] @@ -424,6 +428,9 @@ pub struct UpdateManagedAgentRequest { /// Absent = don't touch. Present = rename the agent. #[serde(default)] pub name: Option, + /// Absent = don't touch. null = clear. "url" = set. + #[serde(default)] + pub avatar_url: Option>, /// Absent = don't touch. null = clear to agent default. "id" = set. #[serde(default)] pub model: Option>, @@ -685,7 +692,13 @@ mod tests { assert_eq!(record.auth_tag, None); assert_eq!(record.avatar_url, None); + assert!(!record.avatar_url_cleared); assert_eq!(record.pubkey, "abcd1234"); + + let mut value = serde_json::to_value(&record).expect("should serialize"); + value["avatar_url_cleared"] = true.into(); + let cleared: ManagedAgentRecord = serde_json::from_value(value).unwrap(); + assert!(cleared.avatar_url_cleared); } /// Agent records WITH an auth_tag round-trip correctly through serde. diff --git a/desktop/src/app/routes/channels.$channelId.tsx b/desktop/src/app/routes/channels.$channelId.tsx index 3c64f3ec9..433212ad3 100644 --- a/desktop/src/app/routes/channels.$channelId.tsx +++ b/desktop/src/app/routes/channels.$channelId.tsx @@ -1,13 +1,17 @@ import * as React from "react"; import { createFileRoute } from "@tanstack/react-router"; +import { + parseProfilePanelView, + type ProfilePanelView, +} from "@/features/profile/ui/UserProfilePanelUtils"; import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; type ChannelRouteSearch = { agentSession?: string; messageId?: string; profile?: string; - profileView?: "memories" | "channels"; + profileView?: Exclude; thread?: string; threadRootId?: string; }; @@ -16,8 +20,11 @@ function nonEmptyString(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } -function profileViewValue(value: unknown): "memories" | "channels" | undefined { - return value === "memories" || value === "channels" ? value : undefined; +function profileViewValue( + value: unknown, +): Exclude | undefined { + const view = parseProfilePanelView(value); + return view && view !== "summary" ? view : undefined; } function validateChannelSearch( diff --git a/desktop/src/app/routes/pulse.tsx b/desktop/src/app/routes/pulse.tsx index e1a5a001e..0fad54adc 100644 --- a/desktop/src/app/routes/pulse.tsx +++ b/desktop/src/app/routes/pulse.tsx @@ -1,6 +1,10 @@ import * as React from "react"; import { createFileRoute } from "@tanstack/react-router"; +import { + parseProfilePanelView, + type ProfilePanelView, +} from "@/features/profile/ui/UserProfilePanelUtils"; import { usePreviewFeatureWarning } from "@/shared/features"; import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; @@ -11,9 +15,16 @@ const PulseScreen = React.lazy(async () => { type PulseRouteSearch = { profile?: string; - profileView?: "memories" | "channels"; + profileView?: Exclude; }; +function profileViewValue( + value: unknown, +): Exclude | undefined { + const view = parseProfilePanelView(value); + return view && view !== "summary" ? view : undefined; +} + function validatePulseSearch( search: Record, ): PulseRouteSearch { @@ -22,10 +33,7 @@ function validatePulseSearch( typeof search.profile === "string" && search.profile.length > 0 ? search.profile : undefined, - profileView: - search.profileView === "memories" || search.profileView === "channels" - ? search.profileView - : undefined, + profileView: profileViewValue(search.profileView), }; } diff --git a/desktop/src/features/agents/agentReuse.test.mjs b/desktop/src/features/agents/agentReuse.test.mjs index 88b632490..4bee6eee3 100644 --- a/desktop/src/features/agents/agentReuse.test.mjs +++ b/desktop/src/features/agents/agentReuse.test.mjs @@ -6,19 +6,22 @@ import { parseTimestamp, pickPreferredManagedAgent, findReusablePersonaAgent, + findReusablePersonaAgentForRequest, findReusableGenericAgent, findReusableAgent, } from "./agentReuse.ts"; const PUB_A = "a".repeat(64); const PUB_B = "b".repeat(64); -const PUB_C = "c".repeat(64); function makeAgent(overrides = {}) { return { id: "agent-1", pubkey: PUB_A, agentCommand: "goose", + agentArgs: ["acp"], + mcpCommand: "", + backend: { type: "local" }, status: "running", personaId: null, systemPrompt: null, @@ -173,22 +176,19 @@ test("pickPreferredManagedAgent: undefined updatedAt treated as epoch 0", () => test("findReusablePersonaAgent: finds agent with matching personaId", () => { const agent = makeAgent({ personaId: "persona-1", pubkey: PUB_A }); - const channelMembers = new Set([PUB_B]); - const result = findReusablePersonaAgent([agent], "persona-1", channelMembers); + const result = findReusablePersonaAgent([agent], "persona-1"); assert.equal(result, agent); }); -test("findReusablePersonaAgent: excludes agent already in channel", () => { +test("findReusablePersonaAgent: reuses agent already in channel", () => { const agent = makeAgent({ personaId: "persona-1", pubkey: PUB_A }); - const channelMembers = new Set([PUB_A]); - const result = findReusablePersonaAgent([agent], "persona-1", channelMembers); - assert.equal(result, undefined); + const result = findReusablePersonaAgent([agent], "persona-1"); + assert.equal(result, agent); }); test("findReusablePersonaAgent: excludes agent with different personaId", () => { const agent = makeAgent({ personaId: "persona-2", pubkey: PUB_A }); - const channelMembers = new Set([PUB_B]); - const result = findReusablePersonaAgent([agent], "persona-1", channelMembers); + const result = findReusablePersonaAgent([agent], "persona-1"); assert.equal(result, undefined); }); @@ -207,22 +207,88 @@ test("findReusablePersonaAgent: prefers running agent", () => { status: "running", updatedAt: "2025-01-01T00:00:00Z", }); - const channelMembers = new Set([PUB_C]); - const result = findReusablePersonaAgent( - [stopped, running], - "p1", - channelMembers, - ); + const result = findReusablePersonaAgent([stopped, running], "p1"); assert.equal(result.id, "r"); }); -test("findReusablePersonaAgent: pubkey comparison is case-insensitive", () => { +test("findReusablePersonaAgent: channel membership does not affect reuse", () => { const agent = makeAgent({ personaId: "p1", pubkey: PUB_A.toUpperCase() }); const channelMembers = new Set([PUB_A]); - const result = findReusablePersonaAgent([agent], "p1", channelMembers); + const result = findReusableAgent([agent], channelMembers, { + personaId: "p1", + command: "goose", + }); + assert.equal(result, agent); +}); + +test("findReusablePersonaAgentForRequest: matches persona and requested local runtime", () => { + const agent = makeAgent({ personaId: "p1" }); + const result = findReusablePersonaAgentForRequest([agent], { + personaId: "p1", + command: "goose", + defaultArgs: ["acp"], + mcpCommand: null, + }); + assert.equal(result, agent); +}); + +test("findReusablePersonaAgentForRequest: rejects runtime command overrides", () => { + const agent = makeAgent({ personaId: "p1", agentCommand: "goose" }); + const result = findReusablePersonaAgentForRequest([agent], { + personaId: "p1", + command: "claude-acp", + defaultArgs: ["acp"], + mcpCommand: null, + }); assert.equal(result, undefined); }); +test("findReusablePersonaAgentForRequest: rejects runtime arg overrides", () => { + const agent = makeAgent({ personaId: "p1", agentArgs: ["acp"] }); + const result = findReusablePersonaAgentForRequest([agent], { + personaId: "p1", + command: "goose", + defaultArgs: ["acp", "--profile", "work"], + mcpCommand: null, + }); + assert.equal(result, undefined); +}); + +test("findReusablePersonaAgentForRequest: rejects backend overrides", () => { + const agent = makeAgent({ personaId: "p1", backend: { type: "local" } }); + const result = findReusablePersonaAgentForRequest([agent], { + personaId: "p1", + command: "goose", + defaultArgs: ["acp"], + mcpCommand: null, + backend: { type: "provider", id: "remote-a", config: {} }, + }); + assert.equal(result, undefined); +}); + +test("findReusablePersonaAgentForRequest: accepts equivalent provider backend config", () => { + const agent = makeAgent({ + personaId: "p1", + backend: { + type: "provider", + id: "remote-a", + config: { beta: true, alpha: { second: 2, first: 1 } }, + }, + }); + const result = findReusablePersonaAgentForRequest([agent], { + personaId: "p1", + command: "goose", + defaultArgs: ["acp"], + mcpCommand: "", + backend: { + type: "provider", + id: "remote-a", + config: { alpha: { first: 1, second: 2 }, beta: true }, + }, + }); + assert.equal(result, agent); +}); + // --- findReusableGenericAgent --- test("findReusableGenericAgent: finds agent with matching command and no persona/prompt", () => { diff --git a/desktop/src/features/agents/agentReuse.ts b/desktop/src/features/agents/agentReuse.ts index b0d800703..071b16f19 100644 --- a/desktop/src/features/agents/agentReuse.ts +++ b/desktop/src/features/agents/agentReuse.ts @@ -1,4 +1,4 @@ -import type { ManagedAgent } from "@/shared/api/types"; +import type { ManagedAgent, ManagedAgentBackend } from "@/shared/api/types"; /** Inline normalization — avoids runtime dependency on @/shared/lib/pubkey. */ function normalizePubkey(pubkey: string): string { @@ -49,16 +49,71 @@ export function pickPreferredManagedAgent(agents: ManagedAgent[]) { export function findReusablePersonaAgent( agents: ManagedAgent[], personaId: string, - channelMemberPubkeys: ReadonlySet, ): ManagedAgent | undefined { - const candidates = agents.filter( - (agent) => - agent.personaId === personaId && - !channelMemberPubkeys.has(normalizePubkey(agent.pubkey)), + const candidates = agents.filter((agent) => agent.personaId === personaId); + return pickPreferredManagedAgent(candidates); +} + +export type ReusablePersonaAgentRequest = { + personaId: string; + command: string; + defaultArgs: readonly string[]; + mcpCommand?: string | null; + backend?: ManagedAgentBackend; +}; + +export function findReusablePersonaAgentForRequest( + agents: ManagedAgent[], + request: ReusablePersonaAgentRequest, +): ManagedAgent | undefined { + const candidates = agents.filter((agent) => + reusablePersonaAgentMatchesRequest(agent, request), ); return pickPreferredManagedAgent(candidates); } +export function reusablePersonaAgentMatchesRequest( + agent: ManagedAgent, + request: ReusablePersonaAgentRequest, +): boolean { + return ( + agent.personaId === request.personaId && + managedAgentRuntimeMatchesRequest(agent, request) && + managedAgentBackendMatchesRequest(agent.backend, request.backend) + ); +} + +export function managedAgentRuntimeMatchesRequest( + agent: ManagedAgent, + request: Pick< + ReusablePersonaAgentRequest, + "command" | "defaultArgs" | "mcpCommand" + >, +) { + return ( + commandsMatch(agent.agentCommand, request.command) && + stringArraysEqual(agent.agentArgs, request.defaultArgs) && + normalizeOptionalCommand(agent.mcpCommand) === + normalizeOptionalCommand(request.mcpCommand) + ); +} + +export function managedAgentBackendMatchesRequest( + existing: ManagedAgentBackend, + requested: ManagedAgentBackend | undefined, +) { + const normalizedRequested = requested ?? { type: "local" as const }; + if (existing.type !== normalizedRequested.type) return false; + if (existing.type === "local") return true; + if (normalizedRequested.type !== "provider") return false; + + return ( + existing.id === normalizedRequested.id && + stableStringify(existing.config) === + stableStringify(normalizedRequested.config) + ); +} + export function findReusableGenericAgent( agents: ManagedAgent[], command: string, @@ -88,11 +143,7 @@ export function findReusableAgent( }, ): ManagedAgent | undefined { if (input.personaId) { - return findReusablePersonaAgent( - agents, - input.personaId, - channelMemberPubkeys, - ); + return findReusablePersonaAgent(agents, input.personaId); } if (!input.systemPrompt?.trim()) { return findReusableGenericAgent( @@ -103,3 +154,31 @@ export function findReusableAgent( } return undefined; } + +function stringArraysEqual(left: readonly string[], right: readonly string[]) { + if (left.length !== right.length) return false; + return left.every((value, index) => value === right[index]); +} + +function normalizeOptionalCommand(value: string | null | undefined) { + return value?.trim() ?? ""; +} + +function stableStringify(value: unknown): string { + return JSON.stringify(sortJsonLikeValue(value)); +} + +function sortJsonLikeValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(sortJsonLikeValue); + } + if (!value || typeof value !== "object") { + return value; + } + + return Object.fromEntries( + Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entry]) => [key, sortJsonLikeValue(entry)]), + ); +} diff --git a/desktop/src/features/agents/channelAgents.test.mjs b/desktop/src/features/agents/channelAgents.test.mjs new file mode 100644 index 000000000..691be6187 --- /dev/null +++ b/desktop/src/features/agents/channelAgents.test.mjs @@ -0,0 +1,110 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + respondToUpdateForReusedAgent, + runtimeUpdateForReusedAgent, +} from "./channelAgents.ts"; + +const PUBKEY = "a".repeat(64); + +function agent(overrides = {}) { + return { + id: "agent-1", + pubkey: PUBKEY, + name: "Reusable", + personaId: "persona-1", + relayUrl: "ws://localhost:3000", + acpCommand: "buzz-acp", + agentCommand: "goose", + agentArgs: [], + mcpCommand: "", + turnTimeoutSeconds: 320, + idleTimeoutSeconds: null, + maxTurnDurationSeconds: null, + parallelism: 1, + systemPrompt: null, + avatarUrl: null, + model: null, + mcpToolsets: null, + envVars: {}, + status: "running", + pid: null, + createdAt: new Date(0).toISOString(), + updatedAt: new Date(0).toISOString(), + lastStartedAt: null, + lastStoppedAt: null, + lastExitCode: null, + lastError: null, + logPath: null, + startOnAppLaunch: false, + backend: { type: "local" }, + backendAgentId: null, + respondTo: "owner-only", + respondToAllowlist: [], + ...overrides, + }; +} + +test("respondToUpdateForReusedAgent resets omitted mode to owner-only", () => { + assert.deepEqual( + respondToUpdateForReusedAgent( + agent({ + respondTo: "anyone", + respondToAllowlist: [PUBKEY], + }), + {}, + ), + { + respondTo: "owner-only", + respondToAllowlist: [], + }, + ); +}); + +test("respondToUpdateForReusedAgent leaves matching owner-only agents unchanged", () => { + assert.equal(respondToUpdateForReusedAgent(agent(), {}), null); +}); + +test("respondToUpdateForReusedAgent carries explicit allowlist choices", () => { + assert.deepEqual( + respondToUpdateForReusedAgent(agent(), { + respondTo: "allowlist", + respondToAllowlist: [PUBKEY], + }), + { + respondTo: "allowlist", + respondToAllowlist: [PUBKEY], + }, + ); +}); + +test("runtimeUpdateForReusedAgent leaves matching runtime unchanged", () => { + assert.equal( + runtimeUpdateForReusedAgent(agent({ agentArgs: ["acp"] }), { + id: "goose", + label: "Goose", + command: "goose", + defaultArgs: ["acp"], + mcpCommand: null, + }), + null, + ); +}); + +test("runtimeUpdateForReusedAgent returns command fields for runtime overrides", () => { + assert.deepEqual( + runtimeUpdateForReusedAgent(agent({ agentArgs: ["acp"] }), { + id: "claude", + label: "Claude Code", + command: "claude-acp", + defaultArgs: ["--mode", "acp"], + mcpCommand: "claude-mcp", + }), + { + agentCommand: "claude-acp", + agentArgs: ["--mode", "acp"], + mcpCommand: "claude-mcp", + }, + ); +}); diff --git a/desktop/src/features/agents/channelAgents.ts b/desktop/src/features/agents/channelAgents.ts index 4ce40ec41..12aa5ca3e 100644 --- a/desktop/src/features/agents/channelAgents.ts +++ b/desktop/src/features/agents/channelAgents.ts @@ -2,6 +2,8 @@ import { commandsMatch, findReusableGenericAgent, findReusablePersonaAgent, + managedAgentBackendMatchesRequest, + managedAgentRuntimeMatchesRequest, pickPreferredManagedAgent, } from "@/features/agents/agentReuse"; export { findReusableAgent } from "@/features/agents/agentReuse"; @@ -69,7 +71,7 @@ export type CreateChannelManagedAgentInput = { respondTo?: RespondToMode; /** Hex pubkeys for allowlist mode. */ respondToAllowlist?: string[]; - /** Skip reuse logic and always create a fresh agent instance. */ + /** Skip generic-agent reuse. Persona-backed agents always reuse by personaId. */ forceNewInstance?: boolean; }; @@ -91,6 +93,53 @@ export type CreateChannelManagedAgentsResult = { failures: CreateChannelManagedAgentBatchFailure[]; }; +export function respondToUpdateForReusedAgent( + agent: ManagedAgent, + input: Pick< + CreateChannelManagedAgentInput, + "respondTo" | "respondToAllowlist" + >, +): Pick< + CreateChannelManagedAgentInput, + "respondTo" | "respondToAllowlist" +> | null { + const nextRespondTo = input.respondTo ?? "owner-only"; + const nextAllowlist = + nextRespondTo === "allowlist" ? (input.respondToAllowlist ?? []) : []; + const allowlistChanged = + agent.respondToAllowlist.join(",") !== nextAllowlist.join(","); + + if (agent.respondTo === nextRespondTo && !allowlistChanged) { + return null; + } + + return { + respondTo: nextRespondTo, + respondToAllowlist: nextAllowlist, + }; +} + +export function runtimeUpdateForReusedAgent( + agent: ManagedAgent, + runtime: ChannelAgentRuntime, +): { agentCommand: string; agentArgs: string[]; mcpCommand: string } | null { + if ( + managedAgentRuntimeMatchesRequest(agent, { + command: runtime.command, + defaultArgs: runtime.defaultArgs, + mcpCommand: runtime.mcpCommand, + }) + ) { + return null; + } + + return { + agentCommand: runtime.command, + agentArgs: runtime.defaultArgs, + mcpCommand: runtime.mcpCommand ?? "", + }; +} + export async function attachManagedAgentToChannel( channelId: string, input: AttachManagedAgentToChannelInput, @@ -259,33 +308,40 @@ export async function createChannelManagedAgent( throw new Error("Agent name is required."); } - // Smart reuse: if a managed agent with the same personaId already exists - // and is not already in this channel, attach it instead of creating a new one. + // Persona-backed agents are singleton by personaId: adding a persona to any + // channel should attach the existing agent key instead of creating another. if ( input.personaId && - !input.forceNewInstance && context?.managedAgents && context.channelMemberPubkeys ) { const reusable = findReusablePersonaAgent( context.managedAgents, input.personaId, - context.channelMemberPubkeys, ); - if (reusable) { + // Runtime command changes can be applied to the singleton agent below. + // Backend changes cannot be updated by the managed-agent API, so a backend + // mismatch falls through to create the requested backend instance. + if ( + reusable && + managedAgentBackendMatchesRequest(reusable.backend, input.backend) + ) { // Apply the caller's respondTo settings so the user's permission // choice in the dialog is always honored, even when reusing. - const needsRespondToUpdate = - input.respondTo && input.respondTo !== "owner-only"; - const updatedAgent = needsRespondToUpdate + const respondToUpdate = respondToUpdateForReusedAgent(reusable, input); + const runtimeUpdate = runtimeUpdateForReusedAgent( + reusable, + input.runtime, + ); + const agentUpdate = { + ...(runtimeUpdate ?? {}), + ...(respondToUpdate ?? {}), + }; + const updatedAgent = Object.keys(agentUpdate).length ? ( await updateManagedAgent({ pubkey: reusable.pubkey, - respondTo: input.respondTo, - respondToAllowlist: - input.respondTo === "allowlist" - ? input.respondToAllowlist - : undefined, + ...agentUpdate, }) ).agent : reusable; @@ -318,17 +374,12 @@ export async function createChannelManagedAgent( context.channelMemberPubkeys, ); if (reusable) { - const needsRespondToUpdate = - input.respondTo && input.respondTo !== "owner-only"; - const updatedAgent = needsRespondToUpdate + const respondToUpdate = respondToUpdateForReusedAgent(reusable, input); + const updatedAgent = respondToUpdate ? ( await updateManagedAgent({ pubkey: reusable.pubkey, - respondTo: input.respondTo, - respondToAllowlist: - input.respondTo === "allowlist" - ? input.respondToAllowlist - : undefined, + ...respondToUpdate, }) ).agent : reusable; diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index 02b9129ba..65ea29432 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -264,11 +264,11 @@ export function useUpdateManagedAgentMutation() { ); }, onSettled: async (_data, _error, variables) => { - // Backend republishes kind:0 on a name change (sync_managed_agent_profile), - // so the relay has fresh profile data — but the desktop's React Query cache - // for ["user-profile", pubkey] has a 60s staleTime and will not refetch on + // Backend republishes kind:0 on name/avatar changes, so the relay has + // fresh profile data — but the desktop's React Query cache for + // ["user-profile", pubkey] has a 60s staleTime and will not refetch on // its own. Invalidate explicitly so the profile pane re-renders against - // the new display name / about / NIP-05 immediately. Also poke any + // the new display name/avatar immediately. Also poke any // ["users-batch", ...] entries that include this pubkey so sidebar member // rows, channel header chips, and message author labels refresh too. const lowerPubkey = variables.pubkey.toLowerCase(); diff --git a/desktop/src/features/agents/lib/managedAgentControlActions.ts b/desktop/src/features/agents/lib/managedAgentControlActions.ts index 0bf30b70d..ea162ff73 100644 --- a/desktop/src/features/agents/lib/managedAgentControlActions.ts +++ b/desktop/src/features/agents/lib/managedAgentControlActions.ts @@ -44,7 +44,7 @@ export function getManagedAgentPrimaryActionLabel(agent: ManagedAgent) { return "Stop"; } - return agent.status === "stopped" ? "Respawn" : "Spawn"; + return "Start agent"; } export function resolveManagedAgentChannelId( diff --git a/desktop/src/features/agents/ui/AgentGroupRows.tsx b/desktop/src/features/agents/ui/AgentGroupRows.tsx index 2aa95372e..378684907 100644 --- a/desktop/src/features/agents/ui/AgentGroupRows.tsx +++ b/desktop/src/features/agents/ui/AgentGroupRows.tsx @@ -16,6 +16,7 @@ export type AgentGroupRowsProps = { selectedLogAgentPubkey: string | null; onAddToChannel: (agent: ManagedAgent) => void; onDelete: (pubkey: string) => void; + onOpenProfile?: (pubkey: string) => void; onSelectLogAgent: (pubkey: string | null) => void; onStart: (pubkey: string) => void; onStop: (pubkey: string) => void; @@ -36,6 +37,7 @@ export function AgentGroupRows({ selectedLogAgentPubkey, onAddToChannel, onDelete, + onOpenProfile, onSelectLogAgent, onStart, onStop, @@ -61,6 +63,7 @@ export function AgentGroupRows({ presenceLookup={presenceLookup} onAddToChannel={onAddToChannel} onDelete={onDelete} + onOpenProfile={onOpenProfile} onSelectLogAgent={onSelectLogAgent} onStart={onStart} onStop={onStop} diff --git a/desktop/src/features/agents/ui/AgentIdentityCard.tsx b/desktop/src/features/agents/ui/AgentIdentityCard.tsx new file mode 100644 index 000000000..90630a374 --- /dev/null +++ b/desktop/src/features/agents/ui/AgentIdentityCard.tsx @@ -0,0 +1,43 @@ +import { cn } from "@/shared/lib/cn"; +import { AgentProviderCollage } from "./AgentProviderCollage"; + +type AgentIdentityCardProps = { + ariaLabel: string; + avatarUrl?: string | null; + dataTestId: string; + label: string; + modelLabel: string; + onClick: () => void; +}; + +export function AgentIdentityCard({ + ariaLabel, + avatarUrl, + dataTestId, + label, + modelLabel, + onClick, +}: AgentIdentityCardProps) { + return ( + + ); +} diff --git a/desktop/src/features/agents/ui/AgentProviderCollage.tsx b/desktop/src/features/agents/ui/AgentProviderCollage.tsx new file mode 100644 index 000000000..9fbd0d025 --- /dev/null +++ b/desktop/src/features/agents/ui/AgentProviderCollage.tsx @@ -0,0 +1,60 @@ +import type { CSSProperties } from "react"; + +import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; +import { IdentityInitialsAvatar } from "./IdentityInitialsAvatar"; + +type ClusterLayout = { + avatarStyle: CSSProperties; +}; + +type AgentProviderCollageProps = { + avatarUrl?: string | null; + label: string; +}; + +const CLUSTER_CENTER_TOP_PERCENT = 50; +const AGENT_INITIAL_AVATAR_SIZE = 152; + +export function AgentProviderCollage({ + avatarUrl, + label, +}: AgentProviderCollageProps) { + const { avatarStyle } = buildClusterPoints(); + + return ( +
+
+
+ {avatarUrl ? ( + + ) : ( + + )} +
+
+
+ ); +} + +function buildClusterPoints(): ClusterLayout { + return { + avatarStyle: { + left: "50%", + top: `${CLUSTER_CENTER_TOP_PERCENT}%`, + }, + }; +} diff --git a/desktop/src/features/agents/ui/AgentsScreen.tsx b/desktop/src/features/agents/ui/AgentsScreen.tsx index 9bcedca67..dadb07f31 100644 --- a/desktop/src/features/agents/ui/AgentsScreen.tsx +++ b/desktop/src/features/agents/ui/AgentsScreen.tsx @@ -1,5 +1,12 @@ import * as React from "react"; +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { useOpenDmMutation } from "@/features/channels/hooks"; +import { UserProfilePanel } from "@/features/profile/ui/UserProfilePanel"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import type { AgentPersona } from "@/shared/api/types"; +import { ProfilePanelProvider } from "@/shared/context/ProfilePanelContext"; +import { useThreadPanelWidth } from "@/shared/hooks/useThreadPanelWidth"; import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; const AgentsView = React.lazy(async () => { @@ -7,12 +14,63 @@ const AgentsView = React.lazy(async () => { return { default: module.AgentsView }; }); +type ProfilePanelTarget = + | { kind: "pubkey"; pubkey: string } + | { kind: "persona"; persona: AgentPersona }; + export function AgentsScreen() { + const identityQuery = useIdentityQuery(); + const [profilePanelTarget, setProfilePanelTarget] = + React.useState(null); + const threadPanelWidth = useThreadPanelWidth(); + const openDmMutation = useOpenDmMutation(); + const { goChannel } = useAppNavigation(); + + const handleOpenDm = React.useCallback( + async (pubkeys: string[]) => { + const dm = await openDmMutation.mutateAsync({ pubkeys }); + await goChannel(dm.id); + }, + [goChannel, openDmMutation], + ); + return ( -
- }> - - -
+ + setProfilePanelTarget({ kind: "persona", persona }) + } + onOpenProfilePanel={(pubkey) => + setProfilePanelTarget({ kind: "pubkey", pubkey }) + } + > +
+
+ }> + + + {profilePanelTarget ? ( + setProfilePanelTarget(null)} + onOpenDm={handleOpenDm} + onResetWidth={threadPanelWidth.onResetWidth} + onResizeStart={threadPanelWidth.onResizeStart} + persona={ + profilePanelTarget.kind === "persona" + ? profilePanelTarget.persona + : undefined + } + pubkey={ + profilePanelTarget.kind === "pubkey" + ? profilePanelTarget.pubkey + : undefined + } + widthPx={threadPanelWidth.widthPx} + /> + ) : null} +
+
+
); } diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index 639bd3569..79f61366a 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -19,8 +19,10 @@ import { UnifiedAgentsSection } from "./UnifiedAgentsSection"; import { useManagedAgentActions } from "./useManagedAgentActions"; import { usePersonaActions } from "./usePersonaActions"; import { useTeamActions } from "./useTeamActions"; +import { useProfilePanel } from "@/shared/context/ProfilePanelContext"; export function AgentsView() { + const { openPersonaProfilePanel, openProfilePanel } = useProfilePanel(); const agents = useManagedAgentActions(); const personas = usePersonaActions(); const teamActions = useTeamActions( @@ -34,14 +36,6 @@ export function AgentsView() { }, ); - const isActionPending = - agents.isPending || - personas.isPending || - teamActions.exportTeamJsonMutation.isPending || - teamActions.createTeamMutation.isPending || - teamActions.updateTeamMutation.isPending || - teamActions.deleteTeamMutation.isPending; - return ( <>
{ - agents.setActionNoticeMessage(null); - agents.setActionErrorMessage(null); - agents.setAgentToAddToChannel(agent); - }} - onBulkRemoveStopped={() => { - void agents.handleBulkRemoveStopped(); - }} - onBulkStopRunning={() => { - void agents.handleBulkStopRunning(); - }} onCreateAgent={() => { agents.setIsCreateOpen(true); }} - onDeleteAgent={(pubkey) => { - void agents.handleDelete(pubkey); - }} - onSelectLogAgent={agents.setLogAgentPubkey} - onStartAgent={(pubkey) => { - void agents.handleStart(pubkey); + onOpenAgentProfile={(pubkey) => { + openProfilePanel?.(pubkey); }} - onStopAgent={(pubkey) => { - void agents.handleStop(pubkey); + onOpenPersonaProfile={(persona) => { + openPersonaProfilePanel?.(persona); }} - onToggleStartOnAppLaunch={(pubkey, startOnAppLaunch) => { - void agents.handleToggleStartOnAppLaunch( - pubkey, - startOnAppLaunch, - ); - }} - selectedLogAgentPubkey={agents.logAgentPubkey} // Persona props canChooseCatalog={personas.catalogPersonas.length > 0} personas={personas.libraryPersonas} @@ -128,13 +87,6 @@ export function AgentsView() { isPersonasPending={personas.isPending} onCreatePersona={personas.openCreate} onChooseCatalog={personas.openCatalog} - onDuplicatePersona={personas.openDuplicate} - onEditPersona={personas.openEdit} - onExportPersona={personas.handleExport} - onDeactivatePersona={(persona) => { - void personas.handleSetActive(persona, false, "library"); - }} - onDeletePersona={personas.openDelete} onImportPersonaFile={(fileBytes, fileName) => { void personas.handleImportFile(fileBytes, fileName); }} @@ -157,10 +109,11 @@ export function AgentsView() { onDuplicate={teamActions.openDuplicateDialog} onEdit={teamActions.openEditDialog} onExport={teamActions.handleExportTeam} - onImportFile={teamActions.handleImportFile} - onInstallFromDirectory={teamActions.handleInstallFromDirectory} onSync={teamActions.handleSyncTeam} onRevealInFinder={teamActions.handleRevealInFinder} + onImportTeamFile={(fileBytes, fileName) => { + void teamActions.handleImportFile(fileBytes, fileName); + }} onAddToChannel={teamActions.setTeamToAddToChannel} personas={personas.libraryPersonas} teams={teamActions.teams} @@ -219,10 +172,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={ @@ -300,6 +250,7 @@ export function AgentsView() { } }} onDeleteRemovedPersonas={teamActions.handleDeleteRemovedPersonas} + onInstallFromDirectory={teamActions.handleInstallFromDirectory} onSubmit={teamActions.handleTeamSubmit} open={teamActions.teamDialogState !== null} personas={personas.libraryPersonas} diff --git a/desktop/src/features/agents/ui/BatchImportDialog.tsx b/desktop/src/features/agents/ui/BatchImportDialog.tsx index 5e0ba0f6c..5b2a0e612 100644 --- a/desktop/src/features/agents/ui/BatchImportDialog.tsx +++ b/desktop/src/features/agents/ui/BatchImportDialog.tsx @@ -9,6 +9,7 @@ import { import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import type { ParsePersonaFilesResult } from "@/shared/api/tauriPersonas"; import { createPersona } from "@/shared/api/tauriPersonas"; +import { resolveImportedPersonaAvatarUrl } from "@/shared/avatars/gooseAppAvatarRefs"; import { Button } from "@/shared/ui/button"; import { Checkbox } from "@/shared/ui/checkbox"; import { @@ -18,6 +19,7 @@ import { DialogHeader, DialogTitle, } from "@/shared/ui/dialog"; +import { buildBatchImportPersonaInput } from "./batchImportPersonaInput"; type BatchImportDialogProps = { fileName: string; @@ -91,13 +93,7 @@ export function BatchImportDialog({ }); try { - await createPersona({ - displayName: persona.displayName, - avatarUrl: persona.avatarDataUrl ?? undefined, - systemPrompt: persona.systemPrompt, - runtime: persona.runtime ?? undefined, - model: persona.model ?? undefined, - }); + await createPersona(buildBatchImportPersonaInput(persona)); completed += 1; setImportedCount(completed); setItemStatuses((prev) => { @@ -178,7 +174,7 @@ export function BatchImportDialog({ onClick={(e: React.MouseEvent) => e.stopPropagation()} /> diff --git a/desktop/src/features/agents/ui/CreateIdentityCard.tsx b/desktop/src/features/agents/ui/CreateIdentityCard.tsx new file mode 100644 index 000000000..3efd91e19 --- /dev/null +++ b/desktop/src/features/agents/ui/CreateIdentityCard.tsx @@ -0,0 +1,37 @@ +import * as React from "react"; +import { Plus } from "lucide-react"; + +import { cn } from "@/shared/lib/cn"; + +type CreateIdentityCardProps = React.ButtonHTMLAttributes & { + ariaLabel: string; + dataTestId: string; + label: string; +}; + +export const CreateIdentityCard = React.forwardRef< + HTMLButtonElement, + CreateIdentityCardProps +>(function CreateIdentityCard( + { ariaLabel, className, dataTestId, label, ...buttonProps }, + ref, +) { + return ( + + ); +}); diff --git a/desktop/src/features/agents/ui/IdentityInitialsAvatar.tsx b/desktop/src/features/agents/ui/IdentityInitialsAvatar.tsx new file mode 100644 index 000000000..71fa31065 --- /dev/null +++ b/desktop/src/features/agents/ui/IdentityInitialsAvatar.tsx @@ -0,0 +1,59 @@ +import { UserRound } from "lucide-react"; + +import { getInitials } from "@/shared/lib/initials"; +import { cn } from "@/shared/lib/cn"; + +const IDENTITY_INITIAL_AVATAR_CLASS_NAMES = [ + "bg-muted text-foreground", + "bg-secondary text-secondary-foreground", + "bg-accent text-accent-foreground", + "bg-card text-card-foreground", + "bg-popover text-popover-foreground", + "bg-background text-foreground", +] as const; + +type IdentityInitialsAvatarProps = { + className?: string; + colorIndex?: number; + colorSeed?: string; + label: string; + size: number; +}; + +export function IdentityInitialsAvatar({ + className, + colorIndex, + colorSeed, + label, + size, +}: IdentityInitialsAvatarProps) { + const initials = getInitials(label); + const seed = colorSeed ?? (label || "agent"); + const paletteIndex = colorIndex ?? getStableColorIndex(seed); + const colorClassName = + IDENTITY_INITIAL_AVATAR_CLASS_NAMES[ + paletteIndex % IDENTITY_INITIAL_AVATAR_CLASS_NAMES.length + ]; + const fontSize = Math.round(Math.min(40, Math.max(22, size * 0.28))); + + return ( + + {initials.length > 0 ? initials : } + + ); +} + +function getStableColorIndex(seed: string) { + let hash = 0; + for (let index = 0; index < seed.length; index += 1) { + hash = (hash * 31 + seed.charCodeAt(index)) >>> 0; + } + return hash; +} diff --git a/desktop/src/features/agents/ui/ManagedAgentLogPanel.tsx b/desktop/src/features/agents/ui/ManagedAgentLogPanel.tsx index 86fdc05cf..cc9cff7ad 100644 --- a/desktop/src/features/agents/ui/ManagedAgentLogPanel.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentLogPanel.tsx @@ -25,30 +25,43 @@ export function ManagedAgentLogPanel({ return null; } + const logDescription = selectedAgent + ? `${selectedAgent.name} · ${describeLogFile(selectedAgent.logPath)}` + : "Select a local agent to inspect recent output."; + return (
-
-
-

Harness log

-

- {selectedAgent - ? `${selectedAgent.name} · ${describeLogFile(selectedAgent.logPath)}` - : "Select a local agent to inspect recent output."} +

+ {isInline ? ( +

+ {logDescription}

-
+ ) : ( +
+

+ Harness log +

+

{logDescription}

+
+ )} {selectedAgent ? ( ) : null}
{!selectedAgent ? ( -
+

No local agent selected

@@ -57,14 +70,14 @@ export function ManagedAgentLogPanel({

) : isLoading ? ( -
+
) : ( -
+
{selectedAgent.name} {selectedAgent.status} @@ -82,7 +95,7 @@ export function ManagedAgentLogPanel({ )} {error ? ( -

+

{error.message}

diff --git a/desktop/src/features/agents/ui/ManagedAgentRow.tsx b/desktop/src/features/agents/ui/ManagedAgentRow.tsx index 556f83a72..3066b4d75 100644 --- a/desktop/src/features/agents/ui/ManagedAgentRow.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentRow.tsx @@ -55,6 +55,7 @@ export function ManagedAgentRow({ presenceLookup, onAddToChannel, onDelete, + onOpenProfile, onSelectLogAgent, onStart, onStop, @@ -73,6 +74,7 @@ export function ManagedAgentRow({ presenceLookup: PresenceLookup; onAddToChannel: (agent: ManagedAgent) => void; onDelete: (pubkey: string) => void; + onOpenProfile?: (pubkey: string) => void; onSelectLogAgent: (pubkey: string | null) => void; onStart: (pubkey: string) => void; onStop: (pubkey: string) => void; @@ -128,9 +130,13 @@ export function ManagedAgentRow({