mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
Carl
Wes
Carl
parent
d12d825778
commit
076081bfc6
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
@@ -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"]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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(¤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");
|
||||
}
|
||||
}
|
||||
@@ -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"],
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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"]',
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user