Skip to content
Merged
18 changes: 14 additions & 4 deletions desktop/scripts/check-file-sizes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,9 @@ const overrides = new Map([
// across the local-archive + agent-metric-archive PR series. store_tests.rs
// (~731 lines) is under 1000 so needs no override.
["src-tauri/src/archive/mod_tests.rs", 1208],
["src-tauri/src/commands/agents.rs", 1443],
// unified-agent-model 1A.1: profile reconcile split to agents_profile.rs,
// ratcheting 1443 -> 1295. Queued to split further in the A2 fold.
["src-tauri/src/commands/agents.rs", 1295],
// #1418 read-path fix: get_thread_replies' blocker fix (shared TIMELINE_KINDS
// const + build_thread_replies_filter helper, mirroring the channel sibling so
// the two p-gate filters can't drift) plus two guard unit tests. The file was
Expand All @@ -90,7 +92,9 @@ const overrides = new Map([
// queued to split with the rest of this list.
// +4 lines: adopt shared create_symlink wrapper (behavior-preserving refactor
// for multi-line rustfmt expansion of the skills symlink call site).
["src-tauri/src/managed_agents/nest.rs", 1575],
// unified-agent-model 1A.1: inline test module moved to nest/tests.rs,
// ratcheting 1575 -> 679 (under the 1000 default; entry kept as a ratchet).
["src-tauri/src/managed_agents/nest.rs", 679],
// harness-persona-sync: persona-runtime resolution threaded into the spawn
// path here. Load-bearing feature growth; queued to split in the resolver
// unify refactor followup. +26 for resolve_effective_prompt_model_provider
Expand Down Expand Up @@ -153,20 +157,26 @@ const overrides = new Map([
// same-runtime / alias / sentinel / non-override / persona-less test matrix
// (~135 lines, mostly tests) so a deliberate Custom pin survives the update
// path instead of being dropped back to inherit. Load-bearing, not debt.
["src-tauri/src/managed_agents/discovery.rs", 1259],
// unified-agent-model 1A.1: inline test module moved to discovery/tests.rs,
// ratcheting 1259 -> 802 (under the 1000 default; entry kept as a ratchet).
["src-tauri/src/managed_agents/discovery.rs", 802],
// migration_tests.rs carries the harness-sync migration coverage plus the
// patch_json_records owner-only writeback regression test (SECURITY.md:90
// crash-safe 0o600 fallback). Load-bearing security + feature coverage, not
// generic debt growth. Approved override; still queued to split. Event-sync
// (persona/team event reconcile) tests were split out to event_sync_tests.rs
// and the limit ratcheted 1410 → 1110.
// unified-agent-model 1A.1: materialize tests live with their module in
// migration/materialize.rs; ratchet held at 1110.
["src-tauri/src/migration_tests.rs", 1110],
["src-tauri/src/nostr_convert.rs", 1126],
["src/shared/api/relayClientSession.ts", 1022],
// Boot-time event sync (persona/team/agent event reconcile) was split out
// to event_sync.rs, ratcheting this limit 1575 → 1310. Remaining content is
// the pre-identity data migrations; still queued to split further.
["src-tauri/src/migration.rs", 1310],
// unified-agent-model 1A.1: materialize_agent_runtimes split to
// migration/materialize.rs, ratcheting 1310 -> 1297.
["src-tauri/src/migration.rs", 1297],
// onMarkRead + isUnread prop threading (mirrors the onMarkUnread prop
// already here) for the single-toggle mark-read/unread menu item — a small
// overage from load-bearing per-message plumbing, not generic debt growth.
Expand Down
20 changes: 12 additions & 8 deletions desktop/src-tauri/src/commands/agent_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ use crate::{
NormalizedField, RuntimeConfigSurface, SessionConfigCache,
},
},
current_instance_id, effective_agent_command, known_acp_runtime, load_managed_agents,
load_personas, resolve_effective_prompt_model_provider, save_managed_agents,
sync_managed_agent_processes, KnownAcpRuntime, ManagedAgentRecord, PersonaRecord,
current_instance_id, known_acp_runtime, load_managed_agents, load_personas,
resolve_effective_prompt_model_provider, save_managed_agents, sync_managed_agent_processes,
KnownAcpRuntime, ManagedAgentRecord, PersonaRecord,
},
};

Expand Down Expand Up @@ -242,11 +242,7 @@ pub async fn get_agent_config_surface(
};

let personas = load_personas(&app).unwrap_or_default();
let effective_cmd = effective_agent_command(
record.persona_id.as_deref(),
&personas,
record.agent_command_override.as_deref(),
);
let effective_cmd = crate::managed_agents::record_agent_command(&record, &personas);
let runtime_meta = known_acp_runtime(&effective_cmd);
let session_cache = state.get_session_cache(&pubkey);

Expand Down Expand Up @@ -516,6 +512,14 @@ mod tests {
last_error: None,
respond_to: RespondTo::OwnerOnly,
respond_to_allowlist: vec![],
display_name: None,
slug: None,
runtime: None,
name_pool: Vec::new(),
is_builtin: false,
is_active: true,
source_team: None,
source_team_persona_slug: None,
relay_mesh: None,
agent_command_override: None,
persona_source_version: None,
Expand Down
12 changes: 2 additions & 10 deletions desktop/src-tauri/src/commands/agent_models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,7 @@ pub async fn get_agent_models(
// so model discovery runs against the persona's current harness, not the
// frozen record snapshot. An explicit per-agent override wins.
let personas = load_personas(&app).unwrap_or_default();
let effective_command = crate::managed_agents::effective_agent_command(
record.persona_id.as_deref(),
&personas,
record.agent_command_override.as_deref(),
);
let effective_command = crate::managed_agents::record_agent_command(record, &personas);

let args = normalize_agent_args(&effective_command, record.agent_args.clone());

Expand Down Expand Up @@ -936,11 +932,7 @@ pub async fn update_managed_agent(
// not the frozen snapshot, so an inherited harness picks the right
// default avatar.
let personas = load_personas(&app).unwrap_or_default();
let effective_command = crate::managed_agents::effective_agent_command(
record.persona_id.as_deref(),
&personas,
record.agent_command_override.as_deref(),
);
let effective_command = crate::managed_agents::record_agent_command(record, &personas);
let avatar_url = record
.avatar_url
.clone()
Expand Down
180 changes: 16 additions & 164 deletions desktop/src-tauri/src/commands/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -843,6 +843,14 @@ pub async fn create_managed_agent(
last_error: None,
respond_to: input.respond_to,
respond_to_allowlist: respond_to_allowlist.clone(),
display_name: None,
slug: None,
runtime: None,
name_pool: Vec::new(),
is_builtin: false,
is_active: true,
source_team: None,
source_team_persona_slug: None,
relay_mesh: relay_mesh.clone(),
};

Expand Down Expand Up @@ -975,27 +983,6 @@ pub async fn create_managed_agent(
}

/// Data needed for background profile reconciliation after agent start.
pub(crate) struct ProfileReconcileData {
pub(crate) private_key_nsec: String,
pub(crate) name: String,
pub(crate) relay_url: String,
/// Expected avatar URL for the published profile. `None` for legacy records
/// that predate the `avatar_url` field — these will be backfilled from the
/// relay's existing kind:0 profile on first reconciliation.
pub(crate) avatar_url: Option<String>,
pub(crate) auth_tag: Option<String>,
/// The agent's pubkey (hex). Needed to update the persisted record during
/// avatar backfill migration.
pub(crate) pubkey: String,
/// The agent's command (e.g. "goose"). Used as fallback when no profile
/// exists on the relay during avatar backfill.
pub(crate) agent_command: String,
/// Persona ID if this agent was created from a persona. Used during avatar
/// backfill to recover the correct avatar from the persona record when the
/// relay profile has been corrupted.
pub(crate) persona_id: Option<String>,
}

#[tauri::command]
pub async fn start_managed_agent(
pubkey: String,
Expand Down Expand Up @@ -1042,11 +1029,8 @@ pub async fn start_managed_agent(
// profile reconcile (the create-time snapshot may be empty or stale for
// a persona-inherited harness).
let reconcile_personas = load_personas(&app).unwrap_or_default();
let reconcile_effective_command = crate::managed_agents::effective_agent_command(
record.persona_id.as_deref(),
&reconcile_personas,
record.agent_command_override.as_deref(),
);
let reconcile_effective_command =
crate::managed_agents::record_agent_command(record, &reconcile_personas);

let reconcile = ProfileReconcileData {
private_key_nsec: record.private_key_nsec.clone(),
Expand Down Expand Up @@ -1139,144 +1123,6 @@ pub async fn start_managed_agent(
result
}

/// Resolve the avatar to backfill for a legacy agent record (pre-PR-921, no
/// stored `avatar_url`).
///
/// Priority: the persona's avatar wins, because the old reconciliation code
/// could have overwritten the relay's kind:0 `picture` with the command default
/// — making the relay an unreliable source for persona-backed agents. Only fall
/// back to the relay's `picture`, then the command icon, for agents with no
/// persona avatar to recover from.
fn resolve_legacy_avatar(
persona_avatar: Option<String>,
relay_picture: Option<String>,
agent_command: &str,
) -> String {
persona_avatar
.or(relay_picture)
.or_else(|| managed_agent_avatar_url(agent_command))
.unwrap_or_default()
}

/// Reconcile an agent's kind:0 profile on the relay.
///
/// Queries the relay for the agent's existing profile and re-publishes if missing
/// or stale (display_name or picture mismatch). This is fire-and-forget — errors
/// are returned to the caller for logging but never block agent startup.
///
/// For legacy records (pre-PR-921) where `avatar_url` is `None`, this function
/// backfills via `resolve_legacy_avatar` — preferring the persona record's avatar
/// over the relay's `picture`, since the old code may have corrupted the relay
/// profile — and persists the updated record. After backfill, normal
/// reconciliation proceeds.
///
/// Query and publish target the relay returned by `effective_agent_relay_url`
/// for every agent regardless of backend: an explicit per-agent `relay_url`
/// wins, and a blank one falls back to the active workspace relay. This keeps
/// reconciliation following the session's relay for never-pinned agents while
/// honoring a deliberate pin wherever it points.
pub(crate) async fn reconcile_agent_profile(
state: &AppState,
app: &AppHandle,
agent_pubkey: &str,
data: &ProfileReconcileData,
) -> Result<(), String> {
use crate::relay::{query_agent_profile, sync_managed_agent_profile};

// An explicit per-agent relay wins; an empty one falls back to the active
// workspace relay. Resolved once and used for both the read and write-back.
let relay_url = crate::relay::effective_agent_relay_url(
&data.relay_url,
&relay_ws_url_with_override(state),
);

// Query the relay for the agent's existing kind:0 profile.
let existing = query_agent_profile(state, &relay_url, agent_pubkey).await?;

// Resolve the expected avatar — backfilling for legacy records that have no
// stored avatar_url yet.
let expected_avatar = match data.avatar_url.as_deref() {
Some(url) => url.to_string(),
None => {
// Legacy record: the relay profile may have been corrupted by the
// old reconciliation code (it overwrote the persona avatar with the
// command default), so the persona record is the authoritative source.
let persona_avatar = data.persona_id.as_ref().and_then(|pid| {
load_personas(app)
.ok()?
.into_iter()
.find(|p| p.id == *pid)?
.avatar_url
});

let backfilled = resolve_legacy_avatar(
persona_avatar,
existing.as_ref().and_then(|info| info.picture.clone()),
&data.agent_command,
);

// Persist the backfilled avatar so this migration only runs once.
if !backfilled.is_empty() {
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|e| e.to_string())?;
let mut records = load_managed_agents(app)?;
if let Some(record) = records.iter_mut().find(|r| r.pubkey == data.pubkey) {
record.avatar_url = Some(backfilled.clone());
save_managed_agents(app, &records)?;
}
}

backfilled
}
};

let expected_avatar = if expected_avatar.is_empty() {
None
} else {
Some(expected_avatar)
};

if !profile_needs_sync(existing.as_ref(), &data.name, expected_avatar.as_deref()) {
return Ok(());
}

let agent_keys = Keys::parse(&data.private_key_nsec)
.map_err(|e| format!("failed to parse agent keys: {e}"))?;

sync_managed_agent_profile(
state,
&relay_url,
&agent_keys,
&data.name,
expected_avatar.as_deref(),
data.auth_tag.as_deref(),
)
.await
}

/// Decide whether a published profile is missing or stale relative to the
/// expected name and avatar. A missing profile always needs sync; a present
/// one is stale when either the display name or picture diverges.
fn profile_needs_sync(
existing: Option<&crate::relay::AgentProfileInfo>,
expected_name: &str,
expected_avatar: Option<&str>,
) -> bool {
match existing {
None => true,
Some(info) => {
let name_matches = info.display_name.as_deref() == Some(expected_name);
let picture_matches = info.picture.as_deref() == expected_avatar;
!name_matches || !picture_matches
}
}
}

// Async so the blocking body (disk reads/writes + process termination) runs off
// the main UI thread via spawn_blocking. State is re-derived from the owned
// AppHandle inside the closure (`State<'_, _>` is borrowed, MutexGuard is !Send).
#[tauri::command]
pub async fn stop_managed_agent(
pubkey: String,
Expand Down Expand Up @@ -1437,6 +1283,12 @@ pub fn get_managed_agent_log(
// 3. Desktop's existing presence polling sees "offline" — UI updates automatically
// No backend Tauri command needed. Presence IS the status.

#[path = "agents_profile.rs"]
mod profile;
#[cfg(test)]
use profile::{profile_needs_sync, resolve_legacy_avatar};
pub(crate) use profile::{reconcile_agent_profile, ProfileReconcileData};

#[cfg(test)]
#[path = "agents_tests.rs"]
mod tests;
Loading