Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions desktop/scripts/check-file-sizes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ const rules = [
// Exceptions should stay rare and temporary. Prefer splitting files instead.
const overrides = new Map([
["src-tauri/src/managed_agents/nest.rs", 1420], // version-gated AGENTS.md + SKILL.md refresh + .agents/.claude symlink migration + ensure_skill_symlinks (all known providers) + managed section upsert + dynamic agent context + tests
["src-tauri/src/managed_agents/personas.rs", 950], // built-in persona system prompts (Solo + Kit + Scout) + merge_personas inequality checks + persona pack import/uninstall/list + uninstall safety check
["src-tauri/src/managed_agents/personas.rs", 980], // built-in persona system prompts (Solo + Kit + Scout) + merge_personas inequality checks + persona pack import/uninstall/list + uninstall safety check + retired persona migration (RETIRED_PERSONAS constant + migrate_retired_personas)
["src-tauri/src/managed_agents/teams.rs", 580], // built-in team registry (Kit & Scout) + merge_teams + validate_team_deletion + JSON export/import + tests
["src-tauri/src/managed_agents/persona_card.rs", 970], // PNG/ZIP/MD persona card codec + pack-zip detection + nested root finder + provider/model/namePool fields + 27 unit tests
["src/app/AppShell.tsx", 835], // message edit state + handlers + ChannelPane edit prop threading + scrollback pagination + workflows view + projects view + memory-leak safeguards + home-badge state lifted here so it consumes the same NIP-RS read-state as the sidebar (single ReadStateManager) + dock bounce wiring + mark-all-read context + channel notification callback + desktopEnabled guard
Expand All @@ -46,6 +46,7 @@ const overrides = new Map([
["src/features/settings/ui/SettingsView.tsx", 600],
["src/features/sidebar/ui/AppSidebar.tsx", 860], // channels + forums creation forms + Pulse nav
["src/shared/api/relayClientSession.ts", 1040], // durable websocket session manager with reconnect/replay/recovery state + sendTypingIndicator + fetchChannelHistoryBefore + subscribeToChannelLive (huddle TTS) + subscribeToHuddleEvents (huddle indicator) + disconnect() for workspace switch teardown + fetchEvents/subscribeLive/publishEvent for NIP-RS read state + publishUserStatus/subscribeToUserStatusUpdates (NIP-38) + ConnectionState plumbing & stall-watchdog wiring for half-open WS detection (Warp orange-icon case) + terminal session latch (auth rejection no longer racing back to reconnecting) — emitter + watchdog + reconnect policy logic extracted to relayConnectionStateEmitter.ts / relayStallWatchdog.ts / relayReconnectPolicy.ts
["src-tauri/src/migration.rs", 630], // worktree shared-agent-data symlink sync (SHARED_AGENT_FILES symlink-to-canonical) + mcp_command provider reconciliation + tests
["src-tauri/src/commands/media.rs", 730], // ffmpeg video transcode + poster frame extraction + run_ffmpeg_with_timeout (find_ffmpeg via resolve_command, is_video_file, transcode_to_mp4, extract_poster_frame, transcode_and_extract_poster) + spawn_blocking wrappers + tests
["src-tauri/src/commands/agents.rs", 881], // remote agent lifecycle routing (local + provider branches) + scope enforcement + persona pack metadata wiring + mcp_toolsets field + NIP-OA auth_tag in deploy payload
["src-tauri/src/commands/messages.rs", 515], // feed multi-query + NIP-50 search + forum thread resolution + thread ref + reactions via REQ + edit_message media_tags param (Slack-style attachment-editable edits)
Expand Down Expand Up @@ -77,7 +78,7 @@ const overrides = new Map([
["src-tauri/src/huddle/tts.rs", 1380], // TTS pipeline + session warmup + cancel/shutdown handling + apply_fade_out (fade-out only — leading fade removed 2026-05-18 after onset-attenuation regression measured in examples/pocket_onset_probe.rs) + FIRST_APPEND_LEAD_IN_SAMPLES + build_sentence_append_plan (pure helper enforcing the lead-in fires exactly once per utterance, not per sentence — see lead_in_pad_fires_exactly_once_per_utterance regression test) + normalize_for_playback (per-sentence peak normalization to -3 dBFS ceiling with MAX_GAIN cap) + 30 unit tests (18 interrupt + 5 fade-out + 1 first-append-lead-in + 3 build-sentence-append-plan + 6 normalize)
["src-tauri/src/relay.rs", 510], // +4 lines for NIP-OA auth tag injection in profile sync (build_profile_event) + verification test
["src-tauri/src/commands/pairing.rs", 600], // NIP-AB pairing actor: 3 Tauri commands + background WS task + NIP-42 auth + NIP-43 probe + event parsing helpers
["src-tauri/src/lib.rs", 730], // +4 lines for PairingHandle managed state + 3 pairing command registrations + parse_message_deep_link helper extracted with 6 unit tests covering empty-param filter regression
["src-tauri/src/lib.rs", 733], // +4 lines for PairingHandle managed state + 3 pairing command registrations + parse_message_deep_link helper extracted with 6 unit tests covering empty-param filter regression + mod migration + sync_shared_agent_data/reconcile_provider_mcp_commands calls on launch
["src/shared/api/tauri.ts", 1212], // pairing command wrappers + applyWorkspace + NIP-44 encrypt/decrypt wrappers + observer_url field + relay member API functions (list/get/add/remove/change-role) + prevent sleep + AcpProviderCatalogEntry raw types + fromRawAcpProviderCatalogEntry converter + installAcpRuntime
]);

Expand Down
7 changes: 7 additions & 0 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ mod events;
mod huddle;
mod managed_agents;
mod media_proxy;
mod migration;
mod models;
pub mod nostr_convert;
mod prevent_sleep;
Expand Down Expand Up @@ -398,6 +399,12 @@ pub fn run() {
let app_handle = app.handle().clone();
let shutdown_started = Arc::clone(&restore_shutdown_started);

// Sync shared agent data from the canonical dev data directory to
// 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_provider_mcp_commands(&app_handle);

// Resolve persisted identity key (env var → file → generate+save).
// This is fatal — the app should not start with an ephemeral identity
// that will be lost on restart, as that silently breaks channel
Expand Down
74 changes: 73 additions & 1 deletion desktop/src-tauri/src/managed_agents/personas.rs
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,33 @@ Your name is Scout. You are friendly and helpful. You are understated, but have
},
];

const RETIRED_PERSONAS: &[(&str, &str)] = &[
(
"builtin:orchestrator",
"You are an orchestration agent. Coordinate multi-step work across specialized agents, keep the overall plan moving, and synthesize results into a clear final outcome. When another agent should take a task, @mention them explicitly with the assignment, expected deliverable, and any relevant constraints or deadlines.",
),
(
"builtin:researcher",
"You are a research agent. Gather relevant information, compare sources, call out uncertainty, and return concise findings with evidence.",
),
(
"builtin:planner",
"You are a planning agent. Turn ambiguous requests into structured plans with milestones, dependencies, risks, and clear next actions. Do not implement the work yourself unless asked.",
),
(
"builtin:implementer",
"You are a builder agent. Execute tasks directly, make code and configuration changes carefully, validate the result, and explain important decisions and follow-up items.",
),
(
"builtin:refactor",
"You are a refactoring agent. Improve structure, naming, duplication, and module boundaries without changing externally observable behavior. Keep changes incremental, preserve compatibility, and add or update validation when behavior could drift.",
),
(
"builtin:reviewer",
"You are a review agent. Inspect plans, code, and outputs for bugs, regressions, edge cases, security issues, and missing tests. Prioritize findings by severity, cite concrete evidence, and keep summaries secondary to the actual review.",
),
];

fn personas_store_path(app: &AppHandle) -> Result<PathBuf, String> {
Ok(managed_agents_base_dir(app)?.join("personas.json"))
}
Expand Down Expand Up @@ -562,10 +589,55 @@ fn merge_personas(mut stored: Vec<PersonaRecord>, now: &str) -> (Vec<PersonaReco
}
}

// Soft-deprecate retired built-in personas that were replaced by
// Solo/Kit/Scout. Runs after demotion so the records are already
// marked as non-builtin.
if migrate_retired_personas(&mut stored, now) {
changed = true;
}

sort_personas(&mut stored);
(stored, changed)
}

/// Soft-deprecate retired built-in personas by appending " (retired)" to
/// their display name and marking them inactive. Never removes records —
/// the cost is 6 extra records for pre-transition users, but this
/// eliminates dangling `persona_id` references in managed-agents.json
/// and teams.json.
fn migrate_retired_personas(stored: &mut [PersonaRecord], now: &str) -> bool {
let mut changed = false;

for record in stored.iter_mut() {
if let Some((_, original_prompt)) = RETIRED_PERSONAS.iter().find(|(id, _)| *id == record.id)
{
let retired_suffix = " (retired)";
let needs_suffix = !record.display_name.ends_with(retired_suffix);
if needs_suffix || record.is_active {
let was_unmodified = record.system_prompt == *original_prompt;
eprintln!(
"sprout-desktop: persona-migration: retiring {} persona '{}' → '{} (retired)'",
if was_unmodified {
"unmodified"
} else {
"customized"
},
record.display_name,
record.display_name,
);
if needs_suffix {
record.display_name = format!("{}{}", record.display_name, retired_suffix);
}
record.is_active = false;
record.updated_at = now.to_string();
changed = true;
}
}
}

changed
}

pub fn ensure_persona_is_active(
personas: &[PersonaRecord],
persona_id: &str,
Expand Down Expand Up @@ -893,7 +965,7 @@ pub fn save_personas(app: &AppHandle, records: &[PersonaRecord]) -> Result<(), S
let path = personas_store_path(app)?;
let payload = serde_json::to_vec_pretty(&sorted)
.map_err(|error| format!("failed to serialize persona store: {error}"))?;
fs::write(&path, payload).map_err(|error| format!("failed to write persona store: {error}"))
crate::managed_agents::storage::atomic_write_json(&path, &payload)
}

#[cfg(test)]
Expand Down
122 changes: 118 additions & 4 deletions desktop/src-tauri/src/managed_agents/personas/tests.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use super::{
ensure_persona_ids_are_active, ensure_persona_is_active, merge_personas, validate_pack_id,
validate_persona_activation_change, validate_persona_deletion, BUILT_IN_PERSONAS,
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,
};
use crate::managed_agents::PersonaRecord;

Expand Down Expand Up @@ -161,6 +162,9 @@ fn merge_personas_backfills_new_builtins_for_existing_store() {

#[test]
fn merge_personas_demotes_retired_builtins() {
// custom_persona uses "Custom prompt", which doesn't match the original
// retired system prompt, so the migration pass soft-deprecates rather
// than removes the record.
let mut retired = custom_persona("builtin:reviewer", "Reviewer");
retired.is_builtin = true;
retired.is_active = true;
Expand All @@ -172,9 +176,11 @@ fn merge_personas_demotes_retired_builtins() {
let demoted = records
.iter()
.find(|record| record.id == "builtin:reviewer")
.expect("retired built-in should be retained as a custom persona");
.expect("retired built-in should be retained as a soft-deprecated custom persona");
assert!(!demoted.is_builtin);
assert!(demoted.is_active);
// migrate_retired_personas deactivates customized retired personas.
assert!(!demoted.is_active);
assert_eq!(demoted.display_name, "Reviewer (retired)");
assert_eq!(demoted.created_at, original_created_at);
assert_eq!(demoted.updated_at, "2026-04-01T00:00:00Z");
}
Expand Down Expand Up @@ -344,3 +350,111 @@ fn pack_id_rejects_too_long() {
let max_id = "a".repeat(128);
assert!(validate_pack_id(&max_id).is_ok());
}

// ── migrate_retired_personas ──────────────────────────────────────────────────

#[test]
fn migrate_retires_unmodified_personas() {
let now = "2026-04-01T00:00:00Z";
// Simulate a store from before the Solo/Kit/Scout transition: all 6
// retired personas with original system prompts.
let mut stored: Vec<PersonaRecord> = RETIRED_PERSONAS
.iter()
.map(|(id, prompt)| PersonaRecord {
id: id.to_string(),
system_prompt: prompt.to_string(),
is_builtin: false, // already demoted by merge_personas
..custom_persona(id, "Test Persona")
})
.collect();

let changed = migrate_retired_personas(&mut stored, now);

assert!(changed);
assert_eq!(
stored.len(),
RETIRED_PERSONAS.len(),
"all retired personas should be soft-deprecated, not removed",
);
assert!(
stored
.iter()
.all(|r| r.display_name.ends_with(" (retired)")),
"all retired personas should have ' (retired)' suffix",
);
assert!(
stored.iter().all(|r| !r.is_active),
"all retired personas should be inactive",
);
assert!(
stored.iter().all(|r| r.updated_at == now),
"all retired personas should have refreshed updated_at",
);
}

#[test]
fn migrate_preserves_customized_personas() {
let now = "2026-04-01T00:00:00Z";
let mut stored = vec![PersonaRecord {
id: "builtin:researcher".to_string(),
display_name: "My Researcher".to_string(),
system_prompt: "My custom research workflow with special instructions".to_string(),
is_builtin: false,
is_active: true,
..custom_persona("builtin:researcher", "My Researcher")
}];

let changed = migrate_retired_personas(&mut stored, now);

assert!(changed);
assert_eq!(stored.len(), 1);
let record = &stored[0];
assert_eq!(record.display_name, "My Researcher (retired)");
assert!(!record.is_active);
assert_eq!(
record.system_prompt,
"My custom research workflow with special instructions"
);
assert_eq!(record.updated_at, now);
}

#[test]
fn migrate_is_idempotent() {
let now = "2026-04-01T00:00:00Z";

// 1. Non-retired persona — no-op.
let mut stored = vec![custom_persona("custom:test", "Custom")];
assert!(!migrate_retired_personas(&mut stored, now));
assert_eq!(stored.len(), 1);

// 2. Already-retired persona (display_name ends with " (retired)") — no-op.
let mut stored_with_retired = vec![PersonaRecord {
id: "builtin:researcher".to_string(),
display_name: "Researcher (retired)".to_string(),
system_prompt: "My custom prompt".to_string(),
is_builtin: false,
is_active: false,
..custom_persona("builtin:researcher", "Researcher (retired)")
}];
assert!(
!migrate_retired_personas(&mut stored_with_retired, now),
"already-retired persona should not trigger another change"
);

// 3. Retired persona still marked is_builtin: true (pre-demotion).
// migrate_retired_personas should still soft-deprecate it.
let mut stored_pre_demotion = vec![PersonaRecord {
id: "builtin:reviewer".to_string(),
display_name: "Reviewer".to_string(),
system_prompt: "Custom review prompt".to_string(),
is_builtin: true,
is_active: true,
..custom_persona("builtin:reviewer", "Reviewer")
}];
assert!(migrate_retired_personas(&mut stored_pre_demotion, now));
assert_eq!(stored_pre_demotion[0].display_name, "Reviewer (retired)");
assert!(!stored_pre_demotion[0].is_active);

// 4. Run again on result of (3) — should be no-op.
assert!(!migrate_retired_personas(&mut stored_pre_demotion, now));
}
20 changes: 12 additions & 8 deletions desktop/src-tauri/src/managed_agents/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,14 +56,18 @@ pub fn save_managed_agents(app: &AppHandle, records: &[ManagedAgentRecord]) -> R
let payload = serde_json::to_vec_pretty(&sorted)
.map_err(|error| format!("failed to serialize agent store: {error}"))?;

// Atomic write: write to a temp file then rename. This prevents partial
// writes from corrupting the store if the process crashes mid-write.
// rename() is atomic on the same filesystem on both macOS and Linux.
let tmp_path = path.with_extension("json.tmp");
fs::write(&tmp_path, &payload)
.map_err(|error| format!("failed to write temp agent store: {error}"))?;
fs::rename(&tmp_path, &path)
.map_err(|error| format!("failed to rename temp agent store: {error}"))
atomic_write_json(&path, &payload)
}

/// Atomic, symlink-preserving JSON write.
/// Resolves symlinks so the tmp+rename happens at the real target path,
/// preserving any symlink at `path`.
pub(crate) fn atomic_write_json(path: &Path, payload: &[u8]) -> Result<(), String> {
let resolved = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
let tmp = resolved.with_extension("json.tmp");
std::fs::write(&tmp, payload).map_err(|e| format!("failed to write {}: {e}", tmp.display()))?;
std::fs::rename(&tmp, &resolved)
.map_err(|e| format!("failed to rename {}: {e}", resolved.display()))
}

/// Maximum log file size before rotation (10 MB).
Expand Down
2 changes: 1 addition & 1 deletion desktop/src-tauri/src/managed_agents/teams.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ pub fn save_teams(app: &AppHandle, records: &[TeamRecord]) -> Result<(), String>
let path = teams_store_path(app)?;
let payload = serde_json::to_vec_pretty(&sorted)
.map_err(|error| format!("failed to serialize teams store: {error}"))?;
fs::write(&path, payload).map_err(|error| format!("failed to write teams store: {error}"))
crate::managed_agents::storage::atomic_write_json(&path, &payload)
}

// ---------------------------------------------------------------------------
Expand Down
Loading