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
25 changes: 25 additions & 0 deletions crates/buzz-relay/src/handlers/ingest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3179,6 +3179,31 @@ mod tests {
assert!(validate_persona_envelope(&ev).is_ok());
}

#[test]
fn persona_envelope_accepts_promptless_content() {
// Unified agent model: system_prompt is optional — a definition can be
// pure configuration. The relay validates only the envelope, so a
// prompt-less body must ingest identically to a full one.
let ev = make_event_with_tags(
KIND_PERSONA,
r#"{"display_name":"config-only"}"#,
&[&["d", "config-only"]],
);
assert!(validate_persona_envelope(&ev).is_ok());
}

#[test]
fn persona_envelope_accepts_behavioral_fields() {
// Widened content (respond_to / mcp_toolsets / parallelism) is opaque
// to the relay — unknown-field tolerance is the contract.
let ev = make_event_with_tags(
KIND_PERSONA,
r#"{"display_name":"x","respond_to":"owner-only","respond_to_allowlist":[],"mcp_toolsets":"default","parallelism":2}"#,
&[&["d", "behavioral"]],
);
assert!(validate_persona_envelope(&ev).is_ok());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test gap: relay conformance tests don't exercise the real ingest path.

persona_envelope_accepts_promptless_content and persona_envelope_accepts_behavioral_fields call only validate_persona_envelope. The production path calls that helper from ingest_event_inner and then stores via the parameterized-replaceable branch, but these tests skip auth/scope/global-storage/round-trip — and the e2e addition covers legacy fat kind:30177, not 30175. A future content parse or gate introduced in the real ingest path would pass these helper tests silently.

For a relay conformance claim, the boundary should be the actual ingest or a round-trip: add an ingest-level unit test (or ignored e2e) that publishes a prompt-less kind:30175 and a behavioral-field kind:30175, asserts OK accepted, then queries the coordinate and compares content byte-for-byte.

}

