Skip to content
Open
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
3 changes: 2 additions & 1 deletion desktop/scripts/check-file-sizes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ const rules = [

// Exceptions should stay rare and temporary. Prefer splitting files instead.
const overrides = new Map([
["src-tauri/src/managed_agents/personas.rs", 900], // built-in persona system prompts (Solo + Kit + Scout) + persona pack import/uninstall/list + uninstall safety check
["src-tauri/src/managed_agents/personas.rs", 980], // built-in persona system prompts (Solo + Kit + Scout) + 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", 815], // 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)
Expand All @@ -45,6 +45,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", 640], // legacy data dir migration + worktree shared-agent-data sync (SHARED_AGENT_FILES copy-with-overwrite + runtime_pid scrub) + tests for both
["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", 510], // feed multi-query + NIP-50 search + forum thread resolution + thread ref + reactions via REQ
Expand Down
5 changes: 5 additions & 0 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,11 @@ pub fn run() {
// resolving identity, so the persisted key is available at the new
// path on first launch after the identifier change.
migration::migrate_legacy_data_dir(&app_handle);
// Sync shared agent data from the canonical dev data directory to
// this worktree's data directory. Must run after legacy migration
// (which may populate missing files) and before
// restore_managed_agents_on_launch (which reads managed-agents.json).
migration::sync_shared_agent_data(&app_handle);

// Resolve persisted identity key (env var → file → generate+save).
// This is fatal — the app should not start with an ephemeral identity
Expand Down
76 changes: 76 additions & 0 deletions desktop/src-tauri/src/managed_agents/personas.rs
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,39 @@ Your name is Scout. You are friendly and helpful. You are understated, but have
},
];

const RETIRED_PERSONAS: &[(&str, &str, &str)] = &[
(
"builtin:orchestrator",
"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",
"Researcher",
"You are a research agent. Gather relevant information, compare sources, call out uncertainty, and return concise findings with evidence.",
),
(
"builtin:planner",
"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",
"Builder",
"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",
"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",
"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 @@ -553,10 +586,53 @@ 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_name, original_prompt)) =
RETIRED_PERSONAS.iter().find(|(id, _, _)| *id == record.id)
{
let retired_suffix = " (retired)";
if !record.display_name.ends_with(retired_suffix) {
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,
);
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
123 changes: 119 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,112 @@ 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, name, prompt)| PersonaRecord {
id: id.to_string(),
display_name: name.to_string(),
system_prompt: prompt.to_string(),
is_builtin: false, // already demoted by merge_personas
..custom_persona(id, name)
})
.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));
}
Loading
Loading