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
1 change: 1 addition & 0 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,7 @@ pub fn run() {
// 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::reconcile_provider_mcp_commands(&app_handle);
migration::migrate_persona_provider_to_runtime(&app_handle);

// Resolve persisted identity key (env var → file → generate+save).
Expand Down
22 changes: 14 additions & 8 deletions desktop/src-tauri/src/managed_agents/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -797,14 +797,20 @@ pub fn spawn_agent_child(
let agent_args = normalize_agent_args(&record.agent_command, record.agent_args.clone());
let resolved_acp_command = resolve_command(&record.acp_command)
.ok_or_else(|| missing_command_message(&record.acp_command, "ACP harness command"))?;
let resolved_mcp_command: Option<std::path::PathBuf> =
if record.mcp_command.is_empty() {
None
} else {
Some(resolve_command(&record.mcp_command).ok_or_else(|| {
missing_command_message(&record.mcp_command, "MCP server command")
})?)
};
let resolved_mcp_command: Option<std::path::PathBuf> = if record.mcp_command.is_empty() {
None
} else {
match resolve_command(&record.mcp_command) {
Some(path) => Some(path),
None => {
eprintln!(
"sprout-desktop: mcp_command {:?} not found, skipping",
record.mcp_command
);
None
}
}
};
// Resolve agent command to a full path (DMG launches have minimal PATH).
let resolved_agent_command = resolve_command(&record.agent_command)
.map(|p| p.display().to_string())
Expand Down
173 changes: 173 additions & 0 deletions desktop/src-tauri/src/migration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,65 @@ pub fn reconcile_persona_pack_paths(app: &tauri::AppHandle) {
reconcile_pack_paths_in_file(&path, &canonical_dir);
}

fn reconcile_mcp_commands_in_file(path: &Path) {
patch_json_records(path, |obj| {
let agent_command = match obj.get("agent_command").and_then(|v| v.as_str()) {
Some(cmd) => cmd.to_string(),
None => return false,
};
let Some(runtime) = crate::managed_agents::known_acp_runtime(&agent_command) else {
return false;
};
let expected = runtime.mcp_command.unwrap_or("");
let current = obj
.get("mcp_command")
.and_then(|v| v.as_str())
.unwrap_or("");
if current == expected {
return false;
}
// Only fix values that are clearly stale (empty or a removed binary).
// Leave user-customized values untouched.
if !current.is_empty() && current != "sprout-mcp-server" {
return false;
}
eprintln!(
"sprout-desktop: runtime-reconcile: {:?} ({:?}): mcp_command {:?} → {:?}",
obj.get("name").and_then(|v| v.as_str()).unwrap_or("?"),
agent_command,
current,
expected,
);
obj.insert(
"mcp_command".to_string(),
serde_json::Value::String(expected.to_string()),
);
true
});
}

/// Reconcile `mcp_command` values in managed-agents.json against the
/// discovery table. Known runtimes get their canonical mcp_command;
/// unknown/custom agents are left untouched. Covers both the current
/// app data dir and the canonical dev data dir (for worktree instances).
pub fn reconcile_provider_mcp_commands(app: &tauri::AppHandle) {
let Ok(current_dir) = app.path().app_data_dir() else {
return;
};
let mut dirs = vec![current_dir.clone()];
if let Some(canonical) = canonical_dev_data_dir(&current_dir) {
if canonical.exists() && canonical != current_dir {
dirs.push(canonical);
}
}
for dir in dirs {
let path = dir.join("agents/managed-agents.json");
if path.exists() {
reconcile_mcp_commands_in_file(&path);
}
}
}

fn rename_provider_to_runtime_in_personas(path: &Path) {
patch_json_records(path, |obj| {
if obj.contains_key("runtime") {
Expand Down Expand Up @@ -899,4 +958,118 @@ mod tests {
// 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());
}
}