#[test]
fn persona_envelope_accepts_single_char() {
let ev = make_persona(&[&["d", "a"]]);
Expand Down
45 changes: 43 additions & 2 deletions crates/buzz-test-client/tests/e2e_managed_agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,29 @@ fn sub_id(name: &str) -> String {
/// `desktop/src-tauri/src/managed_agents/agent_events.rs`). Built inline here
/// because the e2e crate does not depend on the desktop crate; the
/// projection-function exclusion contract is unit-tested in that module. This
/// is exactly the field set the desktop publishes — secrets, the backend blob,
/// env vars, and runtime fields are absent by construction.
/// is exactly the field set the desktop publishes for a definition-less
/// record (a definition-linked one omits the definition quad — the slimmed
/// NIP-AP shape; the relay accepts both, as the legacy-fat test below proves) —
/// secrets, the backend blob, env vars, and runtime fields are absent by
/// construction.
fn agent_projection_content(name: &str) -> String {
serde_json::json!({
"name": name,
"system_prompt": "You are a test agent.",
"model": "claude-opus-4",
"provider": "anthropic",
"mcp_toolsets": "default",
"parallelism": 24,
"respond_to": "allowlist",
"respond_to_allowlist": ["79be667e"]
})
.to_string()
}

/// A legacy "fat" definition-linked projection (pre-slimming shape): carries
/// both `persona_id` and the definition quad. Old clients still publish this;
/// the relay must keep accepting it.
fn legacy_fat_agent_projection_content(name: &str) -> String {
serde_json::json!({
"name": name,
"persona_id": "persona-1",
Expand Down Expand Up @@ -134,6 +154,27 @@ async fn test_managed_agent_publish_and_query() {
client.disconnect().await.expect("disconnect");
}

/// The relay keeps accepting the legacy "fat" definition-linked projection
/// (pre-slimming shape) — old clients publish it during the transition.
#[tokio::test]
#[ignore]
async fn test_legacy_fat_agent_event_still_accepted() {
let url = relay_url();
let keys = Keys::generate();
let d_tag = agent_d_tag();
let content = legacy_fat_agent_projection_content("Legacy Agent");

let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
let event = agent_event(&keys, &d_tag, &content);
let ok = client.send_event(event).await.expect("send legacy agent");
assert!(
ok.accepted,
"relay rejected legacy fat managed-agent event: {}",
ok.message
);
client.disconnect().await.expect("disconnect");
}

/// The relay round-trips published content byte-for-byte: a projection-shaped
/// body goes out, and the relay returns exactly those fields and nothing more.
/// The body here is a hand-built secret-free literal (`agent_projection_content`),
Expand Down
83 changes: 83 additions & 0 deletions crates/buzz-test-client/tests/e2e_persona.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,89 @@ async fn test_persona_publish_and_query() {
client.disconnect().await.expect("disconnect");
}

/// NIP-AP revision: a prompt-less definition (display_name only) must ingest
/// through the REAL relay path and round-trip byte-for-byte — not just pass
/// the envelope validator helper.
#[tokio::test]
#[ignore]
async fn test_promptless_persona_ingests_and_round_trips() {
let url = relay_url();
let keys = Keys::generate();
let d_tag = format!("promptless-{}", &uuid::Uuid::new_v4().to_string()[..8]);
let content = serde_json::json!({ "display_name": "Config Only" }).to_string();

let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
let event = persona_event(&keys, &d_tag, &content);
let ok = client.send_event(event).await.expect("send promptless");
assert!(
ok.accepted,
"relay rejected prompt-less persona: {}",
ok.message
);

let sid = sub_id("promptless");
let filter = Filter::new()
.kind(Kind::Custom(PERSONA_KIND))
.author(keys.public_key())
.custom_tags(SingleLetterTag::lowercase(Alphabet::D), [d_tag.as_str()]);
client
.subscribe(&sid, vec![filter])
.await
.expect("subscribe");
let events = client
.collect_until_eose(&sid, Duration::from_secs(5))
.await
.expect("collect");
assert_eq!(events.len(), 1);
assert_eq!(events[0].content, content, "byte-for-byte round-trip");
client.disconnect().await.expect("disconnect");
}

/// NIP-AP revision: the reserved behavioral fields ingest through the real
/// relay path as opaque content and round-trip byte-for-byte.
#[tokio::test]
#[ignore]
async fn test_behavioral_fields_persona_ingests_and_round_trips() {
let url = relay_url();
let keys = Keys::generate();
let d_tag = format!("behavioral-{}", &uuid::Uuid::new_v4().to_string()[..8]);
let content = serde_json::json!({
"display_name": "Behavioral",
"system_prompt": "p",
"respond_to": "owner-only",
"respond_to_allowlist": [],
"mcp_toolsets": "default",
"parallelism": 2
})
.to_string();

let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
let event = persona_event(&keys, &d_tag, &content);
let ok = client.send_event(event).await.expect("send behavioral");
assert!(
ok.accepted,
"relay rejected behavioral-fields persona: {}",
ok.message
);

let sid = sub_id("behavioral");
let filter = Filter::new()
.kind(Kind::Custom(PERSONA_KIND))
.author(keys.public_key())
.custom_tags(SingleLetterTag::lowercase(Alphabet::D), [d_tag.as_str()]);
client
.subscribe(&sid, vec![filter])
.await
.expect("subscribe");
let events = client
.collect_until_eose(&sid, Duration::from_secs(5))
.await
.expect("collect");
assert_eq!(events.len(), 1);
assert_eq!(events[0].content, content, "byte-for-byte round-trip");
client.disconnect().await.expect("disconnect");
}

#[tokio::test]
#[ignore]
async fn test_persona_nip33_replacement_newer_wins() {
Expand Down
55 changes: 51 additions & 4 deletions desktop/src-tauri/src/commands/personas/inbound_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -268,14 +268,61 @@ fn inbound_managed_agent_drops_injected_secrets_and_harness() {
] {
assert!(!json.contains(needle), "injected value leaked: {needle}");
}
// Projected fields ARE updated from the inbound event.
// Instance-level projected fields ARE updated from the inbound event.
assert_eq!(a.name, "Remote Agent");
assert_eq!(a.system_prompt, Some("remote prompt".to_string()));
assert_eq!(a.model, Some("remote-model".to_string()));
assert_eq!(a.provider, Some("remote-provider".to_string()));
assert_eq!(a.parallelism, 99);
assert_eq!(a.respond_to, crate::managed_agents::RespondTo::Anyone);
assert_eq!(a.respond_to_allowlist, vec!["deadbeef".to_string()]);
// Definition-linked inbound (persona_id present): the definition quad is
// NOT applied — those fields resolve through the kind:30175 definition,
// and absent-on-the-wire must never clear a local snapshot.
assert_eq!(
a.system_prompt,
Some("local prompt".to_string()),
"linked inbound must not touch the local prompt snapshot"
);
}

/// Definition-less inbound (persona_id absent) still applies the definition
/// quad unconditionally — the record is its own definition and the wire is
/// its sync channel.
#[test]
fn inbound_definition_less_agent_applies_quad() {
use nostr::{EventBuilder, Keys, Kind, Tag};
// Same wire shape as the hostile fixture, minus persona_id — a
// definition-less instance syncing its own definition fields.
let content = serde_json::json!({
"name": "Remote Agent",
"system_prompt": "remote prompt",
"model": "remote-model",
"provider": "remote-provider",
"mcp_toolsets": "remote",
"persona_source_version": "remote-version",
"parallelism": 99,
"respond_to": "anyone",
"respond_to_allowlist": ["deadbeef"],
});
let keys = Keys::generate();
let event = EventBuilder::new(Kind::Custom(30177), content.to_string())
.tags(vec![Tag::parse(["d", AGENT_PUBKEY]).unwrap()])
.sign_with_keys(&keys)
.unwrap();

let content =
crate::managed_agents::agent_events::managed_agent_content_from_event(&event).unwrap();
let mut agents = vec![local_agent()];
apply_inbound_managed_agent(&mut agents, AGENT_PUBKEY, content);

let a = &agents[0];
assert_eq!(a.persona_id, None);
assert_eq!(a.system_prompt, Some("remote prompt".to_string()));
assert_eq!(a.model, Some("remote-model".to_string()));
assert_eq!(a.provider, Some("remote-provider".to_string()));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test gap: definition-less apply omits persona_source_version.

The fixture has no persona_source_version field and the assertions don't verify it, yet local_agent() seeds persona_source_version: Some("local-hash"). A failure to apply this 4th quad field on a definition-less sync would silently leave the stale local value — wrong for a record that is its own definition.

Fix: add "persona_source_version": "remote-version" to the fixture JSON and assert a.persona_source_version == Some("remote-version".to_string()) after apply.

assert_eq!(
a.persona_source_version,
Some("remote-version".to_string()),
"all four quad fields must apply on a definition-less sync"
);
}

#[test]
Expand Down
16 changes: 12 additions & 4 deletions desktop/src-tauri/src/commands/personas/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -749,12 +749,20 @@ fn apply_inbound_managed_agent(
) {
if let Some(local) = agents.iter_mut().find(|record| record.pubkey == d_tag) {
local.name = inbound.name;
// Mirror of the slimmed writer (agent_event_content): a
// definition-linked event omits the definition quad because those
// fields resolve through the kind:30175 definition — absent means
// "not carried", never "clear". Definition-less events still carry
// the quad and apply it unconditionally (including clears).
let definition_linked = inbound.persona_id.is_some();
local.persona_id = inbound.persona_id;
local.system_prompt = inbound.system_prompt;
local.model = inbound.model;
local.provider = inbound.provider;
if !definition_linked {
local.system_prompt = inbound.system_prompt;
local.model = inbound.model;
local.provider = inbound.provider;
local.persona_source_version = inbound.persona_source_version;
}
local.mcp_toolsets = inbound.mcp_toolsets;
local.persona_source_version = inbound.persona_source_version;
local.parallelism = inbound.parallelism;
local.respond_to = inbound.respond_to;
local.respond_to_allowlist = inbound.respond_to_allowlist;
Expand Down
90 changes: 84 additions & 6 deletions desktop/src-tauri/src/managed_agents/agent_events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,14 +70,39 @@ pub struct ManagedAgentEventContent {
/// operational start/stop produces an identical projection and never
/// republishes.
pub fn agent_event_content(record: &ManagedAgentRecord) -> ManagedAgentEventContent {
// Slimmed projection (NIP-AP "Slimming: kind:30177"): definition-linked
// instances resolve prompt/model/provider/source_version through their
// kind:30175 definition, so those fields are omitted from the wire.
// Definition-less instances ARE their own definition and keep emitting
// the quad — old readers parse a slimmed event successfully and would
// otherwise overwrite their local snapshot with absent values, with no
// restore path. This branch retires once every record is
// definition-backed (B5 backfill).
let definition_linked = record.persona_id.is_some();
ManagedAgentEventContent {
name: record.name.clone(),
persona_id: record.persona_id.clone(),
system_prompt: record.system_prompt.clone(),
model: record.model.clone(),
provider: record.provider.clone(),
system_prompt: if definition_linked {
None
} else {
record.system_prompt.clone()
},
model: if definition_linked {
None
} else {
record.model.clone()
},
provider: if definition_linked {
None
} else {
record.provider.clone()
},
mcp_toolsets: record.mcp_toolsets.clone(),
persona_source_version: record.persona_source_version.clone(),
persona_source_version: if definition_linked {
None
} else {
record.persona_source_version.clone()
},
parallelism: record.parallelism,
respond_to: record.respond_to,
respond_to_allowlist: record.respond_to_allowlist.clone(),
Expand Down Expand Up @@ -266,7 +291,41 @@ mod tests {
assert!(json.contains("\"name\""));
assert!(json.contains("Test Agent"));
assert!(json.contains("persona_id"));
assert!(json.contains("system_prompt"));
// Slimmed projection: a definition-linked record resolves its prompt
// through the definition, so the wire must NOT carry it.
assert!(
!json.contains("system_prompt"),
"definition-linked projection must omit system_prompt"
);
}

/// Slimming (NIP-AP): definition-linked records omit the definition quad;
/// definition-less records keep emitting it (they ARE their own
/// definition — old readers would otherwise wipe fields with no restore
/// path).
#[test]
fn projection_slims_definition_quad_only_when_linked() {
let linked = sample_agent(); // persona_id: Some
let json = serde_json::to_string(&agent_event_content(&linked)).unwrap();
assert!(!json.contains("system_prompt"));
assert!(!json.contains("\"model\""));
assert!(!json.contains("\"provider\""));
assert!(!json.contains("persona_source_version"));
// Instance fields stay on the wire.
assert!(json.contains("parallelism"));
assert!(json.contains("respond_to"));
assert!(json.contains("mcp_toolsets"));

let mut standalone = sample_agent();
standalone.persona_id = None;
let json = serde_json::to_string(&agent_event_content(&standalone)).unwrap();
assert!(json.contains("system_prompt"), "standalone keeps prompt");
assert!(json.contains("\"model\""), "standalone keeps model");
assert!(json.contains("\"provider\""), "standalone keeps provider");
assert!(
json.contains("persona_source_version"),
"standalone keeps source_version"
);
}

#[test]
Expand Down Expand Up @@ -297,10 +356,29 @@ mod tests {

#[test]
fn projection_changes_on_meaningful_edit() {
// Instance-level edit surfaces for every record.
let agent = sample_agent();
let mut edited = agent.clone();
edited.system_prompt = Some("A different prompt.".to_string());
edited.parallelism += 1;
assert_ne!(agent_event_content(&agent), agent_event_content(&edited));

// Definition-level edit surfaces only for definition-less records —
// linked records resolve the prompt through their definition.
let mut standalone = sample_agent();
standalone.persona_id = None;
let mut edited = standalone.clone();
edited.system_prompt = Some("A different prompt.".to_string());
assert_ne!(
agent_event_content(&standalone),
agent_event_content(&edited)
);
let mut linked_edit = sample_agent();
linked_edit.system_prompt = Some("A different prompt.".to_string());
assert_eq!(
agent_event_content(&sample_agent()),
agent_event_content(&linked_edit),
"a linked record's local prompt snapshot is not wire state"
);
}

/// The inbound structural guard: a foreign event whose content JSON crams
Expand Down
Loading
Loading