refactor(desktop): consolidate packs into teams (#852)

This commit is contained in:
Will Pfleger
2026-06-09 23:22:34 -04:00
committed by GitHub
parent 384a34ec31
commit ba2fdbf697
26 changed files with 2283 additions and 1312 deletions

3
.gitignore vendored
View File

@@ -60,3 +60,6 @@ identity.key
# Claude Code worktrees
.claude/worktrees/
# mesh-llm build cache
.cache/

View File

@@ -319,8 +319,19 @@ Right-click shows "Mark as read".
{{02-context-menu}}
```
Re-runs for the same PR overwrite previous images. Cleanup:
`git push origin --delete agent-screenshots/<username>`.
Re-runs overwrite the image blobs on the `agent-screenshots/<username>`
branch, but the script **appends a new PR comment** — it does not edit or
delete the previous one. After reposting, delete the superseded comment so
only the current set remains, otherwise reviewers still see the stale images:
```bash
# List screenshot comments to find the stale one's id
gh pr view <pr> --repo block/sprout --json comments \
--jq '.comments[] | select(.body | test("pr-<pr>--")) | {id, url}'
gh api -X DELETE repos/block/sprout/issues/comments/<stale-comment-id>
```
Branch cleanup when fully done: `git push origin --delete agent-screenshots/<username>`.
### Writing E2E Screenshot Specs
@@ -357,6 +368,20 @@ await menuItem.evaluate((el) =>
**Cropping:** Use `clip` — full-window (1280x720) screenshots are unreadable
for sidebar features. Sidebar = 256px; context menus ~450px.
**Distinct states — verify before posting:** when one view renders many
elements at once (e.g. all team cards in a single grid), an unscoped
full-page `page.screenshot()` captures the *same* pixels for every shot, so
multiple PNGs come out byte-identical. Scope each shot to its subject with
`locator.screenshot()` (full-page `clip` only when an overlay like an open
dropdown must be included). Then gate on hash distinctness before posting:
```bash
shasum -a 256 test-results/<dir>/*.png # every hash must be unique
```
Identical hashes mean two shots captured the same state — fix the spec, do
not post. This catches the most common screenshot regression.
**`general` has pre-seeded messages** making `hasUnread` always true. Use
`engineering` for "muted + no unread" visual states.

View File

@@ -30,6 +30,7 @@ export default defineConfig({
"**/channel-mute-screenshots.spec.ts",
"**/channel-star-screenshots.spec.ts",
"**/channel-controls-screenshots.spec.ts",
"**/team-management-screenshots.spec.ts",
"**/file-attachment.spec.ts",
"**/mentions.spec.ts",
"**/relay-reconnect.spec.ts",

View File

@@ -40,6 +40,7 @@ const overrides = new Map([
["src-tauri/src/nostr_convert.rs", 1116],
["src/shared/api/relayClientSession.ts", 1022],
["src-tauri/src/migration.rs", 1295],
["src-tauri/src/managed_agents/teams.rs", 1020],
]);
await runFileSizeCheck({

View File

@@ -462,13 +462,13 @@ pub async fn create_managed_agent(
requested_persona_id.as_deref().and_then(|pid| {
let personas = load_personas(&app).ok()?;
let persona = personas.iter().find(|p| p.id == pid)?;
let pack_id = persona.source_pack.as_deref()?;
let slug = persona.source_pack_persona_slug.as_deref()?;
let team_id = persona.source_team.as_deref()?;
let slug = persona.source_team_persona_slug.as_deref()?;
let base = managed_agents_base_dir(&app).ok()?;
let pack_path = base.join("packs").join(pack_id);
let team_path = base.join("teams").join(team_id);
// Use the validated slug stored during import — no need to
// re-resolve the pack. The slug is [a-zA-Z0-9_-]+ by construction.
Some((pack_path, slug.to_owned()))
Some((team_path, slug.to_owned()))
});
// Resolve the avatar URL once at creation and persist it on the record.
@@ -540,11 +540,11 @@ pub async fn create_managed_agent(
backend: input.backend.clone(),
backend_agent_id: None,
provider_binary_path,
// Pack-backed personas: record path + internal slug so the runtime
// can resolve pack config at startup. Must be the slug (e.g., "lep"),
// Team-backed personas: record path + internal slug so the runtime
// can resolve team config at startup. Must be the slug (e.g., "lep"),
// NOT the display_name — ACP's resolve_persona_by_name() matches slugs.
persona_pack_path: pack_metadata.as_ref().map(|(path, _)| path.clone()),
persona_name_in_pack: pack_metadata.as_ref().map(|(_, name)| name.clone()),
persona_team_dir: pack_metadata.as_ref().map(|(path, _)| path.clone()),
persona_name_in_team: pack_metadata.as_ref().map(|(_, name)| name.clone()),
env_vars: input.env_vars.clone(),
created_at: now_iso(),
updated_at: now_iso(),

View File

@@ -5,12 +5,11 @@ use super::export_util::save_json_with_dialog;
use crate::{
app_state::AppState,
managed_agents::{
encode_persona_json, import_persona_pack, list_installed_packs, load_managed_agents,
load_personas, load_teams, parse_json_persona, parse_md_persona, parse_png_persona,
parse_zip_personas, save_managed_agents, save_personas, try_regenerate_nest,
uninstall_persona_pack as do_uninstall_persona_pack, validate_persona_activation_change,
validate_persona_deletion, CreatePersonaRequest, PackSummary, ParsePersonaFilesResult,
PersonaRecord, UpdatePersonaRequest,
encode_persona_json, load_managed_agents, load_personas, load_teams, parse_json_persona,
parse_md_persona, parse_png_persona, parse_zip_personas, save_managed_agents,
save_personas, try_regenerate_nest, validate_persona_activation_change,
validate_persona_deletion, CreatePersonaRequest, ParsePersonaFilesResult, PersonaRecord,
UpdatePersonaRequest,
},
util::now_iso,
};
@@ -79,8 +78,8 @@ pub fn create_persona(
name_pool,
is_builtin: false,
is_active: true,
source_pack: None,
source_pack_persona_slug: None,
source_team: None,
source_team_persona_slug: None,
env_vars: input.env_vars,
created_at: now.clone(),
updated_at: now,
@@ -374,44 +373,3 @@ pub async fn export_persona_to_json(
let filename = format!("{slug}.persona.json");
save_json_with_dialog(&app, &filename, &json_bytes).await
}
// ── Pack management commands ──────────────────────────────────────────────────
#[tauri::command]
pub fn install_persona_pack(
app: AppHandle,
state: State<'_, AppState>,
path: String,
) -> Result<Vec<PersonaRecord>, String> {
let _lock = state
.managed_agents_store_lock
.lock()
.map_err(|e| e.to_string())?;
let source = std::path::PathBuf::from(&path);
if !source.is_dir() {
return Err(format!("pack path is not a directory: {path}"));
}
let result = import_persona_pack(&app, &source)?;
try_regenerate_nest(&app);
Ok(result)
}
#[tauri::command]
pub fn uninstall_persona_pack(
app: AppHandle,
state: State<'_, AppState>,
pack_id: String,
) -> Result<(), String> {
let _lock = state
.managed_agents_store_lock
.lock()
.map_err(|e| e.to_string())?;
do_uninstall_persona_pack(&app, &pack_id)?;
try_regenerate_nest(&app);
Ok(())
}
#[tauri::command]
pub fn list_persona_packs(app: AppHandle) -> Result<Vec<PackSummary>, String> {
list_installed_packs(&app)
}

View File

@@ -5,9 +5,10 @@ use super::export_util::save_json_with_dialog;
use crate::{
app_state::AppState,
managed_agents::{
encode_team_json, ensure_persona_ids_are_active, load_personas, load_teams,
parse_team_json, save_teams, validate_team_deletion, CreateTeamRequest, ParsedTeamPreview,
TeamRecord, UpdateTeamRequest,
delete_team_with_cascade, encode_team_json, ensure_persona_ids_are_active,
import_team_from_directory as do_import_team, load_personas, load_teams, parse_team_json,
save_teams, sync_team_from_dir as do_sync_team, try_regenerate_nest, CreateTeamRequest,
ParsedTeamPreview, SyncResult, TeamRecord, UpdateTeamRequest,
},
util::now_iso,
};
@@ -59,6 +60,10 @@ pub fn create_team(
description,
persona_ids: input.persona_ids,
is_builtin: false,
source_dir: None,
is_symlink: false,
symlink_target: None,
version: None,
created_at: now.clone(),
updated_at: now,
};
@@ -104,14 +109,51 @@ pub fn delete_team(id: String, app: AppHandle, state: State<'_, AppState>) -> Re
.managed_agents_store_lock
.lock()
.map_err(|error| error.to_string())?;
let mut teams = load_teams(&app)?;
let team = teams
.iter()
.find(|record| record.id == id)
.ok_or_else(|| format!("team {id} not found"))?;
validate_team_deletion(team)?;
teams.retain(|record| record.id != id);
save_teams(&app, &teams)
delete_team_with_cascade(&app, &id)?;
try_regenerate_nest(&app);
Ok(())
}
#[tauri::command]
pub fn install_team_from_directory(
app: AppHandle,
state: State<'_, AppState>,
path: String,
symlink: Option<bool>,
) -> Result<TeamRecord, String> {
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|e| e.to_string())?;
let source = std::path::PathBuf::from(&path);
if !source.is_dir() {
return Err(format!("team path is not a directory: {path}"));
}
let result = do_import_team(&app, &source, symlink.unwrap_or(false))?;
try_regenerate_nest(&app);
Ok(result)
}
#[tauri::command]
pub fn sync_team_directory(
app: AppHandle,
state: State<'_, AppState>,
team_id: String,
) -> Result<SyncResult, String> {
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|e| e.to_string())?;
let result = do_sync_team(&app, &team_id)?;
try_regenerate_nest(&app);
Ok(result)
}
#[tauri::command]
pub async fn pick_team_directory(app: AppHandle) -> Result<Option<String>, String> {
use tauri_plugin_dialog::DialogExt;
let path = app.dialog().file().blocking_pick_folder();
Ok(path.map(|p| p.to_string()))
}
// ---------------------------------------------------------------------------

View File

@@ -520,12 +520,13 @@ pub fn run() {
// this worktree's data directory. Must run before
// restore_managed_agents_on_launch (which reads managed-agents.json).
migration::sync_shared_agent_data(&app_handle);
migration::reconcile_persona_pack_paths(&app_handle);
migration::migrate_packs_to_teams(&app_handle);
migration::reconcile_persona_team_dirs(&app_handle);
migration::reconcile_provider_mcp_commands(&app_handle);
migration::migrate_persona_provider_to_runtime(&app_handle);
if let Err(e) = managed_agents::sync_pack_personas(&app_handle) {
eprintln!("sprout-desktop: sync-pack-personas: {e}");
if let Err(e) = managed_agents::sync_team_personas(&app_handle) {
eprintln!("sprout-desktop: sync-team-personas: {e}");
}
// Resolve persisted identity key (env var → file → generate+save).
@@ -734,13 +735,13 @@ pub fn run() {
create_team,
update_team,
delete_team,
install_team_from_directory,
sync_team_directory,
pick_team_directory,
export_team_to_json,
parse_team_file,
parse_persona_files,
export_persona_to_json,
install_persona_pack,
uninstall_persona_pack,
list_persona_packs,
get_channel_workflows,
get_workflow,
create_workflow,

View File

@@ -947,8 +947,8 @@ mod tests {
name_pool: vec![],
is_builtin: false,
is_active: true,
source_pack: None,
source_pack_persona_slug: None,
source_team: None,
source_team_persona_slug: None,
env_vars: std::collections::BTreeMap::new(),
created_at: String::new(),
updated_at: String::new(),
@@ -980,8 +980,8 @@ mod tests {
backend: BackendKind::default(),
backend_agent_id: None,
provider_binary_path: None,
persona_pack_path: None,
persona_name_in_pack: None,
persona_team_dir: None,
persona_name_in_team: None,
created_at: String::new(),
updated_at: String::new(),
last_started_at: None,

View File

@@ -1,7 +1,6 @@
use std::{fs, path::PathBuf};
use tauri::AppHandle;
use uuid::Uuid;
use crate::{
managed_agents::{managed_agents_base_dir, PersonaRecord},
@@ -506,8 +505,8 @@ fn built_in_persona_records(now: &str) -> Vec<PersonaRecord> {
name_pool: persona.name_pool.iter().map(|s| s.to_string()).collect(),
is_builtin: true,
is_active: true,
source_pack: None,
source_pack_persona_slug: None,
source_team: None,
source_team_persona_slug: None,
env_vars: std::collections::BTreeMap::new(),
created_at: now.to_string(),
updated_at: now.to_string(),
@@ -677,9 +676,9 @@ pub fn validate_persona_deletion(
return Err("Built-in personas cannot be deleted.".to_string());
}
if persona.source_pack.is_some() {
if persona.source_team.is_some() {
return Err(format!(
"{} belongs to a pack. Use \"Uninstall Pack\" to remove all pack personas together.",
"{} belongs to a team. Delete the team to remove all team personas together.",
persona.display_name
));
}
@@ -723,324 +722,6 @@ pub fn validate_persona_activation_change(
Ok(())
}
// ── Pack import ───────────────────────────────────────────────────────────────
/// Packs directory: `<AppDataDir>/agents/packs/`
fn packs_dir(app: &AppHandle) -> Result<PathBuf, String> {
let dir = managed_agents_base_dir(app)?.join("packs");
fs::create_dir_all(&dir).map_err(|e| format!("failed to create packs dir: {e}"))?;
Ok(dir)
}
/// Validate pack ID: only `[a-zA-Z0-9._-]+` allowed (Lep F2: zip-slip defense).
fn validate_pack_id(id: &str) -> Result<(), String> {
if id.is_empty() {
return Err("pack ID is empty".into());
}
if id.len() > 128 {
return Err(format!("pack ID too long: {} chars (max 128)", id.len()));
}
// Character allowlist: [a-zA-Z0-9._-]
if !id
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-')
{
return Err(format!(
"pack ID contains invalid characters: \"{id}\". Only [a-zA-Z0-9._-] allowed."
));
}
// Path traversal defense: block ".", "..", leading dots, and IDs with
// no alphanumeric characters (e.g., "---", "...").
if id.starts_with('.') {
return Err(format!("pack ID \"{id}\" must not start with '.'"));
}
if !id.chars().any(|c| c.is_ascii_alphanumeric()) {
return Err(format!(
"pack ID \"{id}\" must contain at least one alphanumeric character"
));
}
Ok(())
}
/// Copy a directory tree, skipping symlinks (Lep F2: zip-slip defense).
fn copy_dir_no_symlinks(src: &std::path::Path, dst: &std::path::Path) -> Result<(), String> {
fs::create_dir_all(dst).map_err(|e| format!("failed to create {}: {e}", dst.display()))?;
for entry in fs::read_dir(src).map_err(|e| format!("failed to read {}: {e}", src.display()))? {
let entry = entry.map_err(|e| format!("dir entry error: {e}"))?;
let ft = entry
.file_type()
.map_err(|e| format!("file type error: {e}"))?;
let src_path = entry.path();
let dst_path = dst.join(entry.file_name());
if ft.is_symlink() {
// Skip symlinks — defense against symlink escape attacks.
continue;
} else if ft.is_dir() {
copy_dir_no_symlinks(&src_path, &dst_path)?;
} else if ft.is_file() {
fs::copy(&src_path, &dst_path)
.map_err(|e| format!("failed to copy {}: {e}", src_path.display()))?;
}
}
Ok(())
}
/// Import a persona pack from a directory into the app's pack storage.
///
/// 1. Validate + resolve the pack at the source path
/// 2. Sanitize pack ID (Lep F2)
/// 3. Check for existing pack with same ID
/// 4. Copy pack to `<AppDataDir>/agents/packs/<pack-id>/` (no symlinks)
/// 5. Re-validate the copy (Lep F2: defense-in-depth)
/// 6. Create PersonaRecords for each persona
/// 7. Merge into personas.json
pub fn import_persona_pack(
app: &AppHandle,
source_dir: &std::path::Path,
) -> Result<Vec<PersonaRecord>, String> {
// 1. Validate + resolve at source
let resolved = sprout_persona::resolve::resolve_pack(source_dir)
.map_err(|e| format!("pack validation failed: {e}"))?;
// 2. Sanitize pack ID
validate_pack_id(&resolved.id)?;
// 3. Check for existing pack
let packs = packs_dir(app)?;
let dest = packs.join(&resolved.id);
if dest.exists() {
return Err(format!(
"Pack \"{}\" is already installed. Uninstall it first.",
resolved.id
));
}
// 4. Copy pack (no symlinks)
copy_dir_no_symlinks(source_dir, &dest)?;
// 5. Re-validate the copy (defense-in-depth)
sprout_persona::resolve::resolve_pack(&dest).map_err(|e| {
// Clean up failed copy
let _ = fs::remove_dir_all(&dest);
format!("pack re-validation failed after copy: {e}")
})?;
// 6. Create PersonaRecords
let now = now_iso();
let new_personas: Vec<PersonaRecord> = resolved
.personas
.iter()
.map(|p| PersonaRecord {
id: Uuid::new_v4().to_string(),
display_name: p.display_name.clone(),
avatar_url: p.avatar.clone(),
system_prompt: p.system_prompt.clone(),
runtime: p.runtime.clone(),
model: p.model.clone(),
provider: p.llm_provider.clone(),
name_pool: Vec::new(),
is_builtin: false,
is_active: true,
source_pack: Some(resolved.id.clone()),
source_pack_persona_slug: Some(p.name.clone()),
// Filter derived provider/model env keys at import time so stale
// values don't shadow the structured fields after UI edits.
env_vars: crate::managed_agents::env_vars::filter_derived_provider_model_env_vars(
p.runtime_env_vars.iter().cloned(),
),
created_at: now.clone(),
updated_at: now.clone(),
})
.collect();
// 7. Merge into personas.json
let mut personas = load_personas(app)?;
personas.extend(new_personas.clone());
save_personas(app, &personas)?;
Ok(new_personas)
}
/// Uninstall a persona pack: remove pack directory + pack PersonaRecords.
pub fn uninstall_persona_pack(app: &AppHandle, pack_id: &str) -> Result<(), String> {
validate_pack_id(pack_id)?;
// Block uninstall if any managed agent still references this pack.
let agents = crate::managed_agents::load_managed_agents(app)?;
let referencing: Vec<&str> = agents
.iter()
.filter(|a| {
a.persona_pack_path
.as_ref()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
== Some(pack_id)
})
.map(|a| a.name.as_str())
.collect();
if !referencing.is_empty() {
return Err(format!(
"Cannot uninstall pack \"{pack_id}\": {} agent(s) still reference it ({}). \
Delete or reconfigure them first.",
referencing.len(),
referencing.join(", ")
));
}
// Remove pack directory (or symlink)
let packs = packs_dir(app)?;
let pack_dir = packs.join(pack_id);
if pack_dir.exists() {
let is_symlink = fs::symlink_metadata(&pack_dir)
.map(|m| m.file_type().is_symlink())
.unwrap_or(false);
if is_symlink {
fs::remove_file(&pack_dir)
.map_err(|e| format!("failed to remove pack symlink: {e}"))?;
} else {
fs::remove_dir_all(&pack_dir)
.map_err(|e| format!("failed to remove pack directory: {e}"))?;
}
}
// Remove pack PersonaRecords
let mut personas = load_personas(app)?;
personas.retain(|p| p.source_pack.as_deref() != Some(pack_id));
save_personas(app, &personas)?;
Ok(())
}
/// List installed packs (by scanning packs directory).
pub fn list_installed_packs(app: &AppHandle) -> Result<Vec<PackSummary>, String> {
let packs = packs_dir(app)?;
let mut summaries = Vec::new();
if !packs.exists() {
return Ok(summaries);
}
for entry in fs::read_dir(&packs).map_err(|e| format!("failed to read packs dir: {e}"))? {
let entry = entry.map_err(|e| format!("dir entry error: {e}"))?;
let pack_dir = entry.path();
// Use stat (follows symlinks) rather than lstat so symlinked packs are recognised.
if !pack_dir.is_dir() {
continue;
}
if let Ok(resolved) = sprout_persona::resolve::resolve_pack(&pack_dir) {
summaries.push(PackSummary {
id: resolved.id,
name: resolved.name,
version: resolved.version,
persona_count: resolved.personas.len(),
path: pack_dir,
});
}
}
Ok(summaries)
}
/// Summary of an installed pack (for listing).
#[derive(Debug, Clone, serde::Serialize)]
pub struct PackSummary {
pub id: String,
pub name: String,
pub version: String,
pub persona_count: usize,
pub path: PathBuf,
}
/// Re-read pack directories and update persona records whose source content
/// has changed. Runs on launch so pack edits on disk propagate without
/// manual intervention.
pub fn sync_pack_personas(app: &AppHandle) -> Result<(), String> {
let mut records = load_personas(app)?;
let packs = packs_dir(app)?;
if !packs.exists() {
return Ok(());
}
let mut changed = false;
for record in records.iter_mut() {
let pack_id = match &record.source_pack {
Some(id) => id.clone(),
None => continue,
};
let slug = match &record.source_pack_persona_slug {
Some(s) => s.clone(),
None => continue,
};
// Find the pack directory whose resolved ID matches
let pack_dir_entries =
fs::read_dir(&packs).map_err(|e| format!("failed to read packs dir: {e}"))?;
let mut found = false;
for entry in pack_dir_entries {
let entry = entry.map_err(|e| format!("dir entry error: {e}"))?;
let dir = entry.path();
if !dir.is_dir() {
continue;
}
let resolved = match sprout_persona::resolve::resolve_pack(&dir) {
Ok(r) => r,
Err(_) => continue,
};
if resolved.id != pack_id {
continue;
}
// Found the matching pack — find the persona by slug
if let Some(persona) = resolved.personas.iter().find(|p| p.name == slug) {
let mut record_changed = false;
if record.system_prompt != persona.system_prompt {
record.system_prompt = persona.system_prompt.clone();
record_changed = true;
}
if record.model != persona.model {
record.model = persona.model.clone();
record_changed = true;
}
if record.avatar_url != persona.avatar {
record.avatar_url = persona.avatar.clone();
record_changed = true;
}
if record.display_name != persona.display_name {
record.display_name = persona.display_name.clone();
record_changed = true;
}
if record_changed {
record.updated_at = now_iso();
changed = true;
eprintln!(
"sprout-desktop: sync-pack-personas: updated {:?} from pack {:?}",
record.display_name, pack_id
);
}
}
found = true;
break;
}
if !found {
// Pack directory no longer exists or doesn't resolve — skip silently
continue;
}
}
if changed {
save_personas(app, &records)?;
}
Ok(())
}
pub fn load_personas(app: &AppHandle) -> Result<Vec<PersonaRecord>, String> {
let path = personas_store_path(app)?;
let now = now_iso();

View File

@@ -1,8 +1,9 @@
use super::{
ensure_persona_ids_are_active, ensure_persona_is_active, merge_personas,
migrate_retired_personas, validate_pack_id, validate_persona_activation_change,
validate_persona_deletion, BUILT_IN_PERSONAS, RETIRED_PERSONAS,
migrate_retired_personas, validate_persona_activation_change, validate_persona_deletion,
BUILT_IN_PERSONAS, RETIRED_PERSONAS,
};
use crate::managed_agents::validate_team_id;
use crate::managed_agents::PersonaRecord;
fn custom_persona(id: &str, display_name: &str) -> PersonaRecord {
@@ -17,8 +18,8 @@ fn custom_persona(id: &str, display_name: &str) -> PersonaRecord {
name_pool: Vec::new(),
is_builtin: false,
is_active: true,
source_pack: None,
source_pack_persona_slug: None,
source_team: None,
source_team_persona_slug: None,
env_vars: std::collections::BTreeMap::new(),
created_at: "2026-03-19T00:00:00Z".to_string(),
updated_at: "2026-03-19T00:00:00Z".to_string(),
@@ -297,59 +298,59 @@ fn validate_persona_deletion_allows_safe_custom_personas() {
assert!(validate_persona_deletion(&persona, false).is_ok());
}
// ── validate_pack_id ──────────────────────────────────────────────────────────
// ── validate_team_id ──────────────────────────────────────────────────────────
#[test]
fn pack_id_valid_reverse_dns() {
assert!(validate_pack_id("com.example.security-team").is_ok());
assert!(validate_team_id("com.example.security-team").is_ok());
}
#[test]
fn pack_id_valid_simple() {
assert!(validate_pack_id("my-pack").is_ok());
assert!(validate_team_id("my-pack").is_ok());
}
#[test]
fn pack_id_rejects_empty() {
assert!(validate_pack_id("").is_err());
assert!(validate_team_id("").is_err());
}
#[test]
fn pack_id_rejects_dot_dot_path_traversal() {
// Critical regression test: ".." must never pass validation.
// A pack with id ".." would write into the parent directory.
assert!(validate_pack_id("..").is_err());
assert!(validate_team_id("..").is_err());
}
#[test]
fn pack_id_rejects_single_dot() {
assert!(validate_pack_id(".").is_err());
assert!(validate_team_id(".").is_err());
}
#[test]
fn pack_id_rejects_leading_dot() {
assert!(validate_pack_id(".hidden").is_err());
assert!(validate_team_id(".hidden").is_err());
}
#[test]
fn pack_id_rejects_slashes() {
assert!(validate_pack_id("../etc/passwd").is_err());
assert!(validate_pack_id("foo/bar").is_err());
assert!(validate_team_id("../etc/passwd").is_err());
assert!(validate_team_id("foo/bar").is_err());
}
#[test]
fn pack_id_rejects_no_alphanumeric() {
assert!(validate_pack_id("---").is_err());
assert!(validate_pack_id("___").is_err());
assert!(validate_team_id("---").is_err());
assert!(validate_team_id("___").is_err());
}
#[test]
fn pack_id_rejects_too_long() {
let long_id = "a".repeat(129);
assert!(validate_pack_id(&long_id).is_err());
assert!(validate_team_id(&long_id).is_err());
// 128 chars is fine
let max_id = "a".repeat(128);
assert!(validate_pack_id(&max_id).is_ok());
assert!(validate_team_id(&max_id).is_ok());
}
// ── migrate_retired_personas ──────────────────────────────────────────────────

View File

@@ -86,8 +86,8 @@ mod tests {
backend: BackendKind::Local,
backend_agent_id: None,
provider_binary_path: None,
persona_pack_path: None,
persona_name_in_pack: None,
persona_team_dir: None,
persona_name_in_team: None,
created_at: "now".into(),
updated_at: "now".into(),
last_started_at: None,

View File

@@ -913,10 +913,10 @@ pub fn spawn_agent_child(
}
}
}
if let (Some(pack_path), Some(persona_name)) =
(&record.persona_pack_path, &record.persona_name_in_pack)
if let (Some(team_dir), Some(persona_name)) =
(&record.persona_team_dir, &record.persona_name_in_team)
{
command.env("SPROUT_ACP_PERSONA_PACK", pack_path);
command.env("SPROUT_ACP_PERSONA_PACK", team_dir);
command.env("SPROUT_ACP_PERSONA_NAME", persona_name);
}

View File

@@ -84,8 +84,8 @@ fn fixture(
backend: Default::default(),
backend_agent_id: None,
provider_binary_path: None,
persona_pack_path: None,
persona_name_in_pack: None,
persona_team_dir: None,
persona_name_in_team: None,
created_at: "now".into(),
updated_at: "now".into(),
last_started_at: None,
@@ -210,8 +210,8 @@ fn persona_with_provider(
name_pool: Vec::new(),
is_builtin: false,
is_active: true,
source_pack: None,
source_pack_persona_slug: None,
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(),

View File

@@ -67,6 +67,10 @@ fn built_in_team_records(now: &str) -> Vec<TeamRecord> {
description: team.description.map(|s| s.to_string()),
persona_ids: team.persona_ids.iter().map(|s| s.to_string()).collect(),
is_builtin: true,
source_dir: None,
is_symlink: false,
symlink_target: None,
version: None,
created_at: now.to_string(),
updated_at: now.to_string(),
})
@@ -156,6 +160,440 @@ pub fn save_teams(app: &AppHandle, records: &[TeamRecord]) -> Result<(), String>
crate::managed_agents::storage::atomic_write_json(&path, &payload)
}
// ---------------------------------------------------------------------------
// Directory-backed team operations
// ---------------------------------------------------------------------------
/// Teams directory: `<AppDataDir>/agents/teams/`
fn teams_dir(app: &AppHandle) -> Result<PathBuf, String> {
let dir = managed_agents_base_dir(app)?.join("teams");
fs::create_dir_all(&dir).map_err(|e| format!("failed to create teams dir: {e}"))?;
Ok(dir)
}
/// Validate team/pack ID: only `[a-zA-Z0-9._-]+` allowed (zip-slip defense).
pub(crate) fn validate_team_id(id: &str) -> Result<(), String> {
if id.is_empty() {
return Err("team ID is empty".into());
}
if id.len() > 128 {
return Err(format!("team ID too long: {} chars (max 128)", id.len()));
}
if !id
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-')
{
return Err(format!(
"team ID contains invalid characters: \"{id}\". Only [a-zA-Z0-9._-] allowed."
));
}
if id.starts_with('.') {
return Err(format!("team ID \"{id}\" must not start with '.'"));
}
if !id.chars().any(|c| c.is_ascii_alphanumeric()) {
return Err(format!(
"team ID \"{id}\" must contain at least one alphanumeric character"
));
}
Ok(())
}
/// Copy a directory tree, skipping symlinks (zip-slip defense).
fn copy_dir_no_symlinks(src: &std::path::Path, dst: &std::path::Path) -> Result<(), String> {
fs::create_dir_all(dst).map_err(|e| format!("failed to create {}: {e}", dst.display()))?;
for entry in fs::read_dir(src).map_err(|e| format!("failed to read {}: {e}", src.display()))? {
let entry = entry.map_err(|e| format!("dir entry error: {e}"))?;
let ft = entry
.file_type()
.map_err(|e| format!("file type error: {e}"))?;
let src_path = entry.path();
let dst_path = dst.join(entry.file_name());
if ft.is_symlink() {
continue;
} else if ft.is_dir() {
copy_dir_no_symlinks(&src_path, &dst_path)?;
} else if ft.is_file() {
fs::copy(&src_path, &dst_path)
.map_err(|e| format!("failed to copy {}: {e}", src_path.display()))?;
}
}
Ok(())
}
/// Import a team from a local directory in open plugin format.
///
/// Copies the directory into `<data>/agents/teams/<resolved.id>/`,
/// creates PersonaRecords for each persona, creates a TeamRecord with source_dir set.
pub fn import_team_from_directory(
app: &AppHandle,
source_dir: &std::path::Path,
symlink: bool,
) -> Result<TeamRecord, String> {
use uuid::Uuid;
// 1. Validate + resolve at source
let resolved = sprout_persona::resolve::resolve_pack(source_dir)
.map_err(|e| format!("team directory validation failed: {e}"))?;
// 2. Sanitize team ID
validate_team_id(&resolved.id)?;
// 3. Check for existing team with same ID
let teams_base = teams_dir(app)?;
let dest = teams_base.join(&resolved.id);
if dest.exists() {
return Err(format!(
"Team \"{}\" is already installed. Delete it first or use sync.",
resolved.id
));
}
// 4. Determine install mode: symlink or copy
let source_is_symlink = fs::symlink_metadata(source_dir)
.map(|m| m.file_type().is_symlink())
.unwrap_or(false);
let use_symlink = symlink || source_is_symlink;
if use_symlink {
// Resolve the canonical target for symlink
let canonical = fs::canonicalize(source_dir)
.map_err(|e| format!("failed to resolve symlink target: {e}"))?;
#[cfg(unix)]
{
std::os::unix::fs::symlink(&canonical, &dest)
.map_err(|e| format!("failed to create symlink: {e}"))?;
}
#[cfg(not(unix))]
{
// Fallback to copy on non-unix
copy_dir_no_symlinks(source_dir, &dest)?;
}
} else {
copy_dir_no_symlinks(source_dir, &dest)?;
}
// 5. Re-validate the copy/symlink target (defense-in-depth)
let re_resolved = sprout_persona::resolve::resolve_pack(&dest).map_err(|e| {
// Clean up on failure
if use_symlink {
let _ = fs::remove_file(&dest);
} else {
let _ = fs::remove_dir_all(&dest);
}
format!("team re-validation failed after install: {e}")
})?;
// 6. Create PersonaRecords
let now = now_iso();
let new_personas: Vec<PersonaRecord> = re_resolved
.personas
.iter()
.map(|p| PersonaRecord {
id: Uuid::new_v4().to_string(),
display_name: p.display_name.clone(),
avatar_url: p.avatar.clone(),
system_prompt: p.system_prompt.clone(),
runtime: p.runtime.clone(),
model: p.model.clone(),
provider: p.llm_provider.clone(),
name_pool: Vec::new(),
is_builtin: false,
is_active: true,
source_team: Some(resolved.id.clone()),
source_team_persona_slug: Some(p.name.clone()),
env_vars: crate::managed_agents::env_vars::filter_derived_provider_model_env_vars(
p.runtime_env_vars.iter().cloned(),
),
created_at: now.clone(),
updated_at: now.clone(),
})
.collect();
let persona_ids: Vec<String> = new_personas.iter().map(|p| p.id.clone()).collect();
// 7. Save personas
let mut personas = super::load_personas(app)?;
personas.extend(new_personas);
super::save_personas(app, &personas)?;
// 8. Create and save TeamRecord
let symlink_target = if use_symlink {
fs::canonicalize(source_dir)
.ok()
.map(|p| p.display().to_string())
} else {
None
};
let team = TeamRecord {
id: resolved.id,
name: resolved.name,
description: if resolved.description.is_empty() {
None
} else {
Some(resolved.description)
},
persona_ids,
is_builtin: false,
source_dir: Some(dest),
is_symlink: use_symlink,
symlink_target,
version: Some(resolved.version),
created_at: now.clone(),
updated_at: now,
};
let mut teams = load_teams(app)?;
teams.push(team.clone());
save_teams(app, &teams)?;
Ok(team)
}
/// Delete a team. For directory-backed teams, also removes the backing directory
/// and all personas sourced from that team. For JSON-only teams, only removes
/// the team record (personas are preserved).
pub fn delete_team_with_cascade(app: &AppHandle, team_id: &str) -> Result<(), String> {
let mut teams = load_teams(app)?;
let team = teams
.iter()
.find(|record| record.id == team_id)
.ok_or_else(|| format!("team {team_id} not found"))?;
validate_team_deletion(team)?;
if team.source_dir.is_some() {
// Directory-backed team: full cascade
// 1. Check no managed agents reference these personas
let agents = crate::managed_agents::load_managed_agents(app)?;
let referencing: Vec<&str> = agents
.iter()
.filter(|a| {
a.persona_team_dir
.as_ref()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
== Some(team_id)
})
.map(|a| a.name.as_str())
.collect();
if !referencing.is_empty() {
return Err(format!(
"Cannot delete team \"{team_id}\": {} agent(s) still reference it ({}). \
Delete or reconfigure them first.",
referencing.len(),
referencing.join(", ")
));
}
// 2. Remove all PersonaRecords where source_team == team_id
let mut personas = super::load_personas(app)?;
personas.retain(|p| p.source_team.as_deref() != Some(team_id));
super::save_personas(app, &personas)?;
// 3. Remove directory
if let Some(source_dir) = &team.source_dir {
if source_dir.exists() {
let is_symlink = fs::symlink_metadata(source_dir)
.map(|m| m.file_type().is_symlink())
.unwrap_or(false);
if is_symlink {
fs::remove_file(source_dir)
.map_err(|e| format!("failed to remove team symlink: {e}"))?;
} else {
fs::remove_dir_all(source_dir)
.map_err(|e| format!("failed to remove team directory: {e}"))?;
}
}
}
}
// 4. Remove TeamRecord
teams.retain(|record| record.id != team_id);
save_teams(app, &teams)
}
/// Re-reads a directory-backed team and reconciles with stored records.
pub fn sync_team_from_dir(
app: &AppHandle,
team_id: &str,
) -> Result<crate::managed_agents::SyncResult, String> {
use uuid::Uuid;
let teams = load_teams(app)?;
let team = teams
.iter()
.find(|t| t.id == team_id)
.ok_or_else(|| format!("team {team_id} not found"))?;
let source_dir = team
.source_dir
.as_ref()
.ok_or_else(|| format!("team {team_id} is not directory-backed"))?;
if !source_dir.exists() {
return Err(format!(
"team directory does not exist: {}",
source_dir.display()
));
}
// Resolve current state of the directory
let resolved = sprout_persona::resolve::resolve_pack(source_dir)
.map_err(|e| format!("failed to resolve team directory: {e}"))?;
let mut personas = super::load_personas(app)?;
let now = now_iso();
let mut added = Vec::new();
let mut removed = Vec::new();
let mut updated = Vec::new();
// Find existing personas for this team
let existing_slugs: Vec<(String, String)> = personas
.iter()
.filter(|p| p.source_team.as_deref() == Some(team_id))
.map(|p| {
(
p.source_team_persona_slug.clone().unwrap_or_default(),
p.id.clone(),
)
})
.collect();
// Check for new personas in directory
for dir_persona in &resolved.personas {
if let Some((_slug, persona_id)) = existing_slugs
.iter()
.find(|(slug, _)| slug == &dir_persona.name)
{
// Existing persona — check for content changes
if let Some(record) = personas.iter_mut().find(|p| p.id == *persona_id) {
let mut changed = false;
if record.display_name != dir_persona.display_name {
record.display_name = dir_persona.display_name.clone();
changed = true;
}
if record.system_prompt != dir_persona.system_prompt {
record.system_prompt = dir_persona.system_prompt.clone();
changed = true;
}
if record.avatar_url != dir_persona.avatar {
record.avatar_url = dir_persona.avatar.clone();
changed = true;
}
if record.runtime != dir_persona.runtime {
record.runtime = dir_persona.runtime.clone();
changed = true;
}
if record.model != dir_persona.model {
record.model = dir_persona.model.clone();
changed = true;
}
if record.provider != dir_persona.llm_provider {
record.provider = dir_persona.llm_provider.clone();
changed = true;
}
if changed {
record.updated_at = now.clone();
updated.push(persona_id.clone());
}
}
} else {
// New persona — create record
let new_persona = PersonaRecord {
id: Uuid::new_v4().to_string(),
display_name: dir_persona.display_name.clone(),
avatar_url: dir_persona.avatar.clone(),
system_prompt: dir_persona.system_prompt.clone(),
runtime: dir_persona.runtime.clone(),
model: dir_persona.model.clone(),
provider: dir_persona.llm_provider.clone(),
name_pool: Vec::new(),
is_builtin: false,
is_active: true,
source_team: Some(team_id.to_string()),
source_team_persona_slug: Some(dir_persona.name.clone()),
env_vars: crate::managed_agents::env_vars::filter_derived_provider_model_env_vars(
dir_persona.runtime_env_vars.iter().cloned(),
),
created_at: now.clone(),
updated_at: now.clone(),
};
added.push(new_persona.id.clone());
personas.push(new_persona);
}
}
// Check for personas removed from directory
let dir_slugs: Vec<&str> = resolved.personas.iter().map(|p| p.name.as_str()).collect();
let to_remove: Vec<String> = existing_slugs
.iter()
.filter(|(slug, _)| !dir_slugs.contains(&slug.as_str()))
.map(|(_, id)| id.clone())
.collect();
// Only remove if no active managed agent uses the persona
let agents = crate::managed_agents::load_managed_agents(app)?;
for persona_id in &to_remove {
let in_use = agents
.iter()
.any(|a| a.persona_id.as_deref() == Some(persona_id));
if !in_use {
personas.retain(|p| p.id != *persona_id);
removed.push(persona_id.clone());
}
}
// Update team metadata if changed
let mut teams = load_teams(app)?;
let mut metadata_changed = false;
if let Some(team_record) = teams.iter_mut().find(|t| t.id == team_id) {
if team_record.name != resolved.name {
team_record.name = resolved.name;
metadata_changed = true;
}
let new_desc = if resolved.description.is_empty() {
None
} else {
Some(resolved.description)
};
if team_record.description != new_desc {
team_record.description = new_desc;
metadata_changed = true;
}
let new_version = Some(resolved.version);
if team_record.version != new_version {
team_record.version = new_version;
metadata_changed = true;
}
// Update persona_ids to reflect current state
let current_ids: Vec<String> = personas
.iter()
.filter(|p| p.source_team.as_deref() == Some(team_id))
.map(|p| p.id.clone())
.collect();
if team_record.persona_ids != current_ids {
team_record.persona_ids = current_ids;
metadata_changed = true;
}
if metadata_changed {
team_record.updated_at = now;
}
}
super::save_personas(app, &personas)?;
save_teams(app, &teams)?;
Ok(crate::managed_agents::SyncResult {
personas_added: added,
personas_removed: removed,
personas_updated: updated,
metadata_changed,
})
}
// ---------------------------------------------------------------------------
// Team JSON export / import
// ---------------------------------------------------------------------------
@@ -274,6 +712,21 @@ pub fn parse_team_json(json_bytes: &[u8]) -> Result<ParsedTeamPreview, String> {
})
}
/// Sync all directory-backed teams on launch — the team equivalent of the
/// former `sync_pack_personas`. Silently skips teams whose source directory
/// is missing (e.g., external drive unmounted).
pub fn sync_team_personas(app: &AppHandle) -> Result<(), String> {
let teams = load_teams(app)?;
for team in &teams {
if team.source_dir.as_ref().is_some_and(|d| d.exists()) {
if let Err(e) = sync_team_from_dir(app, &team.id) {
eprintln!("sprout-desktop: sync team {}: {e}", team.id);
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{
@@ -289,6 +742,10 @@ mod tests {
description: None,
persona_ids: Vec::new(),
is_builtin: false,
source_dir: None,
is_symlink: false,
symlink_target: None,
version: None,
created_at: "2026-03-20T00:00:00Z".to_string(),
updated_at: "2026-03-20T00:00:00Z".to_string(),
}
@@ -335,8 +792,8 @@ mod tests {
name_pool: Vec::new(),
is_builtin: false,
is_active: true,
source_pack: None,
source_pack_persona_slug: None,
source_team: None,
source_team_persona_slug: None,
env_vars: std::collections::BTreeMap::new(),
created_at: "2026-03-20T00:00:00Z".to_string(),
updated_at: "2026-03-20T00:00:00Z".to_string(),

View File

@@ -41,15 +41,23 @@ pub struct PersonaRecord {
pub is_builtin: bool,
#[serde(default = "default_record_active")]
pub is_active: bool,
/// Pack ID if this persona was imported from a persona pack.
/// Pack personas are non-editable (system_prompt, model locked).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_pack: Option<String>,
/// Internal persona slug within the pack (e.g., "lep", "pip").
/// Team ID if this persona was imported from a team directory.
/// Team personas are non-editable (system_prompt, model locked).
#[serde(
default,
skip_serializing_if = "Option::is_none",
alias = "source_pack"
)]
pub source_team: Option<String>,
/// Internal persona slug within the team (e.g., "lep", "pip").
/// Used by ACP's `resolve_persona_by_name()` to find the right persona.
/// Validated: `[a-zA-Z0-9_-]+`, max 64 chars (safe for env vars and paths).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_pack_persona_slug: Option<String>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
alias = "source_pack_persona_slug"
)]
pub source_team_persona_slug: Option<String>,
/// Harness-level configuration passed to the agent subprocess as environment variables.
/// Opaque to Sprout — keys and values are runtime-specific.
///
@@ -133,12 +141,20 @@ pub struct ManagedAgentRecord {
pub backend_agent_id: Option<String>,
#[serde(default)]
pub provider_binary_path: Option<String>,
/// Installed pack path (absolute). Set when agent was created from a pack persona.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub persona_pack_path: Option<PathBuf>,
/// Persona name within the pack.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub persona_name_in_pack: Option<String>,
/// Installed team directory path (absolute). Set when agent was created from a team persona.
#[serde(
default,
skip_serializing_if = "Option::is_none",
alias = "persona_pack_path"
)]
pub persona_team_dir: Option<PathBuf>,
/// Persona name within the team.
#[serde(
default,
skip_serializing_if = "Option::is_none",
alias = "persona_name_in_pack"
)]
pub persona_name_in_team: Option<String>,
pub created_at: String,
pub updated_at: String,
pub last_started_at: Option<String>,
@@ -473,6 +489,18 @@ pub struct TeamRecord {
pub persona_ids: Vec<String>,
#[serde(default)]
pub is_builtin: bool,
/// Absolute path to the team's backing directory (if directory-backed).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_dir: Option<PathBuf>,
/// Whether `source_dir` is a symlink to an external directory.
#[serde(default)]
pub is_symlink: bool,
/// Resolved symlink target path (for display). Only set when `is_symlink` is true.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub symlink_target: Option<String>,
/// Version from the team's `plugin.json` manifest.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
pub created_at: String,
pub updated_at: String,
}
@@ -496,6 +524,24 @@ pub struct UpdateTeamRequest {
pub persona_ids: Vec<String>,
}
/// Result of syncing a directory-backed team with its backing directory.
#[derive(Debug, Clone, Serialize)]
pub struct SyncResult {
pub personas_added: Vec<String>,
pub personas_removed: Vec<String>,
pub personas_updated: Vec<String>,
pub metadata_changed: bool,
}
/// Report from the one-time packs→teams migration.
#[derive(Debug, Clone, Serialize)]
pub struct MigrationReport {
pub packs_migrated: usize,
pub personas_updated: usize,
pub agents_updated: usize,
pub errors: Vec<String>,
}
pub const DEFAULT_ACP_COMMAND: &str = "sprout-acp";
pub const DEFAULT_AGENT_COMMAND: &str = "goose";
/// ~5 min (320s) — matches the CLI harness default (SPROUT_ACP_IDLE_TIMEOUT).
@@ -581,6 +627,7 @@ pub fn validate_respond_to_allowlist(input: &[String]) -> Result<Vec<String>, St
#[cfg(test)]
mod tests {
use super::{ManagedAgentRecord, PersonaRecord};
use std::path::PathBuf;
#[test]
fn persona_record_defaults_active_when_field_is_missing() {
@@ -827,4 +874,105 @@ mod tests {
let back: RelayMeshConfig = serde_json::from_str(&json).unwrap();
assert_eq!(back, config);
}
// ── Packs → Teams serde alias backward compatibility ────────────────
#[test]
fn persona_record_deserializes_old_source_pack_fields_via_alias() {
let record: PersonaRecord = serde_json::from_str(
r#"{
"id": "persona-1",
"display_name": "Test",
"avatar_url": null,
"system_prompt": "Prompt",
"source_pack": "com.example.my-pack",
"source_pack_persona_slug": "agent-one",
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z"
}"#,
)
.expect("old-format persona with source_pack should deserialize via alias");
assert_eq!(record.source_team.as_deref(), Some("com.example.my-pack"));
assert_eq!(
record.source_team_persona_slug.as_deref(),
Some("agent-one")
);
}
#[test]
fn persona_record_serializes_new_field_names() {
let record: PersonaRecord = serde_json::from_str(
r#"{
"id": "persona-1",
"display_name": "Test",
"avatar_url": null,
"system_prompt": "Prompt",
"source_team": "com.example.my-team",
"source_team_persona_slug": "agent-one",
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z"
}"#,
)
.unwrap();
let json = serde_json::to_string(&record).unwrap();
assert!(json.contains("source_team"));
assert!(json.contains("source_team_persona_slug"));
assert!(!json.contains("source_pack"));
}
#[test]
fn managed_agent_record_deserializes_old_pack_path_fields_via_alias() {
let record: ManagedAgentRecord = serde_json::from_str(
r#"{
"pubkey": "abcd1234",
"name": "test-agent",
"private_key_nsec": "nsec1fake",
"relay_url": "wss://localhost:3000",
"acp_command": "sprout-acp",
"agent_command": "goose",
"agent_args": [],
"mcp_command": "",
"turn_timeout_seconds": 320,
"system_prompt": null,
"persona_pack_path": "/path/to/agents/packs/my-pack",
"persona_name_in_pack": "agent-one",
"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
}"#,
)
.expect("old-format agent with persona_pack_path should deserialize via alias");
assert_eq!(
record.persona_team_dir,
Some(PathBuf::from("/path/to/agents/packs/my-pack"))
);
assert_eq!(record.persona_name_in_team.as_deref(), Some("agent-one"));
}
#[test]
fn team_record_deserializes_without_new_fields() {
let record: super::TeamRecord = serde_json::from_str(
r#"{
"id": "team-1",
"name": "My Team",
"description": null,
"persona_ids": ["p1", "p2"],
"is_builtin": false,
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z"
}"#,
)
.expect("team record without new fields should deserialize with defaults");
assert_eq!(record.source_dir, None);
assert!(!record.is_symlink);
assert_eq!(record.symlink_target, None);
assert_eq!(record.version, None);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,833 @@
use super::*;
#[test]
fn canonical_dev_data_dir_replaces_last_component() {
let current =
PathBuf::from("/Users/me/Library/Application Support/xyz.block.sprout.app.dev.my-branch");
let canonical = canonical_dev_data_dir(&current).unwrap();
assert_eq!(
canonical,
PathBuf::from("/Users/me/Library/Application Support/xyz.block.sprout.app.dev")
);
}
#[test]
fn canonical_dev_data_dir_returns_none_for_root() {
// A root path has no parent — should return None.
assert!(canonical_dev_data_dir(Path::new("/")).is_none());
}
/// Helper: create a temp dir structure mimicking canonical + worktree layout.
/// Packs live in a `.main` sibling (not canonical) to match real-world state.
/// Returns `(parent_dir_handle, canonical_dir, worktree_dir)`.
fn setup_sync_layout() -> (tempfile::TempDir, PathBuf, PathBuf) {
let parent = tempfile::tempdir().unwrap();
let canonical = parent.path().join(CANONICAL_DEV_IDENTIFIER);
let worktree = parent.path().join("xyz.block.sprout.app.dev.my-branch");
let main_instance = parent.path().join("xyz.block.sprout.app.dev.main");
std::fs::create_dir_all(canonical.join("agents")).unwrap();
std::fs::write(
canonical.join("agents/managed-agents.json"),
r#"[{"id":"agent-1"}]"#,
)
.unwrap();
std::fs::write(
canonical.join("agents/personas.json"),
r#"[{"id":"builtin:solo"}]"#,
)
.unwrap();
std::fs::write(canonical.join("agents/teams.json"), r#"[{"id":"team-1"}]"#).unwrap();
// Teams installed from `.main` — canonical has no teams dir.
let team_dir = main_instance.join("agents/teams/com.example.test-pack");
std::fs::create_dir_all(&team_dir).unwrap();
std::fs::write(team_dir.join("instructions.md"), "# Test pack").unwrap();
std::fs::write(team_dir.join("solo.persona.md"), "# Solo").unwrap();
(parent, canonical, worktree)
}
/// Helper: sync files directly (without a Tauri AppHandle) for unit testing.
/// Mirrors the symlink loop of `sync_shared_agent_data` but takes explicit
/// paths. `sync_shared_agent_data` requires a live Tauri AppHandle and
/// cannot be unit-tested directly.
fn sync_files(canonical: &Path, worktree: &Path) -> u32 {
let mut synced = 0u32;
for rel in SHARED_AGENT_FILES {
let src = canonical.join(rel);
let dst = worktree.join(rel);
if !src.exists() {
continue;
}
if let Some(parent) = dst.parent() {
std::fs::create_dir_all(parent).unwrap();
}
if dst.is_symlink() {
if let Ok(target) = std::fs::read_link(&dst) {
if target == src {
continue;
}
}
}
if dst.exists() || dst.is_symlink() {
let _ = std::fs::remove_file(&dst);
}
std::os::unix::fs::symlink(&src, &dst).unwrap();
synced += 1;
}
// Migrate packs from siblings to canonical (mirrors production logic).
for rel in SHARED_AGENT_DIRS {
let canonical_target = canonical.join(rel);
if !canonical_target.exists() {
std::fs::create_dir_all(&canonical_target).unwrap();
if let Some(parent) = canonical.parent() {
if let Ok(entries) = std::fs::read_dir(parent) {
for entry in entries.flatten() {
let sibling = entry.path();
if sibling == canonical {
continue;
}
let sibling_dir = sibling.join(rel);
if sibling_dir.is_dir() && !sibling_dir.is_symlink() {
if let Ok(children) = std::fs::read_dir(&sibling_dir) {
for child in children.flatten() {
let dest = canonical_target.join(child.file_name());
if !dest.exists() {
let _ = std::fs::rename(child.path(), &dest);
}
}
}
let _ = std::fs::remove_dir_all(&sibling_dir);
let _ = std::os::unix::fs::symlink(&canonical_target, &sibling_dir);
break;
}
}
}
}
}
}
for rel in SHARED_AGENT_DIRS {
let src = canonical.join(rel);
let dst = worktree.join(rel);
if !src.exists() {
continue;
}
if let Some(parent) = dst.parent() {
std::fs::create_dir_all(parent).unwrap();
}
if dst.is_symlink() {
if let Ok(target) = std::fs::read_link(&dst) {
if target == src {
continue;
}
}
}
if dst.is_symlink() {
let _ = std::fs::remove_file(&dst);
} else if dst.exists() {
let _ = std::fs::remove_dir_all(&dst);
}
std::os::unix::fs::symlink(&src, &dst).unwrap();
synced += 1;
}
synced
}
#[test]
fn sync_creates_symlinks_to_fresh_worktree() {
let (_parent, canonical, worktree) = setup_sync_layout();
let synced = sync_files(&canonical, &worktree);
assert_eq!(synced, 4);
for rel in SHARED_AGENT_FILES {
let dst = worktree.join(rel);
assert!(dst.is_symlink(), "{rel} should be a symlink");
assert_eq!(std::fs::read_link(&dst).unwrap(), canonical.join(rel));
}
for rel in SHARED_AGENT_DIRS {
let dst = worktree.join(rel);
assert!(dst.is_symlink(), "{rel} should be a symlink");
assert_eq!(std::fs::read_link(&dst).unwrap(), canonical.join(rel));
}
assert_eq!(
std::fs::read_to_string(worktree.join("agents/managed-agents.json")).unwrap(),
r#"[{"id":"agent-1"}]"#,
);
}
#[test]
fn sync_replaces_existing_files_with_symlinks() {
let (_parent, canonical, worktree) = setup_sync_layout();
std::fs::create_dir_all(worktree.join("agents")).unwrap();
std::fs::write(worktree.join("agents/managed-agents.json"), "[]").unwrap();
std::fs::write(worktree.join("agents/personas.json"), "[]").unwrap();
std::fs::write(worktree.join("agents/teams.json"), "[]").unwrap();
let synced = sync_files(&canonical, &worktree);
assert_eq!(synced, 4);
for rel in SHARED_AGENT_FILES {
let dst = worktree.join(rel);
assert!(
dst.is_symlink(),
"{rel} should be a symlink after replacing regular file"
);
assert_eq!(std::fs::read_link(&dst).unwrap(), canonical.join(rel));
}
assert_eq!(
std::fs::read_to_string(worktree.join("agents/managed-agents.json")).unwrap(),
r#"[{"id":"agent-1"}]"#,
);
}
#[test]
fn sync_preserves_correct_symlinks() {
let (_parent, canonical, worktree) = setup_sync_layout();
assert_eq!(sync_files(&canonical, &worktree), 4);
assert_eq!(sync_files(&canonical, &worktree), 0);
for rel in SHARED_AGENT_FILES {
let dst = worktree.join(rel);
assert!(dst.is_symlink());
assert_eq!(std::fs::read_link(&dst).unwrap(), canonical.join(rel));
}
}
#[test]
fn sync_replaces_wrong_symlinks() {
let (_parent, canonical, worktree) = setup_sync_layout();
let wrong_target = PathBuf::from("/nonexistent/wrong-target.json");
std::fs::create_dir_all(worktree.join("agents")).unwrap();
for rel in SHARED_AGENT_FILES {
std::os::unix::fs::symlink(&wrong_target, worktree.join(rel)).unwrap();
}
let synced = sync_files(&canonical, &worktree);
assert_eq!(synced, 4);
for rel in SHARED_AGENT_FILES {
assert_eq!(
std::fs::read_link(worktree.join(rel)).unwrap(),
canonical.join(rel)
);
}
}
#[test]
fn sync_handles_broken_symlinks() {
let (_parent, canonical, worktree) = setup_sync_layout();
std::fs::create_dir_all(worktree.join("agents")).unwrap();
let broken_target = PathBuf::from("/this/does/not/exist.json");
for rel in SHARED_AGENT_FILES {
std::os::unix::fs::symlink(&broken_target, worktree.join(rel)).unwrap();
}
let synced = sync_files(&canonical, &worktree);
assert_eq!(synced, 4);
for rel in SHARED_AGENT_FILES {
let dst = worktree.join(rel);
assert!(dst.is_symlink());
assert_eq!(std::fs::read_link(&dst).unwrap(), canonical.join(rel));
// Content should be readable through the fixed symlink.
assert!(std::fs::read_to_string(&dst).is_ok());
}
}
#[test]
fn writes_through_symlink_reach_canonical() {
let (_parent, canonical, worktree) = setup_sync_layout();
sync_files(&canonical, &worktree);
let worktree_path = worktree.join("agents/personas.json");
let canonical_path = canonical.join("agents/personas.json");
// Write through the symlink using the same pattern as atomic_write_json.
let new_content = r#"[{"id":"builtin:solo","updated":true}]"#;
let resolved = std::fs::canonicalize(&worktree_path).unwrap();
let tmp = resolved.with_extension("json.tmp");
std::fs::write(&tmp, new_content.as_bytes()).unwrap();
std::fs::rename(&tmp, &resolved).unwrap();
// The canonical file should have the new content.
assert_eq!(
std::fs::read_to_string(&canonical_path).unwrap(),
new_content
);
// The worktree path should still be a symlink.
assert!(worktree_path.is_symlink());
// Reading through the symlink should return the new content.
assert_eq!(
std::fs::read_to_string(&worktree_path).unwrap(),
new_content
);
}
#[test]
fn canonical_dev_data_dir_returns_self_for_canonical_instance() {
// When the current app data dir IS the canonical dev identifier,
// canonical_dev_data_dir returns the exact same path — the caller
// (sync_shared_agent_data) uses this equality to skip the sync.
// The env-var guards (SPROUT_SHARE_IDENTITY, SPROUT_PRIVATE_KEY)
// require a live Tauri AppHandle and are covered by integration
// testing only.
let current = PathBuf::from("/Users/me/Library/Application Support/xyz.block.sprout.app.dev");
assert_eq!(canonical_dev_data_dir(&current).unwrap(), current);
// Also verify with a temp dir on the real filesystem.
let parent = tempfile::tempdir().unwrap();
let canonical = parent.path().join(CANONICAL_DEV_IDENTIFIER);
assert_eq!(canonical_dev_data_dir(&canonical).unwrap(), canonical);
}
fn write_agents_json(dir: &Path, records: &serde_json::Value) {
std::fs::create_dir_all(dir.join("agents")).unwrap();
std::fs::write(
dir.join("agents/managed-agents.json"),
serde_json::to_vec_pretty(records).unwrap(),
)
.unwrap();
}
fn read_agents_json(dir: &Path) -> Vec<serde_json::Value> {
let content = std::fs::read_to_string(dir.join("agents/managed-agents.json")).unwrap();
serde_json::from_str(&content).unwrap()
}
#[test]
fn sync_creates_teams_directory_symlink() {
let (_parent, canonical, worktree) = setup_sync_layout();
sync_files(&canonical, &worktree);
let teams_link = worktree.join("agents/teams");
assert!(teams_link.is_symlink());
assert_eq!(
std::fs::read_link(&teams_link).unwrap(),
canonical.join("agents/teams")
);
assert_eq!(
std::fs::read_to_string(
worktree.join("agents/teams/com.example.test-pack/instructions.md")
)
.unwrap(),
"# Test pack"
);
}
#[test]
fn sync_migrates_teams_from_sibling_to_canonical() {
let (_parent, canonical, worktree) = setup_sync_layout();
let main_instance = canonical
.parent()
.unwrap()
.join("xyz.block.sprout.app.dev.main");
// Before sync: canonical has no teams, .main has the real team dir.
assert!(!canonical.join("agents/teams").exists());
assert!(main_instance
.join("agents/teams/com.example.test-pack")
.is_dir());
sync_files(&canonical, &worktree);
// After sync: canonical has the team, .main is now a symlink.
assert!(canonical
.join("agents/teams/com.example.test-pack/instructions.md")
.exists());
assert!(main_instance.join("agents/teams").is_symlink());
assert_eq!(
std::fs::read_link(main_instance.join("agents/teams")).unwrap(),
canonical.join("agents/teams")
);
}
#[test]
fn sync_replaces_real_teams_dir_with_symlink() {
let (_parent, canonical, worktree) = setup_sync_layout();
let real_teams = worktree.join("agents/teams");
std::fs::create_dir_all(&real_teams).unwrap();
std::fs::write(real_teams.join("stale-file.txt"), "stale").unwrap();
sync_files(&canonical, &worktree);
assert!(worktree.join("agents/teams").is_symlink());
assert_eq!(
std::fs::read_link(worktree.join("agents/teams")).unwrap(),
canonical.join("agents/teams")
);
}
#[test]
fn team_dir_reconcile_rewrites_worktree_path() {
let parent = tempfile::tempdir().unwrap();
let canonical = parent.path().join(CANONICAL_DEV_IDENTIFIER);
std::fs::create_dir_all(canonical.join("agents")).unwrap();
let worktree_pack_path = format!(
"{}/agents/packs/com.wpfleger.sietch-tabr",
parent
.path()
.join("xyz.block.sprout.app.dev.worktree-my-branch")
.display()
);
let expected_path = format!(
"{}/agents/teams/com.wpfleger.sietch-tabr",
canonical.display()
);
write_agents_json(
&canonical,
&serde_json::json!([{
"name": "Paul",
"persona_pack_path": worktree_pack_path
}]),
);
reconcile_team_dirs_in_file(&canonical.join("agents/managed-agents.json"), &canonical);
let records = read_agents_json(&canonical);
assert_eq!(records[0]["persona_team_dir"], expected_path);
// Old field name should be removed
assert!(records[0].get("persona_pack_path").is_none());
}
#[test]
fn team_dir_reconcile_rewrites_new_field_name() {
let parent = tempfile::tempdir().unwrap();
let canonical = parent.path().join(CANONICAL_DEV_IDENTIFIER);
std::fs::create_dir_all(canonical.join("agents")).unwrap();
let worktree_team_path = format!(
"{}/agents/teams/com.wpfleger.sietch-tabr",
parent
.path()
.join("xyz.block.sprout.app.dev.worktree-my-branch")
.display()
);
let expected_path = format!(
"{}/agents/teams/com.wpfleger.sietch-tabr",
canonical.display()
);
write_agents_json(
&canonical,
&serde_json::json!([{
"name": "Paul",
"persona_team_dir": worktree_team_path
}]),
);
reconcile_team_dirs_in_file(&canonical.join("agents/managed-agents.json"), &canonical);
let records = read_agents_json(&canonical);
assert_eq!(records[0]["persona_team_dir"], expected_path);
}
#[test]
fn team_dir_reconcile_leaves_canonical_path_unchanged() {
let parent = tempfile::tempdir().unwrap();
let canonical = parent.path().join(CANONICAL_DEV_IDENTIFIER);
std::fs::create_dir_all(canonical.join("agents")).unwrap();
let canonical_path = format!(
"{}/agents/teams/com.wpfleger.sietch-tabr",
canonical.display()
);
write_agents_json(
&canonical,
&serde_json::json!([{
"name": "Duncan",
"persona_team_dir": canonical_path
}]),
);
let before = std::fs::read_to_string(canonical.join("agents/managed-agents.json")).unwrap();
reconcile_team_dirs_in_file(&canonical.join("agents/managed-agents.json"), &canonical);
let after = std::fs::read_to_string(canonical.join("agents/managed-agents.json")).unwrap();
assert_eq!(before, after);
}
#[test]
fn team_dir_reconcile_skips_records_without_team_dir() {
let parent = tempfile::tempdir().unwrap();
let canonical = parent.path().join(CANONICAL_DEV_IDENTIFIER);
std::fs::create_dir_all(canonical.join("agents")).unwrap();
write_agents_json(
&canonical,
&serde_json::json!([{
"name": "Test Agent",
"agent_command": "sprout-agent"
}]),
);
let before = std::fs::read_to_string(canonical.join("agents/managed-agents.json")).unwrap();
reconcile_team_dirs_in_file(&canonical.join("agents/managed-agents.json"), &canonical);
let after = std::fs::read_to_string(canonical.join("agents/managed-agents.json")).unwrap();
assert_eq!(before, after);
}
#[test]
fn team_dir_reconcile_is_idempotent() {
let parent = tempfile::tempdir().unwrap();
let canonical = parent.path().join(CANONICAL_DEV_IDENTIFIER);
std::fs::create_dir_all(canonical.join("agents")).unwrap();
let worktree_pack_path = format!(
"{}/agents/packs/com.wpfleger.sietch-tabr",
parent
.path()
.join("xyz.block.sprout.app.dev.worktree-my-branch")
.display()
);
write_agents_json(
&canonical,
&serde_json::json!([{
"name": "Paul",
"persona_pack_path": worktree_pack_path
}]),
);
let path = canonical.join("agents/managed-agents.json");
reconcile_team_dirs_in_file(&path, &canonical);
let after_first = std::fs::read_to_string(&path).unwrap();
reconcile_team_dirs_in_file(&path, &canonical);
let after_second = std::fs::read_to_string(&path).unwrap();
assert_eq!(after_first, after_second);
}
// ── Packs → Teams migration tests ───────────────────────────────────
#[test]
fn migrate_packs_merge_preserves_non_empty_dir() {
// When packs/ contains symlinks that weren't moved (e.g., external tools
// recreated them), the migration should NOT delete the packs/ directory.
let parent = tempfile::tempdir().unwrap();
let canonical = parent.path().join(CANONICAL_DEV_IDENTIFIER);
let packs_dir = canonical.join("agents/packs");
let teams_dir = canonical.join("agents/teams");
std::fs::create_dir_all(&packs_dir).unwrap();
std::fs::create_dir_all(&teams_dir).unwrap();
// Simulate an external symlink that already exists in teams/ (conflict)
let external_target = parent.path().join("external-pack");
std::fs::create_dir_all(&external_target).unwrap();
std::os::unix::fs::symlink(&external_target, packs_dir.join("com.ext.pack")).unwrap();
// Same name already in teams/ — so the migration skips it
std::os::unix::fs::symlink(&external_target, teams_dir.join("com.ext.pack")).unwrap();
// Run the merge logic (mirrors what migrate_packs_to_teams does)
if let Ok(entries) = std::fs::read_dir(&packs_dir) {
for entry in entries.flatten() {
let dest = teams_dir.join(entry.file_name());
if !dest.exists() {
let _ = std::fs::rename(entry.path(), &dest);
}
}
}
// This is the fix: remove_dir only succeeds on empty dirs
let _ = std::fs::remove_dir(&packs_dir);
// packs/ should still exist because it has a remaining symlink
assert!(packs_dir.exists(), "packs/ should survive when non-empty");
assert!(packs_dir.join("com.ext.pack").is_symlink());
}
#[test]
fn migrate_packs_to_teams_renames_directory() {
let parent = tempfile::tempdir().unwrap();
let canonical = parent.path().join(CANONICAL_DEV_IDENTIFIER);
let packs_dir = canonical.join("agents/packs/com.example.test-pack");
std::fs::create_dir_all(&packs_dir).unwrap();
std::fs::write(packs_dir.join("plugin.json"), "{}").unwrap();
// No personas or agents JSON needed for directory rename
std::fs::create_dir_all(canonical.join("agents")).unwrap();
// Simulate calling the migration steps directly (no AppHandle needed)
let packs = canonical.join("agents/packs");
let teams = canonical.join("agents/teams");
std::fs::rename(&packs, &teams).unwrap();
assert!(!packs.exists());
assert!(teams.join("com.example.test-pack/plugin.json").exists());
}
#[test]
fn migrate_packs_to_teams_rewrites_personas_json() {
let dir = tempfile::tempdir().unwrap();
write_personas_json(
dir.path(),
&serde_json::json!([{
"id": "persona-1",
"display_name": "Test",
"source_pack": "com.example.my-pack",
"source_pack_persona_slug": "agent-one"
}]),
);
let path = dir.path().join("agents/personas.json");
patch_json_records(&path, |obj| {
let mut changed = false;
if let Some(val) = obj.remove("source_pack") {
obj.insert("source_team".to_string(), val);
changed = true;
}
if let Some(val) = obj.remove("source_pack_persona_slug") {
obj.insert("source_team_persona_slug".to_string(), val);
changed = true;
}
changed
});
let records = read_personas_json(dir.path());
assert_eq!(records[0]["source_team"], "com.example.my-pack");
assert_eq!(records[0]["source_team_persona_slug"], "agent-one");
assert!(records[0].get("source_pack").is_none());
assert!(records[0].get("source_pack_persona_slug").is_none());
}
#[test]
fn migrate_packs_to_teams_rewrites_agents_json() {
let dir = tempfile::tempdir().unwrap();
write_agents_json(
dir.path(),
&serde_json::json!([{
"name": "Paul",
"persona_pack_path": "/data/agents/packs/com.example.my-pack",
"persona_name_in_pack": "agent-one"
}]),
);
let path = dir.path().join("agents/managed-agents.json");
patch_json_records(&path, |obj| {
let mut changed = false;
if let Some(val) = obj.remove("persona_pack_path") {
let new_val = if let Some(s) = val.as_str() {
serde_json::Value::String(s.replace("/packs/", "/teams/"))
} else {
val
};
obj.insert("persona_team_dir".to_string(), new_val);
changed = true;
}
if let Some(val) = obj.remove("persona_name_in_pack") {
obj.insert("persona_name_in_team".to_string(), val);
changed = true;
}
changed
});
let records = read_agents_json(dir.path());
assert_eq!(
records[0]["persona_team_dir"],
"/data/agents/teams/com.example.my-pack"
);
assert_eq!(records[0]["persona_name_in_team"], "agent-one");
assert!(records[0].get("persona_pack_path").is_none());
assert!(records[0].get("persona_name_in_pack").is_none());
}
fn write_personas_json(dir: &Path, records: &serde_json::Value) {
std::fs::create_dir_all(dir.join("agents")).unwrap();
std::fs::write(
dir.join("agents/personas.json"),
serde_json::to_vec_pretty(records).unwrap(),
)
.unwrap();
}
fn read_personas_json(dir: &Path) -> Vec<serde_json::Value> {
let content = std::fs::read_to_string(dir.join("agents/personas.json")).unwrap();
serde_json::from_str(&content).unwrap()
}
#[test]
fn rename_provider_to_runtime_migrates_field() {
let dir = tempfile::tempdir().unwrap();
write_personas_json(
dir.path(),
&serde_json::json!([{
"id": "persona-1",
"displayName": "Alice",
"provider": "goose"
}]),
);
rename_provider_to_runtime_in_personas(&dir.path().join("agents/personas.json"));
let records = read_personas_json(dir.path());
assert_eq!(records[0]["runtime"], "goose");
assert!(records[0].get("provider").is_none());
}
#[test]
fn rename_provider_to_runtime_is_idempotent() {
let dir = tempfile::tempdir().unwrap();
write_personas_json(
dir.path(),
&serde_json::json!([{
"id": "persona-1",
"displayName": "Alice",
"runtime": "goose"
}]),
);
let before = std::fs::read_to_string(dir.path().join("agents/personas.json")).unwrap();
rename_provider_to_runtime_in_personas(&dir.path().join("agents/personas.json"));
let after = std::fs::read_to_string(dir.path().join("agents/personas.json")).unwrap();
assert_eq!(
before, after,
"file should not be rewritten when already migrated"
);
}
#[test]
fn rename_provider_to_runtime_skips_record_without_either_key() {
let dir = tempfile::tempdir().unwrap();
write_personas_json(
dir.path(),
&serde_json::json!([{
"id": "persona-1",
"displayName": "Alice"
}]),
);
let before = std::fs::read_to_string(dir.path().join("agents/personas.json")).unwrap();
rename_provider_to_runtime_in_personas(&dir.path().join("agents/personas.json"));
let after = std::fs::read_to_string(dir.path().join("agents/personas.json")).unwrap();
assert_eq!(
before, after,
"file should not be rewritten when no provider key exists"
);
}
#[test]
fn rename_provider_to_runtime_preserves_existing_runtime_over_provider() {
let dir = tempfile::tempdir().unwrap();
write_personas_json(
dir.path(),
&serde_json::json!([{
"id": "persona-1",
"displayName": "Alice",
"provider": "old-value",
"runtime": "correct-value"
}]),
);
rename_provider_to_runtime_in_personas(&dir.path().join("agents/personas.json"));
let records = read_personas_json(dir.path());
assert_eq!(records[0]["runtime"], "correct-value");
// provider key should still be there since the closure returns false when runtime exists
assert_eq!(records[0]["provider"], "old-value");
}
#[test]
fn reconcile_mcp_commands_clears_stale_sprout_mcp_server() {
let dir = tempfile::tempdir().unwrap();
write_agents_json(
dir.path(),
&serde_json::json!([{
"name": "Solo",
"agent_command": "goose",
"mcp_command": "sprout-mcp-server"
}]),
);
reconcile_mcp_commands_in_file(&dir.path().join("agents/managed-agents.json"));
let records = read_agents_json(dir.path());
assert_eq!(records[0]["mcp_command"], "");
}
#[test]
fn reconcile_mcp_commands_sets_canonical_for_sprout_agent() {
let dir = tempfile::tempdir().unwrap();
write_agents_json(
dir.path(),
&serde_json::json!([{
"name": "Stilgar",
"agent_command": "sprout-agent",
"mcp_command": "sprout-mcp-server"
}]),
);
reconcile_mcp_commands_in_file(&dir.path().join("agents/managed-agents.json"));
let records = read_agents_json(dir.path());
assert_eq!(records[0]["mcp_command"], "sprout-dev-mcp");
}
#[test]
fn reconcile_mcp_commands_leaves_custom_value_untouched() {
let dir = tempfile::tempdir().unwrap();
let json = serde_json::json!([{
"name": "Solo",
"agent_command": "goose",
"mcp_command": "my-custom-mcp"
}]);
write_agents_json(dir.path(), &json);
let path = dir.path().join("agents/managed-agents.json");
let before = std::fs::read_to_string(&path).unwrap();
reconcile_mcp_commands_in_file(&path);
assert_eq!(before, std::fs::read_to_string(&path).unwrap());
}
#[test]
fn reconcile_mcp_commands_leaves_unknown_runtime_untouched() {
let dir = tempfile::tempdir().unwrap();
let json = serde_json::json!([{
"name": "Custom",
"agent_command": "my-custom-agent",
"mcp_command": "sprout-mcp-server"
}]);
write_agents_json(dir.path(), &json);
let path = dir.path().join("agents/managed-agents.json");
let before = std::fs::read_to_string(&path).unwrap();
reconcile_mcp_commands_in_file(&path);
assert_eq!(before, std::fs::read_to_string(&path).unwrap());
}
#[test]
fn reconcile_mcp_commands_is_idempotent() {
let dir = tempfile::tempdir().unwrap();
write_agents_json(
dir.path(),
&serde_json::json!([{
"name": "Solo",
"agent_command": "goose",
"mcp_command": "sprout-mcp-server"
}]),
);
let path = dir.path().join("agents/managed-agents.json");
reconcile_mcp_commands_in_file(&path);
let after_first = std::fs::read_to_string(&path).unwrap();
reconcile_mcp_commands_in_file(&path);
assert_eq!(after_first, std::fs::read_to_string(&path).unwrap());
}
#[test]
fn reconcile_mcp_commands_handles_mixed_agents() {
let dir = tempfile::tempdir().unwrap();
write_agents_json(
dir.path(),
&serde_json::json!([
{"name": "Stale Goose", "agent_command": "goose", "mcp_command": "sprout-mcp-server"},
{"name": "Clean Goose", "agent_command": "goose", "mcp_command": ""},
{"name": "Custom Agent", "agent_command": "goose", "mcp_command": "my-custom-mcp"},
{"name": "Stale Sprout", "agent_command": "sprout-agent", "mcp_command": "sprout-mcp-server"}
]),
);
reconcile_mcp_commands_in_file(&dir.path().join("agents/managed-agents.json"));
let records = read_agents_json(dir.path());
assert_eq!(records[0]["mcp_command"], "");
assert_eq!(records[1]["mcp_command"], "");
assert_eq!(records[2]["mcp_command"], "my-custom-mcp");
assert_eq!(records[3]["mcp_command"], "sprout-dev-mcp");
}
#[test]
fn reconcile_mcp_commands_skips_record_without_agent_command() {
let dir = tempfile::tempdir().unwrap();
let json = serde_json::json!([{
"name": "No Command",
"mcp_command": "sprout-mcp-server"
}]);
write_agents_json(dir.path(), &json);
let path = dir.path().join("agents/managed-agents.json");
let before = std::fs::read_to_string(&path).unwrap();
reconcile_mcp_commands_in_file(&path);
assert_eq!(before, std::fs::read_to_string(&path).unwrap());
}

View File

@@ -150,6 +150,9 @@ export function AgentsView() {
onEdit={teamActions.openEditDialog}
onExport={teamActions.handleExportTeam}
onImportFile={teamActions.handleImportFile}
onInstallFromDirectory={teamActions.handleInstallFromDirectory}
onSync={teamActions.handleSyncTeam}
onRevealInFinder={teamActions.handleRevealInFinder}
onAddToChannel={teamActions.setTeamToAddToChannel}
personas={personas.libraryPersonas}
teams={teamActions.teams}

View File

@@ -72,10 +72,10 @@ export function PersonaActionsMenu({
<Trash2 className="h-4 w-4" />
Remove from My Agents
</DropdownMenuItem>
) : persona.sourcePack ? (
) : persona.sourceTeam ? (
<DropdownMenuItem disabled>
<Trash2 className="h-4 w-4" />
Managed by pack
Managed by team
</DropdownMenuItem>
) : (
<DropdownMenuItem

View File

@@ -2,7 +2,10 @@ import {
CopyPlus,
Download,
Ellipsis,
FolderOpen,
FolderSync,
Info,
Link,
Pencil,
Rocket,
Trash2,
@@ -41,6 +44,9 @@ type TeamsSectionProps = {
onDelete: (team: AgentTeam) => void;
onAddToChannel: (team: AgentTeam) => void;
onImportFile: (fileBytes: number[], fileName: string) => void;
onInstallFromDirectory: () => void;
onSync: (team: AgentTeam) => void;
onRevealInFinder: (team: AgentTeam) => void;
};
export function TeamsSection({
@@ -56,6 +62,9 @@ export function TeamsSection({
onDelete,
onAddToChannel,
onImportFile,
onInstallFromDirectory,
onSync,
onRevealInFinder,
}: TeamsSectionProps) {
const {
fileInputRef,
@@ -93,11 +102,21 @@ export function TeamsSection({
ref={fileInputRef}
type="file"
/>
<CreateNewButton
ariaLabel="Create team"
label="Team"
onClick={onCreate}
/>
<div className="flex items-center gap-2">
<button
className="inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onClick={onInstallFromDirectory}
type="button"
>
<FolderOpen className="h-3.5 w-3.5" />
Install from directory
</button>
<CreateNewButton
ariaLabel="Create team"
label="Team"
onClick={onCreate}
/>
</div>
</div>
{isLoading ? (
@@ -138,6 +157,25 @@ export function TeamsSection({
<p className="truncate text-sm font-semibold tracking-tight">
{team.name}
</p>
{team.isSymlink ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="flex h-4 w-4 shrink-0 items-center justify-center text-muted-foreground">
<Link className="h-3.5 w-3.5" />
</span>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs">
<p>
Linked from {team.symlinkTarget ?? team.sourceDir}
</p>
</TooltipContent>
</Tooltip>
) : null}
{team.version ? (
<span className="shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
v{team.version}
</span>
) : null}
{team.description ? (
<Tooltip>
<TooltipTrigger asChild>
@@ -221,6 +259,24 @@ export function TeamsSection({
<Download className="h-4 w-4" />
Export
</DropdownMenuItem>
{team.sourceDir ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={isPending}
onClick={() => onSync(team)}
>
<FolderSync className="h-4 w-4" />
Sync from directory
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onRevealInFinder(team)}
>
<FolderOpen className="h-4 w-4" />
Reveal in Finder
</DropdownMenuItem>
</>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"

View File

@@ -15,13 +15,17 @@ import {
type ParsedTeamPreview,
createTeam as createTeamApi,
exportTeamToJson,
installTeamFromDirectory,
parseTeamFile,
pickTeamDirectory,
syncTeamDirectory,
} from "@/shared/api/tauriTeams";
import {
createPersona,
deletePersona,
updatePersona,
} from "@/shared/api/tauriPersonas";
import { revealItemInDir } from "@tauri-apps/plugin-opener";
import type {
AgentPersona,
AgentTeam,
@@ -207,6 +211,63 @@ export function useTeamActions(
}
}
async function handleInstallFromDirectory() {
actions.setActionNoticeMessage(null);
actions.setActionErrorMessage(null);
try {
const path = await pickTeamDirectory();
if (!path) return;
const team = await installTeamFromDirectory(path, true);
actions.setActionNoticeMessage(
`Installed team "${team.name}" from directory.`,
);
await Promise.all([
queryClient.invalidateQueries({ queryKey: teamsQueryKey }),
queryClient.invalidateQueries({ queryKey: personasQueryKey }),
queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }),
]);
} catch (err) {
actions.setActionErrorMessage(
err instanceof Error
? err.message
: "Failed to install team from directory.",
);
}
}
async function handleSyncTeam(team: AgentTeam) {
actions.setActionNoticeMessage(null);
actions.setActionErrorMessage(null);
try {
const result = await syncTeamDirectory(team.id);
const changes = [
result.personas_added.length > 0 &&
`${result.personas_added.length} added`,
result.personas_updated.length > 0 &&
`${result.personas_updated.length} updated`,
result.personas_removed.length > 0 &&
`${result.personas_removed.length} removed`,
].filter(Boolean);
const summary =
changes.length > 0 ? changes.join(", ") : "already up to date";
actions.setActionNoticeMessage(`Synced "${team.name}": ${summary}.`);
await Promise.all([
queryClient.invalidateQueries({ queryKey: teamsQueryKey }),
queryClient.invalidateQueries({ queryKey: personasQueryKey }),
queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }),
]);
} catch (err) {
actions.setActionErrorMessage(
err instanceof Error ? err.message : "Failed to sync team directory.",
);
}
}
function handleRevealInFinder(team: AgentTeam) {
if (!team.sourceDir) return;
void revealItemInDir(team.sourceDir);
}
async function handleEditDialogImportUpdateFile(
teamId: string,
fileBytes: number[],
@@ -492,6 +553,9 @@ export function useTeamActions(
handleTeamDeployed,
handleExportTeam,
handleImportFile,
handleInstallFromDirectory,
handleSyncTeam,
handleRevealInFinder,
handleEditDialogImportUpdateFile,
handleTeamImportComplete,
handleTeamImportUpdateApply,

View File

@@ -11,6 +11,10 @@ type RawTeam = {
description: string | null;
persona_ids: string[];
is_builtin?: boolean;
source_dir?: string | null;
is_symlink?: boolean;
symlink_target?: string | null;
version?: string | null;
created_at: string;
updated_at: string;
};
@@ -22,6 +26,10 @@ function fromRawTeam(team: RawTeam): AgentTeam {
description: team.description,
personaIds: team.persona_ids,
isBuiltin: team.is_builtin ?? false,
sourceDir: team.source_dir ?? null,
isSymlink: team.is_symlink ?? false,
symlinkTarget: team.symlink_target ?? null,
version: team.version ?? null,
createdAt: team.created_at,
updatedAt: team.updated_at,
};
@@ -83,3 +91,30 @@ export async function parseTeamFile(
fileName,
});
}
export type SyncResult = {
personas_added: string[];
personas_removed: string[];
personas_updated: string[];
metadata_changed: boolean;
};
export async function pickTeamDirectory(): Promise<string | null> {
return invokeTauri<string | null>("pick_team_directory");
}
export async function installTeamFromDirectory(
path: string,
symlink?: boolean,
): Promise<AgentTeam> {
return fromRawTeam(
await invokeTauri<RawTeam>("install_team_from_directory", {
path,
symlink: symlink ?? false,
}),
);
}
export async function syncTeamDirectory(teamId: string): Promise<SyncResult> {
return invokeTauri<SyncResult>("sync_team_directory", { teamId });
}

View File

@@ -474,8 +474,8 @@ export type AgentPersona = {
namePool: string[];
isBuiltIn: boolean;
isActive: boolean;
/** Pack ID if this persona was imported from a persona pack. Pack personas are non-editable. */
sourcePack?: string | null;
/** Team ID if this persona was imported from a team directory. Team personas are non-editable. */
sourceTeam?: string | null;
/** Environment variables injected for agents created from this persona.
* Layered as: desktop parent env < persona envVars < agent envVars. */
envVars: Record<string, string>;
@@ -513,6 +513,14 @@ export type AgentTeam = {
description: string | null;
personaIds: string[];
isBuiltin: boolean;
/** Absolute path to the team's backing directory (if directory-backed). */
sourceDir: string | null;
/** Whether sourceDir is a symlink to an external directory. */
isSymlink: boolean;
/** Resolved symlink target path (for display). Only set when isSymlink is true. */
symlinkTarget: string | null;
/** Version from the team's plugin.json manifest. */
version: string | null;
createdAt: string;
updatedAt: string;
};

View File

@@ -374,6 +374,10 @@ type RawTeam = {
description: string | null;
persona_ids: string[];
is_builtin: boolean;
source_dir: string | null;
is_symlink: boolean;
symlink_target: string | null;
version: string | null;
created_at: string;
updated_at: string;
};
@@ -880,7 +884,50 @@ function resetMockPersonas() {
}
function resetMockTeams() {
mockTeams = [];
const now = new Date().toISOString();
mockTeams = [
{
id: "team-engineering-001",
name: "Engineering",
description: "Core engineering personas",
// Scout is intentionally excluded so the clean deselect flow has a
// built-in persona that is not pre-referenced by any default team.
persona_ids: ["builtin:solo", "builtin:kit"],
is_builtin: false,
source_dir: null,
is_symlink: false,
symlink_target: null,
version: null,
created_at: now,
updated_at: now,
},
{
id: "team-research-002",
name: "Research Agents",
description: "Directory-backed research team",
persona_ids: ["builtin:solo", "builtin:kit"],
is_builtin: false,
source_dir: "/Users/dev/agents/research",
is_symlink: false,
symlink_target: null,
version: "1.2.0",
created_at: now,
updated_at: now,
},
{
id: "team-platform-003",
name: "Platform Tools",
description: "Symlinked platform team",
persona_ids: ["builtin:kit"],
is_builtin: false,
source_dir: "/Users/dev/agents/platform",
is_symlink: true,
symlink_target: "/opt/shared-teams/platform",
version: "2.0.1",
created_at: now,
updated_at: now,
},
];
}
function getMockProfileByPubkey(pubkey: string): RawProfile | null {
@@ -4160,6 +4207,10 @@ async function handleCreateTeam(args: {
description: args.input.description?.trim() || null,
persona_ids: [...args.input.personaIds],
is_builtin: false,
source_dir: null,
is_symlink: false,
symlink_target: null,
version: null,
created_at: now,
updated_at: now,
};
@@ -4216,6 +4267,50 @@ async function handleExportTeamToJson(args: { id: string }): Promise<boolean> {
return true;
}
async function handlePickTeamDirectory(): Promise<string | null> {
return "/Users/dev/agents/new-team";
}
async function handleInstallTeamFromDirectory(args: {
path: string;
symlink: boolean;
}): Promise<RawTeam> {
const now = new Date().toISOString();
const team: RawTeam = {
id: crypto.randomUUID(),
name: "Installed Team",
description: null,
persona_ids: [],
is_builtin: false,
source_dir: args.path,
is_symlink: args.symlink,
symlink_target: args.symlink ? args.path : null,
version: null,
created_at: now,
updated_at: now,
};
mockTeams.push(team);
return { ...team, persona_ids: [...team.persona_ids] };
}
async function handleSyncTeamDirectory(args: { teamId: string }): Promise<{
personas_added: string[];
personas_removed: string[];
personas_updated: string[];
metadata_changed: boolean;
}> {
const team = mockTeams.find((candidate) => candidate.id === args.teamId);
if (!team) {
throw new Error(`Team ${args.teamId} not found.`);
}
return {
personas_added: [],
personas_removed: [],
personas_updated: [],
metadata_changed: false,
};
}
async function handleParseTeamFile(): Promise<{
name: string;
description: string | null;
@@ -5662,6 +5757,16 @@ export function maybeInstallE2eTauriMocks() {
);
case "export_team_to_json":
return handleExportTeamToJson(payload as { id: string });
case "pick_team_directory":
return handlePickTeamDirectory();
case "install_team_from_directory":
return handleInstallTeamFromDirectory(
payload as Parameters<typeof handleInstallTeamFromDirectory>[0],
);
case "sync_team_directory":
return handleSyncTeamDirectory(
payload as Parameters<typeof handleSyncTeamDirectory>[0],
);
case "parse_team_file":
return handleParseTeamFile();
case "parse_persona_files":

View File

@@ -0,0 +1,218 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
const SHOTS = "test-results/team-management";
async function waitForInvokeBridge(page: import("@playwright/test").Page) {
await page.waitForFunction(
() => {
const tauriWindow = window as Window & {
__SPROUT_E2E_INVOKE_MOCK_COMMAND__?: unknown;
__TAURI_INTERNALS__?: {
invoke?: unknown;
};
};
return (
typeof tauriWindow.__SPROUT_E2E_INVOKE_MOCK_COMMAND__ === "function" ||
typeof tauriWindow.__TAURI_INTERNALS__?.invoke === "function"
);
},
null,
{ timeout: 5_000 },
);
}
async function invokeMockCommand(
page: import("@playwright/test").Page,
command: string,
payload?: Record<string, unknown>,
): Promise<unknown> {
await waitForInvokeBridge(page);
return page.evaluate(
async ({ command: cmd, payload: pl }) => {
const tauriWindow = window as Window & {
__SPROUT_E2E_INVOKE_MOCK_COMMAND__?: (
command: string,
payload?: Record<string, unknown>,
) => Promise<unknown>;
__TAURI_INTERNALS__?: {
invoke?: (
command: string,
payload?: Record<string, unknown>,
) => Promise<unknown>;
};
};
const invoke =
tauriWindow.__SPROUT_E2E_INVOKE_MOCK_COMMAND__ ??
tauriWindow.__TAURI_INTERNALS__?.invoke;
if (!invoke) {
throw new Error("Mock invoke bridge is unavailable.");
}
return invoke(cmd, pl);
},
{ command, payload },
);
}
async function activatePersonas(page: import("@playwright/test").Page) {
for (const id of ["builtin:solo", "builtin:kit", "builtin:scout"]) {
await invokeMockCommand(page, "set_persona_active", { id, active: true });
}
}
async function openAgentsView(page: import("@playwright/test").Page) {
await page.goto("/", { waitUntil: "domcontentloaded" });
await waitForInvokeBridge(page);
await activatePersonas(page);
await page.getByTestId("open-agents-view").click();
await expect(page.getByTestId("agents-library-teams")).toBeVisible({
timeout: 10_000,
});
}
test.describe("team management screenshots", () => {
test.use({ viewport: { width: 1280, height: 720 } });
test("01 — teams section with cards", async ({ page }) => {
await installMockBridge(page);
await openAgentsView(page);
const teamsSection = page.getByTestId("agents-library-teams");
await expect(teamsSection).toContainText("Engineering");
await expect(teamsSection).toContainText("Research Agents");
await expect(teamsSection).toContainText("Platform Tools");
await teamsSection.screenshot({
path: `${SHOTS}/01-teams-section.png`,
});
});
test("02 — regular team context menu", async ({ page }) => {
await installMockBridge(page);
await openAgentsView(page);
const teamsSection = page.getByTestId("agents-library-teams");
const engineeringCard = teamsSection
.locator("[class*='card']")
.filter({ hasText: "Engineering" })
.first();
await engineeringCard.locator("button").last().click();
const deployItem = page.getByRole("menuitem", {
name: "Deploy to channel",
});
await expect(deployItem).toBeVisible();
await expect(page.getByRole("menuitem", { name: "Edit" })).toBeVisible();
await expect(
page.getByRole("menuitem", { name: "Duplicate" }),
).toBeVisible();
await expect(page.getByRole("menuitem", { name: "Export" })).toBeVisible();
await expect(page.getByRole("menuitem", { name: "Delete" })).toBeVisible();
await deployItem.evaluate((el) =>
Promise.all(
el
.closest("[data-state]")
?.getAnimations()
.map((a) => a.finished) ?? [],
),
);
await page.screenshot({
path: `${SHOTS}/02-team-card-menu.png`,
clip: { x: 0, y: 0, width: 1280, height: 720 },
});
});
test("03 — directory-backed team with version badge", async ({ page }) => {
await installMockBridge(page);
await openAgentsView(page);
const teamsSection = page.getByTestId("agents-library-teams");
const versionBadge = teamsSection.locator("span", { hasText: "v1.2.0" });
await expect(versionBadge).toBeVisible();
const researchCard = teamsSection
.locator("[class*='card']")
.filter({ hasText: "Research Agents" })
.first();
await expect(researchCard).toBeVisible();
await researchCard.screenshot({
path: `${SHOTS}/03-directory-team-card.png`,
});
});
test("04 — directory team context menu", async ({ page }) => {
await installMockBridge(page);
await openAgentsView(page);
const teamsSection = page.getByTestId("agents-library-teams");
const researchCard = teamsSection
.locator("[class*='card']")
.filter({ hasText: "Research Agents" })
.first();
await researchCard.locator("button").last().click();
const syncItem = page.getByRole("menuitem", {
name: "Sync from directory",
});
await expect(syncItem).toBeVisible();
await expect(
page.getByRole("menuitem", { name: "Reveal in Finder" }),
).toBeVisible();
await syncItem.evaluate((el) =>
Promise.all(
el
.closest("[data-state]")
?.getAnimations()
.map((a) => a.finished) ?? [],
),
);
await page.screenshot({
path: `${SHOTS}/04-directory-team-menu.png`,
clip: { x: 0, y: 0, width: 1280, height: 720 },
});
});
test("05 — symlinked team with link icon", async ({ page }) => {
await installMockBridge(page);
await openAgentsView(page);
const teamsSection = page.getByTestId("agents-library-teams");
const platformCard = teamsSection
.locator("[class*='card']")
.filter({ hasText: "Platform Tools" })
.first();
await expect(platformCard).toBeVisible();
const versionBadge = platformCard.locator("span", { hasText: "v2.0.1" });
await expect(versionBadge).toBeVisible();
await platformCard.screenshot({
path: `${SHOTS}/05-symlinked-team.png`,
});
});
test("06 — install from directory button", async ({ page }) => {
await installMockBridge(page);
await openAgentsView(page);
const teamsSection = page.getByTestId("agents-library-teams");
const installButton = teamsSection.getByRole("button", {
name: "Install from directory",
});
await expect(installButton).toBeVisible();
await installButton.screenshot({
path: `${SHOTS}/06-install-from-directory.png`,
});
});
});