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
1 change: 1 addition & 0 deletions desktop/src-tauri/src/commands/agent_config_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ fn persona_with_model(model: &str) -> AgentDefinition {
display_name: "Persona".to_string(),
avatar_url: None,
system_prompt: "You are a persona.".to_string(),
acp_command: None,
runtime: None,
model: Some(model.to_string()),
provider: None,
Expand Down
2 changes: 1 addition & 1 deletion desktop/src-tauri/src/commands/agent_models_tests.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
use super::*;

#[test]
fn access_policy_change_requires_runtime_refresh_for_effective_gate_changes() {
use crate::managed_agents::RespondTo;
Expand Down Expand Up @@ -433,6 +432,7 @@ fn model_discovery_ignores_stale_record_for_linked_agent() {
display_name: "Persona".to_string(),
avatar_url: None,
system_prompt: "You are a persona.".to_string(),
acp_command: None,
runtime: Some("goose".to_string()),
model: Some("persona-model".to_string()),
provider: Some("anthropic".to_string()),
Expand Down
20 changes: 19 additions & 1 deletion desktop/src-tauri/src/commands/agent_providers.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,22 @@
use crate::managed_agents::{discover_provider_candidates, invoke_provider, BackendProviderInfo};
use crate::managed_agents::{
discover_acp_command_candidates, discover_provider_candidates, invoke_provider,
AcpCommandCandidate, BackendProviderInfo,
};

#[tauri::command]
pub async fn discover_acp_commands() -> Result<Vec<AcpCommandCandidate>, String> {
tokio::task::spawn_blocking(|| {
discover_acp_command_candidates()
.into_iter()
.map(|(command, path)| AcpCommandCandidate {
command,
binary_path: path.display().to_string(),
})
.collect()
})
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))
}

#[tauri::command]
pub async fn discover_backend_providers() -> Result<Vec<BackendProviderInfo>, String> {
Expand Down
10 changes: 5 additions & 5 deletions desktop/src-tauri/src/commands/agents.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
use super::managed_agent_definition::validate_create_definition;
use nostr::{Keys, ToBech32};
use tauri::{AppHandle, State};

use super::managed_agent_definition::validate_create_definition;

use crate::{
app_state::AppState,
managed_agents::{
Expand Down Expand Up @@ -647,9 +646,10 @@ pub async fn create_managed_agent(
auth_tag: auth_tag.clone(),
relay_url: resolved_relay_url.clone(),
avatar_url: resolved_avatar_url.clone(),
acp_command: input
.acp_command
.as_deref()
acp_command: linked_persona
.as_ref()
.and_then(|persona| persona.acp_command.as_deref())
.or(input.acp_command.as_deref())
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(DEFAULT_ACP_COMMAND)
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/commands/agents_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ fn persona_record(id: &str, model: Option<&str>, provider: Option<&str>) -> Agen
display_name: "Test Persona".to_string(),
avatar_url: None,
system_prompt: "".to_string(),
acp_command: None,
runtime: None,
model: model.map(str::to_string),
provider: provider.map(str::to_string),
Expand Down
3 changes: 3 additions & 0 deletions desktop/src-tauri/src/commands/media_download.rs
Original file line number Diff line number Diff line change
Expand Up @@ -612,6 +612,7 @@ mod tests {
format: FORMAT_DISCRIMINATOR.to_string(),
version: FORMAT_VERSION,
definition: AgentSnapshotDefinition {
acp_command: None,
name: "test".to_string(),
source_is_builtin: false,
system_prompt: None,
Expand Down Expand Up @@ -662,6 +663,7 @@ mod tests {
format: FORMAT_DISCRIMINATOR.to_string(),
version: FORMAT_VERSION,
definition: AgentSnapshotDefinition {
acp_command: None,
name: "test".to_string(),
source_is_builtin: false,
system_prompt: None,
Expand Down Expand Up @@ -708,6 +710,7 @@ mod tests {
format: FORMAT_DISCRIMINATOR.to_string(),
version: FORMAT_VERSION,
definition: AgentSnapshotDefinition {
acp_command: None,
name: "test".to_string(),
source_is_builtin: false,
system_prompt: None,
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/commands/media_snapshot_png.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ mod tests {
format: "buzz-agent-snapshot".to_string(),
version: 1,
definition: AgentSnapshotDefinition {
acp_command: None,
name: "Tree Trunks".to_string(),
source_is_builtin: false,
system_prompt: Some("You are a helpful agent.".to_string()),
Expand Down
2 changes: 2 additions & 0 deletions desktop/src-tauri/src/commands/personas/create.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ pub async fn create_persona(
let system_prompt = input.system_prompt.clone();
validate_agent_definition_text(&display_name, &system_prompt)?;
let avatar_url = trim_optional(input.avatar_url);
let acp_command = trim_optional(input.acp_command);
let runtime = trim_optional(input.runtime);
let model = trim_optional(input.model);
let provider = trim_optional(input.provider);
Expand Down Expand Up @@ -59,6 +60,7 @@ pub async fn create_persona(
display_name,
avatar_url,
system_prompt,
acp_command,
runtime,
model,
provider,
Expand Down
19 changes: 16 additions & 3 deletions desktop/src-tauri/src/commands/personas/inbound.rs
Original file line number Diff line number Diff line change
Expand Up @@ -671,9 +671,11 @@ fn event_d_tag(event: &nostr::Event) -> Result<String, String> {
/// in place, or push it when none matches.
///
/// The match key is `persona_d_tag` — the same derivation the outbound path
/// uses — so the inbound and outbound keys can never drift. On match, only the
/// projected fields are overwritten; local `id`, `env_vars`, `source_team`, and
/// `created_at` survive. On no match, the parsed record is inserted as-is; since
/// uses — so the inbound and outbound keys can never drift. On match, every
/// field projected by `PersonaEventContent` is overwritten (except a redacted
/// shared transport preserves a nonportable local override), while local `id`,
/// `env_vars`, `source_team`, and `created_at` survive. On no match, the parsed
/// record is inserted as-is; since
/// `persona_from_event` sets `id = d_tag`, an in-app persona reuses its d-tag as
/// the id and a re-received event stays idempotent (no duplicate row).
fn apply_inbound_persona(personas: &mut Vec<AgentDefinition>, inbound: AgentDefinition) {
Expand All @@ -686,6 +688,17 @@ fn apply_inbound_persona(personas: &mut Vec<AgentDefinition>, inbound: AgentDefi
local.display_name = inbound.display_name;
local.avatar_url = inbound.avatar_url;
local.system_prompt = inbound.system_prompt;
// Shared absence can be a redacted machine-local transport. Keep
// only that local override; portable/legacy absence still resets.
// Shared writers emit explicit buzz-acp for an intentional reset.
let redacted_local_command = inbound.shared
&& inbound.acp_command.is_none()
&& local.acp_command.as_deref().is_some_and(|command| {
!crate::managed_agents::is_portable_acp_command(command)
});
if !redacted_local_command {
local.acp_command = inbound.acp_command;
}
local.runtime = inbound.runtime;
local.model = inbound.model;
local.provider = inbound.provider;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ fn member(id: &str, display_name: &str) -> AgentDefinition {
display_name: display_name.to_string(),
avatar_url: None,
system_prompt: "Do the work.".to_string(),
acp_command: None,
runtime: None,
model: None,
provider: None,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ fn local_in_app() -> AgentDefinition {
display_name: "Local".to_string(),
avatar_url: None,
system_prompt: "local prompt".to_string(),
acp_command: None,
runtime: Some("goose".to_string()),
model: Some("opus".to_string()),
provider: Some("anthropic".to_string()),
Expand Down Expand Up @@ -42,6 +43,7 @@ fn inbound_for(d_tag: &str, display_name: &str) -> AgentDefinition {
display_name: display_name.to_string(),
avatar_url: Some("https://example.com/a.png".to_string()),
system_prompt: "remote prompt".to_string(),
acp_command: None,
runtime: Some("acp".to_string()),
model: Some("sonnet".to_string()),
provider: Some("openai".to_string()),
Expand All @@ -65,13 +67,16 @@ fn inbound_for(d_tag: &str, display_name: &str) -> AgentDefinition {
#[test]
fn in_app_persona_matches_existing_uuid_and_patches() {
let mut personas = vec![local_in_app()];
apply_inbound_persona(&mut personas, inbound_for(UUID, "Remote"));
let mut inbound = inbound_for(UUID, "Remote");
inbound.acp_command = Some("buzz-janet-acp".to_string());
apply_inbound_persona(&mut personas, inbound);

assert_eq!(personas.len(), 1, "no duplicate row");
let p = &personas[0];
// Projected fields patched.
assert_eq!(p.display_name, "Remote");
assert_eq!(p.system_prompt, "remote prompt");
assert_eq!(p.acp_command.as_deref(), Some("buzz-janet-acp"));
assert_eq!(p.provider, Some("openai".to_string()));
// Local identity + secrets + lineage preserved.
assert_eq!(p.id, UUID);
Expand Down Expand Up @@ -915,3 +920,56 @@ fn inbound_definition_less_agent_accepts_visible_multiline_prompt() {

assert!(validate_inbound_managed_agent_definition(&inbound).is_ok());
}

#[test]
fn shared_transport_redaction_preserves_local_override_but_explicit_stock_resets() {
use crate::managed_agents::persona_events::{build_persona_event, persona_from_event};
let keys = nostr::Keys::generate();
let mut local = local_in_app();
local.acp_command = Some("/opt/custom-acp".into());
let mut published = local.clone();
published.shared = true;
let event = build_persona_event(&published)
.unwrap()
.sign_with_keys(&keys)
.unwrap();
assert!(!event.content.contains("/opt/custom-acp"));
let mut personas = vec![local];
for _ in 0..2 {
apply_inbound_persona(&mut personas, persona_from_event(&event).unwrap());
assert_eq!(personas[0].acp_command.as_deref(), Some("/opt/custom-acp"));
}
let mut fresh_device = Vec::new();
apply_inbound_persona(&mut fresh_device, persona_from_event(&event).unwrap());
assert_eq!(fresh_device[0].acp_command, None);
// Redaction must not preserve a stale portable wrapper.
personas[0].acp_command = Some("buzz-old-acp".into());
apply_inbound_persona(&mut personas, persona_from_event(&event).unwrap());
assert_eq!(personas[0].acp_command, None);
// Stock is normalized to None by the store, but shared publication makes
// the reset explicit so it also replaces an owner's legacy local command.
personas[0].acp_command = Some("/opt/custom-acp".into());
published.acp_command = None;
let reset = build_persona_event(&published)
.unwrap()
.sign_with_keys(&keys)
.unwrap();
apply_inbound_persona(&mut personas, persona_from_event(&reset).unwrap());
assert_eq!(personas[0].acp_command.as_deref(), Some("buzz-acp"));
// Non-catalog owner-sync keeps both legacy custom values and clears.
published.shared = false;
published.acp_command = Some("/opt/other-acp".into());
let custom = build_persona_event(&published)
.unwrap()
.sign_with_keys(&keys)
.unwrap();
apply_inbound_persona(&mut personas, persona_from_event(&custom).unwrap());
assert_eq!(personas[0].acp_command.as_deref(), Some("/opt/other-acp"));
published.acp_command = None;
let clear = build_persona_event(&published)
.unwrap()
.sign_with_keys(&keys)
.unwrap();
apply_inbound_persona(&mut personas, persona_from_event(&clear).unwrap());
assert_eq!(personas[0].acp_command, None);
}
1 change: 1 addition & 0 deletions desktop/src-tauri/src/commands/personas/pending.rs
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,7 @@ mod tests {
display_name: "Catalog Reviewer".to_string(),
avatar_url: None,
system_prompt: "Review the catalog.".to_string(),
acp_command: None,
runtime: None,
model: None,
provider: None,
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/commands/personas/sharing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ mod tests {
display_name: "Catalog Reviewer".to_string(),
avatar_url: None,
system_prompt: "Review the catalog.".to_string(),
acp_command: None,
runtime: None,
model: None,
provider: None,
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/commands/personas/snapshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,7 @@ mod png_body_tests {
format: crate::managed_agents::agent_snapshot::FORMAT_DISCRIMINATOR.to_string(),
version: crate::managed_agents::agent_snapshot::FORMAT_VERSION,
definition: crate::managed_agents::agent_snapshot::AgentSnapshotDefinition {
acp_command: None,
name: "Agent".to_string(),
source_is_builtin: false,
system_prompt: None,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ fn make_snapshot(
format: FORMAT_DISCRIMINATOR.to_string(),
version: FORMAT_VERSION,
definition: AgentSnapshotDefinition {
acp_command: None,
name: "Test Agent".to_string(),
source_is_builtin: false,
system_prompt: Some("You are helpful.".to_string()),
Expand Down
11 changes: 8 additions & 3 deletions desktop/src-tauri/src/commands/personas/snapshot/import.rs
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,7 @@ pub async fn confirm_agent_snapshot_import(
.system_prompt
.clone()
.unwrap_or_default(),
acp_command: snapshot.definition.acp_command.clone(),
runtime: snapshot.definition.runtime.clone(),
model: snapshot.definition.model.clone(),
provider: snapshot.definition.provider.clone(),
Expand Down Expand Up @@ -605,9 +606,13 @@ pub async fn confirm_agent_snapshot_import(
auth_tag: auth_tag.clone(),
relay_url: String::new(), // resolves to workspace relay at runtime
avatar_url: effective_avatar.clone(),
// Machine-local commands: derive from the runtime catalog at
// spawn time — never manufacture from snapshot data.
acp_command: crate::managed_agents::DEFAULT_ACP_COMMAND.to_string(),
// Only the validated portable ACP alias crosses the snapshot boundary.
// Harness paths still resolve locally at spawn.
acp_command: snapshot
.definition
.acp_command
.clone()
.unwrap_or_else(|| crate::managed_agents::DEFAULT_ACP_COMMAND.to_string()),
agent_command: String::new(),
agent_command_override: None,
agent_args: vec![],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ fn make_snapshot(
format: FORMAT_DISCRIMINATOR.to_string(),
version: FORMAT_VERSION,
definition: AgentSnapshotDefinition {
acp_command: None,
name: "Test Agent".to_string(),
source_is_builtin: false,
system_prompt: Some("You are helpful.".to_string()),
Expand Down
2 changes: 2 additions & 0 deletions desktop/src-tauri/src/commands/personas/update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ pub(super) async fn update_persona_with<R: Send + 'static>(
let system_prompt = input.system_prompt.clone();
validate_agent_definition_text(&display_name, &system_prompt)?;
let avatar_url = trim_optional(input.avatar_url);
let acp_command = trim_optional(input.acp_command);
let runtime = trim_optional(input.runtime);
let model = trim_optional(input.model);
let provider = trim_optional(input.provider);
Expand All @@ -120,6 +121,7 @@ pub(super) async fn update_persona_with<R: Send + 'static>(
persona.display_name = display_name;
persona.avatar_url = avatar_url;
persona.system_prompt = system_prompt;
persona.acp_command = acp_command;
persona.runtime = runtime;
persona.model = model;
persona.provider = provider;
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/commands/team_snapshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ fn definition_from_snapshot(
display_name: member.profile.display_name.trim().to_string(),
avatar_url: effective_avatar(member),
system_prompt: member.definition.system_prompt.clone().unwrap_or_default(),
acp_command: member.definition.acp_command.clone(),
runtime: member.definition.runtime.clone(),
model: member.definition.model.clone(),
provider: member.definition.provider.clone(),
Expand Down
10 changes: 10 additions & 0 deletions desktop/src-tauri/src/commands/team_snapshot/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ fn member(name: &str) -> AgentSnapshot {
format: crate::managed_agents::agent_snapshot::FORMAT_DISCRIMINATOR.to_string(),
version: crate::managed_agents::agent_snapshot::FORMAT_VERSION,
definition: AgentSnapshotDefinition {
acp_command: None,
name: name.to_string(),
source_is_builtin: false,
system_prompt: Some(format!("{name} prompt")),
Expand Down Expand Up @@ -59,6 +60,7 @@ fn team_export_round_trip_preserves_team_and_excludes_member_memory() {
display_name: "Alice".to_string(),
avatar_url: None,
system_prompt: "Alice prompt".to_string(),
acp_command: Some("buzz-janet-acp".to_string()),
runtime: Some("goose".to_string()),
model: None,
provider: None,
Expand All @@ -82,6 +84,7 @@ fn team_export_round_trip_preserves_team_and_excludes_member_memory() {
display_name: "Bob".to_string(),
avatar_url: None,
system_prompt: "Bob prompt".to_string(),
acp_command: None,
runtime: Some("goose".to_string()),
model: None,
provider: None,
Expand Down Expand Up @@ -136,6 +139,12 @@ fn team_export_round_trip_preserves_team_and_excludes_member_memory() {
assert_eq!(decoded.team.description.as_deref(), Some("Reviews changes"));
assert_eq!(decoded.team.instructions.as_deref(), Some("Be thorough."));
assert_eq!(decoded.members.len(), 2);
let imported = build_import_definitions(&decoded, false, "now").unwrap();
assert_eq!(imported[0].acp_command.as_deref(), Some("buzz-janet-acp"));
assert_eq!(
imported[0].clone().into_agent_record().acp_command,
"buzz-janet-acp"
);
assert!(decoded.members.iter().all(|member| {
member.memory.level == MemoryLevel::None && member.memory.entries.is_empty()
}));
Expand All @@ -148,6 +157,7 @@ fn team_export_with_instance_and_memory_level_uses_supplied_entries() {
display_name: "Alice".to_string(),
avatar_url: None,
system_prompt: "Alice prompt".to_string(),
acp_command: None,
runtime: Some("goose".to_string()),
model: None,
provider: None,
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/commands/teams/adopt/apply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,7 @@ fn member_copy(
display_name: member.display_name.clone(),
avatar_url: member.avatar_url.clone(),
system_prompt: member.system_prompt.clone().unwrap_or_default(),
acp_command: member.acp_command.clone(),
runtime: member.runtime.clone(),
model: member.model.clone(),
provider: member.provider.clone(),
Expand Down
Loading
Loading