mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
feat(desktop): unified AgentRecord groundwork — record-first resolution + runtime materialization (Phase 1A.1) (#1618)
Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
@@ -70,7 +70,9 @@ const overrides = new Map([
|
||||
// across the local-archive + agent-metric-archive PR series. store_tests.rs
|
||||
// (~731 lines) is under 1000 so needs no override.
|
||||
["src-tauri/src/archive/mod_tests.rs", 1208],
|
||||
["src-tauri/src/commands/agents.rs", 1443],
|
||||
// unified-agent-model 1A.1: profile reconcile split to agents_profile.rs,
|
||||
// ratcheting 1443 -> 1295. Queued to split further in the A2 fold.
|
||||
["src-tauri/src/commands/agents.rs", 1295],
|
||||
// #1418 read-path fix: get_thread_replies' blocker fix (shared TIMELINE_KINDS
|
||||
// const + build_thread_replies_filter helper, mirroring the channel sibling so
|
||||
// the two p-gate filters can't drift) plus two guard unit tests. The file was
|
||||
@@ -90,7 +92,9 @@ const overrides = new Map([
|
||||
// queued to split with the rest of this list.
|
||||
// +4 lines: adopt shared create_symlink wrapper (behavior-preserving refactor
|
||||
// for multi-line rustfmt expansion of the skills symlink call site).
|
||||
["src-tauri/src/managed_agents/nest.rs", 1575],
|
||||
// unified-agent-model 1A.1: inline test module moved to nest/tests.rs,
|
||||
// ratcheting 1575 -> 679 (under the 1000 default; entry kept as a ratchet).
|
||||
["src-tauri/src/managed_agents/nest.rs", 679],
|
||||
// harness-persona-sync: persona-runtime resolution threaded into the spawn
|
||||
// path here. Load-bearing feature growth; queued to split in the resolver
|
||||
// unify refactor followup. +26 for resolve_effective_prompt_model_provider
|
||||
@@ -153,20 +157,26 @@ const overrides = new Map([
|
||||
// same-runtime / alias / sentinel / non-override / persona-less test matrix
|
||||
// (~135 lines, mostly tests) so a deliberate Custom pin survives the update
|
||||
// path instead of being dropped back to inherit. Load-bearing, not debt.
|
||||
["src-tauri/src/managed_agents/discovery.rs", 1259],
|
||||
// unified-agent-model 1A.1: inline test module moved to discovery/tests.rs,
|
||||
// ratcheting 1259 -> 802 (under the 1000 default; entry kept as a ratchet).
|
||||
["src-tauri/src/managed_agents/discovery.rs", 802],
|
||||
// migration_tests.rs carries the harness-sync migration coverage plus the
|
||||
// patch_json_records owner-only writeback regression test (SECURITY.md:90
|
||||
// crash-safe 0o600 fallback). Load-bearing security + feature coverage, not
|
||||
// generic debt growth. Approved override; still queued to split. Event-sync
|
||||
// (persona/team event reconcile) tests were split out to event_sync_tests.rs
|
||||
// and the limit ratcheted 1410 → 1110.
|
||||
// unified-agent-model 1A.1: materialize tests live with their module in
|
||||
// migration/materialize.rs; ratchet held at 1110.
|
||||
["src-tauri/src/migration_tests.rs", 1110],
|
||||
["src-tauri/src/nostr_convert.rs", 1126],
|
||||
["src/shared/api/relayClientSession.ts", 1022],
|
||||
// Boot-time event sync (persona/team/agent event reconcile) was split out
|
||||
// to event_sync.rs, ratcheting this limit 1575 → 1310. Remaining content is
|
||||
// the pre-identity data migrations; still queued to split further.
|
||||
["src-tauri/src/migration.rs", 1310],
|
||||
// unified-agent-model 1A.1: materialize_agent_runtimes split to
|
||||
// migration/materialize.rs, ratcheting 1310 -> 1297.
|
||||
["src-tauri/src/migration.rs", 1297],
|
||||
// onMarkRead + isUnread prop threading (mirrors the onMarkUnread prop
|
||||
// already here) for the single-toggle mark-read/unread menu item — a small
|
||||
// overage from load-bearing per-message plumbing, not generic debt growth.
|
||||
|
||||
@@ -12,9 +12,9 @@ use crate::{
|
||||
NormalizedField, RuntimeConfigSurface, SessionConfigCache,
|
||||
},
|
||||
},
|
||||
current_instance_id, effective_agent_command, known_acp_runtime, load_managed_agents,
|
||||
load_personas, resolve_effective_prompt_model_provider, save_managed_agents,
|
||||
sync_managed_agent_processes, KnownAcpRuntime, ManagedAgentRecord, PersonaRecord,
|
||||
current_instance_id, known_acp_runtime, load_managed_agents, load_personas,
|
||||
resolve_effective_prompt_model_provider, save_managed_agents, sync_managed_agent_processes,
|
||||
KnownAcpRuntime, ManagedAgentRecord, PersonaRecord,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -242,11 +242,7 @@ pub async fn get_agent_config_surface(
|
||||
};
|
||||
|
||||
let personas = load_personas(&app).unwrap_or_default();
|
||||
let effective_cmd = effective_agent_command(
|
||||
record.persona_id.as_deref(),
|
||||
&personas,
|
||||
record.agent_command_override.as_deref(),
|
||||
);
|
||||
let effective_cmd = crate::managed_agents::record_agent_command(&record, &personas);
|
||||
let runtime_meta = known_acp_runtime(&effective_cmd);
|
||||
let session_cache = state.get_session_cache(&pubkey);
|
||||
|
||||
@@ -516,6 +512,14 @@ mod tests {
|
||||
last_error: None,
|
||||
respond_to: RespondTo::OwnerOnly,
|
||||
respond_to_allowlist: vec![],
|
||||
display_name: None,
|
||||
slug: None,
|
||||
runtime: None,
|
||||
name_pool: Vec::new(),
|
||||
is_builtin: false,
|
||||
is_active: true,
|
||||
source_team: None,
|
||||
source_team_persona_slug: None,
|
||||
relay_mesh: None,
|
||||
agent_command_override: None,
|
||||
persona_source_version: None,
|
||||
|
||||
@@ -62,11 +62,7 @@ pub async fn get_agent_models(
|
||||
// so model discovery runs against the persona's current harness, not the
|
||||
// frozen record snapshot. An explicit per-agent override wins.
|
||||
let personas = load_personas(&app).unwrap_or_default();
|
||||
let effective_command = crate::managed_agents::effective_agent_command(
|
||||
record.persona_id.as_deref(),
|
||||
&personas,
|
||||
record.agent_command_override.as_deref(),
|
||||
);
|
||||
let effective_command = crate::managed_agents::record_agent_command(record, &personas);
|
||||
|
||||
let args = normalize_agent_args(&effective_command, record.agent_args.clone());
|
||||
|
||||
@@ -936,11 +932,7 @@ pub async fn update_managed_agent(
|
||||
// not the frozen snapshot, so an inherited harness picks the right
|
||||
// default avatar.
|
||||
let personas = load_personas(&app).unwrap_or_default();
|
||||
let effective_command = crate::managed_agents::effective_agent_command(
|
||||
record.persona_id.as_deref(),
|
||||
&personas,
|
||||
record.agent_command_override.as_deref(),
|
||||
);
|
||||
let effective_command = crate::managed_agents::record_agent_command(record, &personas);
|
||||
let avatar_url = record
|
||||
.avatar_url
|
||||
.clone()
|
||||
|
||||
@@ -843,6 +843,14 @@ pub async fn create_managed_agent(
|
||||
last_error: None,
|
||||
respond_to: input.respond_to,
|
||||
respond_to_allowlist: respond_to_allowlist.clone(),
|
||||
display_name: None,
|
||||
slug: None,
|
||||
runtime: None,
|
||||
name_pool: Vec::new(),
|
||||
is_builtin: false,
|
||||
is_active: true,
|
||||
source_team: None,
|
||||
source_team_persona_slug: None,
|
||||
relay_mesh: relay_mesh.clone(),
|
||||
};
|
||||
|
||||
@@ -975,27 +983,6 @@ pub async fn create_managed_agent(
|
||||
}
|
||||
|
||||
/// Data needed for background profile reconciliation after agent start.
|
||||
pub(crate) struct ProfileReconcileData {
|
||||
pub(crate) private_key_nsec: String,
|
||||
pub(crate) name: String,
|
||||
pub(crate) relay_url: String,
|
||||
/// Expected avatar URL for the published profile. `None` for legacy records
|
||||
/// that predate the `avatar_url` field — these will be backfilled from the
|
||||
/// relay's existing kind:0 profile on first reconciliation.
|
||||
pub(crate) avatar_url: Option<String>,
|
||||
pub(crate) auth_tag: Option<String>,
|
||||
/// The agent's pubkey (hex). Needed to update the persisted record during
|
||||
/// avatar backfill migration.
|
||||
pub(crate) pubkey: String,
|
||||
/// The agent's command (e.g. "goose"). Used as fallback when no profile
|
||||
/// exists on the relay during avatar backfill.
|
||||
pub(crate) agent_command: String,
|
||||
/// Persona ID if this agent was created from a persona. Used during avatar
|
||||
/// backfill to recover the correct avatar from the persona record when the
|
||||
/// relay profile has been corrupted.
|
||||
pub(crate) persona_id: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn start_managed_agent(
|
||||
pubkey: String,
|
||||
@@ -1042,11 +1029,8 @@ 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::effective_agent_command(
|
||||
record.persona_id.as_deref(),
|
||||
&reconcile_personas,
|
||||
record.agent_command_override.as_deref(),
|
||||
);
|
||||
let reconcile_effective_command =
|
||||
crate::managed_agents::record_agent_command(record, &reconcile_personas);
|
||||
|
||||
let reconcile = ProfileReconcileData {
|
||||
private_key_nsec: record.private_key_nsec.clone(),
|
||||
@@ -1139,144 +1123,6 @@ pub async fn start_managed_agent(
|
||||
result
|
||||
}
|
||||
|
||||
/// Resolve the avatar to backfill for a legacy agent record (pre-PR-921, no
|
||||
/// stored `avatar_url`).
|
||||
///
|
||||
/// Priority: the persona's avatar wins, because the old reconciliation code
|
||||
/// could have overwritten the relay's kind:0 `picture` with the command default
|
||||
/// — making the relay an unreliable source for persona-backed agents. Only fall
|
||||
/// back to the relay's `picture`, then the command icon, for agents with no
|
||||
/// persona avatar to recover from.
|
||||
fn resolve_legacy_avatar(
|
||||
persona_avatar: Option<String>,
|
||||
relay_picture: Option<String>,
|
||||
agent_command: &str,
|
||||
) -> String {
|
||||
persona_avatar
|
||||
.or(relay_picture)
|
||||
.or_else(|| managed_agent_avatar_url(agent_command))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Reconcile an agent's kind:0 profile on the relay.
|
||||
///
|
||||
/// Queries the relay for the agent's existing profile and re-publishes if missing
|
||||
/// or stale (display_name or picture mismatch). This is fire-and-forget — errors
|
||||
/// are returned to the caller for logging but never block agent startup.
|
||||
///
|
||||
/// For legacy records (pre-PR-921) where `avatar_url` is `None`, this function
|
||||
/// backfills via `resolve_legacy_avatar` — preferring the persona record's avatar
|
||||
/// over the relay's `picture`, since the old code may have corrupted the relay
|
||||
/// profile — and persists the updated record. After backfill, normal
|
||||
/// reconciliation proceeds.
|
||||
///
|
||||
/// Query and publish target the relay returned by `effective_agent_relay_url`
|
||||
/// for every agent regardless of backend: an explicit per-agent `relay_url`
|
||||
/// wins, and a blank one falls back to the active workspace relay. This keeps
|
||||
/// reconciliation following the session's relay for never-pinned agents while
|
||||
/// honoring a deliberate pin wherever it points.
|
||||
pub(crate) async fn reconcile_agent_profile(
|
||||
state: &AppState,
|
||||
app: &AppHandle,
|
||||
agent_pubkey: &str,
|
||||
data: &ProfileReconcileData,
|
||||
) -> Result<(), 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),
|
||||
);
|
||||
|
||||
// Query the relay for the agent's existing kind:0 profile.
|
||||
let existing = query_agent_profile(state, &relay_url, agent_pubkey).await?;
|
||||
|
||||
// Resolve the expected avatar — backfilling for legacy records that have no
|
||||
// stored avatar_url yet.
|
||||
let expected_avatar = match data.avatar_url.as_deref() {
|
||||
Some(url) => url.to_string(),
|
||||
None => {
|
||||
// Legacy record: the relay profile may have been corrupted by the
|
||||
// old reconciliation code (it overwrote the persona avatar with the
|
||||
// command default), so the persona record is the authoritative source.
|
||||
let persona_avatar = data.persona_id.as_ref().and_then(|pid| {
|
||||
load_personas(app)
|
||||
.ok()?
|
||||
.into_iter()
|
||||
.find(|p| p.id == *pid)?
|
||||
.avatar_url
|
||||
});
|
||||
|
||||
let backfilled = resolve_legacy_avatar(
|
||||
persona_avatar,
|
||||
existing.as_ref().and_then(|info| info.picture.clone()),
|
||||
&data.agent_command,
|
||||
);
|
||||
|
||||
// Persist the backfilled avatar so this migration only runs once.
|
||||
if !backfilled.is_empty() {
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut records = load_managed_agents(app)?;
|
||||
if let Some(record) = records.iter_mut().find(|r| r.pubkey == data.pubkey) {
|
||||
record.avatar_url = Some(backfilled.clone());
|
||||
save_managed_agents(app, &records)?;
|
||||
}
|
||||
}
|
||||
|
||||
backfilled
|
||||
}
|
||||
};
|
||||
|
||||
let expected_avatar = if expected_avatar.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(expected_avatar)
|
||||
};
|
||||
|
||||
if !profile_needs_sync(existing.as_ref(), &data.name, expected_avatar.as_deref()) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let agent_keys = Keys::parse(&data.private_key_nsec)
|
||||
.map_err(|e| format!("failed to parse agent keys: {e}"))?;
|
||||
|
||||
sync_managed_agent_profile(
|
||||
state,
|
||||
&relay_url,
|
||||
&agent_keys,
|
||||
&data.name,
|
||||
expected_avatar.as_deref(),
|
||||
data.auth_tag.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Decide whether a published profile is missing or stale relative to the
|
||||
/// expected name and avatar. A missing profile always needs sync; a present
|
||||
/// one is stale when either the display name or picture diverges.
|
||||
fn profile_needs_sync(
|
||||
existing: Option<&crate::relay::AgentProfileInfo>,
|
||||
expected_name: &str,
|
||||
expected_avatar: Option<&str>,
|
||||
) -> bool {
|
||||
match existing {
|
||||
None => true,
|
||||
Some(info) => {
|
||||
let name_matches = info.display_name.as_deref() == Some(expected_name);
|
||||
let picture_matches = info.picture.as_deref() == expected_avatar;
|
||||
!name_matches || !picture_matches
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Async so the blocking body (disk reads/writes + process termination) runs off
|
||||
// the main UI thread via spawn_blocking. State is re-derived from the owned
|
||||
// AppHandle inside the closure (`State<'_, _>` is borrowed, MutexGuard is !Send).
|
||||
#[tauri::command]
|
||||
pub async fn stop_managed_agent(
|
||||
pubkey: String,
|
||||
@@ -1437,6 +1283,12 @@ pub fn get_managed_agent_log(
|
||||
// 3. Desktop's existing presence polling sees "offline" — UI updates automatically
|
||||
// No backend Tauri command needed. Presence IS the status.
|
||||
|
||||
#[path = "agents_profile.rs"]
|
||||
mod 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"]
|
||||
mod tests;
|
||||
|
||||
170
desktop/src-tauri/src/commands/agents_profile.rs
Normal file
170
desktop/src-tauri/src/commands/agents_profile.rs
Normal file
@@ -0,0 +1,170 @@
|
||||
//! Agent kind:0 profile reconciliation — split from `agents.rs` (file-size
|
||||
//! guard). Owns the reconcile data carrier, the legacy-avatar backfill, and
|
||||
//! the needs-sync predicate.
|
||||
|
||||
use tauri::AppHandle;
|
||||
|
||||
use crate::app_state::AppState;
|
||||
use crate::managed_agents::managed_agent_avatar_url;
|
||||
|
||||
use super::*;
|
||||
|
||||
pub(crate) struct ProfileReconcileData {
|
||||
pub(crate) private_key_nsec: String,
|
||||
pub(crate) name: String,
|
||||
pub(crate) relay_url: String,
|
||||
/// Expected avatar URL for the published profile. `None` for legacy records
|
||||
/// that predate the `avatar_url` field — these will be backfilled from the
|
||||
/// relay's existing kind:0 profile on first reconciliation.
|
||||
pub(crate) avatar_url: Option<String>,
|
||||
pub(crate) auth_tag: Option<String>,
|
||||
/// The agent's pubkey (hex). Needed to update the persisted record during
|
||||
/// avatar backfill migration.
|
||||
pub(crate) pubkey: String,
|
||||
/// The agent's command (e.g. "goose"). Used as fallback when no profile
|
||||
/// exists on the relay during avatar backfill.
|
||||
pub(crate) agent_command: String,
|
||||
/// Persona ID if this agent was created from a persona. Used during avatar
|
||||
/// backfill to recover the correct avatar from the persona record when the
|
||||
/// relay profile has been corrupted.
|
||||
pub(crate) persona_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Resolve the avatar to backfill for a legacy agent record (pre-PR-921, no
|
||||
/// stored `avatar_url`).
|
||||
///
|
||||
/// Priority: the persona's avatar wins, because the old reconciliation code
|
||||
/// could have overwritten the relay's kind:0 `picture` with the command default
|
||||
/// — making the relay an unreliable source for persona-backed agents. Only fall
|
||||
/// back to the relay's `picture`, then the command icon, for agents with no
|
||||
/// persona avatar to recover from.
|
||||
pub(super) fn resolve_legacy_avatar(
|
||||
persona_avatar: Option<String>,
|
||||
relay_picture: Option<String>,
|
||||
agent_command: &str,
|
||||
) -> String {
|
||||
persona_avatar
|
||||
.or(relay_picture)
|
||||
.or_else(|| managed_agent_avatar_url(agent_command))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Reconcile an agent's kind:0 profile on the relay.
|
||||
///
|
||||
/// Queries the relay for the agent's existing profile and re-publishes if missing
|
||||
/// or stale (display_name or picture mismatch). This is fire-and-forget — errors
|
||||
/// are returned to the caller for logging but never block agent startup.
|
||||
///
|
||||
/// For legacy records (pre-PR-921) where `avatar_url` is `None`, this function
|
||||
/// backfills via `resolve_legacy_avatar` — preferring the persona record's avatar
|
||||
/// over the relay's `picture`, since the old code may have corrupted the relay
|
||||
/// profile — and persists the updated record. After backfill, normal
|
||||
/// reconciliation proceeds.
|
||||
///
|
||||
/// Query and publish target the relay returned by `effective_agent_relay_url`
|
||||
/// for every agent regardless of backend: an explicit per-agent `relay_url`
|
||||
/// wins, and a blank one falls back to the active workspace relay. This keeps
|
||||
/// reconciliation following the session's relay for never-pinned agents while
|
||||
/// honoring a deliberate pin wherever it points.
|
||||
pub(crate) async fn reconcile_agent_profile(
|
||||
state: &AppState,
|
||||
app: &AppHandle,
|
||||
agent_pubkey: &str,
|
||||
data: &ProfileReconcileData,
|
||||
) -> Result<(), 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),
|
||||
);
|
||||
|
||||
// Query the relay for the agent's existing kind:0 profile.
|
||||
let existing = query_agent_profile(state, &relay_url, agent_pubkey).await?;
|
||||
|
||||
// Resolve the expected avatar — backfilling for legacy records that have no
|
||||
// stored avatar_url yet.
|
||||
let expected_avatar = match data.avatar_url.as_deref() {
|
||||
Some(url) => url.to_string(),
|
||||
None => {
|
||||
// Legacy record: the relay profile may have been corrupted by the
|
||||
// old reconciliation code (it overwrote the persona avatar with the
|
||||
// command default), so the persona record is the authoritative source.
|
||||
let persona_avatar = data.persona_id.as_ref().and_then(|pid| {
|
||||
load_personas(app)
|
||||
.ok()?
|
||||
.into_iter()
|
||||
.find(|p| p.id == *pid)?
|
||||
.avatar_url
|
||||
});
|
||||
|
||||
let backfilled = resolve_legacy_avatar(
|
||||
persona_avatar,
|
||||
existing.as_ref().and_then(|info| info.picture.clone()),
|
||||
&data.agent_command,
|
||||
);
|
||||
|
||||
// Persist the backfilled avatar so this migration only runs once.
|
||||
if !backfilled.is_empty() {
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut records = load_managed_agents(app)?;
|
||||
if let Some(record) = records.iter_mut().find(|r| r.pubkey == data.pubkey) {
|
||||
record.avatar_url = Some(backfilled.clone());
|
||||
save_managed_agents(app, &records)?;
|
||||
}
|
||||
}
|
||||
|
||||
backfilled
|
||||
}
|
||||
};
|
||||
|
||||
let expected_avatar = if expected_avatar.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(expected_avatar)
|
||||
};
|
||||
|
||||
if !profile_needs_sync(existing.as_ref(), &data.name, expected_avatar.as_deref()) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let agent_keys = Keys::parse(&data.private_key_nsec)
|
||||
.map_err(|e| format!("failed to parse agent keys: {e}"))?;
|
||||
|
||||
sync_managed_agent_profile(
|
||||
state,
|
||||
&relay_url,
|
||||
&agent_keys,
|
||||
&data.name,
|
||||
expected_avatar.as_deref(),
|
||||
data.auth_tag.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Decide whether a published profile is missing or stale relative to the
|
||||
/// expected name and avatar. A missing profile always needs sync; a present
|
||||
/// one is stale when either the display name or picture diverges.
|
||||
pub(super) fn profile_needs_sync(
|
||||
existing: Option<&crate::relay::AgentProfileInfo>,
|
||||
expected_name: &str,
|
||||
expected_avatar: Option<&str>,
|
||||
) -> bool {
|
||||
match existing {
|
||||
None => true,
|
||||
Some(info) => {
|
||||
let name_matches = info.display_name.as_deref() == Some(expected_name);
|
||||
let picture_matches = info.picture.as_deref() == expected_avatar;
|
||||
!name_matches || !picture_matches
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Async so the blocking body (disk reads/writes + process termination) runs off
|
||||
// the main UI thread via spawn_blocking. State is re-derived from the owned
|
||||
// AppHandle inside the closure (`State<'_, _>` is borrowed, MutexGuard is !Send).
|
||||
@@ -161,6 +161,14 @@ fn local_agent() -> ManagedAgentRecord {
|
||||
last_error: None,
|
||||
respond_to: crate::managed_agents::RespondTo::OwnerOnly,
|
||||
respond_to_allowlist: vec![],
|
||||
display_name: None,
|
||||
slug: None,
|
||||
runtime: None,
|
||||
name_pool: Vec::new(),
|
||||
is_builtin: false,
|
||||
is_active: true,
|
||||
source_team: None,
|
||||
source_team_persona_slug: None,
|
||||
relay_mesh: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,6 +176,16 @@ mod tests {
|
||||
last_error: Some("some runtime error".to_string()),
|
||||
respond_to: RespondTo::Allowlist,
|
||||
respond_to_allowlist: vec!["79be667e".to_string()],
|
||||
// Unified-model fields carry real values so the exclusion test
|
||||
// proves they are absent from the wire, not vacuously empty.
|
||||
display_name: Some("Display Name Secretish".to_string()),
|
||||
slug: Some("sample-slug".to_string()),
|
||||
runtime: Some("goose".to_string()),
|
||||
name_pool: vec!["poolname".to_string()],
|
||||
is_builtin: true,
|
||||
is_active: false,
|
||||
source_team: None,
|
||||
source_team_persona_slug: None,
|
||||
relay_mesh: None,
|
||||
}
|
||||
}
|
||||
@@ -237,6 +247,16 @@ mod tests {
|
||||
assert!(!json.contains("last_exit_code"));
|
||||
assert!(!json.contains("last_error"));
|
||||
assert!(!json.contains("some runtime error"));
|
||||
|
||||
// Unified-agent-model fields (Phase 1A) — deliberately NOT published
|
||||
// yet; adding them to the wire projection is a Phase 2 (relay
|
||||
// canonicalization) decision, not a record-shape side effect.
|
||||
assert!(!json.contains("display_name"), "leaked display_name");
|
||||
assert!(!json.contains("\"slug\""), "leaked slug");
|
||||
assert!(!json.contains("\"runtime\""), "leaked runtime");
|
||||
assert!(!json.contains("name_pool"), "leaked name_pool");
|
||||
assert!(!json.contains("is_builtin"), "leaked is_builtin");
|
||||
assert!(!json.contains("is_active"), "leaked is_active");
|
||||
assert!(!json.contains("backend_agent_id"));
|
||||
assert!(!json.contains("provider_binary_path"));
|
||||
assert!(!json.contains("relay_url"));
|
||||
|
||||
@@ -75,6 +75,14 @@ fn test_record() -> ManagedAgentRecord {
|
||||
last_error: None,
|
||||
respond_to: crate::managed_agents::types::RespondTo::OwnerOnly,
|
||||
respond_to_allowlist: vec![],
|
||||
display_name: None,
|
||||
slug: None,
|
||||
runtime: None,
|
||||
name_pool: Vec::new(),
|
||||
is_builtin: false,
|
||||
is_active: true,
|
||||
source_team: None,
|
||||
source_team_persona_slug: None,
|
||||
relay_mesh: None,
|
||||
agent_command_override: None,
|
||||
persona_source_version: None,
|
||||
|
||||
@@ -285,6 +285,40 @@ pub fn default_agent_command() -> String {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Record-first harness resolution (unified agent model, Phase 1A).
|
||||
///
|
||||
/// Resolution order:
|
||||
/// 1. explicit override (non-empty) — a deliberate per-instance pin;
|
||||
/// 2. the record's own `runtime` id mapped to its primary command —
|
||||
/// records materialize their runtime at create/migration time;
|
||||
/// 3. legacy fallback: the linked persona's `runtime` (records created
|
||||
/// before the unified model carry `persona_id` but no `runtime`);
|
||||
/// 4. `default_agent_command()`.
|
||||
pub fn record_agent_command(
|
||||
record: &crate::managed_agents::types::ManagedAgentRecord,
|
||||
personas: &[crate::managed_agents::types::PersonaRecord],
|
||||
) -> String {
|
||||
if let Some(pin) = record
|
||||
.agent_command_override
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
return pin.to_string();
|
||||
}
|
||||
|
||||
if let Some(command) = record
|
||||
.runtime
|
||||
.as_deref()
|
||||
.and_then(known_acp_runtime_exact)
|
||||
.and_then(|r| r.commands.first().copied())
|
||||
{
|
||||
return command.to_string();
|
||||
}
|
||||
|
||||
effective_agent_command(record.persona_id.as_deref(), personas, None)
|
||||
}
|
||||
|
||||
/// Resolve the agent command (harness) for a spawn/deploy/summary. The linked
|
||||
/// persona wins so persona harness edits propagate on the next spawn. An
|
||||
/// explicit per-instance override (`agent_command_override`) takes precedence.
|
||||
@@ -764,495 +798,4 @@ pub fn managed_agent_avatar_url(command: &str) -> Option<String> {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::{
|
||||
classify_runtime, create_time_agent_command_override, default_agent_command,
|
||||
divergent_agent_command_override, effective_agent_command, find_via_login_shell,
|
||||
managed_agent_avatar_url, normalize_agent_args, update_time_agent_command_override,
|
||||
BUZZ_AGENT_AVATAR_URL, CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, GOOSE_AVATAR_URL,
|
||||
};
|
||||
use crate::managed_agents::AcpAvailabilityStatus;
|
||||
|
||||
#[test]
|
||||
fn resolves_known_avatar_for_bare_command() {
|
||||
let avatar_url = managed_agent_avatar_url("goose").expect("goose avatar should resolve");
|
||||
|
||||
assert_eq!(avatar_url, GOOSE_AVATAR_URL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_known_avatar_for_command_paths_and_aliases() {
|
||||
assert_eq!(
|
||||
managed_agent_avatar_url("/usr/local/bin/codex-acp"),
|
||||
Some(CODEX_AVATAR_URL.to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
managed_agent_avatar_url("Claude Code"),
|
||||
Some(CLAUDE_CODE_AVATAR_URL.to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
managed_agent_avatar_url(r"C:\Tools\claude-agent-acp.exe"),
|
||||
Some(CLAUDE_CODE_AVATAR_URL.to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
managed_agent_avatar_url("/usr/local/bin/claude-code-acp"),
|
||||
Some(CLAUDE_CODE_AVATAR_URL.to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_for_unknown_commands() {
|
||||
assert!(managed_agent_avatar_url("custom-agent").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_agent_command_resolves_bundled_buzz_agent() {
|
||||
// The create-path default must be the bundled buzz-agent, never the
|
||||
// bare `goose` that isn't on PATH on a stock Windows install.
|
||||
assert_eq!(default_agent_command(), "buzz-agent");
|
||||
// And buzz-agent takes no `acp` arg — confirm no arg leakage from the default.
|
||||
assert_eq!(
|
||||
normalize_agent_args(&default_agent_command(), vec!["acp".into()]),
|
||||
Vec::<String>::new()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_claude_and_codex_args_to_empty() {
|
||||
assert_eq!(
|
||||
normalize_agent_args("claude-agent-acp", vec!["acp".into()]),
|
||||
Vec::<String>::new()
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_agent_args("claude-code-acp", vec!["acp".into()]),
|
||||
Vec::<String>::new()
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_agent_args("codex-acp", vec!["acp".into()]),
|
||||
Vec::<String>::new()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_buzz_agent_avatar() {
|
||||
assert_eq!(
|
||||
managed_agent_avatar_url("buzz-agent"),
|
||||
Some(BUZZ_AGENT_AVATAR_URL.to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
managed_agent_avatar_url("/usr/local/bin/buzz-agent"),
|
||||
Some(BUZZ_AGENT_AVATAR_URL.to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_buzz_agent_args_to_empty() {
|
||||
assert_eq!(
|
||||
normalize_agent_args("buzz-agent", Vec::new()),
|
||||
Vec::<String>::new()
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_agent_args("buzz-agent", vec!["acp".into()]),
|
||||
Vec::<String>::new()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_shell_lookup_treats_command_as_data() {
|
||||
let marker =
|
||||
std::env::temp_dir().join(format!("buzz-discovery-marker-{}", uuid::Uuid::new_v4()));
|
||||
let payload = format!("doesnotexist; touch {} #", marker.display());
|
||||
|
||||
let resolved = find_via_login_shell(&payload);
|
||||
|
||||
assert!(
|
||||
resolved.is_none(),
|
||||
"payload should not resolve to a command"
|
||||
);
|
||||
assert!(
|
||||
!marker.exists(),
|
||||
"shell lookup must not execute injected commands"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn explicit_path_resolution_ignores_non_executable_files() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("buzz-discovery-path-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&dir).expect("create temp dir");
|
||||
let bin = dir.join("buzz-acp");
|
||||
std::fs::write(&bin, "").expect("write placeholder");
|
||||
std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o644))
|
||||
.expect("chmod placeholder");
|
||||
|
||||
assert!(
|
||||
super::resolve_workspace_command(bin.to_str().expect("utf8 path")).is_none(),
|
||||
"non-executable placeholder must not resolve"
|
||||
);
|
||||
|
||||
std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755))
|
||||
.expect("chmod executable");
|
||||
assert_eq!(
|
||||
super::resolve_workspace_command(bin.to_str().expect("utf8 path")),
|
||||
Some(bin.clone())
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_available_when_adapter_found() {
|
||||
let (status, cmd, path) = classify_runtime(
|
||||
Some(("goose", PathBuf::from("/usr/local/bin/goose"))),
|
||||
None,
|
||||
false,
|
||||
);
|
||||
assert_eq!(status, AcpAvailabilityStatus::Available);
|
||||
assert_eq!(cmd.as_deref(), Some("goose"));
|
||||
assert_eq!(path.as_deref(), Some("/usr/local/bin/goose"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_adapter_missing_when_cli_present() {
|
||||
let (status, cmd, path) = classify_runtime(None, Some("claude"), true);
|
||||
assert_eq!(status, AcpAvailabilityStatus::AdapterMissing);
|
||||
assert!(cmd.is_none());
|
||||
assert!(path.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_not_installed_when_nothing_found() {
|
||||
let (status, cmd, path) = classify_runtime(None, Some("claude"), false);
|
||||
assert_eq!(status, AcpAvailabilityStatus::NotInstalled);
|
||||
assert!(cmd.is_none());
|
||||
assert!(path.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_not_installed_when_no_underlying_cli() {
|
||||
let (status, cmd, path) = classify_runtime(None, None, false);
|
||||
assert_eq!(status, AcpAvailabilityStatus::NotInstalled);
|
||||
assert!(cmd.is_none());
|
||||
assert!(path.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_cli_missing_when_adapter_found_but_cli_absent() {
|
||||
let (status, cmd, path) = classify_runtime(
|
||||
Some(("codex-acp", PathBuf::from("/opt/homebrew/bin/codex-acp"))),
|
||||
Some("codex"),
|
||||
false,
|
||||
);
|
||||
assert_eq!(status, AcpAvailabilityStatus::CliMissing);
|
||||
assert_eq!(cmd.as_deref(), Some("codex-acp"));
|
||||
assert_eq!(path.as_deref(), Some("/opt/homebrew/bin/codex-acp"));
|
||||
}
|
||||
|
||||
fn persona_with_runtime(
|
||||
id: &str,
|
||||
runtime: Option<&str>,
|
||||
) -> crate::managed_agents::PersonaRecord {
|
||||
crate::managed_agents::PersonaRecord {
|
||||
id: id.to_string(),
|
||||
display_name: id.to_string(),
|
||||
avatar_url: None,
|
||||
system_prompt: String::new(),
|
||||
runtime: runtime.map(str::to_string),
|
||||
model: None,
|
||||
provider: None,
|
||||
name_pool: Vec::new(),
|
||||
is_builtin: false,
|
||||
is_active: true,
|
||||
source_team: None,
|
||||
source_team_persona_slug: None,
|
||||
env_vars: std::collections::BTreeMap::new(),
|
||||
created_at: "2026-06-09T00:00:00Z".to_string(),
|
||||
updated_at: "2026-06-09T00:00:00Z".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_agent_command_explicit_override_wins() {
|
||||
// An explicit pin beats the persona's runtime.
|
||||
let personas = vec![persona_with_runtime("p1", Some("claude"))];
|
||||
assert_eq!(
|
||||
effective_agent_command(Some("p1"), &personas, Some("codex-acp")),
|
||||
"codex-acp"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_agent_command_inherits_persona_runtime() {
|
||||
// No override → persona runtime id maps to its primary command.
|
||||
let personas = vec![persona_with_runtime("p1", Some("claude"))];
|
||||
assert_eq!(
|
||||
effective_agent_command(Some("p1"), &personas, None),
|
||||
"claude-agent-acp"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_agent_command_empty_override_is_inherit() {
|
||||
// A blank/whitespace override is treated as "inherit", not a pin.
|
||||
let personas = vec![persona_with_runtime("p1", Some("goose"))];
|
||||
assert_eq!(
|
||||
effective_agent_command(Some("p1"), &personas, Some(" ")),
|
||||
"goose"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_agent_command_falls_back_to_default() {
|
||||
// No override, no persona runtime, and a deleted persona all fall back
|
||||
// to the bundled default.
|
||||
let personas = vec![persona_with_runtime("p1", None)];
|
||||
assert_eq!(
|
||||
effective_agent_command(Some("p1"), &personas, None),
|
||||
default_agent_command()
|
||||
);
|
||||
assert_eq!(
|
||||
effective_agent_command(Some("gone"), &personas, None),
|
||||
default_agent_command()
|
||||
);
|
||||
assert_eq!(
|
||||
effective_agent_command(None, &personas, None),
|
||||
default_agent_command()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn divergent_override_none_when_picked_matches_persona_runtime() {
|
||||
// The persona-backed create/edit flow sends the persona's resolved
|
||||
// command. It must be treated as "inherit" (None), not a pin.
|
||||
let personas = vec![persona_with_runtime("p1", Some("goose"))];
|
||||
assert_eq!(
|
||||
divergent_agent_command_override(Some("p1"), &personas, Some("goose")),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn divergent_override_none_for_alternate_command_of_same_runtime() {
|
||||
// A client with only `claude-code-acp` installed sends that command for
|
||||
// a `claude` persona whose primary command is `claude-agent-acp`. Both
|
||||
// map to the `claude` runtime, so it inherits — string equality would
|
||||
// wrongly bake a pin (CRITICAL-3).
|
||||
let personas = vec![persona_with_runtime("p1", Some("claude"))];
|
||||
assert_eq!(
|
||||
divergent_agent_command_override(Some("p1"), &personas, Some("claude-code-acp")),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn divergent_override_some_when_picked_is_different_runtime() {
|
||||
// A deliberate pin to a different runtime is preserved.
|
||||
let personas = vec![persona_with_runtime("p1", Some("goose"))];
|
||||
assert_eq!(
|
||||
divergent_agent_command_override(Some("p1"), &personas, Some("codex-acp")),
|
||||
Some("codex-acp".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn divergent_override_none_for_empty_or_absent_pick() {
|
||||
// The "Inherit from persona" sentinel (empty) and a name-only edit
|
||||
// (absent) both clear the pin.
|
||||
let personas = vec![persona_with_runtime("p1", Some("goose"))];
|
||||
assert_eq!(
|
||||
divergent_agent_command_override(Some("p1"), &personas, Some(" ")),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
divergent_agent_command_override(Some("p1"), &personas, None),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_time_override_none_when_persona_runtime_not_installed() {
|
||||
// CRITICAL-3 (Case 3): a `claude`-persona agent created on a machine
|
||||
// where the claude adapter isn't installed. `resolvePersonaRuntime`
|
||||
// falls back to the default (`buzz-agent`) and sends THAT command with
|
||||
// `harness_override` false (the user did not pick it). At create this
|
||||
// is a fallback, not a deliberate pin — it must store `None` so the
|
||||
// agent inherits the persona's runtime once it's installed and the
|
||||
// persona is re-edited. Baking `Some("buzz-agent")` here is the exact
|
||||
// bug this resolver chain exists to kill.
|
||||
let personas = vec![persona_with_runtime("p1", Some("claude"))];
|
||||
assert_eq!(
|
||||
create_time_agent_command_override(Some("p1"), &personas, Some("buzz-agent"), false),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_time_override_some_when_user_deliberately_overrides_installed_runtime() {
|
||||
// Case 2 + deliberate override: the persona's `claude` runtime IS
|
||||
// available, but the user explicitly picked `codex` in a deploy dialog's
|
||||
// runtime selector ("overriding persona preferences"), so the frontend
|
||||
// sends `codex-acp` with `harness_override` true. This is a real pin and
|
||||
// MUST be preserved — returning `None` would silently swallow the
|
||||
// deliberate override and inherit `claude` on spawn.
|
||||
let personas = vec![persona_with_runtime("p1", Some("claude"))];
|
||||
assert_eq!(
|
||||
create_time_agent_command_override(Some("p1"), &personas, Some("codex-acp"), true),
|
||||
Some("codex-acp".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_time_override_none_when_persona_runtime_installed() {
|
||||
// Case 2: the persona's runtime is available, so `resolvePersonaRuntime`
|
||||
// sends the persona's own command with no override. Inherits — no pin.
|
||||
let personas = vec![persona_with_runtime("p1", Some("goose"))];
|
||||
assert_eq!(
|
||||
create_time_agent_command_override(Some("p1"), &personas, Some("goose"), false),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_time_override_preserves_selected_runtime_alias() {
|
||||
// A `claude` persona inherits the primary command `claude-agent-acp`,
|
||||
// but discovery may select an installed alias such as `claude-code-acp`.
|
||||
// When UI marks that create-time selection as explicit, preserve the
|
||||
// alias so the first spawn uses a command known to be installed.
|
||||
let personas = vec![persona_with_runtime("p1", Some("claude"))];
|
||||
assert_eq!(
|
||||
create_time_agent_command_override(
|
||||
Some("p1"),
|
||||
&personas,
|
||||
Some("claude-code-acp"),
|
||||
true
|
||||
),
|
||||
Some("claude-code-acp".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_time_override_inherits_exact_persona_command() {
|
||||
let personas = vec![persona_with_runtime("p1", Some("claude"))];
|
||||
assert_eq!(
|
||||
create_time_agent_command_override(
|
||||
Some("p1"),
|
||||
&personas,
|
||||
Some("claude-agent-acp"),
|
||||
true
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_time_override_preserves_pin_for_persona_less_create() {
|
||||
// The standalone CreateAgentDialog creates persona-LESS agents. With no
|
||||
// persona to inherit, the picked command IS the agent's harness and must
|
||||
// be preserved as a real pin (divergence from the bundled default),
|
||||
// regardless of the override flag.
|
||||
let personas = vec![persona_with_runtime("p1", Some("goose"))];
|
||||
assert_eq!(
|
||||
create_time_agent_command_override(None, &personas, Some("codex-acp"), false),
|
||||
Some("codex-acp".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_time_override_preserves_same_runtime_pin_when_overriding() {
|
||||
// The bug this fixes: the user picks "Custom command" in the edit
|
||||
// dialog and saves `goose` verbatim for a goose persona. That is a
|
||||
// deliberate pin (harness_override true) — it must be kept so future
|
||||
// persona runtime edits stop propagating, even though it maps to the
|
||||
// persona's own runtime. `divergent_agent_command_override` alone would
|
||||
// wrongly drop it to `None`.
|
||||
let personas = vec![persona_with_runtime("p1", Some("goose"))];
|
||||
assert_eq!(
|
||||
update_time_agent_command_override(Some("p1"), &personas, Some("goose"), true),
|
||||
Some("goose".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_time_override_preserves_exact_persona_command_when_overriding() {
|
||||
// Even when the pick is byte-identical to the persona's own command, an
|
||||
// explicit Custom selection (harness_override true) is a deliberate pin
|
||||
// and is preserved. This is the core divergence from the create-time
|
||||
// contract: at update, equality reached the force branch only because
|
||||
// the user picked Custom.
|
||||
let personas = vec![persona_with_runtime("p1", Some("claude"))];
|
||||
assert_eq!(
|
||||
update_time_agent_command_override(
|
||||
Some("p1"),
|
||||
&personas,
|
||||
Some("claude-agent-acp"),
|
||||
true
|
||||
),
|
||||
Some("claude-agent-acp".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_time_override_preserves_alias_pin_when_overriding() {
|
||||
// A `claude` persona with an installed `claude-code-acp` alias: picking
|
||||
// it as a Custom pin is a deliberate divergence from the primary
|
||||
// command and must be preserved when overriding.
|
||||
let personas = vec![persona_with_runtime("p1", Some("claude"))];
|
||||
assert_eq!(
|
||||
update_time_agent_command_override(
|
||||
Some("p1"),
|
||||
&personas,
|
||||
Some("claude-code-acp"),
|
||||
true
|
||||
),
|
||||
Some("claude-code-acp".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_time_override_defers_to_divergent_when_not_overriding() {
|
||||
// Without the explicit intent bit (e.g. a name-only edit that still
|
||||
// echoes the command), the persona stays authoritative: a same-runtime
|
||||
// command inherits, a different runtime pins.
|
||||
let personas = vec![persona_with_runtime("p1", Some("goose"))];
|
||||
assert_eq!(
|
||||
update_time_agent_command_override(Some("p1"), &personas, Some("goose"), false),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
update_time_agent_command_override(Some("p1"), &personas, Some("codex-acp"), false),
|
||||
Some("codex-acp".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_time_override_clears_pin_for_inherit_sentinel() {
|
||||
// The empty "Inherit from persona" sentinel always clears the pin,
|
||||
// regardless of the override flag.
|
||||
let personas = vec![persona_with_runtime("p1", Some("goose"))];
|
||||
assert_eq!(
|
||||
update_time_agent_command_override(Some("p1"), &personas, Some(" "), true),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
update_time_agent_command_override(Some("p1"), &personas, None, true),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_time_override_preserves_pin_for_persona_less_agent() {
|
||||
// A persona-less agent has no runtime to inherit, so any picked command
|
||||
// is a real pin — preserved even without the override flag (mirrors the
|
||||
// create-time persona-less contract).
|
||||
let personas = vec![persona_with_runtime("p1", Some("goose"))];
|
||||
assert_eq!(
|
||||
update_time_agent_command_override(None, &personas, Some("codex-acp"), false),
|
||||
Some("codex-acp".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
|
||||
554
desktop/src-tauri/src/managed_agents/discovery/tests.rs
Normal file
554
desktop/src-tauri/src/managed_agents/discovery/tests.rs
Normal file
@@ -0,0 +1,554 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::{
|
||||
classify_runtime, create_time_agent_command_override, default_agent_command,
|
||||
divergent_agent_command_override, effective_agent_command, find_via_login_shell,
|
||||
managed_agent_avatar_url, normalize_agent_args, record_agent_command,
|
||||
update_time_agent_command_override, BUZZ_AGENT_AVATAR_URL, CLAUDE_CODE_AVATAR_URL,
|
||||
CODEX_AVATAR_URL, GOOSE_AVATAR_URL,
|
||||
};
|
||||
use crate::managed_agents::AcpAvailabilityStatus;
|
||||
|
||||
#[test]
|
||||
fn resolves_known_avatar_for_bare_command() {
|
||||
let avatar_url = managed_agent_avatar_url("goose").expect("goose avatar should resolve");
|
||||
|
||||
assert_eq!(avatar_url, GOOSE_AVATAR_URL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_known_avatar_for_command_paths_and_aliases() {
|
||||
assert_eq!(
|
||||
managed_agent_avatar_url("/usr/local/bin/codex-acp"),
|
||||
Some(CODEX_AVATAR_URL.to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
managed_agent_avatar_url("Claude Code"),
|
||||
Some(CLAUDE_CODE_AVATAR_URL.to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
managed_agent_avatar_url(r"C:\Tools\claude-agent-acp.exe"),
|
||||
Some(CLAUDE_CODE_AVATAR_URL.to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
managed_agent_avatar_url("/usr/local/bin/claude-code-acp"),
|
||||
Some(CLAUDE_CODE_AVATAR_URL.to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_for_unknown_commands() {
|
||||
assert!(managed_agent_avatar_url("custom-agent").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_agent_command_resolves_bundled_buzz_agent() {
|
||||
// The create-path default must be the bundled buzz-agent, never the
|
||||
// bare `goose` that isn't on PATH on a stock Windows install.
|
||||
assert_eq!(default_agent_command(), "buzz-agent");
|
||||
// And buzz-agent takes no `acp` arg — confirm no arg leakage from the default.
|
||||
assert_eq!(
|
||||
normalize_agent_args(&default_agent_command(), vec!["acp".into()]),
|
||||
Vec::<String>::new()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_claude_and_codex_args_to_empty() {
|
||||
assert_eq!(
|
||||
normalize_agent_args("claude-agent-acp", vec!["acp".into()]),
|
||||
Vec::<String>::new()
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_agent_args("claude-code-acp", vec!["acp".into()]),
|
||||
Vec::<String>::new()
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_agent_args("codex-acp", vec!["acp".into()]),
|
||||
Vec::<String>::new()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_buzz_agent_avatar() {
|
||||
assert_eq!(
|
||||
managed_agent_avatar_url("buzz-agent"),
|
||||
Some(BUZZ_AGENT_AVATAR_URL.to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
managed_agent_avatar_url("/usr/local/bin/buzz-agent"),
|
||||
Some(BUZZ_AGENT_AVATAR_URL.to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_buzz_agent_args_to_empty() {
|
||||
assert_eq!(
|
||||
normalize_agent_args("buzz-agent", Vec::new()),
|
||||
Vec::<String>::new()
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_agent_args("buzz-agent", vec!["acp".into()]),
|
||||
Vec::<String>::new()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_shell_lookup_treats_command_as_data() {
|
||||
let marker =
|
||||
std::env::temp_dir().join(format!("buzz-discovery-marker-{}", uuid::Uuid::new_v4()));
|
||||
let payload = format!("doesnotexist; touch {} #", marker.display());
|
||||
|
||||
let resolved = find_via_login_shell(&payload);
|
||||
|
||||
assert!(
|
||||
resolved.is_none(),
|
||||
"payload should not resolve to a command"
|
||||
);
|
||||
assert!(
|
||||
!marker.exists(),
|
||||
"shell lookup must not execute injected commands"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn explicit_path_resolution_ignores_non_executable_files() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = std::env::temp_dir().join(format!("buzz-discovery-path-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&dir).expect("create temp dir");
|
||||
let bin = dir.join("buzz-acp");
|
||||
std::fs::write(&bin, "").expect("write placeholder");
|
||||
std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o644))
|
||||
.expect("chmod placeholder");
|
||||
|
||||
assert!(
|
||||
super::resolve_workspace_command(bin.to_str().expect("utf8 path")).is_none(),
|
||||
"non-executable placeholder must not resolve"
|
||||
);
|
||||
|
||||
std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755))
|
||||
.expect("chmod executable");
|
||||
assert_eq!(
|
||||
super::resolve_workspace_command(bin.to_str().expect("utf8 path")),
|
||||
Some(bin.clone())
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_available_when_adapter_found() {
|
||||
let (status, cmd, path) = classify_runtime(
|
||||
Some(("goose", PathBuf::from("/usr/local/bin/goose"))),
|
||||
None,
|
||||
false,
|
||||
);
|
||||
assert_eq!(status, AcpAvailabilityStatus::Available);
|
||||
assert_eq!(cmd.as_deref(), Some("goose"));
|
||||
assert_eq!(path.as_deref(), Some("/usr/local/bin/goose"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_adapter_missing_when_cli_present() {
|
||||
let (status, cmd, path) = classify_runtime(None, Some("claude"), true);
|
||||
assert_eq!(status, AcpAvailabilityStatus::AdapterMissing);
|
||||
assert!(cmd.is_none());
|
||||
assert!(path.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_not_installed_when_nothing_found() {
|
||||
let (status, cmd, path) = classify_runtime(None, Some("claude"), false);
|
||||
assert_eq!(status, AcpAvailabilityStatus::NotInstalled);
|
||||
assert!(cmd.is_none());
|
||||
assert!(path.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_not_installed_when_no_underlying_cli() {
|
||||
let (status, cmd, path) = classify_runtime(None, None, false);
|
||||
assert_eq!(status, AcpAvailabilityStatus::NotInstalled);
|
||||
assert!(cmd.is_none());
|
||||
assert!(path.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_cli_missing_when_adapter_found_but_cli_absent() {
|
||||
let (status, cmd, path) = classify_runtime(
|
||||
Some(("codex-acp", PathBuf::from("/opt/homebrew/bin/codex-acp"))),
|
||||
Some("codex"),
|
||||
false,
|
||||
);
|
||||
assert_eq!(status, AcpAvailabilityStatus::CliMissing);
|
||||
assert_eq!(cmd.as_deref(), Some("codex-acp"));
|
||||
assert_eq!(path.as_deref(), Some("/opt/homebrew/bin/codex-acp"));
|
||||
}
|
||||
|
||||
fn persona_with_runtime(id: &str, runtime: Option<&str>) -> crate::managed_agents::PersonaRecord {
|
||||
crate::managed_agents::PersonaRecord {
|
||||
id: id.to_string(),
|
||||
display_name: id.to_string(),
|
||||
avatar_url: None,
|
||||
system_prompt: String::new(),
|
||||
runtime: runtime.map(str::to_string),
|
||||
model: None,
|
||||
provider: None,
|
||||
name_pool: Vec::new(),
|
||||
is_builtin: false,
|
||||
is_active: true,
|
||||
source_team: None,
|
||||
source_team_persona_slug: None,
|
||||
env_vars: std::collections::BTreeMap::new(),
|
||||
created_at: "2026-06-09T00:00:00Z".to_string(),
|
||||
updated_at: "2026-06-09T00:00:00Z".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_agent_command_explicit_override_wins() {
|
||||
// An explicit pin beats the persona's runtime.
|
||||
let personas = vec![persona_with_runtime("p1", Some("claude"))];
|
||||
assert_eq!(
|
||||
effective_agent_command(Some("p1"), &personas, Some("codex-acp")),
|
||||
"codex-acp"
|
||||
);
|
||||
}
|
||||
|
||||
/// Minimal record for `record_agent_command` tests. Only the resolution
|
||||
/// inputs (runtime / persona_id / agent_command_override) vary.
|
||||
fn record_with(
|
||||
runtime: Option<&str>,
|
||||
persona_id: Option<&str>,
|
||||
override_cmd: Option<&str>,
|
||||
) -> crate::managed_agents::types::ManagedAgentRecord {
|
||||
crate::managed_agents::types::ManagedAgentRecord {
|
||||
pubkey: String::new(),
|
||||
name: "r".to_string(),
|
||||
persona_id: persona_id.map(str::to_string),
|
||||
private_key_nsec: String::new(),
|
||||
auth_tag: None,
|
||||
relay_url: String::new(),
|
||||
avatar_url: None,
|
||||
acp_command: String::new(),
|
||||
agent_command: String::new(),
|
||||
agent_command_override: override_cmd.map(str::to_string),
|
||||
agent_args: vec![],
|
||||
mcp_command: String::new(),
|
||||
turn_timeout_seconds: 0,
|
||||
idle_timeout_seconds: None,
|
||||
max_turn_duration_seconds: None,
|
||||
parallelism: 1,
|
||||
system_prompt: None,
|
||||
model: None,
|
||||
provider: None,
|
||||
persona_source_version: None,
|
||||
mcp_toolsets: None,
|
||||
start_on_app_launch: false,
|
||||
runtime_pid: None,
|
||||
backend: Default::default(),
|
||||
backend_agent_id: None,
|
||||
provider_binary_path: None,
|
||||
persona_team_dir: None,
|
||||
persona_name_in_team: None,
|
||||
env_vars: std::collections::BTreeMap::new(),
|
||||
created_at: String::new(),
|
||||
updated_at: String::new(),
|
||||
last_started_at: None,
|
||||
last_stopped_at: None,
|
||||
last_exit_code: None,
|
||||
last_error: None,
|
||||
respond_to: Default::default(),
|
||||
respond_to_allowlist: vec![],
|
||||
display_name: None,
|
||||
slug: None,
|
||||
runtime: runtime.map(str::to_string),
|
||||
name_pool: Vec::new(),
|
||||
is_builtin: false,
|
||||
is_active: true,
|
||||
source_team: None,
|
||||
source_team_persona_slug: None,
|
||||
relay_mesh: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_agent_command_own_runtime_wins_over_persona() {
|
||||
// A record with its own materialized runtime never consults the
|
||||
// persona list — the unified-model resolution.
|
||||
let personas = vec![persona_with_runtime("p1", Some("goose"))];
|
||||
let record = record_with(Some("claude"), Some("p1"), None);
|
||||
assert_eq!(record_agent_command(&record, &personas), "claude-agent-acp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_agent_command_override_beats_runtime() {
|
||||
let record = record_with(Some("claude"), None, Some("codex-acp"));
|
||||
assert_eq!(record_agent_command(&record, &[]), "codex-acp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_agent_command_legacy_persona_fallback() {
|
||||
// Pre-migration record: persona_id set, no runtime — resolves through
|
||||
// the legacy persona path unchanged.
|
||||
let personas = vec![persona_with_runtime("p1", Some("goose"))];
|
||||
let record = record_with(None, Some("p1"), None);
|
||||
assert_eq!(record_agent_command(&record, &personas), "goose");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_agent_command_bare_record_defaults() {
|
||||
let record = record_with(None, None, None);
|
||||
assert_eq!(record_agent_command(&record, &[]), default_agent_command());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_agent_command_inherits_persona_runtime() {
|
||||
// No override → persona runtime id maps to its primary command.
|
||||
let personas = vec![persona_with_runtime("p1", Some("claude"))];
|
||||
assert_eq!(
|
||||
effective_agent_command(Some("p1"), &personas, None),
|
||||
"claude-agent-acp"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_agent_command_empty_override_is_inherit() {
|
||||
// A blank/whitespace override is treated as "inherit", not a pin.
|
||||
let personas = vec![persona_with_runtime("p1", Some("goose"))];
|
||||
assert_eq!(
|
||||
effective_agent_command(Some("p1"), &personas, Some(" ")),
|
||||
"goose"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_agent_command_falls_back_to_default() {
|
||||
// No override, no persona runtime, and a deleted persona all fall back
|
||||
// to the bundled default.
|
||||
let personas = vec![persona_with_runtime("p1", None)];
|
||||
assert_eq!(
|
||||
effective_agent_command(Some("p1"), &personas, None),
|
||||
default_agent_command()
|
||||
);
|
||||
assert_eq!(
|
||||
effective_agent_command(Some("gone"), &personas, None),
|
||||
default_agent_command()
|
||||
);
|
||||
assert_eq!(
|
||||
effective_agent_command(None, &personas, None),
|
||||
default_agent_command()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn divergent_override_none_when_picked_matches_persona_runtime() {
|
||||
// The persona-backed create/edit flow sends the persona's resolved
|
||||
// command. It must be treated as "inherit" (None), not a pin.
|
||||
let personas = vec![persona_with_runtime("p1", Some("goose"))];
|
||||
assert_eq!(
|
||||
divergent_agent_command_override(Some("p1"), &personas, Some("goose")),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn divergent_override_none_for_alternate_command_of_same_runtime() {
|
||||
// A client with only `claude-code-acp` installed sends that command for
|
||||
// a `claude` persona whose primary command is `claude-agent-acp`. Both
|
||||
// map to the `claude` runtime, so it inherits — string equality would
|
||||
// wrongly bake a pin (CRITICAL-3).
|
||||
let personas = vec![persona_with_runtime("p1", Some("claude"))];
|
||||
assert_eq!(
|
||||
divergent_agent_command_override(Some("p1"), &personas, Some("claude-code-acp")),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn divergent_override_some_when_picked_is_different_runtime() {
|
||||
// A deliberate pin to a different runtime is preserved.
|
||||
let personas = vec![persona_with_runtime("p1", Some("goose"))];
|
||||
assert_eq!(
|
||||
divergent_agent_command_override(Some("p1"), &personas, Some("codex-acp")),
|
||||
Some("codex-acp".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn divergent_override_none_for_empty_or_absent_pick() {
|
||||
// The "Inherit from persona" sentinel (empty) and a name-only edit
|
||||
// (absent) both clear the pin.
|
||||
let personas = vec![persona_with_runtime("p1", Some("goose"))];
|
||||
assert_eq!(
|
||||
divergent_agent_command_override(Some("p1"), &personas, Some(" ")),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
divergent_agent_command_override(Some("p1"), &personas, None),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_time_override_none_when_persona_runtime_not_installed() {
|
||||
// CRITICAL-3 (Case 3): a `claude`-persona agent created on a machine
|
||||
// where the claude adapter isn't installed. `resolvePersonaRuntime`
|
||||
// falls back to the default (`buzz-agent`) and sends THAT command with
|
||||
// `harness_override` false (the user did not pick it). At create this
|
||||
// is a fallback, not a deliberate pin — it must store `None` so the
|
||||
// agent inherits the persona's runtime once it's installed and the
|
||||
// persona is re-edited. Baking `Some("buzz-agent")` here is the exact
|
||||
// bug this resolver chain exists to kill.
|
||||
let personas = vec![persona_with_runtime("p1", Some("claude"))];
|
||||
assert_eq!(
|
||||
create_time_agent_command_override(Some("p1"), &personas, Some("buzz-agent"), false),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_time_override_some_when_user_deliberately_overrides_installed_runtime() {
|
||||
// Case 2 + deliberate override: the persona's `claude` runtime IS
|
||||
// available, but the user explicitly picked `codex` in a deploy dialog's
|
||||
// runtime selector ("overriding persona preferences"), so the frontend
|
||||
// sends `codex-acp` with `harness_override` true. This is a real pin and
|
||||
// MUST be preserved — returning `None` would silently swallow the
|
||||
// deliberate override and inherit `claude` on spawn.
|
||||
let personas = vec![persona_with_runtime("p1", Some("claude"))];
|
||||
assert_eq!(
|
||||
create_time_agent_command_override(Some("p1"), &personas, Some("codex-acp"), true),
|
||||
Some("codex-acp".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_time_override_none_when_persona_runtime_installed() {
|
||||
// Case 2: the persona's runtime is available, so `resolvePersonaRuntime`
|
||||
// sends the persona's own command with no override. Inherits — no pin.
|
||||
let personas = vec![persona_with_runtime("p1", Some("goose"))];
|
||||
assert_eq!(
|
||||
create_time_agent_command_override(Some("p1"), &personas, Some("goose"), false),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_time_override_preserves_selected_runtime_alias() {
|
||||
// A `claude` persona inherits the primary command `claude-agent-acp`,
|
||||
// but discovery may select an installed alias such as `claude-code-acp`.
|
||||
// When UI marks that create-time selection as explicit, preserve the
|
||||
// alias so the first spawn uses a command known to be installed.
|
||||
let personas = vec![persona_with_runtime("p1", Some("claude"))];
|
||||
assert_eq!(
|
||||
create_time_agent_command_override(Some("p1"), &personas, Some("claude-code-acp"), true),
|
||||
Some("claude-code-acp".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_time_override_inherits_exact_persona_command() {
|
||||
let personas = vec![persona_with_runtime("p1", Some("claude"))];
|
||||
assert_eq!(
|
||||
create_time_agent_command_override(Some("p1"), &personas, Some("claude-agent-acp"), true),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_time_override_preserves_pin_for_persona_less_create() {
|
||||
// The standalone CreateAgentDialog creates persona-LESS agents. With no
|
||||
// persona to inherit, the picked command IS the agent's harness and must
|
||||
// be preserved as a real pin (divergence from the bundled default),
|
||||
// regardless of the override flag.
|
||||
let personas = vec![persona_with_runtime("p1", Some("goose"))];
|
||||
assert_eq!(
|
||||
create_time_agent_command_override(None, &personas, Some("codex-acp"), false),
|
||||
Some("codex-acp".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_time_override_preserves_same_runtime_pin_when_overriding() {
|
||||
// The bug this fixes: the user picks "Custom command" in the edit
|
||||
// dialog and saves `goose` verbatim for a goose persona. That is a
|
||||
// deliberate pin (harness_override true) — it must be kept so future
|
||||
// persona runtime edits stop propagating, even though it maps to the
|
||||
// persona's own runtime. `divergent_agent_command_override` alone would
|
||||
// wrongly drop it to `None`.
|
||||
let personas = vec![persona_with_runtime("p1", Some("goose"))];
|
||||
assert_eq!(
|
||||
update_time_agent_command_override(Some("p1"), &personas, Some("goose"), true),
|
||||
Some("goose".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_time_override_preserves_exact_persona_command_when_overriding() {
|
||||
// Even when the pick is byte-identical to the persona's own command, an
|
||||
// explicit Custom selection (harness_override true) is a deliberate pin
|
||||
// and is preserved. This is the core divergence from the create-time
|
||||
// contract: at update, equality reached the force branch only because
|
||||
// the user picked Custom.
|
||||
let personas = vec![persona_with_runtime("p1", Some("claude"))];
|
||||
assert_eq!(
|
||||
update_time_agent_command_override(Some("p1"), &personas, Some("claude-agent-acp"), true),
|
||||
Some("claude-agent-acp".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_time_override_preserves_alias_pin_when_overriding() {
|
||||
// A `claude` persona with an installed `claude-code-acp` alias: picking
|
||||
// it as a Custom pin is a deliberate divergence from the primary
|
||||
// command and must be preserved when overriding.
|
||||
let personas = vec![persona_with_runtime("p1", Some("claude"))];
|
||||
assert_eq!(
|
||||
update_time_agent_command_override(Some("p1"), &personas, Some("claude-code-acp"), true),
|
||||
Some("claude-code-acp".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_time_override_defers_to_divergent_when_not_overriding() {
|
||||
// Without the explicit intent bit (e.g. a name-only edit that still
|
||||
// echoes the command), the persona stays authoritative: a same-runtime
|
||||
// command inherits, a different runtime pins.
|
||||
let personas = vec![persona_with_runtime("p1", Some("goose"))];
|
||||
assert_eq!(
|
||||
update_time_agent_command_override(Some("p1"), &personas, Some("goose"), false),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
update_time_agent_command_override(Some("p1"), &personas, Some("codex-acp"), false),
|
||||
Some("codex-acp".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_time_override_clears_pin_for_inherit_sentinel() {
|
||||
// The empty "Inherit from persona" sentinel always clears the pin,
|
||||
// regardless of the override flag.
|
||||
let personas = vec![persona_with_runtime("p1", Some("goose"))];
|
||||
assert_eq!(
|
||||
update_time_agent_command_override(Some("p1"), &personas, Some(" "), true),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
update_time_agent_command_override(Some("p1"), &personas, None, true),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_time_override_preserves_pin_for_persona_less_agent() {
|
||||
// A persona-less agent has no runtime to inherit, so any picked command
|
||||
// is a real pin — preserved even without the override flag (mirrors the
|
||||
// create-time persona-less contract).
|
||||
let personas = vec![persona_with_runtime("p1", Some("goose"))];
|
||||
assert_eq!(
|
||||
update_time_agent_command_override(None, &personas, Some("codex-acp"), false),
|
||||
Some("codex-acp".to_string())
|
||||
);
|
||||
}
|
||||
@@ -675,899 +675,4 @@ pub fn try_regenerate_nest(app: &AppHandle) {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn nest_dir_is_under_home() {
|
||||
if let Some(dir) = nest_dir() {
|
||||
// Accepts both .buzz (prod) and .buzz-dev (dev) depending on
|
||||
// whether init_nest_dir was called before this test ran.
|
||||
let name = dir.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||
assert!(
|
||||
name == NEST_DIR_PROD || name == NEST_DIR_DEV,
|
||||
"nest_dir must end with .buzz or .buzz-dev, got {dir:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn init_nest_dir_prod_sets_buzz() {
|
||||
// init_nest_dir is idempotent (OnceLock) — once set, subsequent calls
|
||||
// are no-ops. We can only test the fallback path if the OnceLock is
|
||||
// unset, which is only true in a fresh process. Instead, verify that
|
||||
// nest_dir() always returns a path ending with a valid nest suffix.
|
||||
let dir = nest_dir();
|
||||
if let Some(d) = dir {
|
||||
let name = d.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||
assert!(
|
||||
name == NEST_DIR_PROD || name == NEST_DIR_DEV,
|
||||
"nest_dir suffix must be .buzz or .buzz-dev, got {d:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_nest_creates_all_dirs_and_agents_md() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join(".buzz");
|
||||
|
||||
ensure_nest_at(&root).unwrap();
|
||||
|
||||
// All subdirectories exist.
|
||||
for dir in NEST_DIRS {
|
||||
assert!(root.join(dir).is_dir(), "{dir}/ should exist");
|
||||
}
|
||||
// REPOS is provisioned separately (may be a symlink); with no
|
||||
// repos_dir configured it lands as a real directory.
|
||||
assert!(root.join("REPOS").is_dir(), "REPOS/ should exist");
|
||||
|
||||
// AGENTS.md was written with default content.
|
||||
let content = fs::read_to_string(root.join("AGENTS.md")).unwrap();
|
||||
assert_eq!(content, AGENTS_MD);
|
||||
|
||||
// Permissions are 700 on Unix for root and all subdirs.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = fs::metadata(&root).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o700, "root should be 700");
|
||||
for dir in NEST_DIRS {
|
||||
let mode = fs::metadata(root.join(dir)).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o700, "{dir}/ should be 700");
|
||||
}
|
||||
let repos_mode = fs::metadata(root.join("REPOS"))
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o777;
|
||||
assert_eq!(repos_mode, 0o700, "REPOS/ should be 700");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_nest_is_idempotent_and_preserves_custom_content() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join(".buzz");
|
||||
|
||||
// First call creates everything.
|
||||
ensure_nest_at(&root).unwrap();
|
||||
|
||||
// User customizes AGENTS.md.
|
||||
let agents = root.join("AGENTS.md");
|
||||
fs::write(&agents, "my custom instructions").unwrap();
|
||||
|
||||
// Second call succeeds and does not overwrite.
|
||||
ensure_nest_at(&root).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
fs::read_to_string(&agents).unwrap(),
|
||||
"my custom instructions"
|
||||
);
|
||||
|
||||
// All dirs still exist.
|
||||
for dir in NEST_DIRS {
|
||||
assert!(root.join(dir).is_dir(), "{dir}/ should still exist");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn ensure_nest_rejects_symlink_root() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let target = tmp.path().join("real_dir");
|
||||
fs::create_dir(&target).unwrap();
|
||||
let link = tmp.path().join(".buzz");
|
||||
std::os::unix::fs::symlink(&target, &link).unwrap();
|
||||
|
||||
let result = ensure_nest_at(&link);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("symlink"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_nest_creates_skill_file() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join(".buzz");
|
||||
ensure_nest_at(&root).unwrap();
|
||||
|
||||
// Canonical location under .agents.
|
||||
let skill = root.join(".agents/skills/buzz-cli/SKILL.md");
|
||||
assert!(skill.exists(), "SKILL.md should exist at .agents path");
|
||||
let content = fs::read_to_string(&skill).unwrap();
|
||||
assert_eq!(content, BUZZ_CLI_SKILL_MD);
|
||||
|
||||
// On unix, harness-specific symlinks should resolve to the canonical dir.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
for dir in [".goose/skills", ".claude/skills", ".codex/skills"] {
|
||||
let link = root.join(dir).join("buzz-cli");
|
||||
assert!(
|
||||
link.symlink_metadata().unwrap().file_type().is_symlink(),
|
||||
"{dir}/buzz-cli should be a symlink"
|
||||
);
|
||||
assert!(
|
||||
link.join("SKILL.md").exists(),
|
||||
"symlink at {dir}/buzz-cli should resolve to dir with SKILL.md"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_nest_does_not_overwrite_skill_file() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join(".buzz");
|
||||
ensure_nest_at(&root).unwrap();
|
||||
|
||||
let skill = root.join(".agents/skills/buzz-cli/SKILL.md");
|
||||
fs::write(&skill, "custom skill content").unwrap();
|
||||
|
||||
ensure_nest_at(&root).unwrap();
|
||||
assert_eq!(fs::read_to_string(&skill).unwrap(), "custom skill content");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn ensure_nest_skill_dir_has_700_permissions() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join(".buzz");
|
||||
ensure_nest_at(&root).unwrap();
|
||||
// Canonical path and all provider parent dirs should be locked down.
|
||||
// Symlinks (e.g. .goose/skills/buzz-cli) are skipped by the chmod loop.
|
||||
for dir in [
|
||||
".agents",
|
||||
".agents/skills",
|
||||
".agents/skills/buzz-cli",
|
||||
".goose",
|
||||
".goose/skills",
|
||||
".claude",
|
||||
".claude/skills",
|
||||
".codex",
|
||||
".codex/skills",
|
||||
] {
|
||||
let path = root.join(dir);
|
||||
let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o700, "{dir} should be 700");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn ensure_nest_skips_permissions_on_symlinked_child() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join(".buzz");
|
||||
|
||||
// First call creates the real nest.
|
||||
ensure_nest_at(&root).unwrap();
|
||||
|
||||
// Replace REPOS/ with a symlink to an external directory.
|
||||
let external = tmp.path().join("external");
|
||||
fs::create_dir(&external).unwrap();
|
||||
fs::set_permissions(&external, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
fs::remove_dir(root.join("REPOS")).unwrap();
|
||||
std::os::unix::fs::symlink(&external, root.join("REPOS")).unwrap();
|
||||
|
||||
// Second call should succeed — it skips chmod on the symlinked child.
|
||||
ensure_nest_at(&root).unwrap();
|
||||
|
||||
// The external directory's permissions should be unchanged (755, not 700).
|
||||
let mode = fs::metadata(&external).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(
|
||||
mode, 0o755,
|
||||
"symlinked child's target should not be chmod'd"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn ensure_nest_migrates_old_skill_dir() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join(".buzz");
|
||||
|
||||
// Simulate a pre-migration install: real directory at old path.
|
||||
// Create the nest first to get all dirs, then simulate old layout.
|
||||
ensure_nest_at(&root).unwrap();
|
||||
|
||||
// Remove the symlink and new skill dir, recreate old real dir.
|
||||
let _ = fs::remove_file(root.join(".claude/skills/buzz-cli"));
|
||||
let _ = fs::remove_dir_all(root.join(".agents/skills/buzz-cli"));
|
||||
let old_skill_dir = root.join(".claude/skills/buzz-cli");
|
||||
fs::create_dir_all(&old_skill_dir).unwrap();
|
||||
fs::write(old_skill_dir.join("SKILL.md"), "user edited skill").unwrap();
|
||||
|
||||
// Delete version file to force refresh.
|
||||
let _ = fs::remove_file(root.join(".agents/skills/buzz-cli/.skill-version"));
|
||||
|
||||
// Re-run ensure_nest_at — should trigger migration in refresh_skill_md_if_stale.
|
||||
ensure_nest_at(&root).unwrap();
|
||||
|
||||
// New canonical location exists with user's content preserved.
|
||||
let new_skill = root.join(".agents/skills/buzz-cli/SKILL.md");
|
||||
assert!(new_skill.exists(), "SKILL.md should exist at new path");
|
||||
assert_eq!(fs::read_to_string(&new_skill).unwrap(), "user edited skill");
|
||||
|
||||
// Old path is now a symlink, not a real directory.
|
||||
let old_path = root.join(".claude/skills/buzz-cli");
|
||||
assert!(
|
||||
old_path
|
||||
.symlink_metadata()
|
||||
.unwrap()
|
||||
.file_type()
|
||||
.is_symlink(),
|
||||
"old path should now be a symlink"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn ensure_skill_symlinks_are_idempotent() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join(".buzz");
|
||||
ensure_nest_at(&root).unwrap();
|
||||
// Second call should succeed without errors.
|
||||
ensure_nest_at(&root).unwrap();
|
||||
// All symlinks still valid and point to relative targets.
|
||||
for dir in [".goose/skills", ".claude/skills", ".codex/skills"] {
|
||||
let link = root.join(dir).join("buzz-cli");
|
||||
assert!(link.symlink_metadata().unwrap().file_type().is_symlink());
|
||||
assert!(
|
||||
link.join("SKILL.md").exists(),
|
||||
"symlink at {dir}/buzz-cli should resolve to dir with SKILL.md"
|
||||
);
|
||||
let target = fs::read_link(&link).unwrap();
|
||||
assert_eq!(
|
||||
target.to_str().unwrap(),
|
||||
format!("../../{CANONICAL_SKILL_DIR}"),
|
||||
"symlink at {dir}/buzz-cli should use relative target"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn ensure_skill_symlinks_skips_existing_path_during_initial_pass() {
|
||||
// ensure_skill_symlinks skips any path where symlink_metadata succeeds.
|
||||
// However, refresh_skill_md_if_stale (called after ensure_skill_symlinks)
|
||||
// migrates pre-existing real directories at .claude/skills/buzz-cli to
|
||||
// symlinks. This test verifies the end-to-end behavior: a pre-existing real
|
||||
// dir at the claude path is migrated to a symlink.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join(".buzz");
|
||||
// Pre-create a real directory where a symlink would go.
|
||||
let real_dir = root.join(".claude/skills/buzz-cli");
|
||||
fs::create_dir_all(&real_dir).unwrap();
|
||||
// Place SKILL.md so migration preserves it.
|
||||
fs::write(real_dir.join("SKILL.md"), "custom skill content").unwrap();
|
||||
|
||||
ensure_nest_at(&root).unwrap();
|
||||
|
||||
// Migration converts the real dir to a symlink; content is moved to canonical path.
|
||||
assert!(
|
||||
real_dir
|
||||
.symlink_metadata()
|
||||
.unwrap()
|
||||
.file_type()
|
||||
.is_symlink(),
|
||||
".claude/skills/buzz-cli should be migrated to a symlink"
|
||||
);
|
||||
// The canonical path now holds the migrated content.
|
||||
let canonical = root.join(".agents/skills/buzz-cli/SKILL.md");
|
||||
assert_eq!(
|
||||
fs::read_to_string(&canonical).unwrap(),
|
||||
"custom skill content"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn ensure_skill_symlinks_skip_dangling_symlink() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join(".buzz");
|
||||
// Pre-create a dangling symlink where the .codex link would go.
|
||||
let codex_skills = root.join(".codex/skills");
|
||||
fs::create_dir_all(&codex_skills).unwrap();
|
||||
let dangling = codex_skills.join("buzz-cli");
|
||||
std::os::unix::fs::symlink("/nonexistent/target", &dangling).unwrap();
|
||||
|
||||
ensure_nest_at(&root).unwrap();
|
||||
|
||||
// Dangling symlink should be left alone (not clobbered).
|
||||
assert!(dangling
|
||||
.symlink_metadata()
|
||||
.unwrap()
|
||||
.file_type()
|
||||
.is_symlink());
|
||||
assert_eq!(
|
||||
fs::read_link(&dangling).unwrap().to_str().unwrap(),
|
||||
"/nonexistent/target"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_link_name_prod_is_buzz() {
|
||||
assert_eq!(cli_link_name(false), "buzz");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_link_name_dev_is_buzz_dev() {
|
||||
assert_eq!(cli_link_name(true), "buzz-dev");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn ensure_cli_symlink_creates_symlink_prod() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let exe_parent = tmp.path().join("MacOS");
|
||||
fs::create_dir(&exe_parent).unwrap();
|
||||
fs::write(exe_parent.join("buzz"), "binary").unwrap();
|
||||
|
||||
let local_bin = tmp.path().join("local_bin");
|
||||
fs::create_dir_all(&local_bin).unwrap();
|
||||
|
||||
// Prod link name is "buzz"; simulate the symlink creation path.
|
||||
let link = local_bin.join(cli_link_name(false));
|
||||
std::os::unix::fs::symlink(exe_parent.join("buzz"), &link).unwrap();
|
||||
assert!(link.symlink_metadata().unwrap().file_type().is_symlink());
|
||||
assert_eq!(fs::read_link(&link).unwrap(), exe_parent.join("buzz"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn ensure_cli_symlink_creates_symlink_dev() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let exe_parent = tmp.path().join("MacOS");
|
||||
fs::create_dir(&exe_parent).unwrap();
|
||||
fs::write(exe_parent.join("buzz"), "binary").unwrap();
|
||||
|
||||
let local_bin = tmp.path().join("local_bin");
|
||||
fs::create_dir_all(&local_bin).unwrap();
|
||||
|
||||
// Dev link must be "buzz-dev", never "buzz".
|
||||
assert_eq!(cli_link_name(true), "buzz-dev");
|
||||
|
||||
let link = local_bin.join(cli_link_name(true));
|
||||
std::os::unix::fs::symlink(exe_parent.join("buzz"), &link).unwrap();
|
||||
assert!(link.symlink_metadata().unwrap().file_type().is_symlink());
|
||||
assert_eq!(fs::read_link(&link).unwrap(), exe_parent.join("buzz"));
|
||||
// Prod link must not exist — the two builds don't touch each other.
|
||||
assert!(!local_bin.join("buzz").exists());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn ensure_cli_symlink_does_not_clobber_regular_file_prod() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let local_bin = tmp.path().join("local_bin");
|
||||
fs::create_dir_all(&local_bin).unwrap();
|
||||
let link = local_bin.join(cli_link_name(false));
|
||||
fs::write(&link, "user-installed binary").unwrap();
|
||||
|
||||
// Regular files are preserved — the Ok(_) branch skips them.
|
||||
assert!(link.symlink_metadata().unwrap().file_type().is_file());
|
||||
assert_eq!(fs::read_to_string(&link).unwrap(), "user-installed binary");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn ensure_cli_symlink_does_not_clobber_regular_file_dev() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let local_bin = tmp.path().join("local_bin");
|
||||
fs::create_dir_all(&local_bin).unwrap();
|
||||
let link = local_bin.join(cli_link_name(true));
|
||||
fs::write(&link, "user-installed buzz-dev binary").unwrap();
|
||||
|
||||
// Regular files at the dev path are also preserved.
|
||||
assert!(link.symlink_metadata().unwrap().file_type().is_file());
|
||||
assert_eq!(
|
||||
fs::read_to_string(&link).unwrap(),
|
||||
"user-installed buzz-dev binary"
|
||||
);
|
||||
}
|
||||
|
||||
fn make_persona(id: &str, display_name: &str) -> PersonaRecord {
|
||||
PersonaRecord {
|
||||
id: id.to_string(),
|
||||
display_name: display_name.to_string(),
|
||||
avatar_url: None,
|
||||
system_prompt: String::new(),
|
||||
runtime: None,
|
||||
model: None,
|
||||
provider: None,
|
||||
name_pool: vec![],
|
||||
is_builtin: false,
|
||||
is_active: true,
|
||||
source_team: None,
|
||||
source_team_persona_slug: None,
|
||||
env_vars: std::collections::BTreeMap::new(),
|
||||
created_at: String::new(),
|
||||
updated_at: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn make_agent(name: &str, persona_id: Option<&str>) -> ManagedAgentRecord {
|
||||
ManagedAgentRecord {
|
||||
pubkey: String::new(),
|
||||
name: name.to_string(),
|
||||
persona_id: persona_id.map(|s| s.to_string()),
|
||||
private_key_nsec: String::new(),
|
||||
auth_tag: None,
|
||||
relay_url: String::new(),
|
||||
avatar_url: None,
|
||||
acp_command: String::new(),
|
||||
agent_command: String::new(),
|
||||
agent_command_override: None,
|
||||
agent_args: vec![],
|
||||
mcp_command: String::new(),
|
||||
turn_timeout_seconds: 0,
|
||||
idle_timeout_seconds: None,
|
||||
max_turn_duration_seconds: None,
|
||||
parallelism: 1,
|
||||
system_prompt: None,
|
||||
model: None,
|
||||
provider: None,
|
||||
persona_source_version: None,
|
||||
mcp_toolsets: None,
|
||||
start_on_app_launch: false,
|
||||
runtime_pid: None,
|
||||
backend: BackendKind::default(),
|
||||
backend_agent_id: None,
|
||||
provider_binary_path: None,
|
||||
persona_team_dir: None,
|
||||
persona_name_in_team: None,
|
||||
created_at: String::new(),
|
||||
updated_at: String::new(),
|
||||
last_started_at: None,
|
||||
last_stopped_at: None,
|
||||
last_exit_code: None,
|
||||
last_error: None,
|
||||
respond_to: RespondTo::default(),
|
||||
respond_to_allowlist: vec![],
|
||||
env_vars: std::collections::BTreeMap::new(),
|
||||
relay_mesh: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_dynamic_section_with_agents() {
|
||||
let personas = vec![make_persona("p1", "Builder")];
|
||||
let agents = vec![make_agent("Kit", Some("p1"))];
|
||||
let output = render_dynamic_section(&personas, &agents, "ws://example.com:3000");
|
||||
assert!(output.contains("| Kit | Builder | @Kit |"));
|
||||
assert!(output.contains("| Name | Persona | How to address |"));
|
||||
assert!(output.contains("## Workspace"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_dynamic_section_empty() {
|
||||
let output = render_dynamic_section(&[], &[], "ws://example.com:3000");
|
||||
assert!(output.contains("No agents deployed yet"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_dynamic_section_agent_no_persona() {
|
||||
let personas = vec![make_persona("p1", "Builder")];
|
||||
let agents = vec![make_agent("Scout", Some("nonexistent"))];
|
||||
let output = render_dynamic_section(&personas, &agents, "ws://example.com:3000");
|
||||
assert!(output.contains("| Scout | — | @Scout |"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upsert_managed_section_with_markers() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = tmp.path().join("AGENTS.md");
|
||||
fs::write(
|
||||
&file,
|
||||
"# Header\n\nsome content\n\n<!-- BEGIN BUZZ MANAGED — regenerated automatically, do not edit below -->\nold section\n<!-- END BUZZ MANAGED -->\n\nafter\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
upsert_managed_section(&file, "new section").unwrap();
|
||||
|
||||
let result = fs::read_to_string(&file).unwrap();
|
||||
assert!(result.contains("<!-- BEGIN BUZZ MANAGED"));
|
||||
assert!(result.contains("<!-- END BUZZ MANAGED -->"));
|
||||
assert!(result.contains("new section"));
|
||||
assert!(!result.contains("old section"));
|
||||
assert!(result.contains("# Header"));
|
||||
assert!(result.contains("some content"));
|
||||
assert!(result.contains("after"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upsert_managed_section_without_markers() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = tmp.path().join("AGENTS.md");
|
||||
fs::write(&file, "# Header\n\nexisting content\n").unwrap();
|
||||
|
||||
upsert_managed_section(&file, "injected section").unwrap();
|
||||
|
||||
let result = fs::read_to_string(&file).unwrap();
|
||||
assert!(result.contains("# Header"));
|
||||
assert!(result.contains("existing content"));
|
||||
assert!(result.contains("<!-- BEGIN BUZZ MANAGED"));
|
||||
assert!(result.contains("<!-- END BUZZ MANAGED -->"));
|
||||
assert!(result.contains("injected section"));
|
||||
let begin_pos = result.find("<!-- BEGIN BUZZ MANAGED").unwrap();
|
||||
let header_pos = result.find("# Header").unwrap();
|
||||
assert!(
|
||||
header_pos < begin_pos,
|
||||
"original content should precede the managed section"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upsert_managed_section_no_tmp_leftover() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = tmp.path().join("AGENTS.md");
|
||||
fs::write(&file, "# Header\n").unwrap();
|
||||
|
||||
upsert_managed_section(&file, "content").unwrap();
|
||||
|
||||
// Verify no stray temp files in the directory
|
||||
let entries: Vec<_> = fs::read_dir(tmp.path())
|
||||
.unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
entries.len(),
|
||||
1,
|
||||
"only AGENTS.md should remain, no temp files"
|
||||
);
|
||||
assert_eq!(entries[0].file_name(), "AGENTS.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upsert_end_before_begin() {
|
||||
// An END marker that precedes a BEGIN marker forms no valid ordered pair.
|
||||
// find_managed_markers returns None (BEGIN found, but no END after it),
|
||||
// so the orphan BEGIN line is stripped and a new block is appended.
|
||||
// The stray END line and content between END and BEGIN remain in the file
|
||||
// because strip_orphan_begin_marker only removes the BEGIN line itself.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = tmp.path().join("AGENTS.md");
|
||||
fs::write(
|
||||
&file,
|
||||
"# Header\n\n<!-- END BUZZ MANAGED -->\nsome middle content\n<!-- BEGIN BUZZ MANAGED — regenerated automatically, do not edit below -->\nold section\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
upsert_managed_section(&file, "new section").unwrap();
|
||||
|
||||
let result = fs::read_to_string(&file).unwrap();
|
||||
|
||||
assert!(result.contains("# Header"), "original header must survive");
|
||||
assert!(
|
||||
result.contains("new section"),
|
||||
"new content must be present"
|
||||
);
|
||||
assert!(
|
||||
result.contains("some middle content"),
|
||||
"content between markers must survive"
|
||||
);
|
||||
|
||||
// Exactly one BEGIN marker in the output (the orphan was stripped, new one appended).
|
||||
assert_eq!(
|
||||
result.matches(BEGIN_MARKER).count(),
|
||||
1,
|
||||
"exactly one BEGIN marker after orphan cleanup"
|
||||
);
|
||||
|
||||
// The single BEGIN marker must have a matching END marker after it.
|
||||
let begin_pos = result
|
||||
.find(BEGIN_MARKER)
|
||||
.expect("BEGIN marker must be present");
|
||||
let end_pos = result[begin_pos..].find(END_MARKER).map(|p| begin_pos + p);
|
||||
assert!(
|
||||
end_pos.is_some(),
|
||||
"an END marker must appear after the appended BEGIN marker"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upsert_begin_only_no_end() {
|
||||
// A file with BEGIN but no END has an orphan marker.
|
||||
// find_managed_markers returns None (no END found after BEGIN),
|
||||
// so strip_orphan_begin_marker removes the BEGIN line.
|
||||
// Content that followed the orphan BEGIN is preserved (only the marker line is stripped,
|
||||
// not the body that came after it).
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = tmp.path().join("AGENTS.md");
|
||||
fs::write(
|
||||
&file,
|
||||
"# Header\n\nsome content\n\n<!-- BEGIN BUZZ MANAGED — regenerated automatically, do not edit below -->\norphaned section without end marker\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
upsert_managed_section(&file, "fresh section").unwrap();
|
||||
|
||||
let result = fs::read_to_string(&file).unwrap();
|
||||
|
||||
assert!(result.contains("# Header"), "original header must survive");
|
||||
assert!(
|
||||
result.contains("some content"),
|
||||
"original body must survive"
|
||||
);
|
||||
assert!(
|
||||
result.contains("fresh section"),
|
||||
"new content must be present"
|
||||
);
|
||||
|
||||
let begin_pos = result
|
||||
.find(BEGIN_MARKER)
|
||||
.expect("BEGIN marker must be present");
|
||||
let end_pos = result.find(END_MARKER).expect("END marker must be present");
|
||||
assert!(
|
||||
begin_pos < end_pos,
|
||||
"the appended BEGIN marker must precede the appended END marker"
|
||||
);
|
||||
|
||||
// Exactly one BEGIN marker after orphan cleanup.
|
||||
assert_eq!(
|
||||
result.matches(BEGIN_MARKER).count(),
|
||||
1,
|
||||
"exactly one BEGIN marker after orphan cleanup"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upsert_duplicate_markers() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = tmp.path().join("AGENTS.md");
|
||||
fs::write(
|
||||
&file,
|
||||
"# Header\n\n<!-- BEGIN BUZZ MANAGED — regenerated automatically, do not edit below -->\nfirst block\n<!-- END BUZZ MANAGED -->\n\nbetween blocks\n\n<!-- BEGIN BUZZ MANAGED — regenerated automatically, do not edit below -->\nsecond block\n<!-- END BUZZ MANAGED -->\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
upsert_managed_section(&file, "replaced").unwrap();
|
||||
|
||||
let result = fs::read_to_string(&file).unwrap();
|
||||
|
||||
assert!(
|
||||
result.contains("replaced"),
|
||||
"replacement content must be present"
|
||||
);
|
||||
assert!(
|
||||
!result.contains("first block"),
|
||||
"first block must be replaced"
|
||||
);
|
||||
assert!(
|
||||
result.contains("second block"),
|
||||
"second pair content must survive"
|
||||
);
|
||||
assert!(
|
||||
result.contains("between blocks"),
|
||||
"text between pairs must survive"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upsert_marker_in_code_block() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = tmp.path().join("AGENTS.md");
|
||||
// Indented by 4 spaces — not at column 0, so should NOT match as a real marker.
|
||||
fs::write(
|
||||
&file,
|
||||
"# Header\n\n <!-- BEGIN BUZZ MANAGED — some indented marker -->\n\nReal content here\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
upsert_managed_section(&file, "appended content").unwrap();
|
||||
|
||||
let result = fs::read_to_string(&file).unwrap();
|
||||
|
||||
assert!(
|
||||
result.contains(" <!-- BEGIN BUZZ MANAGED — some indented marker -->"),
|
||||
"indented marker inside code block must be preserved verbatim"
|
||||
);
|
||||
assert!(
|
||||
result.contains("appended content"),
|
||||
"new content must be appended"
|
||||
);
|
||||
assert!(
|
||||
result.contains("Real content here"),
|
||||
"existing body must survive"
|
||||
);
|
||||
|
||||
// The real markers appended at the end must be at line-start (column 0).
|
||||
let begin_pos = result
|
||||
.find("<!-- BEGIN BUZZ MANAGED — regenerated")
|
||||
.expect("regenerated BEGIN marker must be present");
|
||||
assert!(
|
||||
begin_pos == 0 || result.as_bytes()[begin_pos - 1] == b'\n',
|
||||
"appended BEGIN marker must be at line start"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_pipe_in_agent_name() {
|
||||
let personas = vec![make_persona("p1", "Builder")];
|
||||
let agents = vec![make_agent("Kit|Pro", Some("p1"))];
|
||||
let output = render_dynamic_section(&personas, &agents, "ws://example.com:3000");
|
||||
|
||||
assert!(
|
||||
output.contains("Kit\\|Pro"),
|
||||
"pipe in agent name must be escaped as \\|"
|
||||
);
|
||||
// An unescaped bare `|` immediately adjacent to "Kit|Pro" would break table parsing.
|
||||
assert!(
|
||||
!output.contains("| Kit|Pro |"),
|
||||
"unescaped pipe in agent name must not appear as a cell boundary"
|
||||
);
|
||||
|
||||
// The row must start and end with `|` and the escaped name and address must appear.
|
||||
let kit_row = output
|
||||
.lines()
|
||||
.find(|l| l.contains("Kit\\|Pro"))
|
||||
.expect("Kit\\|Pro row must be present");
|
||||
assert!(kit_row.starts_with('|'), "row must start with |");
|
||||
assert!(kit_row.ends_with('|'), "row must end with |");
|
||||
assert!(
|
||||
kit_row.contains("@Kit\\|Pro"),
|
||||
"address cell must use escaped name"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_newline_in_persona_name() {
|
||||
let personas = vec![make_persona("p1", "Builder\nExpert")];
|
||||
let agents = vec![make_agent("Scout", Some("p1"))];
|
||||
let output = render_dynamic_section(&personas, &agents, "ws://example.com:3000");
|
||||
|
||||
assert!(
|
||||
output.contains("Builder Expert"),
|
||||
"newline in persona display_name must be replaced with a space"
|
||||
);
|
||||
|
||||
// The table row for Scout must be a single line (no embedded newline).
|
||||
let scout_row = output
|
||||
.lines()
|
||||
.find(|l| l.contains("Scout"))
|
||||
.expect("Scout row must be present");
|
||||
assert!(
|
||||
scout_row.contains("Builder Expert"),
|
||||
"persona name with newline replaced by space must appear on the Scout row"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upsert_idempotent() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = tmp.path().join("AGENTS.md");
|
||||
fs::write(
|
||||
&file,
|
||||
"# Header\n\n<!-- BEGIN BUZZ MANAGED — regenerated automatically, do not edit below -->\nexisting section\n<!-- END BUZZ MANAGED -->\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
upsert_managed_section(&file, "same content").unwrap();
|
||||
let after_first = fs::read_to_string(&file).unwrap();
|
||||
|
||||
upsert_managed_section(&file, "same content").unwrap();
|
||||
let after_second = fs::read_to_string(&file).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
after_first, after_second,
|
||||
"upsert must be idempotent: second call must not alter the file"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_agents_md_writes_version_file() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join(".buzz");
|
||||
ensure_nest_at(&root).unwrap();
|
||||
let version = fs::read_to_string(root.join(".nest-agents-version")).unwrap();
|
||||
assert_eq!(version.trim(), NEST_AGENTS_VERSION.to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_skill_md_writes_version_file() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join(".buzz");
|
||||
ensure_nest_at(&root).unwrap();
|
||||
let version =
|
||||
fs::read_to_string(root.join(".agents/skills/buzz-cli/.skill-version")).unwrap();
|
||||
assert_eq!(version.trim(), NEST_SKILL_VERSION.to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_agents_md_preserves_managed_section() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join(".buzz");
|
||||
ensure_nest_at(&root).unwrap();
|
||||
|
||||
// Simulate a managed section update.
|
||||
let agents_md = root.join("AGENTS.md");
|
||||
upsert_managed_section(
|
||||
&agents_md,
|
||||
"## Active Agents\n\n| Name | Role |\n|------|------|\n| Kit | Builder |",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Remove version file to simulate an upgrade.
|
||||
fs::remove_file(root.join(".nest-agents-version")).unwrap();
|
||||
|
||||
// Re-run ensure_nest_at (triggers refresh).
|
||||
ensure_nest_at(&root).unwrap();
|
||||
|
||||
let content = fs::read_to_string(&agents_md).unwrap();
|
||||
// Static content should be refreshed (from template).
|
||||
assert!(
|
||||
content.starts_with("# Buzz Nest"),
|
||||
"template header must be present"
|
||||
);
|
||||
// Managed section should be preserved.
|
||||
assert!(
|
||||
content.contains("Kit"),
|
||||
"managed section agent table must survive refresh"
|
||||
);
|
||||
assert!(content.contains(BEGIN_MARKER), "BEGIN marker must survive");
|
||||
assert!(content.contains(END_MARKER), "END marker must survive");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_skips_when_version_current() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join(".buzz");
|
||||
ensure_nest_at(&root).unwrap();
|
||||
|
||||
// Manually change AGENTS.md content after version file is written.
|
||||
let agents_md = root.join("AGENTS.md");
|
||||
fs::write(&agents_md, "user modified content").unwrap();
|
||||
|
||||
// Re-run ensure_nest_at — version file is current, so no refresh.
|
||||
ensure_nest_at(&root).unwrap();
|
||||
|
||||
let content = fs::read_to_string(&agents_md).unwrap();
|
||||
assert_eq!(
|
||||
content, "user modified content",
|
||||
"should not overwrite when version is current"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_skill_overwrites_on_version_bump() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join(".buzz");
|
||||
ensure_nest_at(&root).unwrap();
|
||||
|
||||
let skill_md = root.join(".agents/skills/buzz-cli/SKILL.md");
|
||||
fs::write(&skill_md, "stale skill content").unwrap();
|
||||
|
||||
// Remove version file to simulate upgrade.
|
||||
let _ = fs::remove_file(root.join(".agents/skills/buzz-cli/.skill-version"));
|
||||
|
||||
ensure_nest_at(&root).unwrap();
|
||||
|
||||
let content = fs::read_to_string(&skill_md).unwrap();
|
||||
assert_eq!(
|
||||
content, BUZZ_CLI_SKILL_MD,
|
||||
"SKILL.md must be refreshed on version bump"
|
||||
);
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
|
||||
901
desktop/src-tauri/src/managed_agents/nest/tests.rs
Normal file
901
desktop/src-tauri/src/managed_agents/nest/tests.rs
Normal file
@@ -0,0 +1,901 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn nest_dir_is_under_home() {
|
||||
if let Some(dir) = nest_dir() {
|
||||
// Accepts both .buzz (prod) and .buzz-dev (dev) depending on
|
||||
// whether init_nest_dir was called before this test ran.
|
||||
let name = dir.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||
assert!(
|
||||
name == NEST_DIR_PROD || name == NEST_DIR_DEV,
|
||||
"nest_dir must end with .buzz or .buzz-dev, got {dir:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn init_nest_dir_prod_sets_buzz() {
|
||||
// init_nest_dir is idempotent (OnceLock) — once set, subsequent calls
|
||||
// are no-ops. We can only test the fallback path if the OnceLock is
|
||||
// unset, which is only true in a fresh process. Instead, verify that
|
||||
// nest_dir() always returns a path ending with a valid nest suffix.
|
||||
let dir = nest_dir();
|
||||
if let Some(d) = dir {
|
||||
let name = d.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||
assert!(
|
||||
name == NEST_DIR_PROD || name == NEST_DIR_DEV,
|
||||
"nest_dir suffix must be .buzz or .buzz-dev, got {d:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_nest_creates_all_dirs_and_agents_md() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join(".buzz");
|
||||
|
||||
ensure_nest_at(&root).unwrap();
|
||||
|
||||
// All subdirectories exist.
|
||||
for dir in NEST_DIRS {
|
||||
assert!(root.join(dir).is_dir(), "{dir}/ should exist");
|
||||
}
|
||||
// REPOS is provisioned separately (may be a symlink); with no
|
||||
// repos_dir configured it lands as a real directory.
|
||||
assert!(root.join("REPOS").is_dir(), "REPOS/ should exist");
|
||||
|
||||
// AGENTS.md was written with default content.
|
||||
let content = fs::read_to_string(root.join("AGENTS.md")).unwrap();
|
||||
assert_eq!(content, AGENTS_MD);
|
||||
|
||||
// Permissions are 700 on Unix for root and all subdirs.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = fs::metadata(&root).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o700, "root should be 700");
|
||||
for dir in NEST_DIRS {
|
||||
let mode = fs::metadata(root.join(dir)).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o700, "{dir}/ should be 700");
|
||||
}
|
||||
let repos_mode = fs::metadata(root.join("REPOS"))
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o777;
|
||||
assert_eq!(repos_mode, 0o700, "REPOS/ should be 700");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_nest_is_idempotent_and_preserves_custom_content() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join(".buzz");
|
||||
|
||||
// First call creates everything.
|
||||
ensure_nest_at(&root).unwrap();
|
||||
|
||||
// User customizes AGENTS.md.
|
||||
let agents = root.join("AGENTS.md");
|
||||
fs::write(&agents, "my custom instructions").unwrap();
|
||||
|
||||
// Second call succeeds and does not overwrite.
|
||||
ensure_nest_at(&root).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
fs::read_to_string(&agents).unwrap(),
|
||||
"my custom instructions"
|
||||
);
|
||||
|
||||
// All dirs still exist.
|
||||
for dir in NEST_DIRS {
|
||||
assert!(root.join(dir).is_dir(), "{dir}/ should still exist");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn ensure_nest_rejects_symlink_root() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let target = tmp.path().join("real_dir");
|
||||
fs::create_dir(&target).unwrap();
|
||||
let link = tmp.path().join(".buzz");
|
||||
std::os::unix::fs::symlink(&target, &link).unwrap();
|
||||
|
||||
let result = ensure_nest_at(&link);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("symlink"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_nest_creates_skill_file() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join(".buzz");
|
||||
ensure_nest_at(&root).unwrap();
|
||||
|
||||
// Canonical location under .agents.
|
||||
let skill = root.join(".agents/skills/buzz-cli/SKILL.md");
|
||||
assert!(skill.exists(), "SKILL.md should exist at .agents path");
|
||||
let content = fs::read_to_string(&skill).unwrap();
|
||||
assert_eq!(content, BUZZ_CLI_SKILL_MD);
|
||||
|
||||
// On unix, harness-specific symlinks should resolve to the canonical dir.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
for dir in [".goose/skills", ".claude/skills", ".codex/skills"] {
|
||||
let link = root.join(dir).join("buzz-cli");
|
||||
assert!(
|
||||
link.symlink_metadata().unwrap().file_type().is_symlink(),
|
||||
"{dir}/buzz-cli should be a symlink"
|
||||
);
|
||||
assert!(
|
||||
link.join("SKILL.md").exists(),
|
||||
"symlink at {dir}/buzz-cli should resolve to dir with SKILL.md"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_nest_does_not_overwrite_skill_file() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join(".buzz");
|
||||
ensure_nest_at(&root).unwrap();
|
||||
|
||||
let skill = root.join(".agents/skills/buzz-cli/SKILL.md");
|
||||
fs::write(&skill, "custom skill content").unwrap();
|
||||
|
||||
ensure_nest_at(&root).unwrap();
|
||||
assert_eq!(fs::read_to_string(&skill).unwrap(), "custom skill content");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn ensure_nest_skill_dir_has_700_permissions() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join(".buzz");
|
||||
ensure_nest_at(&root).unwrap();
|
||||
// Canonical path and all provider parent dirs should be locked down.
|
||||
// Symlinks (e.g. .goose/skills/buzz-cli) are skipped by the chmod loop.
|
||||
for dir in [
|
||||
".agents",
|
||||
".agents/skills",
|
||||
".agents/skills/buzz-cli",
|
||||
".goose",
|
||||
".goose/skills",
|
||||
".claude",
|
||||
".claude/skills",
|
||||
".codex",
|
||||
".codex/skills",
|
||||
] {
|
||||
let path = root.join(dir);
|
||||
let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o700, "{dir} should be 700");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn ensure_nest_skips_permissions_on_symlinked_child() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join(".buzz");
|
||||
|
||||
// First call creates the real nest.
|
||||
ensure_nest_at(&root).unwrap();
|
||||
|
||||
// Replace REPOS/ with a symlink to an external directory.
|
||||
let external = tmp.path().join("external");
|
||||
fs::create_dir(&external).unwrap();
|
||||
fs::set_permissions(&external, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
fs::remove_dir(root.join("REPOS")).unwrap();
|
||||
std::os::unix::fs::symlink(&external, root.join("REPOS")).unwrap();
|
||||
|
||||
// Second call should succeed — it skips chmod on the symlinked child.
|
||||
ensure_nest_at(&root).unwrap();
|
||||
|
||||
// The external directory's permissions should be unchanged (755, not 700).
|
||||
let mode = fs::metadata(&external).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(
|
||||
mode, 0o755,
|
||||
"symlinked child's target should not be chmod'd"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn ensure_nest_migrates_old_skill_dir() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join(".buzz");
|
||||
|
||||
// Simulate a pre-migration install: real directory at old path.
|
||||
// Create the nest first to get all dirs, then simulate old layout.
|
||||
ensure_nest_at(&root).unwrap();
|
||||
|
||||
// Remove the symlink and new skill dir, recreate old real dir.
|
||||
let _ = fs::remove_file(root.join(".claude/skills/buzz-cli"));
|
||||
let _ = fs::remove_dir_all(root.join(".agents/skills/buzz-cli"));
|
||||
let old_skill_dir = root.join(".claude/skills/buzz-cli");
|
||||
fs::create_dir_all(&old_skill_dir).unwrap();
|
||||
fs::write(old_skill_dir.join("SKILL.md"), "user edited skill").unwrap();
|
||||
|
||||
// Delete version file to force refresh.
|
||||
let _ = fs::remove_file(root.join(".agents/skills/buzz-cli/.skill-version"));
|
||||
|
||||
// Re-run ensure_nest_at — should trigger migration in refresh_skill_md_if_stale.
|
||||
ensure_nest_at(&root).unwrap();
|
||||
|
||||
// New canonical location exists with user's content preserved.
|
||||
let new_skill = root.join(".agents/skills/buzz-cli/SKILL.md");
|
||||
assert!(new_skill.exists(), "SKILL.md should exist at new path");
|
||||
assert_eq!(fs::read_to_string(&new_skill).unwrap(), "user edited skill");
|
||||
|
||||
// Old path is now a symlink, not a real directory.
|
||||
let old_path = root.join(".claude/skills/buzz-cli");
|
||||
assert!(
|
||||
old_path
|
||||
.symlink_metadata()
|
||||
.unwrap()
|
||||
.file_type()
|
||||
.is_symlink(),
|
||||
"old path should now be a symlink"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn ensure_skill_symlinks_are_idempotent() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join(".buzz");
|
||||
ensure_nest_at(&root).unwrap();
|
||||
// Second call should succeed without errors.
|
||||
ensure_nest_at(&root).unwrap();
|
||||
// All symlinks still valid and point to relative targets.
|
||||
for dir in [".goose/skills", ".claude/skills", ".codex/skills"] {
|
||||
let link = root.join(dir).join("buzz-cli");
|
||||
assert!(link.symlink_metadata().unwrap().file_type().is_symlink());
|
||||
assert!(
|
||||
link.join("SKILL.md").exists(),
|
||||
"symlink at {dir}/buzz-cli should resolve to dir with SKILL.md"
|
||||
);
|
||||
let target = fs::read_link(&link).unwrap();
|
||||
assert_eq!(
|
||||
target.to_str().unwrap(),
|
||||
format!("../../{CANONICAL_SKILL_DIR}"),
|
||||
"symlink at {dir}/buzz-cli should use relative target"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn ensure_skill_symlinks_skips_existing_path_during_initial_pass() {
|
||||
// ensure_skill_symlinks skips any path where symlink_metadata succeeds.
|
||||
// However, refresh_skill_md_if_stale (called after ensure_skill_symlinks)
|
||||
// migrates pre-existing real directories at .claude/skills/buzz-cli to
|
||||
// symlinks. This test verifies the end-to-end behavior: a pre-existing real
|
||||
// dir at the claude path is migrated to a symlink.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join(".buzz");
|
||||
// Pre-create a real directory where a symlink would go.
|
||||
let real_dir = root.join(".claude/skills/buzz-cli");
|
||||
fs::create_dir_all(&real_dir).unwrap();
|
||||
// Place SKILL.md so migration preserves it.
|
||||
fs::write(real_dir.join("SKILL.md"), "custom skill content").unwrap();
|
||||
|
||||
ensure_nest_at(&root).unwrap();
|
||||
|
||||
// Migration converts the real dir to a symlink; content is moved to canonical path.
|
||||
assert!(
|
||||
real_dir
|
||||
.symlink_metadata()
|
||||
.unwrap()
|
||||
.file_type()
|
||||
.is_symlink(),
|
||||
".claude/skills/buzz-cli should be migrated to a symlink"
|
||||
);
|
||||
// The canonical path now holds the migrated content.
|
||||
let canonical = root.join(".agents/skills/buzz-cli/SKILL.md");
|
||||
assert_eq!(
|
||||
fs::read_to_string(&canonical).unwrap(),
|
||||
"custom skill content"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn ensure_skill_symlinks_skip_dangling_symlink() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join(".buzz");
|
||||
// Pre-create a dangling symlink where the .codex link would go.
|
||||
let codex_skills = root.join(".codex/skills");
|
||||
fs::create_dir_all(&codex_skills).unwrap();
|
||||
let dangling = codex_skills.join("buzz-cli");
|
||||
std::os::unix::fs::symlink("/nonexistent/target", &dangling).unwrap();
|
||||
|
||||
ensure_nest_at(&root).unwrap();
|
||||
|
||||
// Dangling symlink should be left alone (not clobbered).
|
||||
assert!(dangling
|
||||
.symlink_metadata()
|
||||
.unwrap()
|
||||
.file_type()
|
||||
.is_symlink());
|
||||
assert_eq!(
|
||||
fs::read_link(&dangling).unwrap().to_str().unwrap(),
|
||||
"/nonexistent/target"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_link_name_prod_is_buzz() {
|
||||
assert_eq!(cli_link_name(false), "buzz");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_link_name_dev_is_buzz_dev() {
|
||||
assert_eq!(cli_link_name(true), "buzz-dev");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn ensure_cli_symlink_creates_symlink_prod() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let exe_parent = tmp.path().join("MacOS");
|
||||
fs::create_dir(&exe_parent).unwrap();
|
||||
fs::write(exe_parent.join("buzz"), "binary").unwrap();
|
||||
|
||||
let local_bin = tmp.path().join("local_bin");
|
||||
fs::create_dir_all(&local_bin).unwrap();
|
||||
|
||||
// Prod link name is "buzz"; simulate the symlink creation path.
|
||||
let link = local_bin.join(cli_link_name(false));
|
||||
std::os::unix::fs::symlink(exe_parent.join("buzz"), &link).unwrap();
|
||||
assert!(link.symlink_metadata().unwrap().file_type().is_symlink());
|
||||
assert_eq!(fs::read_link(&link).unwrap(), exe_parent.join("buzz"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn ensure_cli_symlink_creates_symlink_dev() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let exe_parent = tmp.path().join("MacOS");
|
||||
fs::create_dir(&exe_parent).unwrap();
|
||||
fs::write(exe_parent.join("buzz"), "binary").unwrap();
|
||||
|
||||
let local_bin = tmp.path().join("local_bin");
|
||||
fs::create_dir_all(&local_bin).unwrap();
|
||||
|
||||
// Dev link must be "buzz-dev", never "buzz".
|
||||
assert_eq!(cli_link_name(true), "buzz-dev");
|
||||
|
||||
let link = local_bin.join(cli_link_name(true));
|
||||
std::os::unix::fs::symlink(exe_parent.join("buzz"), &link).unwrap();
|
||||
assert!(link.symlink_metadata().unwrap().file_type().is_symlink());
|
||||
assert_eq!(fs::read_link(&link).unwrap(), exe_parent.join("buzz"));
|
||||
// Prod link must not exist — the two builds don't touch each other.
|
||||
assert!(!local_bin.join("buzz").exists());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn ensure_cli_symlink_does_not_clobber_regular_file_prod() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let local_bin = tmp.path().join("local_bin");
|
||||
fs::create_dir_all(&local_bin).unwrap();
|
||||
let link = local_bin.join(cli_link_name(false));
|
||||
fs::write(&link, "user-installed binary").unwrap();
|
||||
|
||||
// Regular files are preserved — the Ok(_) branch skips them.
|
||||
assert!(link.symlink_metadata().unwrap().file_type().is_file());
|
||||
assert_eq!(fs::read_to_string(&link).unwrap(), "user-installed binary");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn ensure_cli_symlink_does_not_clobber_regular_file_dev() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let local_bin = tmp.path().join("local_bin");
|
||||
fs::create_dir_all(&local_bin).unwrap();
|
||||
let link = local_bin.join(cli_link_name(true));
|
||||
fs::write(&link, "user-installed buzz-dev binary").unwrap();
|
||||
|
||||
// Regular files at the dev path are also preserved.
|
||||
assert!(link.symlink_metadata().unwrap().file_type().is_file());
|
||||
assert_eq!(
|
||||
fs::read_to_string(&link).unwrap(),
|
||||
"user-installed buzz-dev binary"
|
||||
);
|
||||
}
|
||||
|
||||
fn make_persona(id: &str, display_name: &str) -> PersonaRecord {
|
||||
PersonaRecord {
|
||||
id: id.to_string(),
|
||||
display_name: display_name.to_string(),
|
||||
avatar_url: None,
|
||||
system_prompt: String::new(),
|
||||
runtime: None,
|
||||
model: None,
|
||||
provider: None,
|
||||
name_pool: vec![],
|
||||
is_builtin: false,
|
||||
is_active: true,
|
||||
source_team: None,
|
||||
source_team_persona_slug: None,
|
||||
env_vars: std::collections::BTreeMap::new(),
|
||||
created_at: String::new(),
|
||||
updated_at: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn make_agent(name: &str, persona_id: Option<&str>) -> ManagedAgentRecord {
|
||||
ManagedAgentRecord {
|
||||
pubkey: String::new(),
|
||||
name: name.to_string(),
|
||||
persona_id: persona_id.map(|s| s.to_string()),
|
||||
private_key_nsec: String::new(),
|
||||
auth_tag: None,
|
||||
relay_url: String::new(),
|
||||
avatar_url: None,
|
||||
acp_command: String::new(),
|
||||
agent_command: String::new(),
|
||||
agent_command_override: None,
|
||||
agent_args: vec![],
|
||||
mcp_command: String::new(),
|
||||
turn_timeout_seconds: 0,
|
||||
idle_timeout_seconds: None,
|
||||
max_turn_duration_seconds: None,
|
||||
parallelism: 1,
|
||||
system_prompt: None,
|
||||
model: None,
|
||||
provider: None,
|
||||
persona_source_version: None,
|
||||
mcp_toolsets: None,
|
||||
start_on_app_launch: false,
|
||||
runtime_pid: None,
|
||||
backend: BackendKind::default(),
|
||||
backend_agent_id: None,
|
||||
provider_binary_path: None,
|
||||
persona_team_dir: None,
|
||||
persona_name_in_team: None,
|
||||
created_at: String::new(),
|
||||
updated_at: String::new(),
|
||||
last_started_at: None,
|
||||
last_stopped_at: None,
|
||||
last_exit_code: None,
|
||||
last_error: None,
|
||||
respond_to: RespondTo::default(),
|
||||
respond_to_allowlist: vec![],
|
||||
env_vars: std::collections::BTreeMap::new(),
|
||||
display_name: None,
|
||||
slug: None,
|
||||
runtime: None,
|
||||
name_pool: Vec::new(),
|
||||
is_builtin: false,
|
||||
is_active: true,
|
||||
source_team: None,
|
||||
source_team_persona_slug: None,
|
||||
relay_mesh: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_dynamic_section_with_agents() {
|
||||
let personas = vec![make_persona("p1", "Builder")];
|
||||
let agents = vec![make_agent("Kit", Some("p1"))];
|
||||
let output = render_dynamic_section(&personas, &agents, "ws://example.com:3000");
|
||||
assert!(output.contains("| Kit | Builder | @Kit |"));
|
||||
assert!(output.contains("| Name | Persona | How to address |"));
|
||||
assert!(output.contains("## Workspace"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_dynamic_section_empty() {
|
||||
let output = render_dynamic_section(&[], &[], "ws://example.com:3000");
|
||||
assert!(output.contains("No agents deployed yet"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_dynamic_section_agent_no_persona() {
|
||||
let personas = vec![make_persona("p1", "Builder")];
|
||||
let agents = vec![make_agent("Scout", Some("nonexistent"))];
|
||||
let output = render_dynamic_section(&personas, &agents, "ws://example.com:3000");
|
||||
assert!(output.contains("| Scout | — | @Scout |"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upsert_managed_section_with_markers() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = tmp.path().join("AGENTS.md");
|
||||
fs::write(
|
||||
&file,
|
||||
"# Header\n\nsome content\n\n<!-- BEGIN BUZZ MANAGED — regenerated automatically, do not edit below -->\nold section\n<!-- END BUZZ MANAGED -->\n\nafter\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
upsert_managed_section(&file, "new section").unwrap();
|
||||
|
||||
let result = fs::read_to_string(&file).unwrap();
|
||||
assert!(result.contains("<!-- BEGIN BUZZ MANAGED"));
|
||||
assert!(result.contains("<!-- END BUZZ MANAGED -->"));
|
||||
assert!(result.contains("new section"));
|
||||
assert!(!result.contains("old section"));
|
||||
assert!(result.contains("# Header"));
|
||||
assert!(result.contains("some content"));
|
||||
assert!(result.contains("after"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upsert_managed_section_without_markers() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = tmp.path().join("AGENTS.md");
|
||||
fs::write(&file, "# Header\n\nexisting content\n").unwrap();
|
||||
|
||||
upsert_managed_section(&file, "injected section").unwrap();
|
||||
|
||||
let result = fs::read_to_string(&file).unwrap();
|
||||
assert!(result.contains("# Header"));
|
||||
assert!(result.contains("existing content"));
|
||||
assert!(result.contains("<!-- BEGIN BUZZ MANAGED"));
|
||||
assert!(result.contains("<!-- END BUZZ MANAGED -->"));
|
||||
assert!(result.contains("injected section"));
|
||||
let begin_pos = result.find("<!-- BEGIN BUZZ MANAGED").unwrap();
|
||||
let header_pos = result.find("# Header").unwrap();
|
||||
assert!(
|
||||
header_pos < begin_pos,
|
||||
"original content should precede the managed section"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upsert_managed_section_no_tmp_leftover() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = tmp.path().join("AGENTS.md");
|
||||
fs::write(&file, "# Header\n").unwrap();
|
||||
|
||||
upsert_managed_section(&file, "content").unwrap();
|
||||
|
||||
// Verify no stray temp files in the directory
|
||||
let entries: Vec<_> = fs::read_dir(tmp.path())
|
||||
.unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
entries.len(),
|
||||
1,
|
||||
"only AGENTS.md should remain, no temp files"
|
||||
);
|
||||
assert_eq!(entries[0].file_name(), "AGENTS.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upsert_end_before_begin() {
|
||||
// An END marker that precedes a BEGIN marker forms no valid ordered pair.
|
||||
// find_managed_markers returns None (BEGIN found, but no END after it),
|
||||
// so the orphan BEGIN line is stripped and a new block is appended.
|
||||
// The stray END line and content between END and BEGIN remain in the file
|
||||
// because strip_orphan_begin_marker only removes the BEGIN line itself.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = tmp.path().join("AGENTS.md");
|
||||
fs::write(
|
||||
&file,
|
||||
"# Header\n\n<!-- END BUZZ MANAGED -->\nsome middle content\n<!-- BEGIN BUZZ MANAGED — regenerated automatically, do not edit below -->\nold section\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
upsert_managed_section(&file, "new section").unwrap();
|
||||
|
||||
let result = fs::read_to_string(&file).unwrap();
|
||||
|
||||
assert!(result.contains("# Header"), "original header must survive");
|
||||
assert!(
|
||||
result.contains("new section"),
|
||||
"new content must be present"
|
||||
);
|
||||
assert!(
|
||||
result.contains("some middle content"),
|
||||
"content between markers must survive"
|
||||
);
|
||||
|
||||
// Exactly one BEGIN marker in the output (the orphan was stripped, new one appended).
|
||||
assert_eq!(
|
||||
result.matches(BEGIN_MARKER).count(),
|
||||
1,
|
||||
"exactly one BEGIN marker after orphan cleanup"
|
||||
);
|
||||
|
||||
// The single BEGIN marker must have a matching END marker after it.
|
||||
let begin_pos = result
|
||||
.find(BEGIN_MARKER)
|
||||
.expect("BEGIN marker must be present");
|
||||
let end_pos = result[begin_pos..].find(END_MARKER).map(|p| begin_pos + p);
|
||||
assert!(
|
||||
end_pos.is_some(),
|
||||
"an END marker must appear after the appended BEGIN marker"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upsert_begin_only_no_end() {
|
||||
// A file with BEGIN but no END has an orphan marker.
|
||||
// find_managed_markers returns None (no END found after BEGIN),
|
||||
// so strip_orphan_begin_marker removes the BEGIN line.
|
||||
// Content that followed the orphan BEGIN is preserved (only the marker line is stripped,
|
||||
// not the body that came after it).
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = tmp.path().join("AGENTS.md");
|
||||
fs::write(
|
||||
&file,
|
||||
"# Header\n\nsome content\n\n<!-- BEGIN BUZZ MANAGED — regenerated automatically, do not edit below -->\norphaned section without end marker\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
upsert_managed_section(&file, "fresh section").unwrap();
|
||||
|
||||
let result = fs::read_to_string(&file).unwrap();
|
||||
|
||||
assert!(result.contains("# Header"), "original header must survive");
|
||||
assert!(
|
||||
result.contains("some content"),
|
||||
"original body must survive"
|
||||
);
|
||||
assert!(
|
||||
result.contains("fresh section"),
|
||||
"new content must be present"
|
||||
);
|
||||
|
||||
let begin_pos = result
|
||||
.find(BEGIN_MARKER)
|
||||
.expect("BEGIN marker must be present");
|
||||
let end_pos = result.find(END_MARKER).expect("END marker must be present");
|
||||
assert!(
|
||||
begin_pos < end_pos,
|
||||
"the appended BEGIN marker must precede the appended END marker"
|
||||
);
|
||||
|
||||
// Exactly one BEGIN marker after orphan cleanup.
|
||||
assert_eq!(
|
||||
result.matches(BEGIN_MARKER).count(),
|
||||
1,
|
||||
"exactly one BEGIN marker after orphan cleanup"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upsert_duplicate_markers() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = tmp.path().join("AGENTS.md");
|
||||
fs::write(
|
||||
&file,
|
||||
"# Header\n\n<!-- BEGIN BUZZ MANAGED — regenerated automatically, do not edit below -->\nfirst block\n<!-- END BUZZ MANAGED -->\n\nbetween blocks\n\n<!-- BEGIN BUZZ MANAGED — regenerated automatically, do not edit below -->\nsecond block\n<!-- END BUZZ MANAGED -->\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
upsert_managed_section(&file, "replaced").unwrap();
|
||||
|
||||
let result = fs::read_to_string(&file).unwrap();
|
||||
|
||||
assert!(
|
||||
result.contains("replaced"),
|
||||
"replacement content must be present"
|
||||
);
|
||||
assert!(
|
||||
!result.contains("first block"),
|
||||
"first block must be replaced"
|
||||
);
|
||||
assert!(
|
||||
result.contains("second block"),
|
||||
"second pair content must survive"
|
||||
);
|
||||
assert!(
|
||||
result.contains("between blocks"),
|
||||
"text between pairs must survive"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upsert_marker_in_code_block() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = tmp.path().join("AGENTS.md");
|
||||
// Indented by 4 spaces — not at column 0, so should NOT match as a real marker.
|
||||
fs::write(
|
||||
&file,
|
||||
"# Header\n\n <!-- BEGIN BUZZ MANAGED — some indented marker -->\n\nReal content here\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
upsert_managed_section(&file, "appended content").unwrap();
|
||||
|
||||
let result = fs::read_to_string(&file).unwrap();
|
||||
|
||||
assert!(
|
||||
result.contains(" <!-- BEGIN BUZZ MANAGED — some indented marker -->"),
|
||||
"indented marker inside code block must be preserved verbatim"
|
||||
);
|
||||
assert!(
|
||||
result.contains("appended content"),
|
||||
"new content must be appended"
|
||||
);
|
||||
assert!(
|
||||
result.contains("Real content here"),
|
||||
"existing body must survive"
|
||||
);
|
||||
|
||||
// The real markers appended at the end must be at line-start (column 0).
|
||||
let begin_pos = result
|
||||
.find("<!-- BEGIN BUZZ MANAGED — regenerated")
|
||||
.expect("regenerated BEGIN marker must be present");
|
||||
assert!(
|
||||
begin_pos == 0 || result.as_bytes()[begin_pos - 1] == b'\n',
|
||||
"appended BEGIN marker must be at line start"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_pipe_in_agent_name() {
|
||||
let personas = vec![make_persona("p1", "Builder")];
|
||||
let agents = vec![make_agent("Kit|Pro", Some("p1"))];
|
||||
let output = render_dynamic_section(&personas, &agents, "ws://example.com:3000");
|
||||
|
||||
assert!(
|
||||
output.contains("Kit\\|Pro"),
|
||||
"pipe in agent name must be escaped as \\|"
|
||||
);
|
||||
// An unescaped bare `|` immediately adjacent to "Kit|Pro" would break table parsing.
|
||||
assert!(
|
||||
!output.contains("| Kit|Pro |"),
|
||||
"unescaped pipe in agent name must not appear as a cell boundary"
|
||||
);
|
||||
|
||||
// The row must start and end with `|` and the escaped name and address must appear.
|
||||
let kit_row = output
|
||||
.lines()
|
||||
.find(|l| l.contains("Kit\\|Pro"))
|
||||
.expect("Kit\\|Pro row must be present");
|
||||
assert!(kit_row.starts_with('|'), "row must start with |");
|
||||
assert!(kit_row.ends_with('|'), "row must end with |");
|
||||
assert!(
|
||||
kit_row.contains("@Kit\\|Pro"),
|
||||
"address cell must use escaped name"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_newline_in_persona_name() {
|
||||
let personas = vec![make_persona("p1", "Builder\nExpert")];
|
||||
let agents = vec![make_agent("Scout", Some("p1"))];
|
||||
let output = render_dynamic_section(&personas, &agents, "ws://example.com:3000");
|
||||
|
||||
assert!(
|
||||
output.contains("Builder Expert"),
|
||||
"newline in persona display_name must be replaced with a space"
|
||||
);
|
||||
|
||||
// The table row for Scout must be a single line (no embedded newline).
|
||||
let scout_row = output
|
||||
.lines()
|
||||
.find(|l| l.contains("Scout"))
|
||||
.expect("Scout row must be present");
|
||||
assert!(
|
||||
scout_row.contains("Builder Expert"),
|
||||
"persona name with newline replaced by space must appear on the Scout row"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upsert_idempotent() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = tmp.path().join("AGENTS.md");
|
||||
fs::write(
|
||||
&file,
|
||||
"# Header\n\n<!-- BEGIN BUZZ MANAGED — regenerated automatically, do not edit below -->\nexisting section\n<!-- END BUZZ MANAGED -->\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
upsert_managed_section(&file, "same content").unwrap();
|
||||
let after_first = fs::read_to_string(&file).unwrap();
|
||||
|
||||
upsert_managed_section(&file, "same content").unwrap();
|
||||
let after_second = fs::read_to_string(&file).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
after_first, after_second,
|
||||
"upsert must be idempotent: second call must not alter the file"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_agents_md_writes_version_file() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join(".buzz");
|
||||
ensure_nest_at(&root).unwrap();
|
||||
let version = fs::read_to_string(root.join(".nest-agents-version")).unwrap();
|
||||
assert_eq!(version.trim(), NEST_AGENTS_VERSION.to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_skill_md_writes_version_file() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join(".buzz");
|
||||
ensure_nest_at(&root).unwrap();
|
||||
let version = fs::read_to_string(root.join(".agents/skills/buzz-cli/.skill-version")).unwrap();
|
||||
assert_eq!(version.trim(), NEST_SKILL_VERSION.to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_agents_md_preserves_managed_section() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join(".buzz");
|
||||
ensure_nest_at(&root).unwrap();
|
||||
|
||||
// Simulate a managed section update.
|
||||
let agents_md = root.join("AGENTS.md");
|
||||
upsert_managed_section(
|
||||
&agents_md,
|
||||
"## Active Agents\n\n| Name | Role |\n|------|------|\n| Kit | Builder |",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Remove version file to simulate an upgrade.
|
||||
fs::remove_file(root.join(".nest-agents-version")).unwrap();
|
||||
|
||||
// Re-run ensure_nest_at (triggers refresh).
|
||||
ensure_nest_at(&root).unwrap();
|
||||
|
||||
let content = fs::read_to_string(&agents_md).unwrap();
|
||||
// Static content should be refreshed (from template).
|
||||
assert!(
|
||||
content.starts_with("# Buzz Nest"),
|
||||
"template header must be present"
|
||||
);
|
||||
// Managed section should be preserved.
|
||||
assert!(
|
||||
content.contains("Kit"),
|
||||
"managed section agent table must survive refresh"
|
||||
);
|
||||
assert!(content.contains(BEGIN_MARKER), "BEGIN marker must survive");
|
||||
assert!(content.contains(END_MARKER), "END marker must survive");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_skips_when_version_current() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join(".buzz");
|
||||
ensure_nest_at(&root).unwrap();
|
||||
|
||||
// Manually change AGENTS.md content after version file is written.
|
||||
let agents_md = root.join("AGENTS.md");
|
||||
fs::write(&agents_md, "user modified content").unwrap();
|
||||
|
||||
// Re-run ensure_nest_at — version file is current, so no refresh.
|
||||
ensure_nest_at(&root).unwrap();
|
||||
|
||||
let content = fs::read_to_string(&agents_md).unwrap();
|
||||
assert_eq!(
|
||||
content, "user modified content",
|
||||
"should not overwrite when version is current"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_skill_overwrites_on_version_bump() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join(".buzz");
|
||||
ensure_nest_at(&root).unwrap();
|
||||
|
||||
let skill_md = root.join(".agents/skills/buzz-cli/SKILL.md");
|
||||
fs::write(&skill_md, "stale skill content").unwrap();
|
||||
|
||||
// Remove version file to simulate upgrade.
|
||||
let _ = fs::remove_file(root.join(".agents/skills/buzz-cli/.skill-version"));
|
||||
|
||||
ensure_nest_at(&root).unwrap();
|
||||
|
||||
let content = fs::read_to_string(&skill_md).unwrap();
|
||||
assert_eq!(
|
||||
content, BUZZ_CLI_SKILL_MD,
|
||||
"SKILL.md must be refreshed on version bump"
|
||||
);
|
||||
}
|
||||
@@ -45,7 +45,6 @@ use crate::managed_agents::{
|
||||
agent_env::baked_build_env,
|
||||
config_bridge::read_goose_file_config,
|
||||
discovery::{known_acp_runtime, resolve_command, KnownAcpRuntime},
|
||||
effective_agent_command,
|
||||
env_vars::merged_user_env,
|
||||
types::{ManagedAgentRecord, PersonaRecord},
|
||||
};
|
||||
@@ -86,11 +85,7 @@ pub(crate) fn resolve_effective_agent_env(
|
||||
personas: &[PersonaRecord],
|
||||
runtime: Option<&KnownAcpRuntime>,
|
||||
) -> EffectiveAgentEnv {
|
||||
let effective_command = effective_agent_command(
|
||||
record.persona_id.as_deref(),
|
||||
personas,
|
||||
record.agent_command_override.as_deref(),
|
||||
);
|
||||
let effective_command = crate::managed_agents::record_agent_command(record, personas);
|
||||
|
||||
// Layer 1: baked build defaults (floor — internal builds only; OSS = empty).
|
||||
let mut env = baked_build_env();
|
||||
@@ -989,6 +984,14 @@ mod tests {
|
||||
last_error: None,
|
||||
respond_to: Default::default(),
|
||||
respond_to_allowlist: vec![],
|
||||
display_name: None,
|
||||
slug: None,
|
||||
runtime: None,
|
||||
name_pool: Vec::new(),
|
||||
is_builtin: false,
|
||||
is_active: true,
|
||||
source_team: None,
|
||||
source_team_persona_slug: None,
|
||||
relay_mesh: None,
|
||||
};
|
||||
|
||||
|
||||
@@ -333,11 +333,8 @@ pub async fn restore_managed_agents_on_launch(
|
||||
// Resolve the effective harness for the avatar-fallback
|
||||
// derivation (the snapshot may be empty/stale for an inherited
|
||||
// harness). Mirrors the UI start path.
|
||||
let effective_command = crate::managed_agents::effective_agent_command(
|
||||
record.persona_id.as_deref(),
|
||||
&reconcile_personas,
|
||||
record.agent_command_override.as_deref(),
|
||||
);
|
||||
let effective_command =
|
||||
crate::managed_agents::record_agent_command(record, &reconcile_personas);
|
||||
Some((
|
||||
pubkey.clone(),
|
||||
crate::commands::ProfileReconcileData {
|
||||
|
||||
@@ -1369,11 +1369,7 @@ pub fn build_managed_agent_summary(
|
||||
|
||||
// Resolve the effective harness the same way, then derive args/mcp from it,
|
||||
// so the UI reflects the persona's current harness (or an explicit pin).
|
||||
let effective_command = crate::managed_agents::effective_agent_command(
|
||||
record.persona_id.as_deref(),
|
||||
personas,
|
||||
record.agent_command_override.as_deref(),
|
||||
);
|
||||
let effective_command = crate::managed_agents::record_agent_command(record, personas);
|
||||
let effective_args = normalize_agent_args(&effective_command, record.agent_args.clone());
|
||||
let effective_mcp_command = known_acp_runtime(&effective_command)
|
||||
.and_then(|r| r.mcp_command)
|
||||
@@ -1522,11 +1518,7 @@ pub fn spawn_agent_child(
|
||||
// command, so we recompute them from the effective value rather than the
|
||||
// frozen record snapshot. Mirrors the model resolution below.
|
||||
let personas = super::load_personas(app).unwrap_or_default();
|
||||
let effective_command = super::effective_agent_command(
|
||||
record.persona_id.as_deref(),
|
||||
&personas,
|
||||
record.agent_command_override.as_deref(),
|
||||
);
|
||||
let effective_command = super::record_agent_command(record, &personas);
|
||||
let agent_args = normalize_agent_args(&effective_command, record.agent_args.clone());
|
||||
let resolved_acp_command = resolve_command(&record.acp_command)
|
||||
.ok_or_else(|| missing_command_message(&record.acp_command, "ACP harness command"))?;
|
||||
|
||||
@@ -160,6 +160,14 @@ fn fixture(
|
||||
last_error: None,
|
||||
respond_to,
|
||||
respond_to_allowlist: allowlist,
|
||||
display_name: None,
|
||||
slug: None,
|
||||
runtime: None,
|
||||
name_pool: Vec::new(),
|
||||
is_builtin: false,
|
||||
is_active: true,
|
||||
source_team: None,
|
||||
source_team_persona_slug: None,
|
||||
relay_mesh: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||
|
||||
use super::{
|
||||
effective_agent_command, known_acp_runtime, normalize_agent_args,
|
||||
known_acp_runtime, normalize_agent_args,
|
||||
persona_events::persona_snapshot_with_agent_config_fallback,
|
||||
resolve_effective_agent_env,
|
||||
types::{ManagedAgentRecord, PersonaRecord},
|
||||
@@ -64,11 +64,7 @@ pub(crate) fn spawn_config_hash(
|
||||
}
|
||||
let record = &record;
|
||||
|
||||
let effective_command = effective_agent_command(
|
||||
record.persona_id.as_deref(),
|
||||
personas,
|
||||
record.agent_command_override.as_deref(),
|
||||
);
|
||||
let effective_command = crate::managed_agents::record_agent_command(record, personas);
|
||||
let runtime_meta = known_acp_runtime(&effective_command);
|
||||
let effective = resolve_effective_agent_env(record, personas, runtime_meta);
|
||||
|
||||
|
||||
@@ -40,6 +40,14 @@ fn record() -> ManagedAgentRecord {
|
||||
last_error: None,
|
||||
respond_to: Default::default(),
|
||||
respond_to_allowlist: vec![],
|
||||
display_name: None,
|
||||
slug: None,
|
||||
runtime: None,
|
||||
name_pool: Vec::new(),
|
||||
is_builtin: false,
|
||||
is_active: true,
|
||||
source_team: None,
|
||||
source_team_persona_slug: None,
|
||||
relay_mesh: None,
|
||||
}
|
||||
}
|
||||
@@ -73,6 +81,28 @@ fn hash_is_deterministic() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn materializing_runtime_keeps_hash_stable() {
|
||||
// Migration cutover invariant (Phase 1A): materializing the linked
|
||||
// persona's runtime onto the record must NOT change the spawn hash —
|
||||
// otherwise every running persona-linked agent would show a spurious
|
||||
// restart badge right after migration. Pre-migration the command resolves
|
||||
// through the persona fallback; post-migration through record.runtime.
|
||||
// Same persona, same runtime, same command → same hash.
|
||||
let personas = vec![persona("p1", Some("goose"), "Persona prompt.")];
|
||||
|
||||
let mut pre = record();
|
||||
pre.persona_id = Some("p1".into());
|
||||
|
||||
let mut post = pre.clone();
|
||||
post.runtime = Some("goose".into());
|
||||
|
||||
assert_eq!(
|
||||
spawn_config_hash(&pre, &personas, "wss://ws.example"),
|
||||
spawn_config_hash(&post, &personas, "wss://ws.example")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_env_var_edit_changes_hash() {
|
||||
let rec = record();
|
||||
|
||||
@@ -214,6 +214,11 @@ fn hydrate_keys(records: &mut [ManagedAgentRecord]) {
|
||||
/// key this boot."
|
||||
fn hydrate_keys_with(store: &impl KeyStore, records: &mut [ManagedAgentRecord]) {
|
||||
for record in records.iter_mut() {
|
||||
// A key-less definition (no pubkey yet — unified agent model) has no
|
||||
// keyring entry by construction; keys are minted on first start.
|
||||
if record.pubkey.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if record.private_key_nsec.is_empty() {
|
||||
match store.load(&agent_keyring_name(&record.pubkey)) {
|
||||
Ok(Some(nsec)) => record.private_key_nsec = nsec,
|
||||
|
||||
@@ -309,6 +309,14 @@ mod tests {
|
||||
respond_to: Default::default(),
|
||||
respond_to_allowlist: vec![],
|
||||
env_vars: std::collections::BTreeMap::new(),
|
||||
display_name: None,
|
||||
slug: None,
|
||||
runtime: None,
|
||||
name_pool: Vec::new(),
|
||||
is_builtin: false,
|
||||
is_active: true,
|
||||
source_team: None,
|
||||
source_team_persona_slug: None,
|
||||
relay_mesh: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,97 @@ pub struct PersonaRecord {
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
impl PersonaRecord {
|
||||
/// Project this persona onto a key-less unified [`ManagedAgentRecord`]
|
||||
/// (Phase 1A store fold). Identity fields stay empty — keys are minted on
|
||||
/// first start. `PersonaRecord.id` becomes `slug`, preserving the 30175
|
||||
/// event coordinate (`d_tag = slug`) across the fold.
|
||||
// Wired in by the stage-3 fold migration (same PR); allow until then.
|
||||
#[allow(dead_code)]
|
||||
pub fn into_agent_record(self) -> ManagedAgentRecord {
|
||||
ManagedAgentRecord {
|
||||
pubkey: String::new(),
|
||||
name: self.display_name.clone(),
|
||||
persona_id: None,
|
||||
private_key_nsec: String::new(),
|
||||
auth_tag: None,
|
||||
relay_url: String::new(),
|
||||
avatar_url: self.avatar_url,
|
||||
acp_command: DEFAULT_ACP_COMMAND.to_string(),
|
||||
agent_command: String::new(),
|
||||
agent_command_override: None,
|
||||
agent_args: Vec::new(),
|
||||
mcp_command: String::new(),
|
||||
turn_timeout_seconds: DEFAULT_AGENT_TURN_TIMEOUT_SECONDS,
|
||||
idle_timeout_seconds: None,
|
||||
max_turn_duration_seconds: None,
|
||||
parallelism: default_agent_parallelism(),
|
||||
system_prompt: (!self.system_prompt.is_empty()).then_some(self.system_prompt),
|
||||
model: self.model,
|
||||
provider: self.provider,
|
||||
persona_source_version: None,
|
||||
mcp_toolsets: None,
|
||||
env_vars: self.env_vars,
|
||||
start_on_app_launch: false,
|
||||
runtime_pid: None,
|
||||
backend: BackendKind::default(),
|
||||
backend_agent_id: None,
|
||||
provider_binary_path: None,
|
||||
persona_team_dir: None,
|
||||
persona_name_in_team: None,
|
||||
created_at: self.created_at,
|
||||
updated_at: self.updated_at,
|
||||
last_started_at: None,
|
||||
last_stopped_at: None,
|
||||
last_exit_code: None,
|
||||
last_error: None,
|
||||
respond_to: RespondTo::default(),
|
||||
respond_to_allowlist: Vec::new(),
|
||||
display_name: Some(self.display_name),
|
||||
slug: Some(self.id),
|
||||
runtime: self.runtime,
|
||||
name_pool: self.name_pool,
|
||||
is_builtin: self.is_builtin,
|
||||
is_active: self.is_active,
|
||||
source_team: self.source_team,
|
||||
source_team_persona_slug: self.source_team_persona_slug,
|
||||
relay_mesh: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ManagedAgentRecord {
|
||||
/// Present a key-less definition record back in the legacy
|
||||
/// [`PersonaRecord`] shape — the compatibility view the persona command
|
||||
/// surface serves until Phase 1B unifies the UI. Inverse of
|
||||
/// [`PersonaRecord::into_agent_record`] for the fields personas carry.
|
||||
// Wired in by the stage-3 fold shims (same PR); allow until then.
|
||||
#[allow(dead_code)]
|
||||
pub fn to_persona_view(&self) -> Option<PersonaRecord> {
|
||||
let slug = self.slug.clone()?;
|
||||
Some(PersonaRecord {
|
||||
id: slug,
|
||||
display_name: self
|
||||
.display_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| self.name.clone()),
|
||||
avatar_url: self.avatar_url.clone(),
|
||||
system_prompt: self.system_prompt.clone().unwrap_or_default(),
|
||||
runtime: self.runtime.clone(),
|
||||
model: self.model.clone(),
|
||||
provider: self.provider.clone(),
|
||||
name_pool: self.name_pool.clone(),
|
||||
is_builtin: self.is_builtin,
|
||||
is_active: self.is_active,
|
||||
source_team: self.source_team.clone(),
|
||||
source_team_persona_slug: self.source_team_persona_slug.clone(),
|
||||
env_vars: self.env_vars.clone(),
|
||||
created_at: self.created_at.clone(),
|
||||
updated_at: self.updated_at.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RelayAgentInfo {
|
||||
pub pubkey: String,
|
||||
@@ -204,6 +295,43 @@ pub struct ManagedAgentRecord {
|
||||
/// Preserved across mode toggles so users don't lose state.
|
||||
#[serde(default)]
|
||||
pub respond_to_allowlist: Vec<String>,
|
||||
/// Optional display name distinct from the unique `name` handle. Absorbed
|
||||
/// from `PersonaRecord.display_name` (unified agent model, Phase 1A).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub display_name: Option<String>,
|
||||
/// Stable definition slug — the former `PersonaRecord.id`. Key-less
|
||||
/// records (definitions not yet instantiated) publish kind:30175 at
|
||||
/// `d_tag = slug`, preserving the pre-merge event coordinates. `None` for
|
||||
/// agents created directly (never persona-backed).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub slug: Option<String>,
|
||||
/// Absorbed from `PersonaRecord.runtime` — the preferred ACP runtime ID
|
||||
/// (e.g. 'goose', 'claude'). Record-first command resolution reads this
|
||||
/// before falling back to legacy persona lookup; populated by the store
|
||||
/// migration and at create time.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub runtime: Option<String>,
|
||||
/// Pool of short thematic names for clones of this agent. Absorbed from
|
||||
/// `PersonaRecord.name_pool`; feeds clone naming.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub name_pool: Vec<String>,
|
||||
/// Absorbed from `PersonaRecord.is_builtin`.
|
||||
#[serde(default)]
|
||||
pub is_builtin: bool,
|
||||
/// Absorbed from `PersonaRecord.is_active` — `false` means an archived
|
||||
/// definition hidden from pickers. Defaults `true` for existing records.
|
||||
#[serde(default = "default_record_active")]
|
||||
pub is_active: bool,
|
||||
/// Absorbed from `PersonaRecord.source_team` — team ID when this
|
||||
/// definition was imported from a team directory (team definitions are
|
||||
/// non-editable). Distinct from `persona_team_dir`/`persona_name_in_team`,
|
||||
/// which are the instance-side spawn plumbing.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub source_team: Option<String>,
|
||||
/// Absorbed from `PersonaRecord.source_team_persona_slug` — the
|
||||
/// definition's slug within its source team.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub source_team_persona_slug: Option<String>,
|
||||
/// Typed marker for relay-mesh agents. `Some(_)` means this agent runs its
|
||||
/// inference through Buzz's relay-mesh local endpoint; the `model_ref` is
|
||||
/// the served model id to route to. `None` is a normal agent.
|
||||
|
||||
@@ -467,3 +467,70 @@ fn sample_agent_record() -> ManagedAgentRecord {
|
||||
)
|
||||
.expect("sample record")
|
||||
}
|
||||
|
||||
// ── PersonaRecord ↔ AgentRecord fold mapping (Phase 1A) ─────────────────────
|
||||
|
||||
fn sample_persona() -> PersonaRecord {
|
||||
PersonaRecord {
|
||||
id: "custom:helper".to_string(),
|
||||
display_name: "Helper".to_string(),
|
||||
avatar_url: Some("https://example.com/a.png".to_string()),
|
||||
system_prompt: "You help.".to_string(),
|
||||
runtime: Some("goose".to_string()),
|
||||
model: Some("gpt-x".to_string()),
|
||||
provider: Some("openai".to_string()),
|
||||
name_pool: vec!["Nimble".to_string()],
|
||||
is_builtin: false,
|
||||
is_active: true,
|
||||
source_team: Some("team-1".to_string()),
|
||||
source_team_persona_slug: Some("helper".to_string()),
|
||||
env_vars: [("K".to_string(), "v".to_string())].into_iter().collect(),
|
||||
created_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
updated_at: "2026-01-02T00:00:00Z".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persona_into_agent_record_is_keyless_and_slugged() {
|
||||
let record = sample_persona().into_agent_record();
|
||||
assert!(record.pubkey.is_empty(), "fold must not mint identity");
|
||||
assert!(record.private_key_nsec.is_empty());
|
||||
assert_eq!(record.slug.as_deref(), Some("custom:helper"));
|
||||
assert_eq!(record.display_name.as_deref(), Some("Helper"));
|
||||
assert_eq!(record.system_prompt.as_deref(), Some("You help."));
|
||||
assert_eq!(record.runtime.as_deref(), Some("goose"));
|
||||
assert_eq!(record.source_team.as_deref(), Some("team-1"));
|
||||
assert_eq!(record.env_vars.get("K").map(String::as_str), Some("v"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persona_view_round_trips_through_agent_record() {
|
||||
let persona = sample_persona();
|
||||
let view = persona
|
||||
.clone()
|
||||
.into_agent_record()
|
||||
.to_persona_view()
|
||||
.expect("slugged record must present a persona view");
|
||||
assert_eq!(
|
||||
serde_json::to_value(&view).unwrap(),
|
||||
serde_json::to_value(&persona).unwrap(),
|
||||
"fold + view must round-trip every persona field"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyed_record_without_slug_has_no_persona_view() {
|
||||
let mut record = sample_persona().into_agent_record();
|
||||
record.slug = None;
|
||||
assert!(
|
||||
record.to_persona_view().is_none(),
|
||||
"instances (no slug) are not definitions"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_prompt_folds_to_none() {
|
||||
let mut persona = sample_persona();
|
||||
persona.system_prompt = String::new();
|
||||
assert_eq!(persona.into_agent_record().system_prompt, None);
|
||||
}
|
||||
|
||||
@@ -153,6 +153,7 @@ pub fn run_boot_migrations(app: &tauri::AppHandle) {
|
||||
eprintln!("buzz-desktop: sync-team-personas: {e}");
|
||||
}
|
||||
reconcile_provider_mcp_commands(app);
|
||||
materialize_agent_runtimes(app);
|
||||
}
|
||||
|
||||
/// Copy one-time app state from the legacy app identifier directory to
|
||||
@@ -1271,6 +1272,9 @@ pub fn migrate_persona_provider_to_runtime(app: &tauri::AppHandle) {
|
||||
rename_provider_to_runtime_in_personas(&path);
|
||||
}
|
||||
|
||||
mod materialize;
|
||||
pub use materialize::materialize_agent_runtimes;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "migration_test_support.rs"]
|
||||
mod test_support;
|
||||
|
||||
140
desktop/src-tauri/src/migration/materialize.rs
Normal file
140
desktop/src-tauri/src/migration/materialize.rs
Normal file
@@ -0,0 +1,140 @@
|
||||
//! Phase 1A (unified agent model): boot-time materialization of each
|
||||
//! persona-linked agent record's `runtime` onto the record itself.
|
||||
//!
|
||||
//! Child module of `migration` so it reuses the parent's private JSON-patch
|
||||
//! helpers (`patch_json_records`, `load_persona_runtimes`,
|
||||
//! `canonical_dev_data_dir`).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use tauri::Manager as _;
|
||||
|
||||
use super::{canonical_dev_data_dir, load_persona_runtimes, patch_json_records};
|
||||
|
||||
/// Materialize each persona-linked agent record's `runtime` from its linked
|
||||
/// persona (unified agent model, Phase 1A). After this, spawn resolution reads
|
||||
/// the record's own runtime (`record_agent_command` step 2) instead of the
|
||||
/// live persona — same effective command by construction, so the spawn-config
|
||||
/// hash is unchanged and no running agent shows a spurious restart badge (see
|
||||
/// `spawn_hash::tests::materializing_runtime_keeps_hash_stable`).
|
||||
///
|
||||
/// Idempotent: records that already carry `runtime` are untouched, as are
|
||||
/// records with no linked persona or a persona without a runtime (both keep
|
||||
/// resolving through the legacy fallback path unchanged).
|
||||
pub fn materialize_agent_runtimes(app: &tauri::AppHandle) {
|
||||
let Ok(current_dir) = app.path().app_data_dir() else {
|
||||
return;
|
||||
};
|
||||
let mut dirs = vec![current_dir.clone()];
|
||||
if let Some(canonical) = canonical_dev_data_dir(¤t_dir) {
|
||||
if canonical.exists() && canonical != current_dir {
|
||||
dirs.push(canonical);
|
||||
}
|
||||
}
|
||||
for dir in dirs {
|
||||
let path = dir.join("agents/managed-agents.json");
|
||||
if path.exists() {
|
||||
materialize_runtimes_in_file(&path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn materialize_runtimes_in_file(path: &Path) {
|
||||
let persona_runtimes = load_persona_runtimes(path);
|
||||
if persona_runtimes.is_empty() {
|
||||
return;
|
||||
}
|
||||
patch_json_records(path, |obj| {
|
||||
if obj.contains_key("runtime") {
|
||||
return false;
|
||||
}
|
||||
let Some(runtime) = obj
|
||||
.get("persona_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|pid| persona_runtimes.get(pid))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
obj.insert(
|
||||
"runtime".to_string(),
|
||||
serde_json::Value::String(runtime.clone()),
|
||||
);
|
||||
true
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::materialize_runtimes_in_file;
|
||||
use crate::migration::test_support::{
|
||||
read_agents_json, write_agents_json, write_personas_json,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn materialize_runtimes_copies_persona_runtime_onto_record() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_personas_json(
|
||||
dir.path(),
|
||||
&serde_json::json!([{ "id": "persona-1", "displayName": "Alice", "runtime": "goose" }]),
|
||||
);
|
||||
write_agents_json(
|
||||
dir.path(),
|
||||
&serde_json::json!([{ "name": "Fizz", "persona_id": "persona-1" }]),
|
||||
);
|
||||
materialize_runtimes_in_file(&dir.path().join("agents/managed-agents.json"));
|
||||
let records = read_agents_json(dir.path());
|
||||
assert_eq!(records[0]["runtime"], "goose");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn materialize_runtimes_is_idempotent_and_preserves_existing() {
|
||||
// A record that already carries runtime (even one diverging from its
|
||||
// persona) is never overwritten, and the second run rewrites nothing.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_personas_json(
|
||||
dir.path(),
|
||||
&serde_json::json!([{ "id": "persona-1", "displayName": "Alice", "runtime": "goose" }]),
|
||||
);
|
||||
write_agents_json(
|
||||
dir.path(),
|
||||
&serde_json::json!([
|
||||
{ "name": "Fizz", "persona_id": "persona-1", "runtime": "claude" },
|
||||
{ "name": "Buzz", "persona_id": "persona-1" }
|
||||
]),
|
||||
);
|
||||
let agents_path = dir.path().join("agents/managed-agents.json");
|
||||
materialize_runtimes_in_file(&agents_path);
|
||||
let records = read_agents_json(dir.path());
|
||||
assert_eq!(
|
||||
records[0]["runtime"], "claude",
|
||||
"existing runtime preserved"
|
||||
);
|
||||
assert_eq!(records[1]["runtime"], "goose");
|
||||
|
||||
let before = std::fs::read_to_string(&agents_path).unwrap();
|
||||
materialize_runtimes_in_file(&agents_path);
|
||||
let after = std::fs::read_to_string(&agents_path).unwrap();
|
||||
assert_eq!(before, after, "second run must be a no-op");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn materialize_runtimes_skips_unlinked_and_unknown_personas() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_personas_json(
|
||||
dir.path(),
|
||||
&serde_json::json!([{ "id": "persona-1", "displayName": "Alice", "runtime": "goose" }]),
|
||||
);
|
||||
write_agents_json(
|
||||
dir.path(),
|
||||
&serde_json::json!([
|
||||
{ "name": "NoPersona" },
|
||||
{ "name": "GonePersona", "persona_id": "deleted" }
|
||||
]),
|
||||
);
|
||||
let agents_path = dir.path().join("agents/managed-agents.json");
|
||||
let before = std::fs::read_to_string(&agents_path).unwrap();
|
||||
materialize_runtimes_in_file(&agents_path);
|
||||
let after = std::fs::read_to_string(&agents_path).unwrap();
|
||||
assert_eq!(before, after, "no linked runtime → untouched file");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user