diff --git a/desktop/public/onboarding/starter-team/bumble.png b/desktop/public/onboarding/starter-team/pollen.png similarity index 100% rename from desktop/public/onboarding/starter-team/bumble.png rename to desktop/public/onboarding/starter-team/pollen.png diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 2613671970..1e456ca464 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -1100,19 +1100,7 @@ pub async fn start_managed_agent( // profile reconcile (the create-time snapshot may be empty or stale for // a persona-inherited harness). let reconcile_personas = load_personas(&app).unwrap_or_default(); - let reconcile_effective_command = - crate::managed_agents::record_agent_command(record, &reconcile_personas); - - let reconcile = ProfileReconcileData { - private_key_nsec: record.private_key_nsec.clone(), - name: record.name.clone(), - relay_url: record.relay_url.clone(), - avatar_url: record.avatar_url.clone(), - auth_tag: record.auth_tag.clone(), - pubkey: record.pubkey.clone(), - agent_command: reconcile_effective_command, - persona_id: record.persona_id.clone(), - }; + let reconcile = profile_reconcile_data(record, &reconcile_personas); let target = if record.backend == BackendKind::Local { StartTarget::Local @@ -1362,9 +1350,9 @@ use deploy::{ensure_remote_provider_supported, resolve_deploy_model_provider}; #[path = "agents_profile.rs"] mod profile; +pub(crate) use profile::*; #[cfg(test)] use profile::{profile_needs_sync, resolve_legacy_avatar}; -pub(crate) use profile::{reconcile_agent_profile, ProfileReconcileData}; #[cfg(test)] #[path = "agents_tests.rs"] diff --git a/desktop/src-tauri/src/commands/agents_profile.rs b/desktop/src-tauri/src/commands/agents_profile.rs index 0675d4c48f..d28b58b50a 100644 --- a/desktop/src-tauri/src/commands/agents_profile.rs +++ b/desktop/src-tauri/src/commands/agents_profile.rs @@ -2,17 +2,27 @@ //! guard). Owns the reconcile data carrier, the legacy-avatar backfill, and //! the needs-sync predicate. -use tauri::AppHandle; +use tauri::{AppHandle, Manager}; use crate::app_state::AppState; use crate::managed_agents::managed_agent_avatar_url; use super::*; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ProfileReconcileOutcome { + Reconciled, + SkippedDisabled, +} + pub(crate) struct ProfileReconcileData { pub(crate) private_key_nsec: String, pub(crate) name: String, pub(crate) relay_url: String, + /// Exact relay for migration work captured while a community is active. + /// Ordinary runtime reconciliation leaves this unset and resolves against + /// the current workspace at execution time. + pub(crate) target_relay_url: Option, /// 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. @@ -49,6 +59,88 @@ pub(super) fn resolve_legacy_avatar( .unwrap_or_default() } +pub(crate) fn profile_reconcile_data( + record: &crate::managed_agents::ManagedAgentRecord, + personas: &[crate::managed_agents::AgentDefinition], +) -> ProfileReconcileData { + ProfileReconcileData { + private_key_nsec: record.private_key_nsec.clone(), + name: record.name.clone(), + relay_url: record.relay_url.clone(), + target_relay_url: None, + avatar_url: record.avatar_url.clone(), + auth_tag: record.auth_tag.clone(), + pubkey: record.pubkey.clone(), + agent_command: crate::managed_agents::record_agent_command(record, personas), + persona_id: record.persona_id.clone(), + } +} + +pub(crate) fn load_pending_profile_reconciliations( + app: &AppHandle, + workspace_relay: &str, +) -> Result, String> { + let state = app.state::(); + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let store_path = crate::managed_agents::managed_agents_store_path(app)?; + let queue_path = crate::migration::profile_reconcile_queue_path(&store_path); + if !queue_path.exists() { + return Ok(Vec::new()); + } + + let relay_key = crate::migration::profile_reconcile_relay_key(workspace_relay)?; + let pending = crate::migration::read_profile_reconcile_queue(&queue_path)?; + let records = crate::managed_agents::load_managed_agents(app)?; + let personas = crate::managed_agents::load_personas(app).unwrap_or_default(); + Ok(records + .iter() + // A queue write deliberately precedes the migrated agent-store write. + // If the process dies between them, retain (but do not execute) the + // stale item until the next boot finishes renaming the record. + .filter(|record| { + pending.iter().any(|entry| { + entry.pubkey == record.pubkey + && entry.expected_name == record.name + && !entry + .reconciled_relays + .iter() + .any(|relay| relay == &relay_key) + }) + }) + .map(|record| { + let mut data = profile_reconcile_data(record, &personas); + // Pin the relay captured by the caller. Otherwise a fast community + // switch could make a queued task for A run on B. + data.target_relay_url = Some(workspace_relay.to_string()); + (record.pubkey.clone(), data) + }) + .collect()) +} + +pub(crate) fn mark_profile_reconciled( + app: &AppHandle, + pubkey: &str, + relay_url: &str, +) -> Result<(), String> { + let state = app.state::(); + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let store_path = crate::managed_agents::managed_agents_store_path(app)?; + let queue_path = crate::migration::profile_reconcile_queue_path(&store_path); + if !queue_path.exists() { + return Ok(()); + } + let relay_key = crate::migration::profile_reconcile_relay_key(relay_url)?; + let mut pending = crate::migration::read_profile_reconcile_queue(&queue_path)?; + crate::migration::record_profile_reconciled(&mut pending, pubkey, relay_key); + crate::migration::write_profile_reconcile_queue(&queue_path, &pending) +} + /// Reconcile an agent's kind:0 profile on the relay. /// /// Queries the relay for the agent's existing profile and re-publishes if missing @@ -71,21 +163,21 @@ pub(crate) async fn reconcile_agent_profile( app: &AppHandle, agent_pubkey: &str, data: &ProfileReconcileData, -) -> Result<(), String> { +) -> Result { use crate::relay::{query_agent_profile, sync_managed_agent_profile}; // An explicit per-agent relay wins; an empty one falls back to the active // workspace relay. Resolved once and used for both the read and write-back. - let relay_url = crate::relay::effective_agent_relay_url( - &data.relay_url, - &relay_ws_url_with_override(state), - ); + let workspace_relay = relay_ws_url_with_override(state); + let relay_url = data.target_relay_url.clone().unwrap_or_else(|| { + crate::relay::effective_agent_relay_url(&data.relay_url, &workspace_relay) + }); if !state .managed_agent_profile_reconcile_enabled .load(std::sync::atomic::Ordering::Acquire) { - return Ok(()); + return Ok(ProfileReconcileOutcome::SkippedDisabled); } // Query the relay for the agent's existing kind:0 profile. @@ -137,7 +229,7 @@ pub(crate) async fn reconcile_agent_profile( }; if !profile_needs_sync(existing.as_ref(), &data.name, expected_avatar.as_deref()) { - return Ok(()); + return Ok(ProfileReconcileOutcome::Reconciled); } let agent_keys = Keys::parse(&data.private_key_nsec) @@ -147,7 +239,7 @@ pub(crate) async fn reconcile_agent_profile( .managed_agent_profile_reconcile_enabled .load(std::sync::atomic::Ordering::Acquire) { - return Ok(()); + return Ok(ProfileReconcileOutcome::SkippedDisabled); } sync_managed_agent_profile( @@ -158,7 +250,8 @@ pub(crate) async fn reconcile_agent_profile( expected_avatar.as_deref(), data.auth_tag.as_deref(), ) - .await + .await?; + Ok(ProfileReconcileOutcome::Reconciled) } /// Decide whether a published profile is missing or stale relative to the diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs index aa88bfe39a..a32fd2c0e0 100644 --- a/desktop/src-tauri/src/commands/workspace.rs +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -132,6 +132,9 @@ pub async fn apply_workspace( app: AppHandle, ) -> Result<(), String> { let restore_app = app.clone(); + // Capture the caller's relay before the blocking apply. Reading shared + // state afterward could pick up a newer concurrent community switch. + let profile_reconcile_relay = relay_url.clone(); tokio::task::spawn_blocking(move || { let state = app.state::(); @@ -213,6 +216,14 @@ pub async fn apply_workspace( let state = restore_app.state::(); super::agents::provider_access::reconcile_on_workspace_apply(&restore_app, &state).await?; + // The Bumble→Pollen migration may have renamed stopped agents. Reconcile + // their relay profiles independently of runtime restore; successful writes + // record this relay while retaining the agent for other communities, and + // failures retry on the next workspace apply. + crate::managed_agents::spawn_pending_profile_reconciliations( + &restore_app, + &profile_reconcile_relay, + ); // Backfill this exact relay+owner scope only after the workspace has been // applied. Running at process boot would target the fallback relay and diff --git a/desktop/src-tauri/src/managed_agents/personas.rs b/desktop/src-tauri/src/managed_agents/personas.rs index 9bf7ab74b0..8ff0e633dc 100644 --- a/desktop/src-tauri/src/managed_agents/personas.rs +++ b/desktop/src-tauri/src/managed_agents/personas.rs @@ -23,7 +23,17 @@ const FIZZ_SYSTEM_PROMPT: &str = "You are Fizz, an energetic maker who turns ide const HONEY_SYSTEM_PROMPT: &str = "You are Honey, a warm and thoughtful communicator. Help users write clearly, organize ideas, brainstorm, summarize, and prepare for conversations. Be kind, creative, and concise. Add occasional bee wordplay or 🍯🐝—keep it sweet, never excessive."; -const BUMBLE_SYSTEM_PROMPT: &str = "You are Bumble, a curious and adventurous researcher. Explore questions, compare options, check assumptions, and explain what you find clearly. Be candid when uncertain and favor useful evidence. Add occasional bee wordplay or 🐝🔎—keep it playful, never chaotic."; +// Keep the published NIP-33 coordinate stable so existing Pollen agents and +// references are upgraded in place instead of being orphaned by the rename. +pub(crate) const POLLEN_PERSONA_ID: &str = "builtin:bumble"; +pub(crate) const POLLEN_DISPLAY_NAME: &str = "Pollen"; +pub(crate) const POLLEN_SYSTEM_PROMPT: &str = "You are Pollen, a curious and adventurous researcher. Explore questions, compare options, check assumptions, and explain what you find clearly. Be candid when uncertain and favor useful evidence. Add occasional bee wordplay or 🐝🔎—keep it playful, never chaotic."; +pub(crate) const POLLEN_LEGACY_DISPLAY_NAME: &str = "Bumble"; +pub(crate) const POLLEN_LEGACY_SYSTEM_PROMPT: &str = "You are Bumble, a curious and adventurous researcher. Explore questions, compare options, check assumptions, and explain what you find clearly. Be candid when uncertain and favor useful evidence. Add occasional bee wordplay or 🐝🔎—keep it playful, never chaotic."; +// The embedded bytes are unchanged by the display-name migration. Keep the +// original storage symbol as the compatibility source and expose the current +// product name everywhere it is consumed. +const POLLEN_AVATAR: &str = BUMBLE_AVATAR; const BUILT_IN_PERSONAS: &[BuiltInPersona] = &[ BuiltInPersona { @@ -32,7 +42,7 @@ const BUILT_IN_PERSONAS: &[BuiltInPersona] = &[ avatar_url: Some(FIZZ_AVATAR), system_prompt: FIZZ_SYSTEM_PROMPT, name_pool: &[ - "Nectar", "Comet", "Bramble", "Clover", "Pollen", "Amber", "Daisy", "Mason", "Thistle", + "Nectar", "Comet", "Bramble", "Clover", "Amber", "Daisy", "Mason", "Thistle", "Waxwing", "Hive", "Meadow", "Juniper", "Aster", "Sage", "Willow", "Orchard", "Buzz", ], model: None, @@ -50,11 +60,11 @@ const BUILT_IN_PERSONAS: &[BuiltInPersona] = &[ default_active: true, }, BuiltInPersona { - id: "builtin:bumble", - display_name: "Bumble", - avatar_url: Some(BUMBLE_AVATAR), - system_prompt: BUMBLE_SYSTEM_PROMPT, - name_pool: &["Bumble"], + id: POLLEN_PERSONA_ID, + display_name: POLLEN_DISPLAY_NAME, + avatar_url: Some(POLLEN_AVATAR), + system_prompt: POLLEN_SYSTEM_PROMPT, + name_pool: &[POLLEN_DISPLAY_NAME], model: None, runtime: None, default_active: true, diff --git a/desktop/src-tauri/src/managed_agents/personas/tests.rs b/desktop/src-tauri/src/managed_agents/personas/tests.rs index 387b4d72c6..cc21861a9f 100644 --- a/desktop/src-tauri/src/managed_agents/personas/tests.rs +++ b/desktop/src-tauri/src/managed_agents/personas/tests.rs @@ -45,7 +45,7 @@ fn merge_personas_adds_missing_built_ins() { .iter() .map(|record| record.display_name.as_str()) .collect(); - assert_eq!(display_names, vec!["Fizz", "Honey", "Bumble"]); + assert_eq!(display_names, vec!["Fizz", "Honey", "Pollen"]); let active_ids: Vec<&str> = records .iter() .filter(|record| record.is_active) diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs index 25dadbeec6..895aad712a 100644 --- a/desktop/src-tauri/src/managed_agents/restore.rs +++ b/desktop/src-tauri/src/managed_agents/restore.rs @@ -438,6 +438,7 @@ pub async fn restore_managed_agents_on_launch( private_key_nsec: record.private_key_nsec.clone(), name: record.name.clone(), relay_url: record.relay_url.clone(), + target_relay_url: None, avatar_url: record.avatar_url.clone(), auth_tag: record.auth_tag.clone(), pubkey: record.pubkey.clone(), @@ -472,6 +473,73 @@ pub async fn restore_managed_agents_on_launch( Ok(()) } +fn profile_reconcile_completed(outcome: crate::commands::ProfileReconcileOutcome) -> bool { + outcome == crate::commands::ProfileReconcileOutcome::Reconciled +} + +pub(crate) fn spawn_pending_profile_reconciliations(app: &tauri::AppHandle, workspace_relay: &str) { + let state = app.state::(); + if !state + .managed_agent_profile_reconcile_enabled + .load(Ordering::Acquire) + { + return; + } + let items = match crate::commands::load_pending_profile_reconciliations(app, workspace_relay) { + Ok(items) => items, + Err(error) => { + eprintln!("buzz-desktop: failed to load pending profile reconciliations: {error}"); + return; + } + }; + + for (pubkey, data) in items { + let reconcile_app = app.clone(); + let relay_url = data + .target_relay_url + .clone() + .unwrap_or_else(|| data.relay_url.clone()); + tauri::async_runtime::spawn(async move { + let state = reconcile_app.state::(); + match crate::commands::reconcile_agent_profile(&state, &reconcile_app, &pubkey, &data) + .await + { + Ok(outcome) if profile_reconcile_completed(outcome) => { + if let Err(error) = crate::commands::mark_profile_reconciled( + &reconcile_app, + &pubkey, + &relay_url, + ) { + eprintln!( + "buzz-desktop: failed to record profile reconciliation for agent {pubkey}: {error}" + ); + } + } + Ok(_) => {} + Err(error) => eprintln!( + "buzz-desktop: profile reconciliation failed for agent {pubkey}: {error}" + ), + } + }); + } +} + +#[cfg(test)] +mod profile_reconcile_tests { + use super::profile_reconcile_completed; + use crate::commands::ProfileReconcileOutcome; + + #[test] + fn skipped_reconciliation_never_retires_pending_work() { + assert!(profile_reconcile_completed( + ProfileReconcileOutcome::Reconciled + )); + assert!(!profile_reconcile_completed( + ProfileReconcileOutcome::SkippedDisabled + )); + } +} + #[cfg(feature = "mesh-llm")] fn persist_restore_error( app: &tauri::AppHandle, diff --git a/desktop/src-tauri/src/migration.rs b/desktop/src-tauri/src/migration.rs index b3e613621e..0baba6456b 100644 --- a/desktop/src-tauri/src/migration.rs +++ b/desktop/src-tauri/src/migration.rs @@ -169,13 +169,11 @@ fn run_boot_migrations_inner(app: &tauri::AppHandle, reset_completed: bool) { } migrate_persona_provider_to_runtime(app); reconcile_legacy_command_names(app); - // Fold personas.json into the unified store HERE: after the JSON-level - // personas.json migrations above (which must see the legacy file), and - // before every consumer of the load/save_personas shims below — - // sync_team_personas would otherwise operate on an empty definition set. - // Post-fold readers of the runtime map (`load_persona_runtimes`) fall - // back to the unified store's definitions. + // Fold personas.json after its JSON-level migrations and before consumers + // below; otherwise sync_team_personas sees an empty definition set. + // Post-fold runtime reads fall back to unified-store definitions. fold_personas_into_agent_store(app); + pollen::migrate_pollen_agent_name(app); // Clean the legacy baked team-instructions suffix out of stored prompts // AFTER the fold (so definitions lifted out of personas.json are cleaned in // the same boot) and BEFORE backfill_standalone_agents (so a manufactured @@ -1376,6 +1374,8 @@ mod backfill; pub use backfill::backfill_standalone_agents; mod detach; pub use detach::detach_directory_backed_teams; +mod pollen; +pub(crate) use pollen::*; mod team_suffix; pub use team_suffix::strip_baked_team_instructions; diff --git a/desktop/src-tauri/src/migration/pollen.rs b/desktop/src-tauri/src/migration/pollen.rs new file mode 100644 index 0000000000..4276301ea2 --- /dev/null +++ b/desktop/src-tauri/src/migration/pollen.rs @@ -0,0 +1,862 @@ +//! Compatibility migration for the Bumble-to-Pollen built-in agent rename. + +use std::path::Path; + +use tauri::Manager; + +use super::persona_version_from_record; + +/// Rename the built-in research agent in persisted definitions and linked +/// instances without overwriting user-customized fields. +pub(super) fn migrate_pollen_agent_name(app: &tauri::AppHandle) { + let Ok(dir) = app.path().app_data_dir() else { + return; + }; + let path = dir.join("agents/managed-agents.json"); + if path.exists() { + migrate_pollen_agent_name_in_file(&path, &crate::util::now_iso()); + } +} + +fn migrate_pollen_agent_name_in_file(path: &Path, now: &str) { + let Ok(contents) = std::fs::read_to_string(path) else { + return; + }; + let Ok(mut records) = serde_json::from_str::>(&contents) else { + eprintln!( + "buzz-desktop: migrate-pollen-agent-name: invalid JSON in {}", + path.display() + ); + return; + }; + + let mut version_updates = stock_version_updates(now); + let has_stock_pollen_instance = records.iter().any(|record| { + record + .get("pubkey") + .and_then(serde_json::Value::as_str) + .is_some_and(|key| !key.is_empty()) + && record.get("persona_id").and_then(serde_json::Value::as_str) + == Some(crate::managed_agents::POLLEN_PERSONA_ID) + && record.get("name").and_then(serde_json::Value::as_str) + == Some(crate::managed_agents::POLLEN_LEGACY_DISPLAY_NAME) + }); + let mut occupied_names = records + .iter() + .filter_map(|record| record.get("name").and_then(serde_json::Value::as_str)) + .map(|name| name.to_lowercase()) + .collect::>(); + let mut profile_reconciliations = Vec::new(); + let mut changed = false; + + // Migrate the definition first so an in-sync linked instance can advance + // its source version instead of surfacing a false out-of-date warning. + for record in &mut records { + let is_definition = record + .get("pubkey") + .and_then(serde_json::Value::as_str) + .is_some_and(str::is_empty); + let Some(persona_id) = record + .get("slug") + .and_then(serde_json::Value::as_str) + .map(str::to_string) + else { + continue; + }; + if !is_definition { + continue; + } + + let old_version = persona_version_from_record(record); + let Some(object) = record.as_object_mut() else { + continue; + }; + let record_changed = if persona_id == crate::managed_agents::POLLEN_PERSONA_ID { + migrate_pollen_fields(object, true) + } else if persona_id == "builtin:fizz" { + remove_pollen_from_legacy_fizz_name_pool(object) + } else { + false + }; + if !record_changed { + continue; + } + + object.insert( + "updated_at".to_string(), + serde_json::Value::String(now.to_string()), + ); + changed = true; + if let (Some(old_version), Some(new_version)) = + (old_version, persona_version_from_record(record)) + { + version_updates.insert(persona_id, (old_version, new_version)); + } + } + + for record in &mut records { + let is_instance = record + .get("pubkey") + .and_then(serde_json::Value::as_str) + .is_some_and(|pubkey| !pubkey.is_empty()); + let Some(persona_id) = record + .get("persona_id") + .and_then(serde_json::Value::as_str) + .map(str::to_string) + else { + continue; + }; + let is_pollen_instance = persona_id == crate::managed_agents::POLLEN_PERSONA_ID; + let is_legacy_fizz_pollen = has_stock_pollen_instance + && persona_id == "builtin:fizz" + && record.get("name").and_then(serde_json::Value::as_str) + == Some(crate::managed_agents::POLLEN_DISPLAY_NAME); + // Definition rows are absent on direct upgrades from the pre-unified + // persona store. The stock hashes still let pristine linked instances + // advance instead of appearing falsely out of date after seeding. + let version_update = version_updates.get(&persona_id); + if !is_instance || (!is_pollen_instance && version_update.is_none()) { + continue; + } + + let source_was_current = version_update.is_some_and(|(old, _)| { + record + .get("persona_source_version") + .and_then(serde_json::Value::as_str) + == Some(old.as_str()) + }); + let Some(object) = record.as_object_mut() else { + continue; + }; + let name_was_migrated = is_pollen_instance + && object.get("name").and_then(serde_json::Value::as_str) + == Some(crate::managed_agents::POLLEN_LEGACY_DISPLAY_NAME); + let mut record_changed = is_pollen_instance && migrate_pollen_fields(object, false); + if is_legacy_fizz_pollen && source_was_current { + let replacement = unique_legacy_fizz_name(&occupied_names); + occupied_names.insert(replacement.to_lowercase()); + object.insert( + "name".to_string(), + serde_json::Value::String(replacement.clone()), + ); + if let Some(pubkey) = object + .get("pubkey") + .and_then(serde_json::Value::as_str) + .filter(|pubkey| !pubkey.is_empty()) + { + profile_reconciliations.push((pubkey.to_string(), replacement)); + } + record_changed = true; + } + if name_was_migrated { + if let Some(pubkey) = object + .get("pubkey") + .and_then(serde_json::Value::as_str) + .filter(|pubkey| !pubkey.is_empty()) + { + profile_reconciliations.push(( + pubkey.to_string(), + crate::managed_agents::POLLEN_DISPLAY_NAME.to_string(), + )); + } + } + if source_was_current { + if let Some((_, new_version)) = version_update { + object.insert( + "persona_source_version".to_string(), + serde_json::Value::String(new_version.clone()), + ); + record_changed = true; + } + } + if record_changed { + object.insert( + "updated_at".to_string(), + serde_json::Value::String(now.to_string()), + ); + changed = true; + } + } + + if !profile_reconciliations.is_empty() { + // Queue first: a crash after this write but before the agent-store write + // leaves harmless stale items. The loader verifies each queued expected + // name against the durable record before publishing. + if let Err(error) = persist_profile_reconcile_queue(path, &profile_reconciliations) { + eprintln!("buzz-desktop: migrate-pollen-agent-name: {error}"); + return; + } + if let Ok(bytes) = serde_json::to_vec_pretty(&records) { + if let Err(error) = crate::managed_agents::atomic_write_json_restricted(path, &bytes) { + eprintln!("buzz-desktop: migrate-pollen-agent-name: {error}"); + } + } + } else if changed { + if let Ok(bytes) = serde_json::to_vec_pretty(&records) { + if let Err(error) = crate::managed_agents::atomic_write_json_restricted(path, &bytes) { + eprintln!("buzz-desktop: migrate-pollen-agent-name: {error}"); + } + } + } +} + +fn unique_legacy_fizz_name(occupied_names: &std::collections::HashSet) -> String { + let base = "Pollen-Fizz"; + if !occupied_names.contains(&base.to_lowercase()) { + return base.to_string(); + } + for suffix in 2.. { + let candidate = format!("{base}-{suffix}"); + if !occupied_names.contains(&candidate.to_lowercase()) { + return candidate; + } + } + unreachable!() +} + +fn stock_version_updates(now: &str) -> std::collections::HashMap { + let mut updates = std::collections::HashMap::new(); + + if let Some(mut legacy_pollen) = crate::managed_agents::built_in_persona_definition( + crate::managed_agents::POLLEN_PERSONA_ID, + now, + ) { + let current_pollen = persona_version(&legacy_pollen); + legacy_pollen.display_name = crate::managed_agents::POLLEN_LEGACY_DISPLAY_NAME.to_string(); + legacy_pollen.system_prompt = + crate::managed_agents::POLLEN_LEGACY_SYSTEM_PROMPT.to_string(); + legacy_pollen.name_pool = + vec![crate::managed_agents::POLLEN_LEGACY_DISPLAY_NAME.to_string()]; + updates.insert( + crate::managed_agents::POLLEN_PERSONA_ID.to_string(), + (persona_version(&legacy_pollen), current_pollen), + ); + } + + if let Some(mut legacy_fizz) = + crate::managed_agents::built_in_persona_definition("builtin:fizz", now) + { + let current_fizz = persona_version(&legacy_fizz); + legacy_fizz + .name_pool + .insert(4, crate::managed_agents::POLLEN_DISPLAY_NAME.to_string()); + updates.insert( + "builtin:fizz".to_string(), + (persona_version(&legacy_fizz), current_fizz), + ); + } + + updates +} + +fn persona_version(definition: &crate::managed_agents::AgentDefinition) -> String { + crate::managed_agents::persona_events::persona_content_hash( + &crate::managed_agents::persona_events::persona_event_content(definition), + ) +} + +#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)] +pub(crate) struct ProfileReconcileQueueEntry { + pub(crate) pubkey: String, + #[serde(default = "default_profile_reconcile_name")] + pub(crate) expected_name: String, + /// Canonical relay identities already repaired for this migrated agent. + /// + /// Keep the entry after success: Desktop does not persist its community + /// list in Rust, so a community that is inactive (or re-added later) must + /// still get one repair when it is next applied. + #[serde(default)] + pub(crate) reconciled_relays: Vec, +} + +fn default_profile_reconcile_name() -> String { + crate::managed_agents::POLLEN_DISPLAY_NAME.to_string() +} + +#[derive(serde::Deserialize)] +struct CurrentProfileReconcileQueueEntry { + pubkey: String, + #[serde(default = "default_profile_reconcile_name")] + expected_name: String, + #[serde(default)] + reconciled_relays: Vec, +} + +#[derive(serde::Deserialize)] +#[serde(untagged)] +enum StoredProfileReconcileQueueEntry { + Current(CurrentProfileReconcileQueueEntry), + Legacy(String), +} + +impl<'de> serde::Deserialize<'de> for ProfileReconcileQueueEntry { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + match StoredProfileReconcileQueueEntry::deserialize(deserializer)? { + StoredProfileReconcileQueueEntry::Current(entry) => Ok(Self { + pubkey: entry.pubkey, + expected_name: entry.expected_name, + reconciled_relays: entry.reconciled_relays, + }), + StoredProfileReconcileQueueEntry::Legacy(pubkey) => Ok(Self { + pubkey, + expected_name: default_profile_reconcile_name(), + reconciled_relays: Vec::new(), + }), + } + } +} + +pub(crate) fn profile_reconcile_queue_path(agent_store_path: &Path) -> std::path::PathBuf { + agent_store_path.with_file_name("profile-reconcile-pending.json") +} + +fn persist_profile_reconcile_queue( + path: &Path, + reconciliations: &[(String, String)], +) -> Result<(), String> { + let queue_path = profile_reconcile_queue_path(path); + let mut pending = if queue_path.exists() { + read_profile_reconcile_queue(&queue_path).unwrap_or_default() + } else { + Vec::new() + }; + for (pubkey, expected_name) in reconciliations { + if let Some(entry) = pending.iter_mut().find(|entry| entry.pubkey == *pubkey) { + entry.expected_name.clone_from(expected_name); + entry.reconciled_relays.clear(); + } else { + pending.push(ProfileReconcileQueueEntry { + pubkey: pubkey.clone(), + expected_name: expected_name.clone(), + reconciled_relays: Vec::new(), + }); + } + } + pending.sort_by(|left, right| left.pubkey.cmp(&right.pubkey)); + write_profile_reconcile_queue(&queue_path, &pending) +} + +pub(crate) const PROFILE_RECONCILE_QUEUE_MAX_BYTES: usize = 1024 * 1024; + +pub(crate) fn read_profile_reconcile_queue( + path: &Path, +) -> Result, String> { + let metadata = std::fs::metadata(path) + .map_err(|error| format!("failed to inspect profile reconcile queue: {error}"))?; + if metadata.len() > PROFILE_RECONCILE_QUEUE_MAX_BYTES as u64 { + return Err("profile reconcile queue exceeds its size limit".to_string()); + } + let contents = std::fs::read_to_string(path) + .map_err(|error| format!("failed to read profile reconcile queue: {error}"))?; + serde_json::from_str(&contents) + .map_err(|error| format!("failed to parse profile reconcile queue: {error}")) +} + +pub(crate) fn write_profile_reconcile_queue( + path: &Path, + entries: &[ProfileReconcileQueueEntry], +) -> Result<(), String> { + if entries.is_empty() { + return match std::fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(format!( + "failed to remove empty profile reconcile queue {}: {error}", + path.display() + )), + }; + } + let bytes = serde_json::to_vec_pretty(entries) + .map_err(|error| format!("failed to serialize profile reconcile queue: {error}"))?; + if bytes.len() > PROFILE_RECONCILE_QUEUE_MAX_BYTES { + return Err("profile reconcile queue exceeds its size limit".to_string()); + } + crate::managed_agents::atomic_write_json_restricted(path, &bytes) +} + +pub(crate) fn profile_reconcile_relay_key(relay_url: &str) -> Result { + buzz_core_pkg::relay::normalize_relay_url(relay_url) + .map_err(|error| format!("invalid profile reconcile relay: {error}")) +} + +#[cfg(test)] +pub(crate) fn profile_reconcile_is_pending( + entries: &[ProfileReconcileQueueEntry], + pubkey: &str, + relay_key: &str, +) -> bool { + entries.iter().any(|entry| { + entry.pubkey == pubkey + && !entry + .reconciled_relays + .iter() + .any(|relay| relay == relay_key) + }) +} + +pub(crate) fn record_profile_reconciled( + entries: &mut [ProfileReconcileQueueEntry], + pubkey: &str, + relay_key: String, +) { + if let Some(entry) = entries.iter_mut().find(|entry| entry.pubkey == pubkey) { + if !entry.reconciled_relays.contains(&relay_key) { + entry.reconciled_relays.push(relay_key); + entry.reconciled_relays.sort(); + } + } +} + +fn migrate_pollen_fields( + record: &mut serde_json::Map, + is_definition: bool, +) -> bool { + let mut changed = false; + for key in ["name", "display_name"] { + if record.get(key).and_then(serde_json::Value::as_str) + == Some(crate::managed_agents::POLLEN_LEGACY_DISPLAY_NAME) + { + record.insert( + key.to_string(), + serde_json::Value::String(crate::managed_agents::POLLEN_DISPLAY_NAME.to_string()), + ); + changed = true; + } + } + if record + .get("system_prompt") + .and_then(serde_json::Value::as_str) + == Some(crate::managed_agents::POLLEN_LEGACY_SYSTEM_PROMPT) + { + record.insert( + "system_prompt".to_string(), + serde_json::Value::String(crate::managed_agents::POLLEN_SYSTEM_PROMPT.to_string()), + ); + changed = true; + } + if is_definition + && record + .get("name_pool") + .and_then(serde_json::Value::as_array) + .is_some_and(|names| { + names.len() == 1 + && names[0].as_str() == Some(crate::managed_agents::POLLEN_LEGACY_DISPLAY_NAME) + }) + { + record.insert( + "name_pool".to_string(), + serde_json::json!([crate::managed_agents::POLLEN_DISPLAY_NAME]), + ); + changed = true; + } + changed +} + +fn remove_pollen_from_legacy_fizz_name_pool( + record: &mut serde_json::Map, +) -> bool { + const LEGACY_FIZZ_NAME_POOL: &[&str] = &[ + "Nectar", "Comet", "Bramble", "Clover", "Pollen", "Amber", "Daisy", "Mason", "Thistle", + "Waxwing", "Hive", "Meadow", "Juniper", "Aster", "Sage", "Willow", "Orchard", "Buzz", + ]; + let Some(names) = record + .get("name_pool") + .and_then(serde_json::Value::as_array) + else { + return false; + }; + if !names + .iter() + .map(|name| name.as_str()) + .eq(LEGACY_FIZZ_NAME_POOL.iter().copied().map(Some)) + { + return false; + } + + let names_without_pollen = names + .iter() + .filter(|name| name.as_str() != Some(crate::managed_agents::POLLEN_DISPLAY_NAME)) + .cloned() + .collect(); + record.insert( + "name_pool".to_string(), + serde_json::Value::Array(names_without_pollen), + ); + true +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::migration::test_support::{read_agents_json, write_agents_json}; + + #[test] + fn pollen_name_migration_updates_seeded_fields_and_preserves_customizations() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("agents/managed-agents.json"); + let mut legacy_definition = crate::managed_agents::built_in_persona_definition( + crate::managed_agents::POLLEN_PERSONA_ID, + "before", + ) + .unwrap(); + legacy_definition.display_name = + crate::managed_agents::POLLEN_LEGACY_DISPLAY_NAME.to_string(); + legacy_definition.system_prompt = + crate::managed_agents::POLLEN_LEGACY_SYSTEM_PROMPT.to_string(); + legacy_definition.name_pool = + vec![crate::managed_agents::POLLEN_LEGACY_DISPLAY_NAME.to_string()]; + let old_version = crate::managed_agents::persona_events::persona_content_hash( + &crate::managed_agents::persona_events::persona_event_content(&legacy_definition), + ); + let mut current_definition = legacy_definition.clone(); + current_definition.display_name = crate::managed_agents::POLLEN_DISPLAY_NAME.to_string(); + current_definition.system_prompt = crate::managed_agents::POLLEN_SYSTEM_PROMPT.to_string(); + current_definition.name_pool = vec![crate::managed_agents::POLLEN_DISPLAY_NAME.to_string()]; + let new_version = crate::managed_agents::persona_events::persona_content_hash( + &crate::managed_agents::persona_events::persona_event_content(¤t_definition), + ); + + let mut definition_record = + serde_json::to_value(legacy_definition.into_agent_record()).unwrap(); + definition_record["future_definition_field"] = serde_json::json!("preserved"); + let pristine_instance = serde_json::json!({ + "pubkey": "pristine-pubkey", + "name": crate::managed_agents::POLLEN_LEGACY_DISPLAY_NAME, + "persona_id": crate::managed_agents::POLLEN_PERSONA_ID, + "system_prompt": crate::managed_agents::POLLEN_LEGACY_SYSTEM_PROMPT, + "persona_source_version": old_version, + "start_on_app_launch": false, + "updated_at": "before", + "future_instance_field": "preserved" + }); + let customized_instance = serde_json::json!({ + "pubkey": "customized-pubkey", + "name": "My researcher", + "persona_id": crate::managed_agents::POLLEN_PERSONA_ID, + "system_prompt": "User-edited instructions", + "persona_source_version": "custom-version", + "updated_at": "before" + }); + let unrelated = serde_json::json!({ + "pubkey": "honey-pubkey", + "name": "Honey", + "persona_id": "builtin:honey", + "system_prompt": "You are Honey.", + "updated_at": "before" + }); + write_agents_json( + dir.path(), + &serde_json::json!([ + definition_record, + pristine_instance, + customized_instance, + unrelated + ]), + ); + + migrate_pollen_agent_name_in_file(&path, "after"); + + let records = read_agents_json(dir.path()); + assert_eq!( + records[0]["slug"], + crate::managed_agents::POLLEN_PERSONA_ID, + "the persisted compatibility id must remain stable" + ); + assert_eq!( + records[0]["name"], + crate::managed_agents::POLLEN_DISPLAY_NAME + ); + assert_eq!( + records[0]["display_name"], + crate::managed_agents::POLLEN_DISPLAY_NAME + ); + assert_eq!( + records[0]["system_prompt"], + crate::managed_agents::POLLEN_SYSTEM_PROMPT + ); + assert_eq!( + records[0]["name_pool"], + serde_json::json!([crate::managed_agents::POLLEN_DISPLAY_NAME]) + ); + assert_eq!(records[0]["future_definition_field"], "preserved"); + assert_eq!(records[0]["updated_at"], "after"); + + assert_eq!( + records[1]["name"], + crate::managed_agents::POLLEN_DISPLAY_NAME + ); + assert_eq!( + records[1]["system_prompt"], + crate::managed_agents::POLLEN_SYSTEM_PROMPT + ); + assert_eq!(records[1]["persona_source_version"], new_version); + assert_eq!(records[1]["future_instance_field"], "preserved"); + assert_eq!(records[1]["updated_at"], "after"); + + assert_eq!(records[2]["name"], "My researcher"); + assert_eq!(records[2]["system_prompt"], "User-edited instructions"); + assert_eq!(records[2]["persona_source_version"], "custom-version"); + assert_eq!(records[2]["updated_at"], "before"); + assert_eq!(records[3], unrelated); + assert_eq!( + read_profile_reconcile_queue(&profile_reconcile_queue_path(&path)).unwrap(), + vec![ProfileReconcileQueueEntry { + expected_name: crate::managed_agents::POLLEN_DISPLAY_NAME.to_string(), + pubkey: "pristine-pubkey".to_string(), + reconciled_relays: Vec::new(), + }], + "a stopped stock instance must retry its relay profile independently of startup" + ); + + let once = std::fs::read(&path).unwrap(); + migrate_pollen_agent_name_in_file(&path, "later"); + assert_eq!( + std::fs::read(path).unwrap(), + once, + "migration is idempotent" + ); + } + + #[test] + fn pollen_name_migration_advances_stock_versions_without_definition_rows() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("agents/managed-agents.json"); + let updates = stock_version_updates("before"); + let (old_pollen, new_pollen) = updates + .get(crate::managed_agents::POLLEN_PERSONA_ID) + .unwrap(); + let (old_fizz, new_fizz) = updates.get("builtin:fizz").unwrap(); + write_agents_json( + dir.path(), + &serde_json::json!([ + { + "pubkey": "pollen-pubkey", + "name": crate::managed_agents::POLLEN_LEGACY_DISPLAY_NAME, + "persona_id": crate::managed_agents::POLLEN_PERSONA_ID, + "system_prompt": crate::managed_agents::POLLEN_LEGACY_SYSTEM_PROMPT, + "persona_source_version": old_pollen, + "start_on_app_launch": false, + "updated_at": "before" + }, + { + "pubkey": "fizz-pubkey", + "name": "Fizz", + "persona_id": "builtin:fizz", + "persona_source_version": old_fizz, + "updated_at": "before" + } + ]), + ); + + migrate_pollen_agent_name_in_file(&path, "after"); + + let records = read_agents_json(dir.path()); + assert_eq!(records[0]["persona_source_version"], *new_pollen); + assert_eq!(records[1]["persona_source_version"], *new_fizz); + assert_eq!( + read_profile_reconcile_queue(&profile_reconcile_queue_path(&path)).unwrap(), + vec![ProfileReconcileQueueEntry { + expected_name: crate::managed_agents::POLLEN_DISPLAY_NAME.to_string(), + pubkey: "pollen-pubkey".to_string(), + reconciled_relays: Vec::new(), + }] + ); + } + + #[test] + fn legacy_profile_reconcile_queue_remains_readable() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("profile-reconcile-pending.json"); + std::fs::write(&path, r#"["pollen-pubkey"]"#).unwrap(); + + assert_eq!( + read_profile_reconcile_queue(&path).unwrap(), + vec![ProfileReconcileQueueEntry { + expected_name: crate::managed_agents::POLLEN_DISPLAY_NAME.to_string(), + pubkey: "pollen-pubkey".to_string(), + reconciled_relays: Vec::new(), + }] + ); + } + + #[test] + fn profile_reconcile_queue_tracks_each_relay_without_dropping_other_communities() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("profile-reconcile-pending.json"); + let relay_a = profile_reconcile_relay_key("WSS://A.EXAMPLE:443/").unwrap(); + let relay_b = profile_reconcile_relay_key("wss://b.example").unwrap(); + let mut entries = vec![ProfileReconcileQueueEntry { + pubkey: "pollen-pubkey".to_string(), + expected_name: crate::managed_agents::POLLEN_DISPLAY_NAME.to_string(), + reconciled_relays: Vec::new(), + }]; + assert!(profile_reconcile_is_pending( + &entries, + "pollen-pubkey", + &relay_a + )); + record_profile_reconciled(&mut entries, "pollen-pubkey", relay_a.clone()); + assert!(!profile_reconcile_is_pending( + &entries, + "pollen-pubkey", + &relay_a + )); + assert!(profile_reconcile_is_pending( + &entries, + "pollen-pubkey", + &relay_b + )); + + write_profile_reconcile_queue(&path, &entries).unwrap(); + assert_eq!(read_profile_reconcile_queue(&path).unwrap(), entries); + assert_eq!( + profile_reconcile_relay_key("wss://a.example").unwrap(), + profile_reconcile_relay_key("WSS://A.EXAMPLE:443/").unwrap(), + "equivalent relay spellings must share one completion key" + ); + } + + #[test] + fn empty_profile_reconcile_queue_is_removed() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("profile-reconcile-pending.json"); + write_profile_reconcile_queue( + &path, + &[ProfileReconcileQueueEntry { + expected_name: crate::managed_agents::POLLEN_DISPLAY_NAME.to_string(), + pubkey: "pollen-pubkey".to_string(), + reconciled_relays: Vec::new(), + }], + ) + .unwrap(); + assert!(path.exists()); + + write_profile_reconcile_queue(&path, &[]).unwrap(); + + assert!(!path.exists()); + } + + #[test] + fn pollen_name_migration_repairs_stock_fizz_collision_and_profiles() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("agents/managed-agents.json"); + let updates = stock_version_updates("before"); + let old_pollen = &updates[crate::managed_agents::POLLEN_PERSONA_ID].0; + let old_fizz = &updates["builtin:fizz"].0; + write_agents_json( + dir.path(), + &serde_json::json!([ + { + "pubkey": "pollen-pubkey", + "name": crate::managed_agents::POLLEN_LEGACY_DISPLAY_NAME, + "persona_id": crate::managed_agents::POLLEN_PERSONA_ID, + "system_prompt": crate::managed_agents::POLLEN_LEGACY_SYSTEM_PROMPT, + "persona_source_version": old_pollen, + "updated_at": "before" + }, + { + "pubkey": "fizz-pubkey", + "name": crate::managed_agents::POLLEN_DISPLAY_NAME, + "persona_id": "builtin:fizz", + "persona_source_version": old_fizz, + "updated_at": "before" + }, + { + "pubkey": "occupied-pubkey", + "name": "pollen-fizz", + "persona_id": "custom:persona", + "updated_at": "before" + }, + { + "pubkey": "custom-fizz-pubkey", + "name": crate::managed_agents::POLLEN_DISPLAY_NAME, + "persona_id": "builtin:fizz", + "persona_source_version": "custom-version", + "updated_at": "before" + } + ]), + ); + + migrate_pollen_agent_name_in_file(&path, "after"); + + let records = read_agents_json(dir.path()); + assert_eq!( + records[0]["name"], + crate::managed_agents::POLLEN_DISPLAY_NAME + ); + assert_eq!(records[1]["name"], "Pollen-Fizz-2"); + assert_eq!(records[2]["name"], "pollen-fizz"); + assert_eq!( + records[3]["name"], + crate::managed_agents::POLLEN_DISPLAY_NAME + ); + assert_eq!(records[3]["updated_at"], "before"); + assert_eq!( + read_profile_reconcile_queue(&profile_reconcile_queue_path(&path)).unwrap(), + vec![ + ProfileReconcileQueueEntry { + pubkey: "fizz-pubkey".to_string(), + expected_name: "Pollen-Fizz-2".to_string(), + reconciled_relays: Vec::new(), + }, + ProfileReconcileQueueEntry { + pubkey: "pollen-pubkey".to_string(), + expected_name: crate::managed_agents::POLLEN_DISPLAY_NAME.to_string(), + reconciled_relays: Vec::new(), + }, + ] + ); + + let once = std::fs::read(&path).unwrap(); + migrate_pollen_agent_name_in_file(&path, "later"); + assert_eq!(std::fs::read(path).unwrap(), once); + } + + #[test] + fn pollen_name_migration_removes_the_new_name_from_the_legacy_fizz_pool() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("agents/managed-agents.json"); + let mut legacy_fizz = + crate::managed_agents::built_in_persona_definition("builtin:fizz", "before").unwrap(); + legacy_fizz + .name_pool + .insert(4, crate::managed_agents::POLLEN_DISPLAY_NAME.to_string()); + let old_version = crate::managed_agents::persona_events::persona_content_hash( + &crate::managed_agents::persona_events::persona_event_content(&legacy_fizz), + ); + let mut current_fizz = legacy_fizz.clone(); + current_fizz + .name_pool + .retain(|name| name != crate::managed_agents::POLLEN_DISPLAY_NAME); + let new_version = crate::managed_agents::persona_events::persona_content_hash( + &crate::managed_agents::persona_events::persona_event_content(¤t_fizz), + ); + write_agents_json( + dir.path(), + &serde_json::json!([ + serde_json::to_value(legacy_fizz.into_agent_record()).unwrap(), + { + "pubkey": "fizz-pubkey", + "name": "Fizz", + "persona_id": "builtin:fizz", + "persona_source_version": old_version, + "updated_at": "before" + } + ]), + ); + + migrate_pollen_agent_name_in_file(&path, "after"); + + let records = read_agents_json(dir.path()); + assert_eq!( + records[0]["name_pool"], + serde_json::json!(current_fizz.name_pool) + ); + assert_eq!(records[0]["updated_at"], "after"); + assert_eq!(records[1]["persona_source_version"], new_version); + assert_eq!(records[1]["updated_at"], "after"); + } +} diff --git a/desktop/src/features/agents/lib/useBotRecents.test.mjs b/desktop/src/features/agents/lib/useBotRecents.test.mjs index 7bd8f8f6d2..930d3211d4 100644 --- a/desktop/src/features/agents/lib/useBotRecents.test.mjs +++ b/desktop/src/features/agents/lib/useBotRecents.test.mjs @@ -34,7 +34,7 @@ test("pickQuickBotPersonas prefers recents before defaults", () => { test("pickQuickBotPersonas seeds the three starter agents", () => { const personas = [ - createPersona("builtin:bumble", "Bumble"), + createPersona("builtin:bumble", "Pollen"), createPersona("builtin:honey", "Honey"), createPersona("builtin:fizz", "Fizz"), createPersona("builtin:reviewer", "Reviewer"), diff --git a/desktop/src/features/agents/lib/useBotRecents.ts b/desktop/src/features/agents/lib/useBotRecents.ts index cf54c00b2e..193fa02856 100644 --- a/desktop/src/features/agents/lib/useBotRecents.ts +++ b/desktop/src/features/agents/lib/useBotRecents.ts @@ -7,7 +7,7 @@ const MAX_RECENTS = 8; // Default persona display names to seed the list when empty. // These are resolved to IDs by the consumer. -export const DEFAULT_PERSONA_NAMES = ["Fizz", "Honey", "Bumble"] as const; +export const DEFAULT_PERSONA_NAMES = ["Fizz", "Honey", "Pollen"] as const; export function pickQuickBotPersonas( personas: readonly AgentPersona[], diff --git a/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx index d2af0cc3bd..7897e51ffd 100644 --- a/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx @@ -56,7 +56,7 @@ function isRelayMembershipDeniedError(error: unknown): boolean { const STARTER_PERSONA_ANIMATIONS: Record = { Fizz: "/onboarding/starter-team/fizz.png", Honey: "/onboarding/starter-team/honey.png", - Bumble: "/onboarding/starter-team/bumble.png", + Pollen: "/onboarding/starter-team/pollen.png", }; /** Fade duration for the "entering" curtain over the mounting app. */ @@ -205,7 +205,7 @@ export function CommunityOnboardingFlow({ void listPersonas() .then((personas) => setStarterPersonas( - ["Fizz", "Honey", "Bumble"].flatMap((name) => { + ["Fizz", "Honey", "Pollen"].flatMap((name) => { const persona = personas.find( (candidate) => candidate.displayName === name, ); diff --git a/desktop/src/features/onboarding/ui/WelcomeKickoffStage.tsx b/desktop/src/features/onboarding/ui/WelcomeKickoffStage.tsx index 40337f2a34..32649ad2bd 100644 --- a/desktop/src/features/onboarding/ui/WelcomeKickoffStage.tsx +++ b/desktop/src/features/onboarding/ui/WelcomeKickoffStage.tsx @@ -15,7 +15,7 @@ type StageCharacter = { const STAGE_CHARACTERS: readonly StageCharacter[] = [ { name: "Fizz", animationUrl: "/onboarding/starter-team/fizz.png" }, { name: "Honey", animationUrl: "/onboarding/starter-team/honey.png" }, - { name: "Bumble", animationUrl: "/onboarding/starter-team/bumble.png" }, + { name: "Pollen", animationUrl: "/onboarding/starter-team/pollen.png" }, ]; const STAGE_EXIT_ANIMATION = "motion-kickoff-stage-exit"; diff --git a/desktop/src/features/onboarding/welcomeCanvas.ts b/desktop/src/features/onboarding/welcomeCanvas.ts index 7d592fe30a..bdd8ff6371 100644 --- a/desktop/src/features/onboarding/welcomeCanvas.ts +++ b/desktop/src/features/onboarding/welcomeCanvas.ts @@ -2,7 +2,7 @@ import { getCanvas, setCanvas } from "@/shared/api/tauri"; export const WELCOME_CANVAS_CONTENT = `# Welcome to Buzz -This private channel is your home base for getting oriented. Fizz, Honey, and Bumble can help you learn the app, troubleshoot setup, and work through something you are building. +This private channel is your home base for getting oriented. Fizz, Honey, and Pollen can help you learn the app, troubleshoot setup, and work through something you are building. ## Work with your agents diff --git a/desktop/src/features/onboarding/welcomeGuide.test.mjs b/desktop/src/features/onboarding/welcomeGuide.test.mjs index 59af9c75d3..746a948777 100644 --- a/desktop/src/features/onboarding/welcomeGuide.test.mjs +++ b/desktop/src/features/onboarding/welcomeGuide.test.mjs @@ -296,7 +296,7 @@ test("welcome team starter definitions and role identities are stable", () => { assert.deepEqual(WELCOME_TEAM_STARTERS, [ { name: "Fizz", personaId: "builtin:fizz", role: "lead" }, { name: "Honey", personaId: "builtin:honey", role: "teammate" }, - { name: "Bumble", personaId: "builtin:bumble", role: "teammate" }, + { name: "Pollen", personaId: "builtin:bumble", role: "teammate" }, ]); }); @@ -332,14 +332,14 @@ test("starter matching uses persona identity rather than display name", () => { }); test("starter matching is relay scoped and normalizes trailing slashes", () => { - const bumble = WELCOME_TEAM_STARTERS[2]; + const pollen = WELCOME_TEAM_STARTERS[2]; const otherRelay = makeAgent({ - personaId: bumble.personaId, + personaId: pollen.personaId, relayUrl: RELAY_B, status: "running", }); const matchingRelay = makeAgent({ - personaId: bumble.personaId, + personaId: pollen.personaId, relayUrl: `${RELAY_A}/`, pubkey: PUB_B, }); @@ -347,7 +347,7 @@ test("starter matching is relay scoped and normalizes trailing slashes", () => { assert.equal( pickWelcomeTeamStarterAgentForRelay( [otherRelay, matchingRelay], - bumble, + pollen, RELAY_A, ), matchingRelay, diff --git a/desktop/src/features/onboarding/welcomeGuide.ts b/desktop/src/features/onboarding/welcomeGuide.ts index 82b6886d74..29d966fa94 100644 --- a/desktop/src/features/onboarding/welcomeGuide.ts +++ b/desktop/src/features/onboarding/welcomeGuide.ts @@ -44,7 +44,7 @@ export type WelcomeTeamStarterDefinition = Readonly<{ export const WELCOME_TEAM_STARTERS = [ { name: "Fizz", personaId: "builtin:fizz", role: "lead" }, { name: "Honey", personaId: "builtin:honey", role: "teammate" }, - { name: "Bumble", personaId: "builtin:bumble", role: "teammate" }, + { name: "Pollen", personaId: "builtin:bumble", role: "teammate" }, ] as const satisfies readonly WelcomeTeamStarterDefinition[]; export type WelcomeTeamAgents = [ManagedAgent, ManagedAgent, ManagedAgent]; @@ -370,11 +370,11 @@ async function provisionWelcomeTeam( const created = await createManagedAgent(desired); agents.push(created.agent); } - const [lead, honey, bumble] = agents; - if (!lead || !honey || !bumble) { + const [lead, honey, pollen] = agents; + if (!lead || !honey || !pollen) { throw new Error("Welcome Team provisioning did not return every starter."); } - const welcomeAgents: WelcomeTeamAgents = [lead, honey, bumble]; + const welcomeAgents: WelcomeTeamAgents = [lead, honey, pollen]; const leadPubkey = lead.pubkey; for (const index of [1, 2] as const) { const teammate = welcomeAgents[index]; diff --git a/desktop/src/features/onboarding/welcomeKickoff.test.mjs b/desktop/src/features/onboarding/welcomeKickoff.test.mjs index 736721434a..d0834e4425 100644 --- a/desktop/src/features/onboarding/welcomeKickoff.test.mjs +++ b/desktop/src/features/onboarding/welcomeKickoff.test.mjs @@ -31,12 +31,12 @@ function agent(name, personaId, pubkey) { const fizz = agent("Fizz", "builtin:fizz", "f".repeat(64)); const honey = agent("Honey", "builtin:honey", "h".repeat(64)); -const bumble = agent("Bumble", "builtin:bumble", "b".repeat(64)); +const pollen = agent("Pollen", "builtin:bumble", "b".repeat(64)); test("resolveWelcomeAgentSet orders agents by stable persona identity", () => { - assert.deepEqual(resolveWelcomeAgentSet([bumble, fizz, honey]), { + assert.deepEqual(resolveWelcomeAgentSet([pollen, fizz, honey]), { lead: fizz, - teammates: [honey, bumble], + teammates: [honey, pollen], }); assert.equal(resolveWelcomeAgentSet([fizz, honey]), null); }); @@ -44,29 +44,29 @@ test("resolveWelcomeAgentSet orders agents by stable persona identity", () => { test("opener uses current agent names and requests bounded simultaneous intros", () => { const opener = buildWelcomeKickoffOpener({ ...fizz, name: "Fizzy" }, [ { ...honey, name: "Honeybee" }, - bumble, + pollen, ]); assert.match(opener, /I'm Fizzy/); - assert.match(opener, /@Honeybee and @Bumble/); + assert.match(opener, /@Honeybee and @Pollen/); assert.doesNotMatch(opener, /@@/); assert.match(opener, /sentence or two/); assert.match(opener, /Don't start any work yet/); }); test("teammates are not ready until every harness publishes online presence", () => { - assert.equal(areWelcomeTeammatesOnline([honey, bumble], undefined), false); + assert.equal(areWelcomeTeammatesOnline([honey, pollen], undefined), false); assert.equal( - areWelcomeTeammatesOnline([honey, bumble], { + areWelcomeTeammatesOnline([honey, pollen], { [honey.pubkey]: "online", - [bumble.pubkey]: "offline", + [pollen.pubkey]: "offline", }), false, ); assert.equal( - areWelcomeTeammatesOnline([honey, bumble], { + areWelcomeTeammatesOnline([honey, pollen], { [honey.pubkey]: "online", - [bumble.pubkey]: "online", + [pollen.pubkey]: "online", }), true, ); @@ -74,41 +74,41 @@ test("teammates are not ready until every harness publishes online presence", () test("readiness wait observes agents becoming online without navigation", async () => { let reads = 0; - const ready = await waitForWelcomeTeammatesOnline([honey, bumble], { + const ready = await waitForWelcomeTeammatesOnline([honey, pollen], { isCancelled: () => false, loadPresence: async () => { reads += 1; return reads < 3 - ? { [honey.pubkey]: "online", [bumble.pubkey]: "offline" } - : { [honey.pubkey]: "online", [bumble.pubkey]: "online" }; + ? { [honey.pubkey]: "online", [pollen.pubkey]: "offline" } + : { [honey.pubkey]: "online", [pollen.pubkey]: "online" }; }, pollMs: 0, waitMs: 1_000, }); - assert.deepEqual(ready, [honey, bumble]); + assert.deepEqual(ready, [honey, pollen]); assert.equal(reads, 3); }); test("readiness wait retries transient presence failures", async () => { let reads = 0; - const ready = await waitForWelcomeTeammatesOnline([honey, bumble], { + const ready = await waitForWelcomeTeammatesOnline([honey, pollen], { isCancelled: () => false, loadPresence: async () => { reads += 1; if (reads === 1) throw new Error("relay unavailable"); - return { [honey.pubkey]: "online", [bumble.pubkey]: "online" }; + return { [honey.pubkey]: "online", [pollen.pubkey]: "online" }; }, pollMs: 0, waitMs: 1_000, }); - assert.deepEqual(ready, [honey, bumble]); + assert.deepEqual(ready, [honey, pollen]); assert.equal(reads, 2); }); test("readiness wait cancels when Welcome loses focus", async () => { - const ready = await waitForWelcomeTeammatesOnline([honey, bumble], { + const ready = await waitForWelcomeTeammatesOnline([honey, pollen], { isCancelled: () => true, loadPresence: async () => { throw new Error("cancelled waits must not query"); @@ -152,23 +152,23 @@ test("closer degrades coherently for partial and total startup failure", () => { assert.match(buildWelcomeKickoffCloser([]), /What can we help you build/); assert.match(buildWelcomeKickoffCloser(["Honey"]), /Honey is having trouble/); assert.match( - buildWelcomeKickoffCloser(["Honey", "Bumble"]), - /Honey and Bumble couldn't start/, + buildWelcomeKickoffCloser(["Honey", "Pollen"]), + /Honey and Pollen couldn't start/, ); assert.match( - buildWelcomeKickoffCloser(["Honey", "Bumble"]), + buildWelcomeKickoffCloser(["Honey", "Pollen"]), /I'm still here to help/, ); }); test("closer names teammates that did not reply before the intro wait", () => { assert.match( - buildWelcomeKickoffCloser([], ["Bumble"]), - /Bumble is taking longer to reply/, + buildWelcomeKickoffCloser([], ["Pollen"]), + /Pollen is taking longer to reply/, ); assert.match( - buildWelcomeKickoffCloser(["Honey"], ["Bumble"]), - /Honey and Bumble are taking longer than expected/, + buildWelcomeKickoffCloser(["Honey"], ["Pollen"]), + /Honey and Pollen are taking longer than expected/, ); }); @@ -188,7 +188,7 @@ test("running teammates restart when their allowlist does not include the lead", assert.equal( welcomeTeammateNeedsRestart( { - ...bumble, + ...pollen, status: "running", respondTo: "allowlist", respondToAllowlist: [honey.pubkey], @@ -241,7 +241,7 @@ test("owner-only-access policy still restarts running teammates for runtime chan }); test("opener keeps partial-readiness warm and mentions only online teammates", () => { - const agentSet = { lead: fizz, teammates: [honey, bumble] }; + const agentSet = { lead: fizz, teammates: [honey, pollen] }; const introTeammates = selectWelcomeKickoffIntroTeammates( agentSet.teammates, [honey], @@ -258,12 +258,12 @@ test("opener keeps partial-readiness warm and mentions only online teammates", ( assert.doesNotMatch(input.content, /@@/); assert.doesNotMatch( input.content, - /Bumble.*trouble|couldn't start|taking longer/i, + /Pollen.*trouble|couldn't start|taking longer/i, ); }); test("opener greets the owner by name and tags their pubkey", () => { - const agentSet = { lead: fizz, teammates: [honey, bumble] }; + const agentSet = { lead: fizz, teammates: [honey, pollen] }; const owner = { pubkey: "owner-pubkey-hex", displayName: "Morgan" }; const input = buildWelcomeKickoffOpenerSendInput( agentSet, @@ -274,7 +274,7 @@ test("opener greets the owner by name and tags their pubkey", () => { assert.deepEqual(input.mentionPubkeys, [ honey.pubkey, - bumble.pubkey, + pollen.pubkey, owner.pubkey, ]); assert.match(input.content, /^Hi @Morgan, I'm Fizz\./); @@ -283,7 +283,7 @@ test("opener greets the owner by name and tags their pubkey", () => { }); test("opener falls back to an unnamed greeting when the display name is missing", () => { - const agentSet = { lead: fizz, teammates: [honey, bumble] }; + const agentSet = { lead: fizz, teammates: [honey, pollen] }; const owner = { pubkey: "owner-pubkey-hex", displayName: " " }; const input = buildWelcomeKickoffOpenerSendInput( agentSet, @@ -299,7 +299,7 @@ test("opener falls back to an unnamed greeting when the display name is missing" }); test("opener greets and tags the owner even when no teammates come online", () => { - const agentSet = { lead: fizz, teammates: [honey, bumble] }; + const agentSet = { lead: fizz, teammates: [honey, pollen] }; const input = buildWelcomeKickoffOpenerSendInput(agentSet, [], "welcome-1", { pubkey: "owner-pubkey-hex", displayName: "Morgan", @@ -311,7 +311,7 @@ test("opener greets and tags the owner even when no teammates come online", () = }); test("opener does not duplicate the owner pubkey if already mentioned", () => { - const agentSet = { lead: fizz, teammates: [honey, bumble] }; + const agentSet = { lead: fizz, teammates: [honey, pollen] }; const input = buildWelcomeKickoffOpenerSendInput( agentSet, [honey], @@ -323,12 +323,12 @@ test("opener does not duplicate the owner pubkey if already mentioned", () => { }); test("opener degrades to one seeded Fizz message when no teammate comes online", () => { - const agentSet = { lead: fizz, teammates: [honey, bumble] }; + const agentSet = { lead: fizz, teammates: [honey, pollen] }; const input = buildWelcomeKickoffOpenerSendInput(agentSet, [], "welcome-1"); assert.deepEqual(input.mentionPubkeys, []); assert.equal(input.additionalMarkers.length, 1); - assert.match(input.content, /I'm here with Honey and Bumble/); + assert.match(input.content, /I'm here with Honey and Pollen/); assert.match(input.content, /What can we help you build/); assert.doesNotMatch( input.content, @@ -337,11 +337,11 @@ test("opener degrades to one seeded Fizz message when no teammate comes online", }); test("readiness wait returns the subset that became online by the deadline", async () => { - const online = await waitForWelcomeTeammatesOnline([honey, bumble], { + const online = await waitForWelcomeTeammatesOnline([honey, pollen], { isCancelled: () => false, loadPresence: async () => ({ [honey.pubkey]: "online", - [bumble.pubkey]: "offline", + [pollen.pubkey]: "offline", }), pollMs: 0, waitMs: 0, @@ -363,7 +363,7 @@ function relayEvent({ id, pubkey, createdAt = 1, tags = [], content = "" }) { } test("closer classification sees replies that arrive during the final beat", async () => { - const agentSet = { lead: fizz, teammates: [honey, bumble] }; + const agentSet = { lead: fizz, teammates: [honey, pollen] }; const opener = relayEvent({ id: "opener", pubkey: fizz.pubkey, @@ -374,7 +374,7 @@ test("closer classification sees replies that arrive during the final beat", asy const beforeBeat = classifyWelcomeKickoffResolution(events, opener, agentSet); assert.deepEqual( beforeBeat.unresolved.map((agent) => agent.name), - ["Honey", "Bumble"], + ["Honey", "Pollen"], ); const beat = waitForWelcomeKickoffBeat({ waitMs: 5 }); @@ -394,7 +394,7 @@ test("closer classification sees replies that arrive during the final beat", asy const afterBeat = classifyWelcomeKickoffResolution(events, opener, agentSet); assert.deepEqual( afterBeat.unresolved.map((agent) => agent.name), - ["Bumble"], + ["Pollen"], ); }); @@ -421,11 +421,11 @@ const kickoffOpener = relayEvent({ // opener and never the intros, and the closer stalled until the user happened // to click into the thread. Merging the opener's subtree in is the fix. test("intro replies reach the closer classification without the user opening the thread", () => { - const agentSet = { lead: fizz, teammates: [honey, bumble] }; + const agentSet = { lead: fizz, teammates: [honey, pollen] }; const channelEvents = [kickoffOpener]; const openerReplies = [ introReply("honey-intro", honey.pubkey, kickoffOpener.id), - introReply("bumble-intro", bumble.pubkey, kickoffOpener.id), + introReply("pollen-intro", pollen.pubkey, kickoffOpener.id), ]; // Pin the pre-fix behaviour: on the channel events alone, both teammates @@ -436,7 +436,7 @@ test("intro replies reach the closer classification without the user opening the kickoffOpener, agentSet, ).unresolved.map((agent) => agent.name), - ["Honey", "Bumble"], + ["Honey", "Pollen"], ); // With the subtree merged in, the same intros resolve the kickoff. @@ -455,12 +455,12 @@ test("merging the opener subtree never double-counts an already-visible reply", // An open thread feeds the same replies in through both sources. const merged = mergeKickoffEvents( [kickoffOpener, honeyIntro], - [honeyIntro, introReply("bumble-intro", bumble.pubkey, kickoffOpener.id)], + [honeyIntro, introReply("pollen-intro", pollen.pubkey, kickoffOpener.id)], ); assert.deepEqual( merged.map((event) => event.id), - ["opener", "honey-intro", "bumble-intro"], + ["opener", "honey-intro", "pollen-intro"], ); }); diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index f0e530172e..18af9893b4 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -2406,9 +2406,9 @@ function resetMockPersonas(config?: E2eConfig) { }, { id: "builtin:bumble", - display_name: "Bumble", + display_name: "Pollen", avatar_url: null, - system_prompt: "You are Bumble.", + system_prompt: "You are Pollen.", }, ]; mockPersonas = builtInPersonas.map((persona) => ({ diff --git a/desktop/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts index 7a3925eb69..e191d29304 100644 --- a/desktop/tests/e2e/agents.spec.ts +++ b/desktop/tests/e2e/agents.spec.ts @@ -237,14 +237,14 @@ test("catalog hides built-ins and shows the shared-agent empty state", async ({ await page.getByTestId("open-agents-view").click(); await expect(page.getByTestId("agents-library-personas")).toBeVisible(); - for (const personaName of ["Fizz", "Honey", "Bumble"]) { + for (const personaName of ["Fizz", "Honey", "Pollen"]) { await expect(page.getByTestId("agents-library-personas")).toContainText( personaName, ); } await openPersonaCatalog(page); - for (const personaName of ["Fizz", "Honey", "Bumble"]) { + for (const personaName of ["Fizz", "Honey", "Pollen"]) { await expect(page.getByTestId("persona-catalog-dialog")).not.toContainText( personaName, ); diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index 801000189f..fc4dd8c546 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -2429,7 +2429,7 @@ test("agent profile popover shows its owner", async ({ page }) => { searchProfiles: [ { pubkey: OWNED_AGENT_PROFILE_PUBKEY, - displayName: "Bumble", + displayName: "Pollen", ownerPubkey: TEST_IDENTITIES.bob.pubkey, isAgent: true, }, @@ -2440,16 +2440,16 @@ test("agent profile popover shows its owner", async ({ page }) => { await expect(page.getByTestId("chat-title")).toHaveText("general"); await waitForMockLiveSubscription(page, "general"); - await emitMockMessage(page, "general", "Bumble checking in.", { + await emitMockMessage(page, "general", "Pollen checking in.", { pubkey: OWNED_AGENT_PROFILE_PUBKEY, }); await waitForTimelineSettled(page); - const bumbleMessage = page + const pollenMessage = page .getByTestId("message-row") - .filter({ hasText: "Bumble checking in." }) + .filter({ hasText: "Pollen checking in." }) .first(); - await bumbleMessage.locator("button").first().hover(); + await pollenMessage.locator("button").first().hover(); const profilePopover = page.locator( '[data-testid="user-profile-popover"][data-state="open"]', @@ -2469,7 +2469,7 @@ test("agent profile popover labels an agent owned by the viewer as you", async ( searchProfiles: [ { pubkey: OWNED_AGENT_PROFILE_PUBKEY, - displayName: "Bumble", + displayName: "Pollen", ownerPubkey: MOCK_VIEWER_PUBKEY, isAgent: true, }, @@ -2480,16 +2480,16 @@ test("agent profile popover labels an agent owned by the viewer as you", async ( await expect(page.getByTestId("chat-title")).toHaveText("general"); await waitForMockLiveSubscription(page, "general"); - await emitMockMessage(page, "general", "Bumble checking in.", { + await emitMockMessage(page, "general", "Pollen checking in.", { pubkey: OWNED_AGENT_PROFILE_PUBKEY, }); await waitForTimelineSettled(page); - const bumbleMessage = page + const pollenMessage = page .getByTestId("message-row") - .filter({ hasText: "Bumble checking in." }) + .filter({ hasText: "Pollen checking in." }) .first(); - await bumbleMessage.locator("button").first().hover(); + await pollenMessage.locator("button").first().hover(); const profilePopover = page.locator( '[data-testid="user-profile-popover"][data-state="open"]', @@ -2509,7 +2509,7 @@ test("agent profile popover falls back to the owner's pubkey", async ({ searchProfiles: [ { pubkey: OWNED_AGENT_PROFILE_PUBKEY, - displayName: "Bumble", + displayName: "Pollen", ownerPubkey: CASEY_PROFILE_PUBKEY, isAgent: true, }, @@ -2520,16 +2520,16 @@ test("agent profile popover falls back to the owner's pubkey", async ({ await expect(page.getByTestId("chat-title")).toHaveText("general"); await waitForMockLiveSubscription(page, "general"); - await emitMockMessage(page, "general", "Bumble checking in.", { + await emitMockMessage(page, "general", "Pollen checking in.", { pubkey: OWNED_AGENT_PROFILE_PUBKEY, }); await waitForTimelineSettled(page); - const bumbleMessage = page + const pollenMessage = page .getByTestId("message-row") - .filter({ hasText: "Bumble checking in." }) + .filter({ hasText: "Pollen checking in." }) .first(); - await bumbleMessage.locator("button").first().hover(); + await pollenMessage.locator("button").first().hover(); const profilePopover = page.locator( '[data-testid="user-profile-popover"][data-state="open"]', diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts index 2d8e58492f..b6b43d5e19 100644 --- a/desktop/tests/e2e/onboarding.spec.ts +++ b/desktop/tests/e2e/onboarding.spec.ts @@ -3217,7 +3217,7 @@ test("first-run onboarding posts the live Fizz kickoff", async ({ page }) => { "Hi Morty QA, I'm Fizz. Welcome to Buzz.", ); await expect(page.getByTestId("message-timeline")).toContainText( - "Honey and Bumble, introduce yourselves", + "Honey and Pollen, introduce yourselves", ); }); diff --git a/docs/welcome-kickoff-silent-failures.md b/docs/welcome-kickoff-silent-failures.md index 58a4a5fa95..df9d5027bf 100644 --- a/docs/welcome-kickoff-silent-failures.md +++ b/docs/welcome-kickoff-silent-failures.md @@ -68,8 +68,8 @@ prompt fix in §2 and the closer fix in §1 are the same change in two places. ## 1. Wrong story: the closer speaks on a timer **Status: fixed on this branch. Observed 2026-07-18, 14:26.** Opener at 2:26. At 2:26+15s Fizz -posted *"Honey and Bumble are taking longer than expected. I'm still here to -help."* Honey and Bumble posted good intros at 2:27. The false story was never +posted *"Honey and Pollen are taking longer than expected. I'm still here to +help."* Honey and Pollen posted good intros at 2:27. The false story was never corrected, because it was already stamped final. ### Mechanism @@ -79,7 +79,7 @@ corrected, because it was already stamped final. 2. It fires. `classifyWelcomeKickoffResolution` (`:292`) splits teammates into `failed` (fact-based, via `failedAfterKickoff`) and `unresolved` (**merely no intro seen yet**). -3. `unresolved.length > 0` → `buildWelcomeKickoffCloser([], ["Honey","Bumble"])` +3. `unresolved.length > 0` → `buildWelcomeKickoffCloser([], ["Honey","Pollen"])` → the "taking longer" text + the CTA (`:253`). 4. It posts **with `closerMarker`** (`sendWelcomeKickoffCloser`, `:443`). That marker is **terminal**: every later pass early-returns on it (`:703`) and the @@ -158,9 +158,9 @@ Codex specifically. Observed on the Codex runtime (`codex-acp`), never reproduced on Claude Code. 21+ replies deep, each an acknowledgement of the previous acknowledgement: -> **Bumble:** `@Fizz` parked; no further replies from me until there's work. +> **Pollen:** `@Fizz` parked; no further replies from me until there's work. > **Honey:** `@Fizz` understood. I won't reply again unless there's a task for me. -> **Fizz:** `@Honey` `@Bumble` acknowledged — stay parked until `@morgan` brings a real task. +> **Fizz:** `@Honey` `@Pollen` acknowledged — stay parked until `@morgan` brings a real task. **The content was the tell: every agent was trying to end the conversation, and announcing it is what kept it alive.** The agents were not malfunctioning — they @@ -298,7 +298,7 @@ All hard-coded client-side; only teammate intro replies are LLM-generated. |---|---|---|---| | 1 | Provider fallback ("connect to an AI provider in Settings…") | Readiness check fails before kickoff | Fizz (`provider-required.v1`) | | 2 | Happy-path opener | Team online | Fizz (`opener.v1`) | -| 3 | Degraded opener ("I'm here with Honey and Bumble…") | Fizz online, zero teammates online within 60s | Fizz (opener + closer markers) | +| 3 | Degraded opener ("I'm here with Honey and Pollen…") | Fizz online, zero teammates online within 60s | Fizz (opener + closer markers) | | 4 | Closer variants (clean / failed / slow) | 3s beat after intros resolve, **or the 120s intro backstop** — see [§1](#1-wrong-story-the-closer-speaks-on-a-timer) | Fizz (`closer.v1`) | | 5 | Setup-mode nudge ("here's what you still need to configure") | Agent spawns but requirements check fails (e.g. missing API key) | The agent process itself (buzz-acp setup-listener mode) | @@ -447,7 +447,7 @@ the human: | Turn | Trigger | `p` tags | Classified | |---|---|---|---| -| Honey/Bumble | Fizz: *"…until `@morgan` brings a real task"* | Honey, Bumble, **morgan** | **human → MUST reply** | +| Honey/Pollen | Fizz: *"…until `@morgan` brings a real task"* | Honey, Pollen, **morgan** | **human → MUST reply** | | Fizz | Honey: *"@Fizz understood"* | Fizz | agent → optional | It exempts only the leg that happens not to name the human — cutting 1 of 3 legs