mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(desktop): fold personas.json into the unified agent store (Phase 1A.2) (#1623)
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:
@@ -74,17 +74,34 @@ fn migrate_personas_in_dir(base_dir: &Path, keys: &nostr::Keys) -> Result<u32, S
|
||||
|
||||
let pubkey = keys.public_key().to_hex();
|
||||
|
||||
// Read personas.json fresh at reconcile time. Nothing to do if absent.
|
||||
let personas_path = base_dir.join("personas.json");
|
||||
if !personas_path.exists() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let content = std::fs::read_to_string(&personas_path)
|
||||
.map_err(|e| format!("failed to read personas.json: {e}"))?;
|
||||
|
||||
let records: Vec<PersonaRecord> = serde_json::from_str(&content)
|
||||
.map_err(|e| format!("failed to parse personas.json: {e}"))?;
|
||||
// Post-fold (Phase 1A.2): definitions live as key-less records in the
|
||||
// unified agent store, presented in the legacy shape. Pre-fold boots
|
||||
// (run_event_sync runs after run_boot_migrations, so the fold has
|
||||
// already happened) never reach this path with personas.json present —
|
||||
// but read it as a fallback for one release in case the fold errored.
|
||||
let records: Vec<PersonaRecord> = {
|
||||
let personas_path = base_dir.join("personas.json");
|
||||
if personas_path.exists() {
|
||||
let content = std::fs::read_to_string(&personas_path)
|
||||
.map_err(|e| format!("failed to read personas.json: {e}"))?;
|
||||
serde_json::from_str(&content)
|
||||
.map_err(|e| format!("failed to parse personas.json: {e}"))?
|
||||
} else {
|
||||
let agents_path = base_dir.join("managed-agents.json");
|
||||
if !agents_path.exists() {
|
||||
return Ok(0);
|
||||
}
|
||||
let content = std::fs::read_to_string(&agents_path)
|
||||
.map_err(|e| format!("failed to read managed-agents.json: {e}"))?;
|
||||
let all: Vec<crate::managed_agents::ManagedAgentRecord> =
|
||||
serde_json::from_str(&content)
|
||||
.map_err(|e| format!("failed to parse managed-agents.json: {e}"))?;
|
||||
all.iter()
|
||||
.filter(|record| record.pubkey.is_empty())
|
||||
.filter_map(|record| record.to_persona_view())
|
||||
.collect()
|
||||
}
|
||||
};
|
||||
|
||||
if records.is_empty() {
|
||||
return Ok(0);
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
use std::{fs, path::PathBuf};
|
||||
use std::fs;
|
||||
|
||||
use tauri::AppHandle;
|
||||
|
||||
use crate::{
|
||||
managed_agents::{managed_agents_base_dir, PersonaRecord},
|
||||
util::now_iso,
|
||||
};
|
||||
use crate::{managed_agents::PersonaRecord, util::now_iso};
|
||||
|
||||
struct BuiltInPersona {
|
||||
id: &'static str,
|
||||
@@ -223,10 +220,6 @@ const RETIRED_PERSONAS: &[(&str, &str)] = &[
|
||||
),
|
||||
];
|
||||
|
||||
fn personas_store_path(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
Ok(managed_agents_base_dir(app)?.join("personas.json"))
|
||||
}
|
||||
|
||||
fn built_in_persona_records(now: &str) -> Vec<PersonaRecord> {
|
||||
BUILT_IN_PERSONAS
|
||||
.iter()
|
||||
@@ -459,34 +452,54 @@ pub fn validate_persona_activation_change(
|
||||
}
|
||||
|
||||
pub fn load_personas(app: &AppHandle) -> Result<Vec<PersonaRecord>, String> {
|
||||
let path = personas_store_path(app)?;
|
||||
let now = now_iso();
|
||||
|
||||
let records = if path.exists() {
|
||||
let content = fs::read_to_string(&path)
|
||||
.map_err(|error| format!("failed to read persona store: {error}"))?;
|
||||
serde_json::from_str::<Vec<PersonaRecord>>(&content)
|
||||
.map_err(|error| format!("failed to parse persona store: {error}"))?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
// Post-fold: definitions live in the unified agent store, presented in
|
||||
// the legacy shape. Pre-fold stores are converted by
|
||||
// `fold_personas_into_agent_store` in boot migrations before any caller
|
||||
// reaches this shim.
|
||||
let records = crate::managed_agents::storage::load_agent_definitions(app)?
|
||||
.iter()
|
||||
.filter_map(|record| record.to_persona_view())
|
||||
.collect();
|
||||
|
||||
let (records, changed) = merge_personas(records, &now);
|
||||
if changed || !path.exists() {
|
||||
if changed {
|
||||
save_personas(app, &records)?;
|
||||
}
|
||||
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
/// Read the raw persona records at `path` — no built-in merge, no write-back.
|
||||
/// The single disk-read seam for persona definitions: `load_personas` layers
|
||||
/// the built-in merge on top, and the boot-time readers that need raw records
|
||||
/// without an `AppHandle` (`event_sync`, `migration::load_persona_runtimes`)
|
||||
/// call it directly. The A2 store fold retargets THIS function at the unified
|
||||
/// store; its callers stay unchanged.
|
||||
pub(crate) fn load_personas_from_path(
|
||||
path: &std::path::Path,
|
||||
) -> Result<Vec<PersonaRecord>, String> {
|
||||
if !path.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let content = fs::read_to_string(path)
|
||||
.map_err(|error| format!("failed to read persona store: {error}"))?;
|
||||
serde_json::from_str::<Vec<PersonaRecord>>(&content)
|
||||
.map_err(|error| format!("failed to parse persona store: {error}"))
|
||||
}
|
||||
|
||||
pub fn save_personas(app: &AppHandle, records: &[PersonaRecord]) -> Result<(), String> {
|
||||
let mut sorted = records.to_vec();
|
||||
sort_personas(&mut sorted);
|
||||
|
||||
let path = personas_store_path(app)?;
|
||||
let payload = serde_json::to_vec_pretty(&sorted)
|
||||
.map_err(|error| format!("failed to serialize persona store: {error}"))?;
|
||||
crate::managed_agents::storage::atomic_write_json(&path, &payload)
|
||||
// Post-fold: persona saves write key-less definition records into the
|
||||
// unified agent store (instances preserved by `save_agent_definitions`).
|
||||
let definitions: Vec<_> = sorted
|
||||
.into_iter()
|
||||
.map(|persona| persona.into_agent_record())
|
||||
.collect();
|
||||
crate::managed_agents::storage::save_agent_definitions(app, &definitions)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -147,7 +147,9 @@ pub(crate) fn spawn_key_refusal(record: &ManagedAgentRecord) -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn load_managed_agents(app: &AppHandle) -> Result<Vec<ManagedAgentRecord>, String> {
|
||||
/// Read the raw unified store — keyed instances AND key-less definitions —
|
||||
/// with fail-loud parse handling. Internal seam; public readers filter.
|
||||
fn load_agent_store(app: &AppHandle) -> Result<Vec<ManagedAgentRecord>, String> {
|
||||
let path = managed_agents_store_path(app)?;
|
||||
if !path.exists() {
|
||||
return Ok(Vec::new());
|
||||
@@ -155,7 +157,7 @@ pub fn load_managed_agents(app: &AppHandle) -> Result<Vec<ManagedAgentRecord>, S
|
||||
|
||||
let content = fs::read_to_string(&path)
|
||||
.map_err(|error| format!("failed to read agent store: {error}"))?;
|
||||
let mut records: Vec<ManagedAgentRecord> = serde_json::from_str(&content).map_err(|error| {
|
||||
serde_json::from_str(&content).map_err(|error| {
|
||||
// Fail loudly and preserve the evidence: a later in-app save rewrites
|
||||
// this file wholesale, which would silently destroy a malformed hand
|
||||
// edit. Best-effort file-authoring contract (see managed_agents::
|
||||
@@ -164,12 +166,28 @@ pub fn load_managed_agents(app: &AppHandle) -> Result<Vec<ManagedAgentRecord>, S
|
||||
// swallowed into an empty store.
|
||||
backup_invalid_store(&path);
|
||||
format!("failed to parse agent store (preserved as .invalid): {error}")
|
||||
})?;
|
||||
})
|
||||
}
|
||||
|
||||
/// Load the keyed agent *instances*. Key-less definitions (former personas,
|
||||
/// folded into the same store) are filtered out so every pre-fold call site
|
||||
/// keeps seeing exactly the records it always did.
|
||||
pub fn load_managed_agents(app: &AppHandle) -> Result<Vec<ManagedAgentRecord>, String> {
|
||||
let mut records = load_agent_store(app)?;
|
||||
records.retain(|record| !record.pubkey.is_empty());
|
||||
hydrate_keys(&mut records);
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
/// Load the key-less agent *definitions* (former personas) from the unified
|
||||
/// store. The persona compatibility shim (`load_personas`) presents these in
|
||||
/// the legacy shape via `to_persona_view`.
|
||||
pub(crate) fn load_agent_definitions(app: &AppHandle) -> Result<Vec<ManagedAgentRecord>, String> {
|
||||
let mut records = load_agent_store(app)?;
|
||||
records.retain(|record| record.pubkey.is_empty());
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
/// Preserve a malformed store file as `<name>.invalid` before the error path
|
||||
/// unwinds. Copy, not rename: the original stays in place so repeated boots
|
||||
/// keep failing loudly (rename would make the next launch look like a fresh
|
||||
@@ -250,8 +268,17 @@ fn hydrate_keys_with(store: &impl KeyStore, records: &mut [ManagedAgentRecord])
|
||||
}
|
||||
}
|
||||
|
||||
/// Save the keyed agent *instances*, preserving the key-less definitions that
|
||||
/// share the unified store: callers pass exactly the records they loaded via
|
||||
/// [`load_managed_agents`], and this re-reads the definition half from disk
|
||||
/// before the wholesale rewrite so a definition is never dropped by an
|
||||
/// instance-side save (and vice versa via [`save_agent_definitions`]).
|
||||
pub fn save_managed_agents(app: &AppHandle, records: &[ManagedAgentRecord]) -> Result<(), String> {
|
||||
let definitions = load_agent_definitions(app).unwrap_or_default();
|
||||
let mut sorted = records.to_vec();
|
||||
// A caller-supplied key-less record would collide with the definition
|
||||
// half re-read below; instances always carry a pubkey.
|
||||
sorted.retain(|record| !record.pubkey.is_empty());
|
||||
sorted.sort_by(|left, right| {
|
||||
left.name
|
||||
.to_lowercase()
|
||||
@@ -264,8 +291,36 @@ pub fn save_managed_agents(app: &AppHandle, records: &[ManagedAgentRecord]) -> R
|
||||
// keyring is unreachable, the key stays inline.
|
||||
persist_agent_keys(&mut sorted);
|
||||
|
||||
write_agent_store(app, definitions, sorted)
|
||||
}
|
||||
|
||||
/// Save the key-less agent *definitions*, preserving the keyed instances —
|
||||
/// the definition-side mirror of [`save_managed_agents`].
|
||||
pub(crate) fn save_agent_definitions(
|
||||
app: &AppHandle,
|
||||
definitions: &[ManagedAgentRecord],
|
||||
) -> Result<(), String> {
|
||||
let mut instances = load_agent_store(app)?;
|
||||
instances.retain(|record| !record.pubkey.is_empty());
|
||||
let mut definitions = definitions.to_vec();
|
||||
definitions.retain(|record| record.pubkey.is_empty());
|
||||
write_agent_store(app, definitions, instances)
|
||||
}
|
||||
|
||||
/// Serialize definitions + instances into the single unified store file.
|
||||
/// Definitions sort first (by slug) for stable diffs; instances keep the
|
||||
/// name/pubkey order their save path established.
|
||||
fn write_agent_store(
|
||||
app: &AppHandle,
|
||||
mut definitions: Vec<ManagedAgentRecord>,
|
||||
instances: Vec<ManagedAgentRecord>,
|
||||
) -> Result<(), String> {
|
||||
definitions.sort_by(|left, right| left.slug.cmp(&right.slug));
|
||||
let mut all = definitions;
|
||||
all.extend(instances);
|
||||
|
||||
let path = managed_agents_store_path(app)?;
|
||||
let payload = serde_json::to_vec_pretty(&sorted)
|
||||
let payload = serde_json::to_vec_pretty(&all)
|
||||
.map_err(|error| format!("failed to serialize agent store: {error}"))?;
|
||||
|
||||
// `managed-agents.json` carries plaintext agent nsecs in the keyringless
|
||||
|
||||
@@ -72,8 +72,6 @@ impl PersonaRecord {
|
||||
/// (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(),
|
||||
@@ -131,8 +129,6 @@ impl ManagedAgentRecord {
|
||||
/// [`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 {
|
||||
|
||||
@@ -149,6 +149,13 @@ pub fn run_boot_migrations(app: &tauri::AppHandle) {
|
||||
reconcile_persona_team_dirs(app);
|
||||
migrate_persona_provider_to_runtime(app);
|
||||
reconcile_legacy_command_names(app);
|
||||
// Fold personas.json into the unified store HERE: after the JSON-level
|
||||
// personas.json migrations above (which must see the legacy file), and
|
||||
// before every consumer of the load/save_personas shims below —
|
||||
// sync_team_personas would otherwise operate on an empty definition set.
|
||||
// Post-fold readers of the runtime map (`load_persona_runtimes`) fall
|
||||
// back to the unified store's definitions.
|
||||
fold_personas_into_agent_store(app);
|
||||
if let Err(e) = crate::managed_agents::sync_team_personas(app) {
|
||||
eprintln!("buzz-desktop: sync-team-personas: {e}");
|
||||
}
|
||||
@@ -1015,31 +1022,6 @@ fn reconcile_mcp_commands_in_file(path: &Path) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Build a `persona_id → runtime` map from the personas.json sibling of the
|
||||
/// given managed-agents.json path. Returns an empty map when personas can't be
|
||||
/// read or parsed — callers then fall back to the record's own snapshot.
|
||||
fn load_persona_runtimes(agents_path: &Path) -> std::collections::HashMap<String, String> {
|
||||
let mut map = std::collections::HashMap::new();
|
||||
let Some(personas_path) = agents_path.parent().map(|dir| dir.join("personas.json")) else {
|
||||
return map;
|
||||
};
|
||||
let Ok(content) = std::fs::read_to_string(&personas_path) else {
|
||||
return map;
|
||||
};
|
||||
let Ok(records) = serde_json::from_str::<Vec<serde_json::Value>>(&content) else {
|
||||
return map;
|
||||
};
|
||||
for record in records {
|
||||
if let (Some(id), Some(runtime)) = (
|
||||
record.get("id").and_then(|v| v.as_str()),
|
||||
record.get("runtime").and_then(|v| v.as_str()),
|
||||
) {
|
||||
map.insert(id.to_string(), runtime.to_string());
|
||||
}
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
fn replace_command_field(
|
||||
obj: &mut serde_json::Map<String, serde_json::Value>,
|
||||
field: &str,
|
||||
@@ -1274,6 +1256,9 @@ pub fn migrate_persona_provider_to_runtime(app: &tauri::AppHandle) {
|
||||
|
||||
mod materialize;
|
||||
pub use materialize::materialize_agent_runtimes;
|
||||
mod fold;
|
||||
pub use fold::fold_personas_into_agent_store;
|
||||
use fold::load_persona_runtimes;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "migration_test_support.rs"]
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
//! Phase 1A.2 (unified agent model): one-way fold of `personas.json` into
|
||||
//! the unified agent store as key-less definition records.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// Fold `personas.json` into the unified agent store (Phase 1A.2).
|
||||
///
|
||||
/// One-way, versioned by presence: runs only while `personas.json` exists.
|
||||
/// Each persona becomes a key-less definition record
|
||||
/// ([`PersonaRecord::into_agent_record`]) appended to `managed-agents.json`
|
||||
/// via the definition-preserving save; the old file is renamed to
|
||||
/// `personas.json.bak` so a second boot is a no-op and the data survives for
|
||||
/// manual recovery. Built-ins are skipped — `merge_personas` regenerates them
|
||||
/// from code on every load, exactly as before.
|
||||
///
|
||||
/// Ordering (see `run_boot_migrations`): runs after the JSON-level
|
||||
/// `personas.json` migrations (which must see the legacy file) and BEFORE
|
||||
/// every consumer of the `load/save_personas` shims — `sync_team_personas`,
|
||||
/// `reconcile_provider_mcp_commands`, and `materialize_agent_runtimes` all
|
||||
/// read definitions post-fold via [`load_persona_runtimes`]'s unified-store
|
||||
/// branch.
|
||||
pub fn fold_personas_into_agent_store(app: &tauri::AppHandle) {
|
||||
let Ok(base_dir) = crate::managed_agents::managed_agents_base_dir(app) else {
|
||||
return;
|
||||
};
|
||||
match fold_personas_in_dir(&base_dir) {
|
||||
Ok(None) => {}
|
||||
Ok(Some(folded)) => {
|
||||
eprintln!(
|
||||
"buzz-desktop: persona-store-fold: {folded} definitions folded into the unified store"
|
||||
);
|
||||
}
|
||||
Err(e) => eprintln!("buzz-desktop: persona-store-fold: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Core fold logic, decoupled from the Tauri `AppHandle` for testing.
|
||||
/// Operates on the raw JSON files — no keyring interaction: instance records
|
||||
/// are passed through byte-identical, and folded definitions carry no keys.
|
||||
/// Returns `Ok(None)` when there is no `personas.json` to fold.
|
||||
fn fold_personas_in_dir(base_dir: &Path) -> Result<Option<usize>, String> {
|
||||
let personas_path = base_dir.join("personas.json");
|
||||
if !personas_path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let personas = crate::managed_agents::load_personas_from_path(&personas_path)?;
|
||||
|
||||
let agents_path = base_dir.join("managed-agents.json");
|
||||
let mut all: Vec<crate::managed_agents::ManagedAgentRecord> = if agents_path.exists() {
|
||||
let content = std::fs::read_to_string(&agents_path)
|
||||
.map_err(|e| format!("failed to read managed-agents.json: {e}"))?;
|
||||
serde_json::from_str(&content)
|
||||
.map_err(|e| format!("failed to parse managed-agents.json: {e}"))?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let existing: std::collections::HashSet<String> = all
|
||||
.iter()
|
||||
.filter_map(|record| record.slug.clone())
|
||||
.collect();
|
||||
|
||||
let mut folded = 0usize;
|
||||
for persona in personas {
|
||||
// Built-ins regenerate from code; a slug already in the store means
|
||||
// a previous partial fold got that far — never duplicate.
|
||||
if persona.is_builtin || existing.contains(&persona.id) {
|
||||
continue;
|
||||
}
|
||||
all.push(persona.into_agent_record());
|
||||
folded += 1;
|
||||
}
|
||||
|
||||
let payload = serde_json::to_vec_pretty(&all)
|
||||
.map_err(|e| format!("failed to serialize unified store: {e}"))?;
|
||||
crate::managed_agents::atomic_write_json_restricted(&agents_path, &payload)?;
|
||||
|
||||
// Rename only after the unified store write succeeded — a crash between
|
||||
// the two leaves personas.json in place and the fold re-runs idempotently
|
||||
// (slug dedup above).
|
||||
std::fs::rename(&personas_path, base_dir.join("personas.json.bak"))
|
||||
.map_err(|e| format!("failed to retire personas.json: {e}"))?;
|
||||
Ok(Some(folded))
|
||||
}
|
||||
|
||||
/// Build a `persona_id → runtime` map from the personas.json sibling of the
|
||||
/// given managed-agents.json path. Returns an empty map when personas can't be
|
||||
/// read or parsed — callers then fall back to the record's own snapshot.
|
||||
pub(super) fn load_persona_runtimes(
|
||||
agents_path: &Path,
|
||||
) -> std::collections::HashMap<String, String> {
|
||||
let mut map = std::collections::HashMap::new();
|
||||
let Some(dir) = agents_path.parent() else {
|
||||
return map;
|
||||
};
|
||||
// Pre-fold boots read the legacy personas.json; post-fold boots read the
|
||||
// key-less definitions sharing the agents store itself (slug + runtime).
|
||||
let personas_path = dir.join("personas.json");
|
||||
if personas_path.exists() {
|
||||
let Ok(content) = std::fs::read_to_string(&personas_path) else {
|
||||
return map;
|
||||
};
|
||||
let Ok(records) = serde_json::from_str::<Vec<serde_json::Value>>(&content) else {
|
||||
return map;
|
||||
};
|
||||
for record in records {
|
||||
if let (Some(id), Some(runtime)) = (
|
||||
record.get("id").and_then(|v| v.as_str()),
|
||||
record.get("runtime").and_then(|v| v.as_str()),
|
||||
) {
|
||||
map.insert(id.to_string(), runtime.to_string());
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
let Ok(content) = std::fs::read_to_string(agents_path) else {
|
||||
return map;
|
||||
};
|
||||
let Ok(records) = serde_json::from_str::<Vec<serde_json::Value>>(&content) else {
|
||||
return map;
|
||||
};
|
||||
for record in records {
|
||||
// Definition records: key-less (no/empty pubkey), slug-addressed.
|
||||
let keyed = record
|
||||
.get("pubkey")
|
||||
.and_then(|v| v.as_str())
|
||||
.is_some_and(|p| !p.is_empty());
|
||||
if keyed {
|
||||
continue;
|
||||
}
|
||||
if let (Some(slug), Some(runtime)) = (
|
||||
record.get("slug").and_then(|v| v.as_str()),
|
||||
record.get("runtime").and_then(|v| v.as_str()),
|
||||
) {
|
||||
map.insert(slug.to_string(), runtime.to_string());
|
||||
}
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::fold_personas_in_dir;
|
||||
use crate::migration::load_persona_runtimes;
|
||||
use crate::migration::test_support::{
|
||||
read_agents_json, write_agents_json, write_personas_json,
|
||||
};
|
||||
|
||||
// ── Persona-store fold (Phase 1A.2) ──────────────────────────────────────────
|
||||
|
||||
fn keyed_agent_json(name: &str, pubkey: &str) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"name": name,
|
||||
"pubkey": pubkey,
|
||||
"relay_url": "ws://localhost:3000",
|
||||
"acp_command": "buzz-acp",
|
||||
"agent_command": "goose",
|
||||
"agent_args": [],
|
||||
"mcp_command": "",
|
||||
"turn_timeout_seconds": 320,
|
||||
"system_prompt": null,
|
||||
"start_on_app_launch": false,
|
||||
"created_at": "2026-01-01T00:00:00Z",
|
||||
"updated_at": "2026-01-01T00:00:00Z",
|
||||
"last_started_at": null,
|
||||
"last_stopped_at": null,
|
||||
"last_exit_code": null,
|
||||
"last_error": null
|
||||
})
|
||||
}
|
||||
|
||||
fn custom_persona_json(id: &str, runtime: &str) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"id": id,
|
||||
"display_name": format!("Name {id}"),
|
||||
"avatar_url": null,
|
||||
"system_prompt": format!("Prompt {id}"),
|
||||
"runtime": runtime,
|
||||
"created_at": "2026-01-01T00:00:00Z",
|
||||
"updated_at": "2026-01-02T00:00:00Z"
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fold_moves_custom_personas_and_retires_the_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_personas_json(
|
||||
dir.path(),
|
||||
&serde_json::json!([
|
||||
custom_persona_json("custom:one", "goose"),
|
||||
{ "id": "builtin:fizz", "display_name": "Fizz", "system_prompt": "P",
|
||||
"is_builtin": true,
|
||||
"created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z" }
|
||||
]),
|
||||
);
|
||||
write_agents_json(
|
||||
dir.path(),
|
||||
&serde_json::json!([keyed_agent_json("Keyed", &"k".repeat(64))]),
|
||||
);
|
||||
|
||||
let base = dir.path().join("agents");
|
||||
let folded = fold_personas_in_dir(&base).unwrap();
|
||||
assert_eq!(folded, Some(1), "custom folds, builtin skipped");
|
||||
|
||||
let records = read_agents_json(dir.path());
|
||||
assert_eq!(records.len(), 2, "definition + preserved instance");
|
||||
let def = records
|
||||
.iter()
|
||||
.find(|r| r.get("slug").is_some())
|
||||
.expect("folded definition present");
|
||||
assert_eq!(def["slug"], "custom:one");
|
||||
assert_eq!(def["runtime"], "goose");
|
||||
// Key-less: pubkey serializes as the empty string (field not skipped).
|
||||
assert_eq!(def["pubkey"], "", "definition must be key-less");
|
||||
let keyed = records.iter().find(|r| r["name"] == "Keyed").unwrap();
|
||||
assert_eq!(keyed["pubkey"].as_str().unwrap().len(), 64);
|
||||
|
||||
assert!(!base.join("personas.json").exists(), "source retired");
|
||||
assert!(base.join("personas.json.bak").exists(), ".bak left behind");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fold_is_idempotent_across_partial_runs() {
|
||||
// Simulate a crash after the store write but before the rename: the
|
||||
// definition is already in the unified store AND personas.json is back.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_personas_json(
|
||||
dir.path(),
|
||||
&serde_json::json!([custom_persona_json("custom:one", "goose")]),
|
||||
);
|
||||
let base = dir.path().join("agents");
|
||||
assert_eq!(fold_personas_in_dir(&base).unwrap(), Some(1));
|
||||
|
||||
// Crash simulation: restore personas.json from the .bak.
|
||||
std::fs::copy(base.join("personas.json.bak"), base.join("personas.json")).unwrap();
|
||||
assert_eq!(
|
||||
fold_personas_in_dir(&base).unwrap(),
|
||||
Some(0),
|
||||
"second run folds nothing (slug dedup)"
|
||||
);
|
||||
let records = read_agents_json(dir.path());
|
||||
assert_eq!(records.len(), 1, "no duplicate definition");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fold_absent_personas_file_is_noop() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_agents_json(
|
||||
dir.path(),
|
||||
&serde_json::json!([keyed_agent_json("Keyed", &"k".repeat(64))]),
|
||||
);
|
||||
let base = dir.path().join("agents");
|
||||
let before = std::fs::read_to_string(base.join("managed-agents.json")).unwrap();
|
||||
assert_eq!(fold_personas_in_dir(&base).unwrap(), None);
|
||||
let after = std::fs::read_to_string(base.join("managed-agents.json")).unwrap();
|
||||
assert_eq!(before, after, "store untouched when nothing to fold");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_fold_runtime_map_reads_unified_definitions() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_personas_json(
|
||||
dir.path(),
|
||||
&serde_json::json!([custom_persona_json("custom:one", "goose")]),
|
||||
);
|
||||
let mut linked = keyed_agent_json("Keyed", &"k".repeat(64));
|
||||
linked["persona_id"] = serde_json::json!("custom:one");
|
||||
write_agents_json(dir.path(), &serde_json::json!([linked]));
|
||||
let base = dir.path().join("agents");
|
||||
let agents_path = base.join("managed-agents.json");
|
||||
|
||||
// Pre-fold: map comes from personas.json.
|
||||
let pre = load_persona_runtimes(&agents_path);
|
||||
assert_eq!(pre.get("custom:one").map(String::as_str), Some("goose"));
|
||||
|
||||
fold_personas_in_dir(&base).unwrap();
|
||||
|
||||
// Post-fold: personas.json is gone; map must come from the unified store
|
||||
// and be identical.
|
||||
let post = load_persona_runtimes(&agents_path);
|
||||
assert_eq!(post.get("custom:one").map(String::as_str), Some("goose"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fold_dedup_prefers_the_store_over_the_persona_file() {
|
||||
// Crash-between-re-run case (Pinky, review): the definition already in
|
||||
// the unified store WINS over a personas.json entry with the same slug —
|
||||
// the store copy is the one the successful fold wrote, and a user may
|
||||
// have edited it since; re-folding the stale file copy would clobber it.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_personas_json(
|
||||
dir.path(),
|
||||
&serde_json::json!([custom_persona_json("custom:one", "goose")]),
|
||||
);
|
||||
let base = dir.path().join("agents");
|
||||
assert_eq!(fold_personas_in_dir(&base).unwrap(), Some(1));
|
||||
|
||||
// Post-fold edit in the unified store.
|
||||
let mut records = read_agents_json(dir.path());
|
||||
records[0]["runtime"] = serde_json::json!("claude");
|
||||
write_agents_json(dir.path(), &serde_json::Value::Array(records));
|
||||
|
||||
// Crash simulation: stale personas.json (runtime still "goose") returns.
|
||||
std::fs::copy(base.join("personas.json.bak"), base.join("personas.json")).unwrap();
|
||||
assert_eq!(fold_personas_in_dir(&base).unwrap(), Some(0));
|
||||
|
||||
let records = read_agents_json(dir.path());
|
||||
assert_eq!(records.len(), 1);
|
||||
assert_eq!(
|
||||
records[0]["runtime"], "claude",
|
||||
"store copy wins over the stale file copy"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user