From bc0e9dc1ce0f50cf0bd0db08d6c4c44f61637faf Mon Sep 17 00:00:00 2001 From: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Date: Sun, 19 Jul 2026 14:36:28 -0400 Subject: [PATCH 1/4] feat(desktop): archive agents when deleted Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> --- desktop/src-tauri/src/commands/agents.rs | 97 +++++++++++++++++++ .../src-tauri/src/commands/agents_tests.rs | 40 ++++++++ .../src-tauri/src/commands/personas/mod.rs | 1 + .../src/managed_agents/persona_events.rs | 37 +++++++ .../managed_agents/persona_events/tests.rs | 31 ++++++ .../profile/ui/UserProfileAgentActions.tsx | 4 + 6 files changed, 210 insertions(+) diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 6ebe6b7c4a..fac71a481f 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -155,6 +155,99 @@ pub(super) fn tombstone_managed_agent_pending( } } +/// Build and sign the NIP-IA `kind:9035` archive request enqueued when an +/// agent is deleted. Pure given the keys — unit-testable without an +/// `AppHandle`. Reuses the same wire builder as the GUI's Archive action +/// (`events::build_archive_identity_request`); the machine-readable reason is +/// `retired` (NIP-IA suggested code for a deliberately decommissioned key). +/// +/// The owner auth tag is minted locally from the same keys used to sign the +/// request, avoiding a network fetch while the managed-agent store lock is +/// held. The relay still independently verifies it against the agent's live +/// kind:0. +pub(super) fn build_agent_archive_request( + keys: &nostr::Keys, + agent_pubkey: &str, +) -> Result { + let auth_tag = if keys + .public_key() + .to_hex() + .eq_ignore_ascii_case(agent_pubkey) + { + None + } else { + // Bridge to the buzz-sdk nostr types via hex round-trip, exactly like + // the create path (see create_managed_agent's auth-tag phase). + let compat_owner = nostr::Keys::parse(&keys.secret_key().to_secret_hex()) + .map_err(|e| format!("failed to bridge owner keys: {e}"))?; + let compat_agent = nostr::PublicKey::from_hex(agent_pubkey) + .map_err(|e| format!("invalid agent pubkey: {e}"))?; + let tag_json = buzz_sdk_pkg::nip_oa::compute_auth_tag(&compat_owner, &compat_agent, "") + .map_err(|e| format!("failed to build owner auth tag: {e}"))?; + let parts: Vec = serde_json::from_str(&tag_json) + .map_err(|e| format!("failed to parse owner auth tag: {e}"))?; + Some( + <[String; 4]>::try_from(parts) + .map_err(|_| "owner auth tag must have four elements".to_string())?, + ) + }; + crate::events::build_archive_identity_request( + agent_pubkey, + "", + Some("retired"), + None, + auth_tag.as_ref(), + )? + .sign_with_keys(keys) + .map_err(|e| format!("failed to sign archive request: {e}")) +} + +/// Enqueue a NIP-IA `kind:9035` archive request for a deleted agent, retained +/// next to its kind:5 tombstone with `pending_sync = 1`. +/// +/// The tombstone removes the agent's 30177 record cross-device, but the +/// agent's `kind:0` and channel membership keep populating member pickers and +/// autocomplete on the relay until the identity is archived. Retaining the +/// request here gives archival the same offline durability as the tombstone; +/// the flush loop is the sole publisher and re-signs the request with a fresh +/// `created_at` at publish time, because the relay enforces a ±120s freshness +/// window on 9035s. +/// +/// Same contract as `tombstone_managed_agent_pending`: called inside the +/// `managed_agents_store_lock`-held delete body, never across an `.await`, +/// best-effort — a failure is logged and swallowed so it never blocks the +/// disk-authoritative delete. +pub(super) fn archive_managed_agent_pending(app: &AppHandle, state: &AppState, agent_pubkey: &str) { + use crate::managed_agents::retention::{open_retention_db, retain_event, RetainedEvent}; + use buzz_core_pkg::kind::KIND_IA_ARCHIVE_REQUEST; + use nostr::JsonUtil; + + let result = (|| -> Result<(), String> { + let (owner_pubkey, event) = { + let keys = state.signing_keys()?; + let owner_pubkey = keys.public_key().to_hex(); + let event = build_agent_archive_request(&keys, agent_pubkey)?; + (owner_pubkey, event) + }; + let conn = open_retention_db(&managed_agents_base_dir(app)?.join("retention.db"))?; + retain_event( + &conn, + &RetainedEvent { + kind: KIND_IA_ARCHIVE_REQUEST, + pubkey: owner_pubkey, + d_tag: agent_pubkey.to_string(), + content: event.content.to_string(), + created_at: event.created_at.as_secs() as i64, + raw_event: event.as_json(), + pending_sync: true, + }, + ) + })(); + if let Err(e) = result { + eprintln!("buzz-desktop: agent-archive: {e}"); + } +} + fn normalize_relay_mesh( config: Option<&RelayMeshConfig>, backend: &BackendKind, @@ -1134,6 +1227,10 @@ pub async fn delete_managed_agent( // deployment's relay record. Inside the lock, before the block closes // (no .await here). Every agent published, so every delete tombstones. tombstone_managed_agent_pending(&app, &state, &pubkey); + // NIP-IA: archive the deleted agent's identity on the relay so it + // stops appearing in member pickers and autocomplete. Same + // best-effort, inside-the-lock contract as the tombstone above. + archive_managed_agent_pending(&app, &state, &pubkey); } try_regenerate_nest(&app); Ok(()) diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index 469095f3c7..1b2e0ed179 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -86,6 +86,46 @@ fn persona_record(id: &str, model: Option<&str>, provider: Option<&str>) -> Agen } } +/// Auto-archive uses the same NIP-IA wire builder as the explicit GUI action, +/// attaches owner consent, and marks a deliberate delete as `retired`. +#[test] +fn build_agent_archive_request_attaches_owner_auth_and_retired_reason() { + use nostr::JsonUtil; + + let owner = nostr::Keys::generate(); + let agent = nostr::Keys::generate(); + let event = build_agent_archive_request(&owner, &agent.public_key().to_hex()) + .expect("build archive request"); + let json: serde_json::Value = serde_json::from_str(&event.as_json()).unwrap(); + let tags = json["tags"].as_array().unwrap(); + + assert_eq!(event.kind.as_u16(), 9035); + assert_eq!(event.pubkey, owner.public_key()); + assert!(event.verify_id()); + assert!(event.verify_signature()); + assert!(tags.iter().any(|tag| { + tag.as_array().is_some_and(|parts| { + parts.first().and_then(serde_json::Value::as_str) == Some("p") + && parts.get(1).and_then(serde_json::Value::as_str) + == Some(agent.public_key().to_hex().as_str()) + }) + })); + assert!(tags.iter().any(|tag| { + tag.as_array().is_some_and(|parts| { + parts.first().and_then(serde_json::Value::as_str) == Some("reason") + && parts.get(1).and_then(serde_json::Value::as_str) == Some("retired") + }) + })); + assert!(tags.iter().any(|tag| { + tag.as_array().is_some_and(|parts| { + parts.first().and_then(serde_json::Value::as_str) == Some("auth") + && parts.get(1).and_then(serde_json::Value::as_str) + == Some(owner.public_key().to_hex().as_str()) + && parts.len() == 4 + }) + })); +} + /// Deploy-path regression for Fix 1 of Thufir pass-2: a persona-linked /// provider agent with a stale record snapshot must use the live persona /// model/provider in the deploy payload, not the stale record values. diff --git a/desktop/src-tauri/src/commands/personas/mod.rs b/desktop/src-tauri/src/commands/personas/mod.rs index 51ad882b51..c3a2c2a22a 100644 --- a/desktop/src-tauri/src/commands/personas/mod.rs +++ b/desktop/src-tauri/src/commands/personas/mod.rs @@ -495,6 +495,7 @@ pub async fn delete_persona(id: String, app: AppHandle) -> Result<(), String> { // Remove nsec from keyring after the record is gone. delete_agent_key(pk); super::agents::tombstone_managed_agent_pending(&app, &state, pk); + super::agents::archive_managed_agent_pending(&app, &state, pk); } tombstone_persona_pending(&app, &state, &d_tag); diff --git a/desktop/src-tauri/src/managed_agents/persona_events.rs b/desktop/src-tauri/src/managed_agents/persona_events.rs index 02b2815230..21b5794995 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events.rs @@ -256,6 +256,20 @@ pub async fn flush_pending_events( let event = nostr::Event::from_json(¤t.raw_event) .map_err(|e| format!("failed to parse retained event '{}': {e}", current.d_tag))?; + // NIP-IA requests are freshness-checked by the relay (±120s on + // `created_at`), so a request retained while the relay was + // unreachable would be permanently stale. Re-sign with a fresh + // timestamp at publish time; kind, tags, and content are preserved, + // and `mark_synced` below still compares against the retained row's + // original `created_at`/`content`, which are untouched. + let is_archive_request = + buzz_core_pkg::kind::is_identity_archive_request_kind(current.kind); + let event = if is_archive_request { + resign_with_fresh_timestamp(&event, state)? + } else { + event + }; + if crate::relay::submit_signed_event(&event, state) .await .is_err() @@ -281,6 +295,29 @@ pub async fn flush_pending_events( Ok(flushed) } +/// Re-sign a retained event with the current owner keys and a fresh +/// `created_at`, preserving kind, tags, and content. +/// +/// Used for relay-freshness-checked kinds (NIP-IA 9035/9036) that would +/// otherwise go permanently stale sitting in the retention store while the +/// relay is unreachable. `.allow_self_tagging()` mirrors +/// `events::build_archive_identity_request` — nostr strips `p` tags matching +/// the signer by default, which would corrupt a self-targeted request. +/// +/// Synchronous; the `state.keys` guard is dropped on return, so callers may +/// `.await` afterwards. +fn resign_with_fresh_timestamp( + event: &nostr::Event, + state: &AppState, +) -> Result { + let keys = state.signing_keys()?; + nostr::EventBuilder::new(event.kind, event.content.clone()) + .tags(event.tags.iter().cloned()) + .allow_self_tagging() + .sign_with_keys(&keys) + .map_err(|e| format!("failed to re-sign retained event: {e}")) +} + /// SHA-256 (lowercase hex) of a persona's canonical content JSON. /// /// The drift indicator compares this digest, not event timestamps, to decide diff --git a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs index d61a6bc8a7..d62cc5b4bb 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs @@ -729,6 +729,37 @@ mod flush_barrier { .expect("retain test event"); } + #[test] + fn archive_request_resign_refreshes_timestamp_and_preserves_payload() { + use nostr::JsonUtil; + + let keys = nostr::Keys::generate(); + let target = nostr::Keys::generate().public_key().to_hex(); + let stale = crate::events::build_archive_identity_request( + &target, + "agent deleted", + Some("retired"), + None, + None, + ) + .unwrap() + .custom_created_at(nostr::Timestamp::from(1)) + .sign_with_keys(&keys) + .unwrap(); + let state = build_app_state(); + *state.keys.lock().unwrap() = keys; + + let fresh = resign_with_fresh_timestamp(&stale, &state).unwrap(); + + assert!(fresh.created_at.as_secs() > stale.created_at.as_secs()); + assert_eq!(fresh.kind, stale.kind); + assert_eq!(fresh.content, stale.content); + assert_eq!(fresh.tags, stale.tags); + assert!(fresh.verify_id()); + assert!(fresh.verify_signature()); + assert_ne!(fresh.as_json(), stale.as_json()); + } + /// The mid-sweep barrier: a tombstone the relay rejects must defer its /// own replacement to the next sweep (still pending, not counted as /// flushed) while unrelated rows in the same sweep publish normally. diff --git a/desktop/src/features/profile/ui/UserProfileAgentActions.tsx b/desktop/src/features/profile/ui/UserProfileAgentActions.tsx index 1f0335294c..81db3405b2 100644 --- a/desktop/src/features/profile/ui/UserProfileAgentActions.tsx +++ b/desktop/src/features/profile/ui/UserProfileAgentActions.tsx @@ -318,6 +318,10 @@ function AgentDeleteConfirmDialog({
  • Removes the local management record and saved agent key
  • Removes the agent from every channel it belongs to
  • +
  • + Archives the agent's identity on the relay so it no longer + appears in member lists or mention suggestions +
  • {isProviderAgent ? "Requests remote deletion; if it is online, Buzz first sends a shutdown command when possible. If the deployment cannot be reached through a channel, the remote process may keep running without local management." From 94610f366880f6d65226c28c06b7fc6dbf7476f4 Mon Sep 17 00:00:00 2001 From: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Date: Sun, 19 Jul 2026 14:51:51 -0400 Subject: [PATCH 2/4] fix(desktop): disclose relay archival in persona cascade delete dialog Persona cascade delete archives each linked instance's identity on the relay (NIP-IA 9035), but the confirmation dialog only mentioned deleting the instances. Extract the description into a pure helper, add cascade-aware archival copy (only when instanceCount > 0), and cover the consent copy with unit tests. Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> --- .../agents/ui/PersonaDeleteDialog.test.mjs | 34 +++++++++++++++++++ .../agents/ui/PersonaDeleteDialog.tsx | 28 +++++++++++++-- 2 files changed, 59 insertions(+), 3 deletions(-) create mode 100644 desktop/src/features/agents/ui/PersonaDeleteDialog.test.mjs diff --git a/desktop/src/features/agents/ui/PersonaDeleteDialog.test.mjs b/desktop/src/features/agents/ui/PersonaDeleteDialog.test.mjs new file mode 100644 index 0000000000..a40e571486 --- /dev/null +++ b/desktop/src/features/agents/ui/PersonaDeleteDialog.test.mjs @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { personaDeleteDescription } from "./PersonaDeleteDialog.tsx"; + +// Regression guard for the persona-cascade consent copy: deleting a persona +// with instances also archives each instance's identity on the relay +// (NIP-IA 9035), a durable externally visible side effect. The confirmation +// dialog must disclose it before the destructive confirm, exactly like the +// direct agent-delete dialog does. + +const persona = { displayName: "Scout" }; + +test("cascade delete discloses relay archival (plural)", () => { + const copy = personaDeleteDescription(persona, 3); + assert.match(copy, /deletes 3 agent instances/); + assert.match(copy, /archives their identities on the relay/); +}); + +test("cascade delete discloses relay archival (singular)", () => { + const copy = personaDeleteDescription(persona, 1); + assert.match(copy, /deletes 1 agent instance /); + assert.match(copy, /archives its identity on the relay/); +}); + +test("no instances → no archival claim (nothing is archived)", () => { + const copy = personaDeleteDescription(persona, 0); + assert.equal(copy, "Delete Scout."); + assert.doesNotMatch(copy, /archiv/i); +}); + +test("null persona keeps the generic fallback", () => { + assert.equal(personaDeleteDescription(null, 2), "Delete this agent."); +}); diff --git a/desktop/src/features/agents/ui/PersonaDeleteDialog.tsx b/desktop/src/features/agents/ui/PersonaDeleteDialog.tsx index a2793e7b4e..fd9da43751 100644 --- a/desktop/src/features/agents/ui/PersonaDeleteDialog.tsx +++ b/desktop/src/features/agents/ui/PersonaDeleteDialog.tsx @@ -20,6 +20,30 @@ type PersonaDeleteDialogProps = { onOpenChange: (open: boolean) => void; }; +/** + * Confirmation copy for deleting a persona. Pure so the cascade archival + * disclosure stays unit-testable without a renderer: whenever instances are + * cascade-deleted, each one's identity is also archived on the relay + * (NIP-IA), and that durable side effect must be disclosed before the + * destructive confirm — matching the direct agent-delete dialog. + */ +export function personaDeleteDescription( + persona: AgentPersona | null, + instanceCount: number, +): string { + if (!persona) { + return "Delete this agent."; + } + if (instanceCount === 0) { + return `Delete ${persona.displayName}.`; + } + const cascade = + instanceCount === 1 + ? "Also deletes 1 agent instance and archives its identity on the relay, so it no longer appears in member lists or mention suggestions." + : `Also deletes ${instanceCount} agent instances and archives their identities on the relay, so they no longer appear in member lists or mention suggestions.`; + return `Delete ${persona.displayName}. ${cascade}`; +} + export function PersonaDeleteDialog({ open, persona, @@ -33,9 +57,7 @@ export function PersonaDeleteDialog({ Delete agent? - {persona - ? `Delete ${persona.displayName}.${instanceCount > 0 ? ` Also deletes ${instanceCount} agent instance${instanceCount === 1 ? "" : "s"}.` : ""}` - : "Delete this agent."} + {personaDeleteDescription(persona, instanceCount)} From 09a5e33c09dc54cb0ca6f19a0cf65d768e6ca9dd Mon Sep 17 00:00:00 2001 From: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Date: Sun, 19 Jul 2026 15:06:10 -0400 Subject: [PATCH 3/4] refactor(desktop): drop vestigial nostr version-bridge in archive request builder The buzz-sdk and desktop crates both resolve to workspace nostr 0.44, so the secret-key hex round-trip (a relic of the 0.37->0.36 bridge era) is an identity conversion; compute_auth_tag accepts the owner keys directly, as messages.rs already does. Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> --- desktop/src-tauri/src/commands/agents.rs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index fac71a481f..ebae5d4514 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -176,13 +176,9 @@ pub(super) fn build_agent_archive_request( { None } else { - // Bridge to the buzz-sdk nostr types via hex round-trip, exactly like - // the create path (see create_managed_agent's auth-tag phase). - let compat_owner = nostr::Keys::parse(&keys.secret_key().to_secret_hex()) - .map_err(|e| format!("failed to bridge owner keys: {e}"))?; - let compat_agent = nostr::PublicKey::from_hex(agent_pubkey) + let agent = nostr::PublicKey::from_hex(agent_pubkey) .map_err(|e| format!("invalid agent pubkey: {e}"))?; - let tag_json = buzz_sdk_pkg::nip_oa::compute_auth_tag(&compat_owner, &compat_agent, "") + let tag_json = buzz_sdk_pkg::nip_oa::compute_auth_tag(keys, &agent, "") .map_err(|e| format!("failed to build owner auth tag: {e}"))?; let parts: Vec = serde_json::from_str(&tag_json) .map_err(|e| format!("failed to parse owner auth tag: {e}"))?; From 2dd2bed3450f5628e57e03869232b4987e468c36 Mon Sep 17 00:00:00 2001 From: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Date: Sun, 19 Jul 2026 15:20:37 -0400 Subject: [PATCH 4/4] test(desktop): update cascade-copy e2e assertions for archival disclosure The persona-delete dialog copy gained the relay-archival disclosure in this PR; the lifecycle-feedback smoke specs still asserted the old sentence-final copy and failed on CI. Assert the new copy, including the singular/plural identity phrasing. Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> --- desktop/tests/e2e/agent-lifecycle-feedback.spec.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/desktop/tests/e2e/agent-lifecycle-feedback.spec.ts b/desktop/tests/e2e/agent-lifecycle-feedback.spec.ts index f4de029019..6848105232 100644 --- a/desktop/tests/e2e/agent-lifecycle-feedback.spec.ts +++ b/desktop/tests/e2e/agent-lifecycle-feedback.spec.ts @@ -118,8 +118,11 @@ test.describe("agent lifecycle feedback screenshots", () => { const dialog = page.getByRole("alertdialog"); await expect(dialog).toBeVisible({ timeout: 5_000 }); - // Core assertion: the cascade copy shows the correct instance count (plural). - await expect(dialog).toContainText("Also deletes 2 agent instances."); + // Core assertion: the cascade copy shows the correct instance count + // (plural) and discloses the relay-side archival (PR #2135). + await expect(dialog).toContainText( + "Also deletes 2 agent instances and archives their identities on the relay", + ); await waitForAnimations(page); @@ -243,8 +246,10 @@ test.describe("agent lifecycle feedback screenshots", () => { const dialog = page.getByRole("alertdialog"); await expect(dialog).toBeVisible({ timeout: 5_000 }); - // Singular copy: "Also deletes 1 agent instance." (not "instances"). - await expect(dialog).toContainText("Also deletes 1 agent instance."); + // Singular copy ("instance", "its identity") plus the archival disclosure. + await expect(dialog).toContainText( + "Also deletes 1 agent instance and archives its identity on the relay", + ); await waitForAnimations(page); await dialog.screenshot({