Rename Bumble agent to Pollen (#5864)

## Summary
- Rename the built-in Bumble agent to Pollen across desktop, onboarding,
docs, and test fixtures.
- Migrate existing stock definitions and instances in place while
preserving customized fields and the stable persona coordinate.
- Reserve the Pollen name by removing it from Fizz's generated-name
pool.

## Validation
- Pre-push desktop checks, typecheck, 4,791 frontend tests, Tauri
clippy, and 2,432 native tests
- Desktop E2E build

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Kenny Lopez <klopez4212@gmail.com>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <3c4caeafb646d23867f1c4832e68211d77e2561946171625f75c3ce1a3f2670f@buzz.block.builderlab.xyz>
Co-authored-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
This commit is contained in:
klopez4212
2026-08-17 10:49:03 -07:00
committed by GitHub
co-authored by Carl Wes Carl
parent d12d825778
commit 076081bfc6
22 changed files with 1157 additions and 125 deletions

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

+2 -14
View File
@@ -1034,19 +1034,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
@@ -1297,9 +1285,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"]
+103 -10
View File
@@ -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<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.
@@ -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<Vec<(String, ProfileReconcileData)>, String> {
let state = app.state::<AppState>();
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::<AppState>();
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<ProfileReconcileOutcome, String> {
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
@@ -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::<AppState>();
@@ -213,6 +216,14 @@ pub async fn apply_workspace(
let state = restore_app.state::<AppState>();
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
@@ -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,
@@ -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)
@@ -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::<AppState>();
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::<AppState>();
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,
+6 -6
View File
@@ -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;
+862
View File
@@ -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::<Vec<serde_json::Value>>(&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::<std::collections::HashSet<_>>();
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>) -> 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<String, (String, String)> {
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<String>,
}
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<String>,
}
#[derive(serde::Deserialize)]
#[serde(untagged)]
enum StoredProfileReconcileQueueEntry {
Current(CurrentProfileReconcileQueueEntry),
Legacy(String),
}
impl<'de> serde::Deserialize<'de> for ProfileReconcileQueueEntry {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
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<Vec<ProfileReconcileQueueEntry>, 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<String, String> {
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<String, serde_json::Value>,
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<String, serde_json::Value>,
) -> 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(&current_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(&current_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");
}
}
@@ -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"),
@@ -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[],
@@ -56,7 +56,7 @@ function isRelayMembershipDeniedError(error: unknown): boolean {
const STARTER_PERSONA_ANIMATIONS: Record<string, string> = {
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,
);
@@ -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";
@@ -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
@@ -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,
@@ -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];
@@ -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"],
);
});
+2 -2
View File
@@ -2408,9 +2408,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) => ({
+2 -2
View File
@@ -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,
);
+15 -15
View File
@@ -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"]',
+1 -1
View File
@@ -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",
);
});
+7 -7
View File
@@ -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