diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 5578875292..cf5bae3da2 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -17,8 +17,12 @@ name = "sprout_lib" crate-type = ["staticlib", "cdylib", "rlib"] [features] -default = [] +default = ["legacy_team_sync"] mesh-llm = ["dep:mesh-llm-sdk", "dep:mesh-llm-host-runtime"] +# Legacy launch-time directory→persona file sync. The engram/event path is +# replacing it; kept compilable (and on by default this release) so the old +# path can be re-enabled for rollback without a revert. +legacy_team_sync = [] [build-dependencies] tauri-build = { version = "2", features = [] } diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 9bbc7f6c7e..214192e32b 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -27,6 +27,41 @@ fn workspace_owner_hex(state: &AppState) -> Result { Ok(keys.public_key().to_hex()) } +/// Resolve `persona_id` to its record and write the agent's `mem/persona` +/// snapshot engram. Skips silently if the persona is no longer present (it may +/// have been deleted between key generation and this point). All relay I/O is +/// the caller's responsibility to treat as best-effort. +async fn write_persona_engram_for_agent( + app: &AppHandle, + state: &AppState, + agent_keys: &Keys, + owner_hex: &str, + persona_id: &str, +) -> Result<(), String> { + let record = { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let personas = load_personas(app)?; + match personas.into_iter().find(|p| p.id == persona_id) { + Some(record) => record, + None => return Ok(()), + } + }; + let owner_pubkey = + nostr::PublicKey::from_hex(owner_hex).map_err(|e| format!("invalid owner pubkey: {e}"))?; + let relay_url = relay_ws_url_with_override(state); + crate::managed_agents::persona_events::write_persona_engram( + agent_keys, + &owner_pubkey, + &record, + &relay_url, + state, + ) + .await +} + fn normalize_relay_mesh( config: Option<&RelayMeshConfig>, backend: &BackendKind, @@ -618,6 +653,23 @@ pub async fn create_managed_agent( .await) .err(); + // ── Phase 4b: persona-snapshot engram (async, best-effort) ─────────────── + // Write a `mem/persona` engram capturing the persona this agent was + // instantiated from. Snapshot/provenance only — never read back into the + // prompt path this release (live resolve stays the runtime source). This + // runs after `create_managed_agent` has done its synchronous work, on the + // command's own async path — never on the restore `std::thread::scope` + // path — so a slow or unreachable relay cannot stall agent spawn. Any + // failure is logged and ignored: NIP-33 replaceable semantics mean a + // rejected write never destroys prior state. + if let Some(persona_id) = requested_persona_id.as_deref() { + if let Err(e) = + write_persona_engram_for_agent(&app, &state, &agent_keys, &owner_hex, persona_id).await + { + eprintln!("sprout-desktop: persona engram write for {pubkey}: {e}"); + } + } + // ── Phase 5: provider deploy (async, outside lock) ─────────────────────── let spawn_error = if input.spawn_after_create && input.backend != BackendKind::Local { if let BackendKind::Provider { ref id, ref config } = input.backend { diff --git a/desktop/src-tauri/src/commands/teams.rs b/desktop/src-tauri/src/commands/teams.rs index ad8930f9fa..7a949bf9ad 100644 --- a/desktop/src-tauri/src/commands/teams.rs +++ b/desktop/src-tauri/src/commands/teams.rs @@ -7,12 +7,15 @@ use crate::{ managed_agents::{ delete_team_with_cascade, encode_team_json, ensure_persona_ids_are_active, import_team_from_directory as do_import_team, load_personas, load_teams, parse_team_json, - save_teams, sync_team_from_dir as do_sync_team, try_regenerate_nest, CreateTeamRequest, - ParsedTeamPreview, SyncResult, TeamRecord, UpdateTeamRequest, + save_teams, try_regenerate_nest, CreateTeamRequest, ParsedTeamPreview, SyncResult, + TeamRecord, UpdateTeamRequest, }, util::now_iso, }; +#[cfg(feature = "legacy_team_sync")] +use crate::managed_agents::sync_team_from_dir as do_sync_team; + fn trim_required(value: &str, label: &str) -> Result { let trimmed = value.trim(); if trimmed.is_empty() { @@ -115,21 +118,17 @@ pub fn delete_team(id: String, app: AppHandle, state: State<'_, AppState>) -> Re } #[tauri::command] -pub fn install_team_from_directory( +pub async fn install_team_from_directory( app: AppHandle, state: State<'_, AppState>, path: String, symlink: Option, ) -> Result { - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|e| e.to_string())?; let source = std::path::PathBuf::from(&path); if !source.is_dir() { return Err(format!("team path is not a directory: {path}")); } - let result = do_import_team(&app, &source, symlink.unwrap_or(false))?; + let result = do_import_team(&app, &state, &source, symlink.unwrap_or(false)).await?; try_regenerate_nest(&app); Ok(result) } @@ -140,13 +139,21 @@ pub fn sync_team_directory( state: State<'_, AppState>, team_id: String, ) -> Result { - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|e| e.to_string())?; - let result = do_sync_team(&app, &team_id)?; - try_regenerate_nest(&app); - Ok(result) + #[cfg(feature = "legacy_team_sync")] + { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let result = do_sync_team(&app, &team_id)?; + try_regenerate_nest(&app); + Ok(result) + } + #[cfg(not(feature = "legacy_team_sync"))] + { + let _ = (&app, &state, &team_id); + Err("team sync moved to directory re-import; reinstall the team instead".to_string()) + } } #[tauri::command] diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 8749f55458..386e6827bb 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -543,8 +543,8 @@ pub fn run() { .map_err(|e| -> Box { e.to_string().into() })?; migration::migrate_personas_to_events(&app_handle, &owner_keys); - if let Err(e) = managed_agents::sync_team_personas(&app_handle) { - eprintln!("sprout-desktop: sync-team-personas: {e}"); + if let Err(e) = managed_agents::dedup_team_personas(&app_handle) { + eprintln!("sprout-desktop: dedup-team-personas: {e}"); } // Store the AppHandle so huddle commands can emit `huddle-state-changed` diff --git a/desktop/src-tauri/src/managed_agents/persona_events.rs b/desktop/src-tauri/src/managed_agents/persona_events.rs index 1f9f69eb1e..6ef4b36f42 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events.rs @@ -5,13 +5,19 @@ use std::collections::BTreeMap; -use nostr::{EventBuilder, Kind, Tag}; +use nostr::{EventBuilder, Keys, Kind, PublicKey, Tag}; use serde::{Deserialize, Serialize}; -use sprout_core::kind::KIND_PERSONA; +use sprout_core::engram::{self, Body}; +use sprout_core::kind::{KIND_AGENT_ENGRAM, KIND_PERSONA}; use super::PersonaRecord; use crate::app_state::AppState; +/// Engram slug under which a managed agent stores the snapshot of the persona +/// it was instantiated from. A `mem/` slug so it passes `engram::validate_slug` +/// and the relay's envelope check. +pub const PERSONA_ENGRAM_SLUG: &str = "mem/persona"; + /// The JSON body stored in a persona event's content field. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PersonaEventContent { @@ -132,6 +138,166 @@ pub async fn fetch_persona_events(state: &AppState) -> Result, crate::relay::query_relay(state, &[filter]).await } +// ── Persona-snapshot engram ────────────────────────────────────────────────── + +/// Provenance recorded inside the encrypted engram body, linking the snapshot +/// back to the persona definition it was taken from. Kept inside the NIP-44 +/// ciphertext (never a plaintext tag) so the agent→persona linkage is not +/// leaked to relay observers and the HMAC-blinded `d` tag stays the only +/// correlation surface. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PersonaProvenance { + /// Owner (workspace) pubkey, hex. + pub owner: String, + /// Source persona event kind (`KIND_PERSONA`, 30175). + pub kind: u32, + /// Source persona slug (the kind:30175 `d` tag). + pub slug: String, + /// Content version of the source persona — its `updated_at`. Changes + /// whenever the persona definition changes, so a future fleet-update pass + /// can diff snapshots without a timestamp comparison. + pub version: String, +} + +/// The decrypted payload of a `mem/persona` engram: the persona definition +/// plus its provenance. Serialized to JSON and carried as the engram body's +/// `value` string. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PersonaSnapshot { + #[serde(flatten)] + pub content: PersonaEventContent, + pub provenance: PersonaProvenance, +} + +impl PartialEq for PersonaEventContent { + fn eq(&self, other: &Self) -> bool { + self.display_name == other.display_name + && self.avatar_url == other.avatar_url + && self.system_prompt == other.system_prompt + && self.runtime == other.runtime + && self.model == other.model + && self.provider == other.provider + && self.name_pool == other.name_pool + && self.env_vars == other.env_vars + } +} +impl Eq for PersonaEventContent {} + +/// Build the `PersonaSnapshot` for a persona record and owner. +fn persona_snapshot(record: &PersonaRecord, owner_pubkey: &PublicKey) -> PersonaSnapshot { + PersonaSnapshot { + content: PersonaEventContent { + display_name: record.display_name.clone(), + avatar_url: record.avatar_url.clone(), + system_prompt: record.system_prompt.clone(), + runtime: record.runtime.clone(), + model: record.model.clone(), + provider: record.provider.clone(), + name_pool: record.name_pool.clone(), + env_vars: record.env_vars.clone(), + }, + provenance: PersonaProvenance { + owner: owner_pubkey.to_hex(), + kind: KIND_PERSONA, + slug: persona_d_tag(record), + version: record.updated_at.clone(), + }, + } +} + +/// Build a signed `mem/persona` engram (kind:30174) snapshotting `record`. +/// +/// The agent signs the event; the owner is the NIP-44 counterparty. The body +/// is `Body::Memory { slug: "mem/persona", value: }`, +/// so it satisfies `validate_and_decrypt` (the body slug re-derives to the +/// HMAC-blinded `d` tag). +pub fn build_persona_engram( + agent_keys: &Keys, + owner_pubkey: &PublicKey, + record: &PersonaRecord, + created_at: u64, +) -> Result { + let snapshot = persona_snapshot(record, owner_pubkey); + let value = serde_json::to_string(&snapshot) + .map_err(|e| format!("failed to serialize persona snapshot: {e}"))?; + let body = Body::Memory { + slug: PERSONA_ENGRAM_SLUG.to_string(), + value: Some(value), + }; + engram::build_event(agent_keys, owner_pubkey, &body, created_at) + .map_err(|e| format!("failed to build persona engram: {e}")) +} + +/// Write the persona-snapshot engram at instantiation, best-effort. +/// +/// Sequence per the canonical spec: publish → assert the relay `accepted` +/// flag → re-fetch and pick the head with `select_head` (latest-wins, not +/// `events[0]`) → `validate_and_decrypt` → assert the decrypted body equals +/// what we published. Any failure is returned as `Err`; the caller logs and +/// continues — a bad write never blocks agent creation, and NIP-33 replaceable +/// semantics mean a rejected write leaves prior state intact. +pub async fn write_persona_engram( + agent_keys: &Keys, + owner_pubkey: &PublicKey, + record: &PersonaRecord, + relay_url: &str, + state: &AppState, +) -> Result<(), String> { + let created_at = engram::monotonic_created_at(nostr::Timestamp::now().as_secs(), None); + let event = build_persona_engram(agent_keys, owner_pubkey, record, created_at)?; + + let response = crate::relay::submit_signed_event(&event, agent_keys, relay_url, state).await?; + if !response.accepted { + return Err(format!( + "relay rejected persona engram: {}", + response.message + )); + } + + // Re-fetch by the HMAC-blinded d tag and verify the round-trip. We + // re-derive the d tag from (agent_sk, owner_pk, slug) rather than trust + // the event we built, so the read path is genuinely end-to-end. + let k_c = engram::conversation_key(agent_keys.secret_key(), owner_pubkey); + let d = engram::d_tag(&k_c, PERSONA_ENGRAM_SLUG); + let filter = serde_json::json!({ + "kinds": [KIND_AGENT_ENGRAM], + "authors": [agent_keys.public_key().to_hex()], + "#d": [d], + }); + let events = crate::relay::query_relay_at( + state, + &crate::relay::relay_http_base_url(relay_url), + &[filter], + ) + .await?; + + let head = engram::select_head(events) + .ok_or_else(|| "persona engram not found on re-fetch".to_string())?; + let body = engram::validate_and_decrypt( + &head, + &agent_keys.public_key(), + owner_pubkey, + agent_keys.secret_key(), + owner_pubkey, + ) + .map_err(|e| format!("persona engram failed validation on re-fetch: {e}"))?; + + let Body::Memory { + value: Some(read_value), + .. + } = body + else { + return Err("persona engram re-fetch returned an unexpected body shape".to_string()); + }; + let read_snapshot: PersonaSnapshot = serde_json::from_str(&read_value) + .map_err(|e| format!("persona engram re-fetch body is not a PersonaSnapshot: {e}"))?; + + if read_snapshot != persona_snapshot(record, owner_pubkey) { + return Err("persona engram re-fetch body does not match what was published".to_string()); + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -243,4 +409,105 @@ mod tests { assert!(!restored.is_builtin); assert!(restored.is_active); } + + // ── Persona-snapshot engram ────────────────────────────────────────────── + + /// The engram built at instantiation must survive the full validation the + /// relay enforces: kind, agent author, single 64-hex `d`, single owner `p`, + /// NIP-44 decrypt, and body-slug re-derivation — and decrypt back to the + /// exact persona plus provenance we put in. + #[test] + fn persona_engram_round_trips_through_validate_and_decrypt() { + let record = sample_persona(); + let agent = nostr::Keys::generate(); + let owner = nostr::Keys::generate(); + let owner_pk = owner.public_key(); + + let event = build_persona_engram(&agent, &owner_pk, &record, 1_700_000_000).unwrap(); + + // Relay-side envelope: kind 30174, agent author, one 64-hex d, one p == owner. + assert_eq!(event.kind.as_u16() as u32, KIND_AGENT_ENGRAM); + assert_eq!(event.pubkey, agent.public_key()); + let d_tags: Vec<_> = event + .tags + .iter() + .filter(|t| t.kind().to_string() == "d") + .collect(); + assert_eq!(d_tags.len(), 1); + assert_eq!(d_tags[0].content().unwrap().len(), 64); + assert!(d_tags[0] + .content() + .unwrap() + .bytes() + .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())); + + // Owner decrypts with its own seckey against the agent pubkey (K_c is symmetric). + let body = engram::validate_and_decrypt( + &event, + &agent.public_key(), + &owner_pk, + owner.secret_key(), + &agent.public_key(), + ) + .unwrap(); + + let Body::Memory { + slug, + value: Some(value), + } = body + else { + panic!("expected memory body with value"); + }; + assert_eq!(slug, PERSONA_ENGRAM_SLUG); + + let snapshot: PersonaSnapshot = serde_json::from_str(&value).unwrap(); + assert_eq!(snapshot, persona_snapshot(&record, &owner_pk)); + assert_eq!(snapshot.content.system_prompt, "You are a test assistant."); + assert_eq!(snapshot.provenance.owner, owner_pk.to_hex()); + assert_eq!(snapshot.provenance.kind, KIND_PERSONA); + assert_eq!(snapshot.provenance.slug, "test-slug"); + assert_eq!(snapshot.provenance.version, record.updated_at); + } + + /// Provenance lives inside the ciphertext, never as a plaintext tag — the + /// only tags are the HMAC-blinded `d` and the owner `p`. + #[test] + fn persona_engram_leaks_no_plaintext_provenance() { + let record = sample_persona(); + let agent = nostr::Keys::generate(); + let owner = nostr::Keys::generate(); + + let event = + build_persona_engram(&agent, &owner.public_key(), &record, 1_700_000_000).unwrap(); + + let tag_names: Vec = event.tags.iter().map(|t| t.kind().to_string()).collect(); + assert_eq!(tag_names, vec!["d".to_string(), "p".to_string()]); + // No persona id, slug, kind, or "provenance" string in any plaintext field. + let plaintext_tags = format!("{:?}", event.tags); + assert!(!plaintext_tags.contains("test-slug")); + assert!(!plaintext_tags.contains("provenance")); + assert!(!plaintext_tags.contains(&KIND_PERSONA.to_string())); + } + + /// The d tag must re-derive from (agent_sk, owner_pk, slug), so a fleet + /// update can locate the engram without any stored tag. + #[test] + fn persona_engram_d_tag_is_derivable() { + let record = sample_persona(); + let agent = nostr::Keys::generate(); + let owner = nostr::Keys::generate(); + + let event = + build_persona_engram(&agent, &owner.public_key(), &record, 1_700_000_000).unwrap(); + + let k_c = engram::conversation_key(agent.secret_key(), &owner.public_key()); + let expected = engram::d_tag(&k_c, PERSONA_ENGRAM_SLUG); + let actual = event + .tags + .iter() + .find(|t| t.kind().to_string() == "d") + .and_then(|t| t.content()) + .unwrap(); + assert_eq!(actual, expected); + } } diff --git a/desktop/src-tauri/src/managed_agents/teams.rs b/desktop/src-tauri/src/managed_agents/teams.rs index 0d9bcd6e99..6d5834be98 100644 --- a/desktop/src-tauri/src/managed_agents/teams.rs +++ b/desktop/src-tauri/src/managed_agents/teams.rs @@ -3,7 +3,8 @@ use std::{fs, path::PathBuf}; use tauri::AppHandle; use crate::{ - managed_agents::{managed_agents_base_dir, PersonaRecord, TeamRecord}, + app_state::AppState, + managed_agents::{managed_agents_base_dir, persona_events, PersonaRecord, TeamRecord}, util::now_iso, }; @@ -225,8 +226,9 @@ fn copy_dir_no_symlinks(src: &std::path::Path, dst: &std::path::Path) -> Result< /// /// Copies the directory into `/agents/teams//`, /// creates PersonaRecords for each persona, creates a TeamRecord with source_dir set. -pub fn import_team_from_directory( +pub async fn import_team_from_directory( app: &AppHandle, + state: &AppState, source_dir: &std::path::Path, symlink: bool, ) -> Result { @@ -314,9 +316,23 @@ pub fn import_team_from_directory( // 7. Save personas let mut personas = super::load_personas(app)?; - personas.extend(new_personas); + personas.extend(new_personas.iter().cloned()); super::save_personas(app, &personas)?; + // 7b. Publish each persona as a kind:30175 event (best-effort). The local + // store is authoritative this release; the relay copy is the durable, + // shareable definition. A publish failure (relay unreachable, oversize + // body) must not fail the import — the personas are already persisted + // locally and a later edit-save re-publishes. + for record in &new_personas { + if let Err(e) = persona_events::publish_persona_event(record, state).await { + eprintln!( + "sprout-desktop: persona event publish for {}: {e}", + record.id + ); + } + } + // 8. Create and save TeamRecord let symlink_target = if use_symlink { fs::canonicalize(source_dir) @@ -415,6 +431,7 @@ pub fn delete_team_with_cascade(app: &AppHandle, team_id: &str) -> Result<(), St } /// Re-reads a directory-backed team and reconciles with stored records. +#[cfg(feature = "legacy_team_sync")] pub fn sync_team_from_dir( app: &AppHandle, team_id: &str, @@ -715,6 +732,8 @@ pub fn parse_team_json(json_bytes: &[u8]) -> Result { /// Sync all directory-backed teams on launch — the team equivalent of the /// former `sync_pack_personas`. Silently skips teams whose source directory /// is missing (e.g., external drive unmounted). +#[cfg(feature = "legacy_team_sync")] +#[allow(dead_code)] // Kept compilable for rollback; no caller after the launch swap to dedup_team_personas. pub fn sync_team_personas(app: &AppHandle) -> Result<(), String> { let teams = load_teams(app)?; for team in &teams { @@ -727,11 +746,49 @@ pub fn sync_team_personas(app: &AppHandle) -> Result<(), String> { Ok(()) } +/// Heal duplicate team-sourced personas left by a prior broken sync. +/// +/// Runs on launch in place of the old `sync_team_personas` clobber. Only +/// team-sourced records (both `source_team` and `source_team_persona_slug` +/// present) are deduplicated, keyed by `(source_team, source_team_persona_slug)` +/// — the canonical persona→team join. UI-created personas, which carry neither +/// field, are never considered duplicates of one another and are left +/// untouched. On a collision the most recently updated record is kept +/// (`updated_at` descending; lexical `id` ascending as a deterministic +/// tiebreak so the outcome does not depend on load order). +pub fn dedup_team_personas(app: &AppHandle) -> Result<(), String> { + let personas = super::load_personas(app)?; + let deduped = dedup_team_sourced(personas); + super::save_personas(app, &deduped) +} + +/// Pure dedup over a persona list. Extracted so the launch path and tests share +/// one implementation. +fn dedup_team_sourced(mut personas: Vec) -> Vec { + use std::collections::HashMap; + + // Most-recent-first, then lexical id, so the first record seen per key is + // the survivor. `updated_at` is canonical UTC ISO-8601, lexically ordered. + personas.sort_by(|a, b| { + b.updated_at + .cmp(&a.updated_at) + .then_with(|| a.id.cmp(&b.id)) + }); + + let mut seen: HashMap<(String, String), ()> = HashMap::new(); + personas.retain(|p| match (&p.source_team, &p.source_team_persona_slug) { + (Some(team), Some(slug)) => seen.insert((team.clone(), slug.clone()), ()).is_none(), + // UI-created (or otherwise un-keyed) personas are never duplicates. + _ => true, + }); + personas +} + #[cfg(test)] mod tests { use super::{ - encode_team_json, merge_teams, parse_team_json, sort_teams, validate_team_deletion, - BUILT_IN_TEAMS, + dedup_team_sourced, encode_team_json, merge_teams, parse_team_json, sort_teams, + validate_team_deletion, BUILT_IN_TEAMS, }; use crate::managed_agents::{PersonaRecord, TeamRecord}; @@ -996,4 +1053,67 @@ mod tests { let custom = team("user-uuid", "My Team"); assert!(validate_team_deletion(&custom).is_ok()); } + + // ----------------------------------------------------------------------- + // dedup_team_sourced tests + // ----------------------------------------------------------------------- + + /// A team-sourced persona, identified by `(source_team, slug)`. + fn team_persona(id: &str, team: &str, slug: &str, updated_at: &str) -> PersonaRecord { + let mut p = persona(id, id, "prompt"); + p.source_team = Some(team.to_string()); + p.source_team_persona_slug = Some(slug.to_string()); + p.updated_at = updated_at.to_string(); + p + } + + #[test] + fn dedup_collapses_team_duplicates_keeping_latest() { + let older = team_persona("a", "team-1", "agent", "2026-01-01T00:00:00Z"); + let newer = team_persona("b", "team-1", "agent", "2026-06-01T00:00:00Z"); + + let result = dedup_team_sourced(vec![older, newer]); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].id, "b"); // latest updated_at survives + } + + #[test] + fn dedup_breaks_updated_at_ties_by_lowest_id() { + let same_time = "2026-06-01T00:00:00Z"; + let p_b = team_persona("b", "team-1", "agent", same_time); + let p_a = team_persona("a", "team-1", "agent", same_time); + + let result = dedup_team_sourced(vec![p_b, p_a]); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].id, "a"); // deterministic: lowest id wins on tie + } + + #[test] + fn dedup_keeps_distinct_team_personas() { + let one = team_persona("a", "team-1", "agent-one", "2026-01-01T00:00:00Z"); + let two = team_persona("b", "team-1", "agent-two", "2026-01-01T00:00:00Z"); + + let result = dedup_team_sourced(vec![one, two]); + + assert_eq!(result.len(), 2); // different slugs are not duplicates + } + + /// UI-created personas carry `(None, None)` source fields and must never be + /// collapsed into one another — an ungated `(source_team, slug)` dedup would + /// bucket them all together and delete all but one. + #[test] + fn dedup_never_collapses_ui_created_personas() { + let one = persona("a", "Assistant One", "prompt one"); + let two = persona("b", "Assistant Two", "prompt two"); + assert!(one.source_team.is_none() && one.source_team_persona_slug.is_none()); + + let result = dedup_team_sourced(vec![one, two]); + + assert_eq!(result.len(), 2); + let ids: Vec<&str> = result.iter().map(|p| p.id.as_str()).collect(); + assert!(ids.contains(&"a")); + assert!(ids.contains(&"b")); + } } diff --git a/desktop/src-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs index 112978311d..986a1a78f6 100644 --- a/desktop/src-tauri/src/relay.rs +++ b/desktop/src-tauri/src/relay.rs @@ -398,6 +398,44 @@ pub async fn submit_event( Ok(result) } +/// POST an already-signed event to `/events`, authenticating the HTTP request +/// with a NIP-98 header signed by `signer_keys` (the event's author). +/// +/// Unlike [`submit_event`], this returns the parsed [`SubmitEventResponse`] +/// without treating `accepted == false` as an error — callers that need to act +/// on the relay's accept/reject decision (e.g. the best-effort engram write, +/// which verifies acceptance before a round-trip read) inspect the flag +/// themselves rather than collapsing it into a transport error. +pub async fn submit_signed_event( + event: &nostr::Event, + signer_keys: &Keys, + relay_url: &str, + state: &AppState, +) -> Result { + let url = format!("{}/events", relay_http_base_url(relay_url)); + let body_bytes = event.as_json().into_bytes(); + let auth = build_nip98_auth_header_for_keys(signer_keys, &Method::POST, &url, &body_bytes)?; + + let response = state + .http_client + .post(&url) + .header("Authorization", auth) + .header("Content-Type", "application/json") + .body(body_bytes) + .send() + .await + .map_err(|e| format!("request failed: {e}"))?; + + if !response.status().is_success() { + return Err(relay_error_message(response).await); + } + + response + .json::() + .await + .map_err(|e| format!("failed to parse response: {e}")) +} + // ── Tests ─────────────────────────────────────────────────────────────────── #[cfg(test)]