feat(desktop): write persona-snapshot engram at instantiation; gate legacy team sync

Part A: each managed agent writes a `mem/persona` engram (kind:30174) when
created, snapshotting the persona it was instantiated from. The engram is
provenance/audit only this release — live resolve stays the runtime prompt
source, so the write is best-effort and off the spawn critical path (never on
the restore std:🧵:scope path). Provenance lives inside the NIP-44
encrypted body, not 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. The write is verified end-to-end: assert the relay accepted flag, then
re-fetch via select_head and validate_and_decrypt, asserting the decrypted body
matches what was published. Reuses sprout_core::engram helpers rather than
hand-rolling the envelope.

Part C: the launch sequence no longer clobbers personas via sync_team_personas.
That path keyed persona->team correctly but overwrote local edits on every
boot; it is now behind the legacy_team_sync feature (default-on for rollback).
Launch instead runs an idempotent dedup that heals duplicate team-sourced
personas left by a prior broken sync, keyed on (source_team, slug) for records
where both are present — UI-created personas (both None) are never treated as
duplicates of one another. import_team_from_directory now also publishes each
persona as a kind:30175 event (best-effort) after persisting locally.

relay::submit_signed_event is added because the existing submit_event signs
with the owner keys and collapses accepted==false into an error; the engram
write needs to sign with the agent keypair and inspect the accept/reject flag.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
2026-06-10 11:45:04 -04:00
parent a2e9c8c077
commit 64598699c3
7 changed files with 513 additions and 25 deletions

View File

@@ -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 = [] }

View File

@@ -27,6 +27,41 @@ fn workspace_owner_hex(state: &AppState) -> Result<String, String> {
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 {

View File

@@ -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<String, String> {
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<bool>,
) -> Result<TeamRecord, String> {
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<SyncResult, String> {
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]

View File

@@ -543,8 +543,8 @@ pub fn run() {
.map_err(|e| -> Box<dyn std::error::Error> { 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`

View File

@@ -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<Vec<nostr::Event>,
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: <PersonaSnapshot JSON> }`,
/// 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<nostr::Event, String> {
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<String> = 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);
}
}

View File

@@ -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 `<data>/agents/teams/<resolved.id>/`,
/// 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<TeamRecord, String> {
@@ -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<ParsedTeamPreview, String> {
/// 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<PersonaRecord>) -> Vec<PersonaRecord> {
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"));
}
}

View File

@@ -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<SubmitEventResponse, String> {
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::<SubmitEventResponse>()
.await
.map_err(|e| format!("failed to parse response: {e}"))
}
// ── Tests ───────────────────────────────────────────────────────────────────
#[cfg(test)]