diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index e0e424f0185..5b7e27131ec 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -516,6 +516,15 @@ pub struct CliArgs { /// Requires `--lazy-pool`; ignored otherwise. 0 disables idle re-sleep. #[arg(long, env = "BUZZ_ACP_IDLE_POOL_SLEEP", default_value_t = 0)] pub idle_pool_sleep: u64, + + /// Unix-seconds replay floor for the startup watermark. A publish-first + /// mention send publishes the triggering message and then spawns this + /// harness, passing the send timestamp here so the first REQ replays past + /// that message however long the spawn takes. Floors older than 15 minutes + /// are clamped to 15 minutes before startup; floors in the future are + /// ignored (the watermark stays at startup time). + #[arg(long, env = "BUZZ_ACP_REPLAY_FLOOR")] + pub replay_floor: Option, } /// Merged NIP-01 subscription filter for a single channel. @@ -605,6 +614,12 @@ pub struct Config { /// woken lazy pool is torn back down to the empty-slot state. 0 = disabled. /// Only meaningful when `lazy_pool` is true. pub idle_pool_sleep_secs: u64, + /// Optional unix-seconds replay floor for the startup watermark + /// (`--replay-floor` / `BUZZ_ACP_REPLAY_FLOOR`), set by a publish-first + /// mention send so the first REQ replays past the already-published + /// triggering message. Clamped where consumed — see + /// `startup_watermark_with_floor`. + pub replay_floor_unix: Option, /// Agent owner pubkey (hex). Used for `--respond-to=owner-only` gate. /// Replaces the old REST-based owner lookup. pub agent_owner: Option, @@ -1185,6 +1200,7 @@ impl Config { exit_after_inactivity_secs: args.exit_after_inactivity, lazy_pool: args.lazy_pool, idle_pool_sleep_secs: args.idle_pool_sleep, + replay_floor_unix: args.replay_floor, agent_owner: args.agent_owner.map(|s| s.trim().to_ascii_lowercase()), no_base_prompt: args.no_base_prompt, base_prompt_content, @@ -1560,6 +1576,7 @@ mod tests { exit_after_inactivity_secs: 0, lazy_pool: false, idle_pool_sleep_secs: 0, + replay_floor_unix: None, agent_owner: None, no_base_prompt: false, base_prompt_content: None, diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index af504a11768..d72d4cf482a 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -2387,6 +2387,63 @@ mod idle_pool_sleep_tests { } } +/// Oldest a caller-supplied replay floor may reach back from startup. Bounds +/// the stale-event burst when a spawn request sat around (e.g. the desktop +/// slept between the send and this spawn actually running). +const REPLAY_FLOOR_MAX_AGE_SECS: u64 = 15 * 60; + +/// Resolve the startup watermark from process-start time and an optional +/// replay floor (`--replay-floor` / `BUZZ_ACP_REPLAY_FLOOR`). +/// +/// A publish-first mention send publishes the triggering message BEFORE this +/// harness spawns, so the watermark must reach back to the send timestamp for +/// the first REQ (`since = watermark − 5s`) to replay that message. Floors +/// older than [`REPLAY_FLOOR_MAX_AGE_SECS`] clamp to that bound; floors in +/// the future clamp to `now` (a skewed sender must not push the watermark +/// forward past startup and re-open the blind spot the watermark closes). +fn startup_watermark_with_floor(now_unix: u64, replay_floor: Option) -> u64 { + match replay_floor { + Some(floor) => floor.clamp(now_unix.saturating_sub(REPLAY_FLOOR_MAX_AGE_SECS), now_unix), + None => now_unix, + } +} + +#[cfg(test)] +mod replay_floor_tests { + use super::{startup_watermark_with_floor, REPLAY_FLOOR_MAX_AGE_SECS}; + + const NOW: u64 = 1_700_000_000; + + #[test] + fn no_floor_keeps_startup_time() { + assert_eq!(startup_watermark_with_floor(NOW, None), NOW); + } + + #[test] + fn recent_floor_moves_watermark_back_to_the_send_timestamp() { + // The publish-first case: message sent 4s before the harness booted. + assert_eq!(startup_watermark_with_floor(NOW, Some(NOW - 4)), NOW - 4); + } + + #[test] + fn stale_floor_clamps_to_the_max_age_bound() { + assert_eq!( + startup_watermark_with_floor(NOW, Some(NOW - REPLAY_FLOOR_MAX_AGE_SECS - 1)), + NOW - REPLAY_FLOOR_MAX_AGE_SECS + ); + } + + #[test] + fn future_floor_is_ignored() { + assert_eq!(startup_watermark_with_floor(NOW, Some(NOW + 60)), NOW); + } + + #[test] + fn early_epoch_now_does_not_underflow() { + assert_eq!(startup_watermark_with_floor(10, Some(0)), 0); + } +} + pub fn run() -> Result<()> { config::propagate_legacy_env_vars(); tokio_main() @@ -2484,10 +2541,24 @@ async fn tokio_main() -> Result<()> { // the initial subscribe_since for channels discovered at startup. The Subscribe // handler falls back to subscribe_since when last_seen is None, closing the // blind spot between "agents ready" and "first REQ sent". - let startup_watermark: u64 = std::time::SystemTime::now() + // + // A publish-first mention send passes the triggering message's send + // timestamp as a replay floor (`--replay-floor` / `BUZZ_ACP_REPLAY_FLOOR`): + // the message is already on the relay when this process spawns, so the + // watermark must reach back to it for the first REQ to replay it — however + // long the spawn took. + let now_unix: u64 = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs(); + let startup_watermark = startup_watermark_with_floor(now_unix, config.replay_floor_unix); + if let Some(floor) = config.replay_floor_unix { + tracing::info!( + floor, + startup_watermark, + "applying replay floor to startup watermark" + ); + } let pubkey_hex = config.keys.public_key().to_hex(); @@ -8862,6 +8933,7 @@ mod build_mcp_servers_tests { exit_after_inactivity_secs: 0, lazy_pool: false, idle_pool_sleep_secs: 0, + replay_floor_unix: None, agent_owner: None, no_base_prompt: false, base_prompt_content: None, @@ -9087,6 +9159,7 @@ mod error_outcome_emission_tests { exit_after_inactivity_secs: 0, lazy_pool: false, idle_pool_sleep_secs: 0, + replay_floor_unix: None, agent_owner: None, no_base_prompt: false, base_prompt_content: None, diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs index 08028ddbe96..f1136e88923 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -129,6 +129,15 @@ pub struct AppState { /// bounded and letting a later leave correctly flip the channel back to /// `is_member=false`. pub pending_owned_channels: Mutex>, + /// NIP-11 `self` pubkeys keyed by relay WS URL, each with its fetch + /// instant. A relay's signing identity is effectively static, yet every + /// send-time agent revalidation used to re-GET the document — one of the + /// dominant costs of agent-mention send latency. Entries expire after + /// `identity_archive::RELAY_SELF_CACHE_TTL` so a relay-side key rotation + /// still converges. Keyed by URL, so switching communities can never serve + /// another relay's identity; only verified `Some` values are stored (an + /// outage or a document without `self` must stay retryable). + pub relay_self_cache: Mutex>, pub archive_db: crate::archive::ArchiveDb, } @@ -231,86 +240,13 @@ pub fn build_app_state() -> AppState { #[cfg(feature = "mesh-llm")] mesh_coordinator: AsyncMutex::new(None), pending_owned_channels: Mutex::new(std::collections::HashSet::new()), + relay_self_cache: Mutex::new(HashMap::new()), archive_db: crate::archive::ArchiveDb::default(), } } -impl AppState { - /// Lock the huddle state mutex, converting a poisoned-lock error to a String. - /// - /// Convenience wrapper — replaces 15+ instances of - /// `state.huddle_state.lock().map_err(|e| e.to_string())?` throughout the - /// huddle module. - pub fn huddle(&self) -> Result, String> { - self.huddle_state.lock().map_err(|e| e.to_string()) - } - - pub fn get_session_cache(&self, key: &ManagedAgentRuntimeKey) -> Option { - self.session_config_cache.lock().ok()?.get(key).cloned() - } - - pub fn put_session_cache(&self, key: ManagedAgentRuntimeKey, cache: SessionConfigCache) { - if let Ok(mut map) = self.session_config_cache.lock() { - map.insert(key, cache); - } - } - - pub fn clear_agent_session_cache(&self, key: &ManagedAgentRuntimeKey) { - if let Ok(mut map) = self.session_config_cache.lock() { - map.remove(key); - } - } - - pub fn clear_agent_session_caches(&self, pubkey: &str) { - if let Ok(mut map) = self.session_config_cache.lock() { - map.retain(|key, _| key.pubkey != pubkey); - } - } - - /// Return the active identity keys if they are in a signable state. - /// - /// Returns `Err` when the identity is in a lost state (`identity_lost` - /// — ephemeral key, user must re-import their nsec) or when the keyring - /// is locked (`keyring_locked` — key is held in a keyring that is - /// unavailable this boot). All signing and publish commands must call - /// this instead of locking `state.keys` directly, so that recovery mode - /// blocks publishing under an invalid or inaccessible identity. - pub fn signing_keys(&self) -> Result { - if self - .identity_lost - .load(std::sync::atomic::Ordering::Acquire) - || self - .keyring_locked - .load(std::sync::atomic::Ordering::Acquire) - { - return Err("identity is in recovery mode; event signing is disabled \ - until the identity is restored and Buzz is relaunched" - .to_string()); - } - self.keys - .lock() - .map_err(|e| e.to_string()) - .map(|k| k.clone()) - } - - /// Emit the current huddle state to the frontend via Tauri event. - /// - /// Acquires both locks (app_handle + huddle_state), clones a snapshot, - /// releases both, then emits. Best-effort — no-op if either lock is - /// poisoned or the app_handle hasn't been set yet. - pub fn emit_huddle_state_changed(&self) { - let app = match self.app_handle.lock() { - Ok(guard) => guard.clone(), - Err(_) => return, - }; - let Some(app) = app else { return }; - let snapshot = match self.huddle_state.lock() { - Ok(hs) => hs.clone(), - Err(_) => return, - }; - crate::huddle::state::emit_huddle_state(&app, &snapshot); - } -} +#[path = "app_state_accessors.rs"] +mod accessors; /// Resolve the user's identity key from the app data directory and wire /// the resulting [`RecoveryState`] into `AppState`. diff --git a/desktop/src-tauri/src/app_state_accessors.rs b/desktop/src-tauri/src/app_state_accessors.rs new file mode 100644 index 00000000000..72744e1605e --- /dev/null +++ b/desktop/src-tauri/src/app_state_accessors.rs @@ -0,0 +1,87 @@ +//! Convenience accessors over [`AppState`]'s lock-guarded fields. +//! +//! Kept apart from `app_state.rs`, which owns the struct, its builder, and the +//! identity-key resolution that populates it. + +use nostr::Keys; + +use crate::app_state::AppState; +use crate::managed_agents::config_bridge::SessionConfigCache; +use crate::managed_agents::ManagedAgentRuntimeKey; + +impl AppState { + /// Lock the huddle state mutex, converting a poisoned-lock error to a String. + /// + /// Convenience wrapper — replaces 15+ instances of + /// `state.huddle_state.lock().map_err(|e| e.to_string())?` throughout the + /// huddle module. + pub fn huddle(&self) -> Result, String> { + self.huddle_state.lock().map_err(|e| e.to_string()) + } + + pub fn get_session_cache(&self, key: &ManagedAgentRuntimeKey) -> Option { + self.session_config_cache.lock().ok()?.get(key).cloned() + } + + pub fn put_session_cache(&self, key: ManagedAgentRuntimeKey, cache: SessionConfigCache) { + if let Ok(mut map) = self.session_config_cache.lock() { + map.insert(key, cache); + } + } + + pub fn clear_agent_session_cache(&self, key: &ManagedAgentRuntimeKey) { + if let Ok(mut map) = self.session_config_cache.lock() { + map.remove(key); + } + } + + pub fn clear_agent_session_caches(&self, pubkey: &str) { + if let Ok(mut map) = self.session_config_cache.lock() { + map.retain(|key, _| key.pubkey != pubkey); + } + } + + /// Return the active identity keys if they are in a signable state. + /// + /// Returns `Err` when the identity is in a lost state (`identity_lost` + /// — ephemeral key, user must re-import their nsec) or when the keyring + /// is locked (`keyring_locked` — key is held in a keyring that is + /// unavailable this boot). All signing and publish commands must call + /// this instead of locking `state.keys` directly, so that recovery mode + /// blocks publishing under an invalid or inaccessible identity. + pub fn signing_keys(&self) -> Result { + if self + .identity_lost + .load(std::sync::atomic::Ordering::Acquire) + || self + .keyring_locked + .load(std::sync::atomic::Ordering::Acquire) + { + return Err("identity is in recovery mode; event signing is disabled \ + until the identity is restored and Buzz is relaunched" + .to_string()); + } + self.keys + .lock() + .map_err(|e| e.to_string()) + .map(|k| k.clone()) + } + + /// Emit the current huddle state to the frontend via Tauri event. + /// + /// Acquires both locks (app_handle + huddle_state), clones a snapshot, + /// releases both, then emits. Best-effort — no-op if either lock is + /// poisoned or the app_handle hasn't been set yet. + pub fn emit_huddle_state_changed(&self) { + let app = match self.app_handle.lock() { + Ok(guard) => guard.clone(), + Err(_) => return, + }; + let Some(app) = app else { return }; + let snapshot = match self.huddle_state.lock() { + Ok(hs) => hs.clone(), + Err(_) => return, + }; + crate::huddle::state::emit_huddle_state(&app, &snapshot); + } +} diff --git a/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs b/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs index eac91f5b71d..8f7493e1e8b 100644 --- a/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs +++ b/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs @@ -145,14 +145,16 @@ async fn list_relay_agents_for_selection( if let Some(requested_pubkeys) = requested_pubkeys { owned_filter["#d"] = serde_json::json!(requested_pubkeys); } - let owned_events = query_all_relay_pages(state, owned_filter) - .await - .map_err(|error| format!("relay owned-agent query failed: {error}"))?; - let owned_candidates = nostr_convert::managed_agent_pubkeys_from_events(&owned_events); + let owned_query = async { + query_all_relay_pages(state, owned_filter) + .await + .map_err(|error| format!("relay owned-agent query failed: {error}")) + }; - // Membership remains authoritative and visible only to this viewer. - // Known owned identities can have any membership role; other candidates - // must still have explicit bot-role evidence. + // Membership remains the authoritative and bounded authorization scope, + // visible only to this viewer. Known owned identities can have any + // membership role; other candidates must still have explicit bot-role + // evidence. let mut membership_filter = serde_json::json!({ "kinds": [39002], "authors": [&relay_pubkey], @@ -161,48 +163,107 @@ async fn list_relay_agents_for_selection( if let Some(channel_id) = channel_id { membership_filter["#d"] = serde_json::json!([channel_id]); } - let membership_events = query_all_relay_pages(state, membership_filter) - .await - .map_err(|error| format!("relay agent channel-membership query failed: {error}"))?; - let mut member_agent_channel_ids = nostr_convert::member_agent_channel_ids_from_events( - &membership_events, - &relay_pubkey, - &owned_candidates, - ); - if let Some(requested_pubkeys) = requested_pubkeys { - member_agent_channel_ids.retain(|pubkey, _| requested_pubkeys.contains(pubkey)); - } - let candidate_pubkeys: Vec = member_agent_channel_ids - .keys() - .cloned() - .chain(owned_candidates) - .collect::>() - .into_iter() - .collect(); - if candidate_pubkeys.is_empty() { - return Ok(Vec::new()); - } - - let directory_filters = exact_author_filters(&candidate_pubkeys, 10100); - let profile_filters = exact_author_filters(&candidate_pubkeys, 0); - // One semaphore per rebuild caps `/query` requests across this rebuild's - // phases, so its runtime-directory and owner-profile phases below stay - // within the ceiling even though `try_join!` runs them concurrently. + let membership_query = async { + query_all_relay_pages(state, membership_filter) + .await + .map_err(|error| format!("relay agent channel-membership query failed: {error}")) + }; + // One semaphore per rebuild caps batched `/query` requests across this + // rebuild's phases, so its runtime-directory and owner-profile phases stay + // within the ceiling even though `try_join!` runs them concurrently. The + // owned-agent and membership pagers are single sequential request streams + // and run outside the semaphore, so the targeted path's ceiling is the + // batches plus two. let semaphore = tokio::sync::Semaphore::new(RELAY_DIRECTORY_MAX_CONCURRENCY); - let (directory_events, profile_events) = tokio::try_join!( - query_filter_batches( - state, - &semaphore, - &directory_filters, - "relay agent runtime-directory query failed", - ), - query_filter_batches( - state, - &semaphore, - &profile_filters, - "relay agent owner-profile query failed", - ), - )?; + let (member_agent_channel_ids, candidate_pubkeys, directory_events, profile_events) = + if let Some(requested_pubkeys) = requested_pubkeys { + // Targeted path: the caller already names the candidates, so + // neither the owned-agent read nor the membership read gates the + // directory/profile fan-out — they all join it, one round-trip + // stage instead of three. The owned read is `#d`-scoped to the + // requested keys, so it can only ever name candidates already in + // this set. Directory, profile, and (below) policy reads may now + // issue for requested pubkeys membership excludes — bounded by the + // user-typed mention set — but the membership/owner retain on the + // final result still drops them, so what is returned is identical. + let candidate_pubkeys: Vec = requested_pubkeys.iter().cloned().collect(); + let directory_filters = exact_author_filters(&candidate_pubkeys, 10100); + let profile_filters = exact_author_filters(&candidate_pubkeys, 0); + let (owned_events, membership_events, directory_events, profile_events) = tokio::try_join!( + owned_query, + membership_query, + query_filter_batches( + state, + &semaphore, + &directory_filters, + "relay agent runtime-directory query failed", + ), + query_filter_batches( + state, + &semaphore, + &profile_filters, + "relay agent owner-profile query failed", + ), + )?; + let owned_candidates = nostr_convert::managed_agent_pubkeys_from_events(&owned_events); + let mut member_agent_channel_ids = nostr_convert::member_agent_channel_ids_from_events( + &membership_events, + &relay_pubkey, + &owned_candidates, + ); + member_agent_channel_ids.retain(|pubkey, _| requested_pubkeys.contains(pubkey)); + ( + member_agent_channel_ids, + candidate_pubkeys, + directory_events, + profile_events, + ) + } else { + // Full rebuild: the owned-agent and membership reads *discover* the + // candidates, so both must resolve before the batch filters can be + // built. Sequential shape retained — this is the autocomplete path, + // not the send path. + let owned_events = owned_query.await?; + let membership_events = membership_query.await?; + let owned_candidates = nostr_convert::managed_agent_pubkeys_from_events(&owned_events); + let member_agent_channel_ids = nostr_convert::member_agent_channel_ids_from_events( + &membership_events, + &relay_pubkey, + &owned_candidates, + ); + let candidate_pubkeys: Vec = member_agent_channel_ids + .keys() + .cloned() + .chain(owned_candidates) + .collect::>() + .into_iter() + .collect(); + if candidate_pubkeys.is_empty() { + return Ok(Vec::new()); + } + let directory_filters = exact_author_filters(&candidate_pubkeys, 10100); + let profile_filters = exact_author_filters(&candidate_pubkeys, 0); + let (directory_events, profile_events) = tokio::try_join!( + query_filter_batches( + state, + &semaphore, + &directory_filters, + "relay agent runtime-directory query failed", + ), + query_filter_batches( + state, + &semaphore, + &profile_filters, + "relay agent owner-profile query failed", + ), + )?; + ( + member_agent_channel_ids, + candidate_pubkeys, + directory_events, + profile_events, + ) + }; // Only the agent's signed NIP-OA profile can name the owner coordinate to // query. Each exact `(owner, d=agent)` filter returns at most one current diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 296123f470d..0ad7fd321c5 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -9,13 +9,12 @@ use crate::{ bestie_assignment::{recover_pending_assignment_cleanup, with_agent_assignments_cleared}, build_managed_agent_summary, current_instance_id, ensure_persona_is_active, find_managed_agent_mut, load_managed_agents, load_personas, load_teams, - managed_agent_avatar_url, managed_agents_base_dir, normalize_agent_args, - resolve_provider_binary, save_managed_agents, start_managed_agent_process, - stop_managed_agent_process, stop_managed_agent_workspace_pair, - sync_managed_agent_processes, try_regenerate_nest, validate_provider_config, BackendKind, - CreateManagedAgentRequest, CreateManagedAgentResponse, ManagedAgentRecord, - ManagedAgentSummary, RelayMeshConfig, DEFAULT_ACP_COMMAND, DEFAULT_AGENT_PARALLELISM, - DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, + managed_agents_base_dir, normalize_agent_args, resolve_provider_binary, + save_managed_agents, start_managed_agent_process, stop_managed_agent_process, + stop_managed_agent_workspace_pair, sync_managed_agent_processes, try_regenerate_nest, + validate_provider_config, BackendKind, CreateManagedAgentRequest, + CreateManagedAgentResponse, ManagedAgentRecord, ManagedAgentSummary, RelayMeshConfig, + DEFAULT_ACP_COMMAND, DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, }, relay::relay_ws_url_with_override, util::now_iso, @@ -56,50 +55,9 @@ pub(super) fn summarize_from_disk( ) } -fn normalize_relay_mesh( - config: Option<&RelayMeshConfig>, - backend: &BackendKind, -) -> Result, String> { - let Some(config) = config else { - return Ok(None); - }; - - let model_ref = config.model_ref.trim(); - if model_ref.is_empty() { - return Err("Buzz shared compute model is required".to_string()); - } - if backend != &BackendKind::Local { - return Err("Buzz shared compute agents must use the local backend".to_string()); - } - - Ok(Some(RelayMeshConfig { - model_ref: model_ref.to_string(), - })) -} - -fn trim_to_optional_string(value: &str) -> Option { - let trimmed = value.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } -} - -fn resolve_created_avatar_url( - requested_avatar_url: Option<&str>, - persona_avatar_url: Option, - agent_command: &str, -) -> Option { - requested_avatar_url - .and_then(trim_to_optional_string) - .or_else(|| { - persona_avatar_url - .as_deref() - .and_then(trim_to_optional_string) - }) - .or_else(|| managed_agent_avatar_url(agent_command)) -} +#[path = "agents_create_fields.rs"] +mod create_fields; +use create_fields::{normalize_relay_mesh, resolve_created_avatar_url, trim_to_optional_string}; #[cfg(feature = "mesh-llm")] async fn ensure_relay_mesh_for_record( @@ -209,6 +167,7 @@ pub(super) async fn start_local_agent_with_preflight( allow_fresh_create_start: bool, expected_relay_url: Option<&str>, expected_signer_pubkey: Option<&str>, + replay_floor_unix: Option, ) -> Result { let record_snapshot = { let _store_guard = state @@ -302,6 +261,7 @@ pub(super) async fn start_local_agent_with_preflight( &mut runtimes, Some(workspace_owner.as_str()), &workspace_relay_url, + replay_floor_unix, )?; save_managed_agents(app, &records)?; if let Some(saved_record) = records.iter().find(|r| r.pubkey == pubkey) { @@ -753,7 +713,8 @@ pub async fn create_managed_agent( // ── Phase 3b: local spawn (async preflight outside store lock) ─────────── let mut spawn_error = None; let agent = if input.spawn_after_create && input.backend == BackendKind::Local { - match start_local_agent_with_preflight(&app, &state, &pubkey, true, None, None).await { + match start_local_agent_with_preflight(&app, &state, &pubkey, true, None, None, None).await + { Ok(agent) => agent, Err(error) => { let _store_guard = state @@ -814,7 +775,7 @@ pub async fn create_managed_agent( build_deploy_payload(&app, &state, rec)? }; match deploy_to_provider( - &app, &state, &pubkey, id, config, agent_json, None, None, None, + &app, &state, &pubkey, id, config, agent_json, None, None, None, None, ) .await { @@ -862,6 +823,7 @@ pub async fn start_managed_agent( pubkey: String, expected_relay_url: Option, expected_signer_pubkey: Option, + replay_floor_unix: Option, app: AppHandle, state: State<'_, AppState>, ) -> Result { @@ -961,6 +923,7 @@ pub async fn start_managed_agent( false, expected_relay_url.as_deref(), expected_signer_pubkey.as_deref(), + replay_floor_unix, ) .await } @@ -973,6 +936,9 @@ pub async fn start_managed_agent( // against the payload rebuilt after the deploy lock — the exact // payload invoked — so a switch racing the lock wait cannot deploy // the agent into the new tenant on behalf of a stale callback. + // The replay floor rides along so a publish-first mention send's + // remote harness replays past the already-published message, same + // as the local spawn path. deploy_to_provider( &app, &state, @@ -983,6 +949,7 @@ pub async fn start_managed_agent( cached_binary_path.as_deref(), expected_relay_url.as_deref(), expected_signer_pubkey.as_deref(), + replay_floor_unix, ) .await?; diff --git a/desktop/src-tauri/src/commands/agents/provider_access.rs b/desktop/src-tauri/src/commands/agents/provider_access.rs index 34c06d25919..69c2d2f7f83 100644 --- a/desktop/src-tauri/src/commands/agents/provider_access.rs +++ b/desktop/src-tauri/src/commands/agents/provider_access.rs @@ -100,6 +100,7 @@ pub(crate) async fn reconcile_on_workspace_apply( cached_binary_path.as_deref(), None, None, + None, ) .await { diff --git a/desktop/src-tauri/src/commands/agents/provider_deploy.rs b/desktop/src-tauri/src/commands/agents/provider_deploy.rs index bb56a67eaa4..15db4dec5aa 100644 --- a/desktop/src-tauri/src/commands/agents/provider_deploy.rs +++ b/desktop/src-tauri/src/commands/agents/provider_deploy.rs @@ -6,7 +6,7 @@ use crate::{ app_state::AppState, managed_agents::{ discover_provider_candidates, load_managed_agents, provider_deploy, - resolve_provider_binary, save_managed_agents, BackendKind, + resolve_provider_binary, save_managed_agents, BackendKind, REPLAY_FLOOR_ENV_VAR, }, util::now_iso, }; @@ -31,6 +31,13 @@ use super::build_deploy_payload; /// deployment fails closed instead of deploying a stale start into the new /// tenant under the new tenant's owner identity. `None` preserves the /// unscoped behavior for callers without a tenant boundary. +/// +/// `replay_floor_unix`: optional unix-seconds replay floor from a +/// publish-first mention send. It is injected into the rebuilt payload's +/// `launch.policy_env` as `BUZZ_ACP_REPLAY_FLOOR`, so the remote harness's +/// startup watermark replays back past the already-published triggering +/// message exactly like a local spawn. Per-invocation only — never persisted +/// on the record, so later redeploys do not carry a stale floor. #[allow(clippy::too_many_arguments)] pub(crate) async fn deploy_to_provider( app: &AppHandle, @@ -42,6 +49,7 @@ pub(crate) async fn deploy_to_provider( _cached_binary_path: Option<&str>, expected_relay_url: Option<&str>, expected_signer_pubkey: Option<&str>, + replay_floor_unix: Option, ) -> Result<(), String> { let deploy_lock = { let mut locks = state @@ -58,7 +66,7 @@ pub(crate) async fn deploy_to_provider( // The payload may have waited behind another deployment. Rebuild it from // the current record so the final provider invocation always carries the // newest saved policy rather than the stale snapshot captured by its caller. - let (provider_id, config, cached_binary_path, agent_json) = { + let (provider_id, config, cached_binary_path, mut agent_json) = { let _store_guard = state .managed_agents_store_lock .lock() @@ -83,6 +91,9 @@ pub(crate) async fn deploy_to_provider( // Assert the caller's captured scope against THIS payload — the exact // value invoked below — not the pre-lock snapshot its caller validated. assert_payload_scope(&agent_json, expected_relay_url, expected_signer_pubkey)?; + // The floor is invocation state, not record state, so the post-lock + // rebuild cannot restore it — inject it into the payload actually invoked. + apply_replay_floor(&mut agent_json, replay_floor_unix); // Resolve via discovered candidates only. Cached path must match BOTH // "is a discovered candidate" AND "belongs to this provider_id". A tampered // record cannot redirect deploys to a different provider's binary. @@ -159,6 +170,58 @@ fn assert_payload_scope( Ok(()) } +/// Inject a caller-supplied replay floor into the deploy payload so the +/// remote harness consumes it exactly like a local spawn: as the +/// [`REPLAY_FLOOR_ENV_VAR`] environment variable. The floor rides +/// `launch.policy_env` (tier 1); any same-named key in `launch.env` (tier 2) +/// is stripped because that tier later-wins and a persisted user value must +/// not shadow this send's floor — the remote mirror of +/// `apply_replay_floor_env`'s post-`descriptor.env` write on the local spawn. +/// With no caller floor the payload is left untouched — a user-supplied +/// `launch.env` value passes through, and plain redeploys never carry a stale +/// floor. +fn apply_replay_floor(agent_json: &mut serde_json::Value, replay_floor_unix: Option) { + let Some(floor) = replay_floor_unix else { + return; + }; + let Some(launch) = agent_json + .get_mut("launch") + .and_then(serde_json::Value::as_object_mut) + else { + return; + }; + if let Some(env) = launch + .get_mut("env") + .and_then(serde_json::Value::as_object_mut) + { + let shadowed: Vec = env + .keys() + .filter(|key| key.eq_ignore_ascii_case(REPLAY_FLOOR_ENV_VAR)) + .cloned() + .collect(); + for key in shadowed { + env.remove(&key); + } + } + match launch + .get_mut("policy_env") + .and_then(serde_json::Value::as_object_mut) + { + Some(policy_env) => { + policy_env.insert( + REPLAY_FLOOR_ENV_VAR.to_string(), + serde_json::Value::String(floor.to_string()), + ); + } + None => { + launch.insert( + "policy_env".to_string(), + serde_json::json!({ (REPLAY_FLOOR_ENV_VAR): floor.to_string() }), + ); + } + } +} + fn policy_matches_payload( record: &crate::managed_agents::ManagedAgentRecord, deployed_agent_json: &serde_json::Value, @@ -283,6 +346,79 @@ mod tests { assert_payload_scope(&serde_json::json!({}), None, None).unwrap(); } + // ── apply_replay_floor: publish-first floor threading into the payload ── + + fn launch_payload() -> serde_json::Value { + serde_json::json!({ + "launch": { + "env": { "KEEP_ME": "yes" }, + "policy_env": { "BUZZ_ACP_LAZY_POOL": "true" }, + }, + }) + } + + #[test] + fn caller_replay_floor_rides_launch_policy_env() { + // A publish-first mention send's floor must reach the remote harness + // as BUZZ_ACP_REPLAY_FLOOR, exactly like a local spawn's env. + let mut payload = launch_payload(); + apply_replay_floor(&mut payload, Some(1_756_600_000)); + assert_eq!( + payload["launch"]["policy_env"]["BUZZ_ACP_REPLAY_FLOOR"], + "1756600000" + ); + assert_eq!(payload["launch"]["env"]["KEEP_ME"], "yes"); + assert_eq!( + payload["launch"]["policy_env"]["BUZZ_ACP_LAZY_POOL"], + "true" + ); + } + + #[test] + fn caller_replay_floor_strips_user_env_shadow() { + // launch.env later-wins over policy_env in the remote three-tier + // model; a persisted user floor must not shadow this send's floor. + let mut payload = launch_payload(); + payload["launch"]["env"]["BUZZ_ACP_REPLAY_FLOOR"] = "1".into(); + payload["launch"]["env"]["buzz_acp_replay_floor"] = "2".into(); + apply_replay_floor(&mut payload, Some(42)); + assert_eq!( + payload["launch"]["policy_env"]["BUZZ_ACP_REPLAY_FLOOR"], + "42" + ); + assert!(payload["launch"]["env"]["BUZZ_ACP_REPLAY_FLOOR"].is_null()); + assert!(payload["launch"]["env"]["buzz_acp_replay_floor"].is_null()); + assert_eq!(payload["launch"]["env"]["KEEP_ME"], "yes"); + } + + #[test] + fn no_caller_floor_leaves_payload_untouched() { + // Create-flow deploys and plain redeploys carry no floor: user env + // passthrough stands and no stale floor is invented. + let mut payload = launch_payload(); + payload["launch"]["env"]["BUZZ_ACP_REPLAY_FLOOR"] = "1".into(); + let before = payload.clone(); + apply_replay_floor(&mut payload, None); + assert_eq!(payload, before); + } + + #[test] + fn replay_floor_tolerates_payload_without_launch() { + let mut payload = serde_json::json!({}); + apply_replay_floor(&mut payload, Some(42)); + assert_eq!(payload, serde_json::json!({})); + } + + #[test] + fn replay_floor_creates_missing_policy_env() { + let mut payload = serde_json::json!({ "launch": {} }); + apply_replay_floor(&mut payload, Some(42)); + assert_eq!( + payload["launch"]["policy_env"]["BUZZ_ACP_REPLAY_FLOOR"], + "42" + ); + } + #[test] fn successful_deploy_acknowledges_pending_policy() { let mut record = record(); diff --git a/desktop/src-tauri/src/commands/agents_create_fields.rs b/desktop/src-tauri/src/commands/agents_create_fields.rs new file mode 100644 index 00000000000..16f840ba2e8 --- /dev/null +++ b/desktop/src-tauri/src/commands/agents_create_fields.rs @@ -0,0 +1,49 @@ +//! Field normalization for `create_managed_agent` — the pure validators and +//! resolvers its request-to-record mapping runs before any side effect. + +use crate::managed_agents::{managed_agent_avatar_url, BackendKind, RelayMeshConfig}; + +pub(super) fn normalize_relay_mesh( + config: Option<&RelayMeshConfig>, + backend: &BackendKind, +) -> Result, String> { + let Some(config) = config else { + return Ok(None); + }; + + let model_ref = config.model_ref.trim(); + if model_ref.is_empty() { + return Err("Buzz shared compute model is required".to_string()); + } + if backend != &BackendKind::Local { + return Err("Buzz shared compute agents must use the local backend".to_string()); + } + + Ok(Some(RelayMeshConfig { + model_ref: model_ref.to_string(), + })) +} + +pub(super) fn trim_to_optional_string(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +pub(super) fn resolve_created_avatar_url( + requested_avatar_url: Option<&str>, + persona_avatar_url: Option, + agent_command: &str, +) -> Option { + requested_avatar_url + .and_then(trim_to_optional_string) + .or_else(|| { + persona_avatar_url + .as_deref() + .and_then(trim_to_optional_string) + }) + .or_else(|| managed_agent_avatar_url(agent_command)) +} diff --git a/desktop/src-tauri/src/commands/identity_archive.rs b/desktop/src-tauri/src/commands/identity_archive.rs index 0cc5679bf7b..bf66b761d32 100644 --- a/desktop/src-tauri/src/commands/identity_archive.rs +++ b/desktop/src-tauri/src/commands/identity_archive.rs @@ -336,14 +336,38 @@ pub(crate) async fn fetch_relay_self(state: &AppState) -> Result, fetch_relay_self_at(state, &relay_ws_url_with_override(state)).await } +/// How long a fetched NIP-11 `self` pubkey stays valid in +/// [`AppState::relay_self_cache`]. The relay's signing identity changes only +/// on an operator-driven key rotation, so minutes of staleness are safe; the +/// TTL exists so even that rare rotation converges without an app restart. +pub(crate) const RELAY_SELF_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(300); + +/// Read a still-fresh cached `self` pubkey for `relay_url`, if any. Fails open +/// (cache miss) on a poisoned lock — the fetch path never depends on the cache. +fn cached_relay_self(state: &AppState, relay_url: &str) -> Option { + let cache = state.relay_self_cache.lock().ok()?; + let (fetched_at, relay_self) = cache.get(relay_url)?; + (fetched_at.elapsed() < RELAY_SELF_CACHE_TTL).then(|| relay_self.clone()) +} + /// Like [`fetch_relay_self`] but reads NIP-11 from an explicit relay WS URL /// instead of re-resolving the workspace override. Used by /// [`fetch_archived_pubkeys_at`] so the advertised signer and the snapshot /// query belong to the same captured relay target. +/// +/// Successful lookups are cached per relay URL for [`RELAY_SELF_CACHE_TTL`]: +/// send-time agent revalidation calls this on every agent-mention send, and +/// the uncached GET was a measurable slice of that latency. Only a verified +/// `Some` is cached — `Ok(None)` covers transient states (non-2xx status, a +/// document momentarily missing `self`) that must be re-tried, not pinned. pub(crate) async fn fetch_relay_self_at( state: &AppState, relay_url: &str, ) -> Result, String> { + if let Some(cached) = cached_relay_self(state, relay_url) { + return Ok(Some(cached)); + } + let http_url = relay_http_base_url(relay_url); let response = state .http_client @@ -367,6 +391,12 @@ pub(crate) async fn fetch_relay_self_at( }; if relay_self.len() == 64 && relay_self.chars().all(|c| c.is_ascii_hexdigit()) { + if let Ok(mut cache) = state.relay_self_cache.lock() { + cache.insert( + relay_url.to_string(), + (std::time::Instant::now(), relay_self.clone()), + ); + } Ok(Some(relay_self)) } else { Ok(None) @@ -476,7 +506,6 @@ pub async fn get_relay_self(state: State<'_, AppState>) -> Result mod tests { use super::*; use nostr::{EventBuilder, Keys, Kind, Tag}; - #[cfg(not(target_os = "windows"))] use std::sync::atomic::{AtomicUsize, Ordering}; /// Counting [`NestRegenTrigger`] double: records how many times the core @@ -575,6 +604,107 @@ mod tests { ); } + /// Spawn a loopback NIP-11 endpoint that counts hits and serves `self_hex` + /// (or a bare 503 when `self_hex` is `None`). Returns the `ws://` base and + /// the shared hit counter. + async fn spawn_nip11_relay(self_hex: Option) -> (String, std::sync::Arc) { + use axum::{http::StatusCode, routing::get, Json, Router}; + + let hits = std::sync::Arc::new(AtomicUsize::new(0)); + let route_hits = hits.clone(); + let router = Router::new().route( + "/", + get(move || { + let self_hex = self_hex.clone(); + let route_hits = route_hits.clone(); + async move { + route_hits.fetch_add(1, Ordering::SeqCst); + match self_hex { + Some(self_hex) => Ok(Json(serde_json::json!({ "self": self_hex }))), + None => Err(StatusCode::SERVICE_UNAVAILABLE), + } + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, router).await.ok(); + }); + (format!("ws://{addr}"), hits) + } + + /// The send path revalidates agent mentions on every agent-mention send, + /// and each pass used to re-GET the NIP-11 document. A second lookup + /// within the TTL must be served from [`AppState::relay_self_cache`] + /// without touching the relay. RED-on-revert: drop the `cached_relay_self` + /// check and the hit counter reads 2. + #[tokio::test] + async fn relay_self_second_fetch_within_ttl_is_served_from_cache() { + let self_hex = Keys::generate().public_key().to_hex(); + let (relay_url, hits) = spawn_nip11_relay(Some(self_hex.clone())).await; + let state = crate::app_state::build_app_state(); + + let first = fetch_relay_self_at(&state, &relay_url).await.unwrap(); + let second = fetch_relay_self_at(&state, &relay_url).await.unwrap(); + + assert_eq!(first.as_deref(), Some(self_hex.as_str())); + assert_eq!(second.as_deref(), Some(self_hex.as_str())); + assert_eq!( + hits.load(Ordering::SeqCst), + 1, + "the second in-TTL lookup must not re-GET the NIP-11 document" + ); + } + + /// A non-success NIP-11 response yields `Ok(None)` and MUST stay + /// retryable: caching the outage would blank the agent directory (and + /// every send-time revalidation) for the full TTL after one relay blip. + #[tokio::test] + async fn relay_self_non_success_response_is_not_cached() { + let (relay_url, hits) = spawn_nip11_relay(None).await; + let state = crate::app_state::build_app_state(); + + assert_eq!(fetch_relay_self_at(&state, &relay_url).await.unwrap(), None); + assert_eq!(fetch_relay_self_at(&state, &relay_url).await.unwrap(), None); + assert_eq!( + hits.load(Ordering::SeqCst), + 2, + "a failed lookup must retry the relay, never pin the outage" + ); + } + + /// An entry older than [`RELAY_SELF_CACHE_TTL`] must be refetched so a + /// relay-side key rotation converges without an app restart. + #[tokio::test] + async fn relay_self_expired_cache_entry_is_refetched() { + let self_hex = Keys::generate().public_key().to_hex(); + let (relay_url, hits) = spawn_nip11_relay(Some(self_hex.clone())).await; + let state = crate::app_state::build_app_state(); + // `Instant` is opaque, so expiry is staged by planting an already-stale + // entry rather than sleeping through the TTL. Skip (vacuous pass) if + // the platform clock cannot represent an instant that far back. + let Some(stale_instant) = std::time::Instant::now() + .checked_sub(RELAY_SELF_CACHE_TTL + std::time::Duration::from_secs(1)) + else { + return; + }; + state + .relay_self_cache + .lock() + .unwrap() + .insert(relay_url.clone(), (stale_instant, "b".repeat(64))); + + let refreshed = fetch_relay_self_at(&state, &relay_url).await.unwrap(); + + assert_eq!(refreshed.as_deref(), Some(self_hex.as_str())); + assert_eq!( + hits.load(Ordering::SeqCst), + 1, + "an expired entry must be refetched from the relay" + ); + } + /// Spec test-vector regression for gotcha #3: the NIP-OA preimage subject /// is the *target/agent* pubkey, not the request signer. The vectors in /// `docs/nips/NIP-IA.md` §Test Vectors fix concrete values; verifying the diff --git a/desktop/src-tauri/src/commands/personas/inbound.rs b/desktop/src-tauri/src/commands/personas/inbound.rs index b4438b67b7a..fe9fcbe406a 100644 --- a/desktop/src-tauri/src/commands/personas/inbound.rs +++ b/desktop/src-tauri/src/commands/personas/inbound.rs @@ -131,6 +131,7 @@ pub async fn reconcile_inbound_persona_event( cached_binary_path.as_deref(), None, None, + None, ) .await .map_err(|error| { diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs index 1d7fb34120e..5b79ccac27f 100644 --- a/desktop/src-tauri/src/managed_agents/restore.rs +++ b/desktop/src-tauri/src/managed_agents/restore.rs @@ -344,6 +344,7 @@ pub async fn restore_managed_agents_on_launch( &key.relay_url, true, owner_hex_ref, + None, ) }) { Ok(process) => { diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index b10271ea1c7..b8d586b32af 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -23,10 +23,13 @@ pub(crate) use super::access_policy::{build_respond_to_env_with_policy, RespondT mod metadata; pub(crate) use metadata::{ - apply_agent_display_env, child_rust_log_filter, resolve_session_title, - runtime_metadata_env_vars, DISPLAY_NAME_ENV_VAR, SESSION_TITLE_ENV_VAR, + apply_agent_display_env, apply_replay_floor_env, child_rust_log_filter, resolve_session_title, + runtime_metadata_env_vars, DISPLAY_NAME_ENV_VAR, REPLAY_FLOOR_ENV_VAR, SESSION_TITLE_ENV_VAR, }; +mod setup_payload; +use setup_payload::apply_setup_payload_env; + mod stop; pub(crate) use stop::managed_agent_runtime_keys; pub use stop::{stop_managed_agent_process, stop_managed_agent_workspace_pair}; @@ -435,12 +438,19 @@ pub(crate) fn spawn_with_effort_proof( /// /// `owner_hex`: the workspace owner's pubkey, used as a fallback for legacy /// records that have no NIP-OA `auth_tag`. See `build_respond_to_env`. +/// +/// `replay_floor_unix`: optional unix-seconds replay floor for the harness's +/// startup watermark (`BUZZ_ACP_REPLAY_FLOOR`). A publish-first mention send +/// publishes the triggering message before this spawn and passes its send +/// timestamp here so the harness's first REQ replays past that message no +/// matter how long the spawn takes. buzz-acp clamps stale floors to ~15 min. pub fn spawn_agent_child( app: &AppHandle, record: &ManagedAgentRecord, relay_url: &str, lazy: bool, owner_hex: Option<&str>, + replay_floor_unix: Option, ) -> Result { if let Some(error) = spawn_key_refusal(record) { return Err(error); @@ -565,6 +575,12 @@ pub fn spawn_agent_child( command.env("BUZZ_RELAY_URL", &effective_relay_url); command.env("BUZZ_ACP_LAZY_POOL", if lazy { "true" } else { "false" }); command.env("BUZZ_ACP_IDLE_POOL_SLEEP", idle_pool_sleep_env(lazy)); + // Publish-first mention sends hand the harness the send timestamp as a + // startup replay floor. Strip any ambient value here — before the + // `descriptor.env` loop — so a floor from the parent environment can never + // leak into an unrelated spawn; the caller's floor is asserted AFTER that + // loop by `apply_replay_floor_env` so saved user env cannot shadow it. + command.env_remove(REPLAY_FLOOR_ENV_VAR); command.env("BUZZ_ACP_AGENT_COMMAND", &resolved_agent_command); command.env("BUZZ_ACP_AGENT_ARGS", agent_args.join(",")); match &resolved_mcp_command { @@ -583,114 +599,9 @@ pub fn spawn_agent_child( } // ── Readiness check: set setup-payload if agent is not ready ───────────── - // - // Build the effective env, run the readiness predicate, and serialize any - // missing requirements into BUZZ_ACP_SETUP_PAYLOAD. buzz-acp enters - // setup-listener mode when this env var is present. - // - // SECURITY: BUZZ_ACP_SETUP_PAYLOAD is in RESERVED_ENV_KEYS (user env cannot - // set it). We also remove it after writing user env as a parent-process guard, - // then set it only when desktop computes NotReady — desktop is the sole source. - // - // `spawned_setup_mode` is captured outside the block to stamp - // `ManagedAgentProcess` (used by `install_acp_runtime` for auto-restart). - let spawned_setup_mode; - { - use crate::managed_agents::readiness::EffectiveAgentEnv; - use crate::managed_agents::{agent_readiness, AgentReadiness, Requirement}; - - // Construct EffectiveAgentEnv from the descriptor computed above — no second - // resolver call; the descriptor's env is already the fully layered result. - let effective = EffectiveAgentEnv { - env: descriptor.env.clone(), - config_file_path: runtime_meta.and_then(|r| r.config_file_path), - effective_command: descriptor.command.clone(), - }; - // Compute the optional payload before touching the command. - let setup_payload_json = - if let AgentReadiness::NotReady { requirements } = agent_readiness(&effective) { - let reqs: Vec = requirements - .into_iter() - .map(|r| match r { - Requirement::NormalizedField { field } => serde_json::json!({ - "surface": "normalized_field", - "field": field, - }), - Requirement::EnvKey { key } => serde_json::json!({ - "surface": "env_key", - "key": key, - }), - Requirement::CliLogin { - probe_args, - setup_copy, - availability, - } => serde_json::json!({ - "surface": "cli_login", - "probe_args": probe_args, - "setup_copy": setup_copy, - "availability": availability, - }), - Requirement::CliConfigInvalid { - probe_args, - setup_copy, - diagnostic, - } => serde_json::json!({ - "surface": "cli_config_invalid", - "probe_args": probe_args, - "setup_copy": setup_copy, - "diagnostic": diagnostic, - }), - Requirement::GitBash => serde_json::json!({ - "surface": "git_bash", - }), - Requirement::MissingBinary { command } => serde_json::json!({ - "surface": "missing_binary", - "command": command, - }), - }) - .collect(); - let payload = serde_json::json!({ - "agent_name": record.name, - "agent_pubkey": record.pubkey, - "requirements": reqs, - }); - match serde_json::to_string(&payload) { - Ok(json) => Some(json), - Err(e) => { - eprintln!( - "buzz-desktop: failed to serialize setup payload for {}: {e}", - record.name - ); - None - } - } - } else { - None - }; - - spawned_setup_mode = setup_payload_json.is_some(); - - // Strip the key from the process-spawned command on every path. - // Two independent guards protect the invariant: - // 1. BUZZ_ACP_SETUP_PAYLOAD is in RESERVED_ENV_KEYS, so - // merged_user_env() can never write it via saved/persona env. - // 2. This env_remove() clears any ambient parent-process value - // inherited by std::process::Command before we conditionally - // set the desktop-computed trusted value below. - // Note: merged_user_env() is written further below in this function; - // ordering relative to that call is NOT what makes this safe — the - // reserved-key strip (guard 1) handles user env regardless of order. - command.env_remove("BUZZ_ACP_SETUP_PAYLOAD"); - - // Set the payload only when desktop computed NotReady. - if let Some(json) = setup_payload_json { - command.env("BUZZ_ACP_SETUP_PAYLOAD", json); - eprintln!( - "buzz-desktop: agent {} not ready — spawning in setup-listener mode", - record.name - ); - } - } + // `spawned_setup_mode` is stamped on `ManagedAgentProcess` below. + let spawned_setup_mode = + apply_setup_payload_env(&mut command, record, &descriptor, runtime_meta); // Emit BUZZ_ACP_IDLE_TIMEOUT only when explicitly set; the harness // DEFAULT_IDLE_TIMEOUT_SECS is the single source of truth. The deprecated // BUZZ_ACP_TURN_TIMEOUT pinned agents to a stale default (320s). @@ -848,6 +759,13 @@ pub fn spawn_agent_child( let acp_session_policy = super::apply_app_acp_session_policy_env(app, &mut command); crate::build_identity::apply_demo_config_home(&mut command)?; + // Publish-first replay floor: written AFTER the `descriptor.env` loop, the + // same post-loop authority ordering the A1 model write uses. This send's + // floor is invocation state and must win over a saved + // BUZZ_ACP_REPLAY_FLOOR — the shadow `apply_replay_floor` strips from the + // provider payload's `launch.env` tier for the same reason. + apply_replay_floor_env(&mut command, replay_floor_unix); + // A1: for local claude agents, ANTHROPIC_MODEL is the single startup model authority. // BUZZ_ACP_MODEL is removed (live ACP switches only; two authorities in the same env // would be ambiguous). @@ -962,6 +880,7 @@ pub fn start_managed_agent_process( runtimes: &mut HashMap, owner_hex: Option<&str>, workspace_relay: &crate::relay::ScopedWorkspaceRelay, + replay_floor_unix: Option, ) -> Result<(), String> { let key = bound_runtime_key(record, workspace_relay)?; if let Some(runtime) = runtimes.get_mut(&key) { @@ -981,7 +900,14 @@ pub fn start_managed_agent_process( // Scalar PIDs are migration-only and never establish pair liveness. record.runtime_pid = None; - let mut process = spawn_agent_child(app, record, &key.relay_url, false, owner_hex)?; + let mut process = spawn_agent_child( + app, + record, + &key.relay_url, + false, + owner_hex, + replay_floor_unix, + )?; let now = now_iso(); let receipt = super::ManagedAgentRuntimeReceipt { key: key.clone(), diff --git a/desktop/src-tauri/src/managed_agents/runtime/metadata.rs b/desktop/src-tauri/src/managed_agents/runtime/metadata.rs index a94e71b222f..769e20cedf6 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/metadata.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/metadata.rs @@ -45,6 +45,40 @@ pub(crate) fn apply_agent_display_env(command: &mut std::process::Command, title } } +/// Env var carrying the startup replay floor to the harness. Shared with the +/// provider deploy path (`commands::agents::provider_deploy`) so the local +/// spawn and the remote `launch.policy_env` injection name the key from one +/// place. +pub(crate) const REPLAY_FLOOR_ENV_VAR: &str = "BUZZ_ACP_REPLAY_FLOOR"; + +/// Apply the publish-first replay floor: inject [`REPLAY_FLOOR_ENV_VAR`] from +/// `replay_floor_unix` (or leave the key untouched if `None`). +/// +/// Must be called **after** `descriptor.env` is written so this send's floor +/// wins over any user-supplied `BUZZ_ACP_REPLAY_FLOOR` entry — the same +/// authority ordering [`super::apply_effort_env`] asserts for effort, and the +/// same shadow strip `apply_replay_floor` performs on the provider payload's +/// `launch.env` tier. Without it a persona/global/agent env entry would +/// override the floor and the harness's startup watermark would be computed +/// from a stale (or `now`-clamped future) value, missing the mention that +/// triggered the spawn. +/// +/// When `replay_floor_unix` is `None` there is no floor to assert; the key is +/// left as `descriptor.env` wrote it, matching the provider path where a +/// user-supplied `launch.env` value passes through on a floorless deploy. The +/// caller strips the ambient parent-process value before the `descriptor.env` +/// loop, so `None` never inherits a floor from the environment Desktop itself +/// was launched with. +pub(crate) fn apply_replay_floor_env( + command: &mut std::process::Command, + replay_floor_unix: Option, +) { + if let Some(floor) = replay_floor_unix { + command.env(REPLAY_FLOOR_ENV_VAR, floor.to_string()); + } + // None: no floor to assert — leave whatever descriptor.env wrote intact. +} + /// Resolve the session title for an agent: its `display_name` when it has one, /// otherwise its unique `name` handle. `None` when both are blank, so the /// caller clears the env var rather than exporting an empty title. @@ -87,7 +121,78 @@ pub(crate) fn child_rust_log_filter() -> String { #[cfg(test)] mod tests { - use super::resolve_session_title; + use super::{apply_replay_floor_env, resolve_session_title, REPLAY_FLOOR_ENV_VAR}; + + fn replay_floor_of(cmd: &std::process::Command) -> Option { + cmd.get_envs() + .find(|(key, _)| *key == std::ffi::OsStr::new(REPLAY_FLOOR_ENV_VAR)) + .and_then(|(_, value)| value) + .map(|value| value.to_string_lossy().into_owned()) + } + + /// The publish-first floor must win over a persona/global/agent env entry + /// written by the `descriptor.env` loop. Before the post-loop application + /// the saved value shadowed the floor and the harness booted blind to the + /// mention that triggered the spawn. + #[test] + fn caller_replay_floor_wins_over_user_env_collision() { + let mut cmd = std::process::Command::new("true"); + // Simulate the descriptor.env loop writing a saved user value. + cmd.env(REPLAY_FLOOR_ENV_VAR, "1"); + + apply_replay_floor_env(&mut cmd, Some(1_756_600_000)); + + assert_eq!( + replay_floor_of(&cmd).as_deref(), + Some("1756600000"), + "this send's floor must win over the user-supplied value" + ); + } + + /// No caller floor: the user value passes through, matching the provider + /// payload path where a floorless deploy leaves `launch.env` untouched. + #[test] + fn user_replay_floor_env_survives_when_no_caller_floor() { + let mut cmd = std::process::Command::new("true"); + cmd.env(REPLAY_FLOOR_ENV_VAR, "1756600000"); + + apply_replay_floor_env(&mut cmd, None); + + assert_eq!( + replay_floor_of(&cmd).as_deref(), + Some("1756600000"), + "a user-supplied floor must survive when the caller supplies none" + ); + } + + /// The ambient strip the spawn does before the `descriptor.env` loop must + /// stay stripped when neither the caller nor user env supplies a floor. + #[test] + fn removed_replay_floor_stays_removed_without_caller_floor() { + let mut cmd = std::process::Command::new("true"); + // Simulate the spawn's pre-loop ambient strip with no user env entry. + cmd.env_remove(REPLAY_FLOOR_ENV_VAR); + + apply_replay_floor_env(&mut cmd, None); + + assert_eq!( + replay_floor_of(&cmd), + None, + "a floorless spawn must not inherit an ambient parent-process floor" + ); + } + + /// A caller floor re-asserts the key even after the pre-loop ambient strip + /// removed it — the common publish-first send with no saved user entry. + #[test] + fn caller_replay_floor_injected_after_ambient_strip() { + let mut cmd = std::process::Command::new("true"); + cmd.env_remove(REPLAY_FLOOR_ENV_VAR); + + apply_replay_floor_env(&mut cmd, Some(42)); + + assert_eq!(replay_floor_of(&cmd).as_deref(), Some("42")); + } #[test] fn resolve_session_title_prefers_display_name() { diff --git a/desktop/src-tauri/src/managed_agents/runtime/setup_payload.rs b/desktop/src-tauri/src/managed_agents/runtime/setup_payload.rs new file mode 100644 index 00000000000..6e3456c0795 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime/setup_payload.rs @@ -0,0 +1,125 @@ +//! Setup-listener payload for a spawn whose agent is not ready to run. +//! +//! The desktop is the sole readiness source; buzz-acp only transports the +//! payload. Kept beside the spawn rather than inside it so the readiness → +//! JSON → env write path reads as one unit. + +use crate::managed_agents::readiness::{EffectiveAgentEnv, EffectiveHarnessDescriptor}; +use crate::managed_agents::{ + agent_readiness, AgentReadiness, KnownAcpRuntime, ManagedAgentRecord, Requirement, +}; + +/// Build the effective env the agent would have at start-time, run the +/// readiness predicate, and if anything is missing, serialize the payload into +/// `BUZZ_ACP_SETUP_PAYLOAD`. buzz-acp detects this env var on startup and +/// enters the minimal setup-listener mode instead of the agent pool. +/// +/// Returns whether the payload was set — stamped on `ManagedAgentProcess` and +/// used by `install_acp_runtime` to target only stuck agents for auto-restart. +/// +/// SECURITY: `BUZZ_ACP_SETUP_PAYLOAD` is in `RESERVED_ENV_KEYS` so user env +/// cannot set it, but we also explicitly remove it after writing user env to +/// guard against the parent-process environment. We then set it only when +/// desktop has computed `NotReady`. +/// +/// The JSON format mirrors `setup_mode::SetupPayload` in buzz-acp: +/// `{ "agent_name": "...", "agent_pubkey": "...", "requirements": [{ "surface": "...", ... }] }` +pub(super) fn apply_setup_payload_env( + command: &mut std::process::Command, + record: &ManagedAgentRecord, + descriptor: &EffectiveHarnessDescriptor, + runtime_meta: Option<&'static KnownAcpRuntime>, +) -> bool { + // Construct EffectiveAgentEnv from the descriptor the caller resolved — no + // second resolver call; the descriptor's env is already the fully layered + // result. + let effective = EffectiveAgentEnv { + env: descriptor.env.clone(), + config_file_path: runtime_meta.and_then(|r| r.config_file_path), + effective_command: descriptor.command.clone(), + }; + // Compute the optional payload before touching the command. + let setup_payload_json = + if let AgentReadiness::NotReady { requirements } = agent_readiness(&effective) { + let reqs: Vec = requirements + .into_iter() + .map(|r| match r { + Requirement::NormalizedField { field } => serde_json::json!({ + "surface": "normalized_field", + "field": field, + }), + Requirement::EnvKey { key } => serde_json::json!({ + "surface": "env_key", + "key": key, + }), + Requirement::CliLogin { + probe_args, + setup_copy, + availability, + } => serde_json::json!({ + "surface": "cli_login", + "probe_args": probe_args, + "setup_copy": setup_copy, + "availability": availability, + }), + Requirement::CliConfigInvalid { + probe_args, + setup_copy, + diagnostic, + } => serde_json::json!({ + "surface": "cli_config_invalid", + "probe_args": probe_args, + "setup_copy": setup_copy, + "diagnostic": diagnostic, + }), + Requirement::GitBash => serde_json::json!({ + "surface": "git_bash", + }), + Requirement::MissingBinary { command } => serde_json::json!({ + "surface": "missing_binary", + "command": command, + }), + }) + .collect(); + let payload = serde_json::json!({ + "agent_name": record.name, + "agent_pubkey": record.pubkey, + "requirements": reqs, + }); + match serde_json::to_string(&payload) { + Ok(json) => Some(json), + Err(e) => { + eprintln!( + "buzz-desktop: failed to serialize setup payload for {}: {e}", + record.name + ); + None + } + } + } else { + None + }; + + // Strip the key from the process-spawned command on every path. + // Two independent guards protect the invariant: + // 1. BUZZ_ACP_SETUP_PAYLOAD is in RESERVED_ENV_KEYS, so + // merged_user_env() can never write it via saved/persona env. + // 2. This env_remove() clears any ambient parent-process value + // inherited by std::process::Command before we conditionally + // set the desktop-computed trusted value below. + // Note: merged_user_env() is written later in the caller; ordering + // relative to that call is NOT what makes this safe — the reserved-key + // strip (guard 1) handles user env regardless of order. + command.env_remove("BUZZ_ACP_SETUP_PAYLOAD"); + + // Set the payload only when desktop computed NotReady. + let Some(json) = setup_payload_json else { + return false; + }; + command.env("BUZZ_ACP_SETUP_PAYLOAD", json); + eprintln!( + "buzz-desktop: agent {} not ready — spawning in setup-listener mode", + record.name + ); + true +} diff --git a/desktop/src-tauri/src/managed_agents/runtime_commands.rs b/desktop/src-tauri/src/managed_agents/runtime_commands.rs index 135224d01db..ba0f91c9f7a 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_commands.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_commands.rs @@ -288,7 +288,8 @@ fn start_pair( .lock() .ok() .map(|keys| keys.public_key().to_hex()); - let mut process = spawn_agent_child(&app, record, &key.relay_url, lazy, owner.as_deref())?; + let mut process = + spawn_agent_child(&app, record, &key.relay_url, lazy, owner.as_deref(), None)?; let now = crate::util::now_iso(); let receipt = ManagedAgentRuntimeReceipt { key: key.clone(), diff --git a/desktop/src/features/agents/channelAgents.accessPolicy.test.mjs b/desktop/src/features/agents/channelAgents.accessPolicy.test.mjs new file mode 100644 index 00000000000..26bf6fe46a4 --- /dev/null +++ b/desktop/src/features/agents/channelAgents.accessPolicy.test.mjs @@ -0,0 +1,149 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { applyReusableAgentAccessPolicy } from "./channelAgents.ts"; + +const AGENT_PUBKEY = "a".repeat(64); +const ALLOWED_PUBKEY = "b".repeat(64); + +// `wrote` is load-bearing: the message-send path (useMentionSendFlow) uses it +// to decide whether an awaited relay round-trip separated its pre-side-effect +// mention-authorization pass from the publish, and therefore whether it must +// revalidate at the publish boundary (#5681). These tests pin the flag against +// the relay write itself, not against the identity of the returned record. + +function rawAgent(overrides = {}) { + return { + pubkey: AGENT_PUBKEY, + name: "fizz", + persona_id: null, + relay_url: "wss://relay.example", + acp_command: "buzz-acp", + agent_command: "goose", + agent_args: [], + mcp_command: "", + turn_timeout_seconds: 0, + idle_timeout_seconds: 0, + max_turn_duration_seconds: 0, + parallelism: 1, + system_prompt: null, + model: null, + status: "running", + pid: null, + created_at: "2026-01-15T00:00:00Z", + updated_at: "2026-01-15T00:00:00Z", + last_started_at: null, + last_stopped_at: null, + last_exit_code: null, + last_error: null, + log_path: null, + start_on_app_launch: false, + backend: { type: "local" }, + backend_agent_id: null, + respond_to: "owner-only", + respond_to_allowlist: [], + ...overrides, + }; +} + +function managedAgent(overrides = {}) { + return { + pubkey: AGENT_PUBKEY, + name: "fizz", + respondTo: "owner-only", + respondToAllowlist: [], + ...overrides, + }; +} + +function installTauriInvoke(handler) { + const prior = globalThis.window; + globalThis.window ??= {}; + window.__TAURI_INTERNALS__ = { invoke: handler }; + return () => { + globalThis.window = prior; + }; +} + +test("a matching access policy reports no write and returns the agent untouched", async (t) => { + const calls = []; + t.after( + installTauriInvoke((command, args) => { + calls.push([command, args]); + return Promise.resolve(null); + }), + ); + + const agent = managedAgent(); + const result = await applyReusableAgentAccessPolicy(agent, {}); + + assert.equal(result.wrote, false); + assert.equal(result.agent, agent); + assert.deepEqual(calls, []); +}); + +test("a diverging access policy reports the write and returns the updated agent", async (t) => { + const calls = []; + t.after( + installTauriInvoke((command, args) => { + calls.push([command, args]); + return Promise.resolve({ + agent: rawAgent({ + respond_to: "allowlist", + respond_to_allowlist: [ALLOWED_PUBKEY], + }), + profile_sync_error: null, + }); + }), + ); + + const agent = managedAgent(); + const result = await applyReusableAgentAccessPolicy(agent, { + respondTo: "allowlist", + respondToAllowlist: [ALLOWED_PUBKEY], + }); + + assert.equal(result.wrote, true); + assert.equal(result.agent.respondTo, "allowlist"); + assert.deepEqual(result.agent.respondToAllowlist, [ALLOWED_PUBKEY]); + assert.deepEqual(calls, [ + [ + "update_managed_agent", + { + input: { + pubkey: AGENT_PUBKEY, + respondTo: "allowlist", + respondToAllowlist: [ALLOWED_PUBKEY], + }, + }, + ], + ]); +}); + +test("the write is reported even when the update hands back an unchanged record", async (t) => { + // Callers must not re-derive the write by comparing the returned record + // against the one they passed in — a backend that normalizes the policy + // away, or a cache layer that mutates in place and hands the caller's own + // object back, still wrote to the relay. Under such a comparison the send + // path would silently skip the publish-boundary revalidation. + let invoked = 0; + t.after( + installTauriInvoke(() => { + invoked += 1; + return Promise.resolve({ + agent: rawAgent(), + profile_sync_error: null, + }); + }), + ); + + const agent = managedAgent(); + const result = await applyReusableAgentAccessPolicy(agent, { + respondTo: "anyone", + }); + + assert.equal(invoked, 1); + assert.equal(result.wrote, true); + assert.equal(result.agent.respondTo, agent.respondTo); + assert.deepEqual(result.agent.respondToAllowlist, agent.respondToAllowlist); +}); diff --git a/desktop/src/features/agents/channelAgents.ts b/desktop/src/features/agents/channelAgents.ts index 3387d135af1..24ace21b520 100644 --- a/desktop/src/features/agents/channelAgents.ts +++ b/desktop/src/features/agents/channelAgents.ts @@ -36,6 +36,16 @@ export type AttachManagedAgentToChannelInput = { agent: ManagedAgent; role?: Exclude; ensureRunning?: boolean; + /** + * When set, a needed start/deploy is handed to this callback instead of + * being awaited: the attach resolves as soon as the membership write lands + * and the callback owns the start, including surfacing its failure. The + * message-send path passes a queue collector here — the wake it records is + * flushed fire-and-forget only after the relay accepts the publish, with a + * replay floor stamped at queue time, so the spawned harness replays the + * published message and an aborted send leaves no orphan wake. + */ + detachedStart?: (agent: ManagedAgent) => void; }; export type AttachManagedAgentToChannelResult = { @@ -85,6 +95,9 @@ export type CreateChannelManagedAgentInput = { respondToAllowlist?: string[]; /** Skip reuse logic and always create a fresh agent instance. */ forceNewInstance?: boolean; + /** Detached start hook forwarded to the channel attach — see + * `AttachManagedAgentToChannelInput.detachedStart`. */ + detachedStart?: (agent: ManagedAgent) => void; }; export type CreateChannelManagedAgentResult = @@ -120,11 +133,25 @@ type ChannelAgentReuseContext = { >[]; }; +export type ApplyReusableAgentAccessPolicyResult = { + agent: ManagedAgent; + /** + * True when reconciling the policy required a relay write. Callers that + * sequence authorization around this call — the message-send path revalidates + * mention authorization at the publish boundary whenever an awaited relay + * round-trip separated it from its earlier pass — depend on this flag rather + * than on comparing the returned record's identity against the input, so the + * signal survives any future change to whether an update returns a fresh + * object. + */ + wrote: boolean; +}; + export async function applyReusableAgentAccessPolicy( agent: ManagedAgent, request: Pick, persona?: Pick, -) { +): Promise { const policy = resolveReusableAgentAccessPolicy(request, persona); const matches = agent.respondTo === policy.respondTo && @@ -132,14 +159,13 @@ export async function applyReusableAgentAccessPolicy( agent.respondToAllowlist.every( (pubkey, index) => pubkey === policy.respondToAllowlist[index], ); - if (matches) return agent; - - return ( - await updateManagedAgent({ - pubkey: agent.pubkey, - ...policy, - }) - ).agent; + if (matches) return { agent, wrote: false }; + + const { agent: updatedAgent } = await updateManagedAgent({ + pubkey: agent.pubkey, + ...policy, + }); + return { agent: updatedAgent, wrote: true }; } export async function attachManagedAgentToChannel( @@ -177,16 +203,16 @@ export async function attachManagedAgentToChannel( // pair — so this ensures the pair the caller is attaching to, never // another community's. const isRemote = input.agent.backend.type === "provider"; - if (isRemote && input.agent.status !== "deployed") { - agent = await startManagedAgent(input.agent.pubkey); - started = true; - } else if ( - !isRemote && - input.agent.status !== "running" && - input.agent.status !== "deployed" - ) { - agent = await startManagedAgent(input.agent.pubkey); - started = true; + const needsStart = isRemote + ? input.agent.status !== "deployed" + : input.agent.status !== "running" && input.agent.status !== "deployed"; + if (needsStart) { + if (input.detachedStart) { + input.detachedStart(input.agent); + } else { + agent = await startManagedAgent(input.agent.pubkey); + started = true; + } } } @@ -317,7 +343,7 @@ export async function provisionChannelManagedAgent( const definition = context.personas.find( (persona) => persona.id === input.personaId, ); - const updatedAgent = await applyReusableAgentAccessPolicy( + const { agent: updatedAgent } = await applyReusableAgentAccessPolicy( reusable, input, definition, @@ -346,7 +372,7 @@ export async function provisionChannelManagedAgent( context.channelMemberPubkeys, ); if (reusable) { - const updatedAgent = await applyReusableAgentAccessPolicy( + const { agent: updatedAgent } = await applyReusableAgentAccessPolicy( reusable, input, ); @@ -411,6 +437,7 @@ export async function createChannelManagedAgent( agent: provisioned.agent, role: input.role ?? "bot", ensureRunning: input.ensureRunning ?? true, + detachedStart: input.detachedStart, }); return { diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index 3daf4fa78cc..ec1ccd262e8 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -594,6 +594,7 @@ export function useStartManagedAgentMutation() { pubkey: string; expectedRelayUrl?: string; expectedSignerPubkey?: string; + replayFloorUnix?: number; }, ) => typeof input === "string" @@ -601,6 +602,7 @@ export function useStartManagedAgentMutation() { : startManagedAgent(input.pubkey, { expectedRelayUrl: input.expectedRelayUrl, expectedSignerPubkey: input.expectedSignerPubkey, + replayFloorUnix: input.replayFloorUnix, }), onSuccess: (updated) => { queryClient.setQueryData( diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index a3b17f5ea36..4a1ddf9d0b8 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -25,6 +25,10 @@ import { resetAudioMediaLoadScheduler } from "@/features/messages/lib/audioMedia import { resetBackgroundMediaUploads } from "@/features/messages/lib/backgroundMediaUploadStore"; import { resetLinkPreviewPreparations } from "@/features/messages/lib/linkPreviewPreparationStore"; import { resetPersistentAgentAudienceStore } from "@/features/messages/lib/persistentAgentAudience"; +import { + resetDetachedToastScope, + setDetachedToastScope, +} from "@/features/messages/lib/detachedToastScope"; import { resetActiveAgentTurnsStore, saveActiveAgentTurnsForCommunity, @@ -80,6 +84,15 @@ async function resetCommunityState({ resetBackgroundMediaUploads(); resetLinkPreviewPreparations(); resetPersistentAgentAudienceStore(); + // Intentionally NOT reset: the in-flight detached agent-start map + // (`useDetachedAgentStart`). Its entries are keyed by the scope each start + // asserts (relay URL + signer + agent pubkey), so they cannot leak into the + // new community, and they self-clean when the start settles. Clearing them + // here is what permitted the A→B→A duplicate provider deploy: the backend's + // scope assertion is a current-state check, so a start held across a + // round-trip is valid again once A is re-applied — the map entry is its only + // duplicate guard. + resetDetachedToastScope(); clearSearchHitEventCache(); clearMarkdownNodeCache(); resetMessageLinkMetadataCache(); @@ -344,6 +357,12 @@ export function useCommunityInit( // trip). This runs after applyCommunity succeeds and before the app // renders so components see the restored timers on first render. restoreActiveAgentTurnsForCommunity(activeCommunity.id); + // From here this community's UI is what renders, so warnings from + // detached agent wakes captured under this scope may deliver again. + setDetachedToastScope({ + relayUrl: activeCommunity.relayUrl, + signerPubkey: identityPubkey, + }); // Prime the ref so the NEXT switch saves this community's state. prevCommunityIdRef.current = activeCommunity.id; setResult({ diff --git a/desktop/src/features/messages/lib/detachedToastScope.ts b/desktop/src/features/messages/lib/detachedToastScope.ts new file mode 100644 index 00000000000..45b34eef021 --- /dev/null +++ b/desktop/src/features/messages/lib/detachedToastScope.ts @@ -0,0 +1,60 @@ +import { normalizePubkey } from "@/shared/lib/pubkey"; + +/** + * The tenant scope whose UI is currently on screen, mirrored at module level + * so code running outside React — the detached agent wake's `.catch` — can + * ask "does my captured scope still describe what the user is looking at?". + * + * `` mounts outside the community remount boundary, so a toast + * fired by a promise that outlived a community switch renders over the *new* + * community's UI. The wake itself already fails closed at the backend; this + * mirror fences only warning delivery, so community A's agent name and error + * detail never surface while community B is on screen. + * + * Set by `useCommunityInit` when a community apply completes; cleared in + * `resetCommunityState()` like every community-scoped module singleton. The + * mirror is compared, not counted: an A→B→A round-trip restores A's scope, so + * a slow start fired in A may still warn once the user is back in A — which + * is exactly where "mention the agent again" is actionable. A reset + * generation could not distinguish that from a one-way switch. + */ +type DetachedToastScope = { + relayUrl: string; + /** Null when the apply completed without a resolved identity. */ + signerPubkey: string | null; +}; + +let activeScope: DetachedToastScope | null = null; + +/** Records the scope the just-applied community renders under. */ +export function setDetachedToastScope(scope: DetachedToastScope): void { + activeScope = scope; +} + +/** + * Clears the mirror. Registered in `resetCommunityState`: between a switch + * and the next apply there is no on-screen scope, and delivery fails closed. + */ +export function resetDetachedToastScope(): void { + activeScope = null; +} + +/** + * Whether a scope captured at fire time still matches the on-screen one. + * Same comparison semantics as the backend's scope assertion: the relay URL + * verbatim past a trim (its check is case-sensitive), the signer + * case-insensitively. No mirror — mid-switch, or before the first apply — + * means no match. + */ +export function matchesDetachedToastScope( + relayUrl: string, + signerPubkey: string, +): boolean { + if (activeScope === null || activeScope.signerPubkey === null) { + return false; + } + return ( + activeScope.relayUrl.trim() === relayUrl.trim() && + normalizePubkey(activeScope.signerPubkey) === normalizePubkey(signerPubkey) + ); +} diff --git a/desktop/src/features/messages/ui/useDetachedAgentStart.test.mjs b/desktop/src/features/messages/ui/useDetachedAgentStart.test.mjs new file mode 100644 index 00000000000..32408c65f08 --- /dev/null +++ b/desktop/src/features/messages/ui/useDetachedAgentStart.test.mjs @@ -0,0 +1,842 @@ +/** + * Tenant scoping and in-flight deduplication for the publish-first detached + * agent wake. + * + * `useMentionSendFlow` no longer awaits `start_managed_agent`, so the call + * outlives the send — and, because a community switch only remounts the React + * subtree, it can outlive the community too. Two consequences are pinned here: + * + * 1. `start_managed_agent` resolves the workspace relay and signing identity at + * execution time, so without a captured scope a wake fired in community A + * can spawn/deploy the agent against community B (carrying A's replay + * floor). + * 2. Nothing else dedupes concurrent wakes any more — the awaited version was + * covered by the composer's `isPending` gate plus the mutation's success + * cache write, and for the whole detached window the cache still reads + * `stopped`, so a second send re-fires. + * 3. A scope that is not yet known is not a scope: the backend reads a missing + * relay or signer as "no assertion", so the wake is refused rather than + * fired unscoped. + * 4. The failure warning is fenced to the scope it was fired under: the + * `` outlives the community remount boundary, so an unfenced + * toast from a start that settled after a switch would name community A's + * agent over community B's UI. Delivery compares the captured scope against + * the on-screen mirror (`detachedToastScope`) at toast time — not a reset + * generation, so an A→B→A round-trip warns again once A is back on screen. + * 5. The in-flight map outlives a community switch on purpose: the backend's + * scope assertion is a current-state check, so an A→B→A round-trip + * re-validates a still-held start — the map entry is its only duplicate + * guard, and it is tenant-keyed, so retention cannot affect the community + * being entered. (`resetCommunityState` no longer clears it; that seam is + * pinned E2E.) + * 6. That dedupe key is the *whole* scope the wake asserts — relay and signer, + * not the relay alone. The backend distinguishes signers before it spawns + * or deploys, so the renderer must not coalesce across them: a start held + * under the identity in force before a mid-session key import cannot stand + * in for the imported identity's wake. + * + * These tests drive the real hook against the real CommunitiesProvider; the + * scope mirror is driven directly through its module seam, standing in for + * `useCommunityInit` (its only production writer), which is not mounted here. + */ + +import assert from "node:assert/strict"; +import { after, afterEach, before, beforeEach, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +const SELF = "1".repeat(64); +// The key a mid-session import puts in force — the signing identity is one +// global per install, so two signers only ever arrive back to back. +const IMPORTED_SELF = "2".repeat(64); +const AGENT = "a".repeat(64); +const OTHER_AGENT = "b".repeat(64); +// Mixed case on purpose: the backend's scope comparison is case-sensitive +// past the scheme, so the stored URL must reach it verbatim. +const RELAY_A = "wss://Tenant-A.example"; +const RELAY_B = "wss://tenant-b.example"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +/** Every `start_managed_agent` payload seen, in call order. */ +let startCalls = []; +/** + * Settlers for `start_managed_agent` calls held open by `holdStarts`, in call + * order. A held start is what the dedupe exists for: the seconds-long window + * where a cold spawn or first deploy has not yet updated the agent record. + */ +let heldStarts = []; +let holdStarts = false; +/** + * Settlers for `get_identity` calls held open by `holdIdentity` — the window + * before the identity query resolves, where the signer half of the scope is + * simply not known yet. + */ +let heldIdentity = []; +let holdIdentity = false; +/** + * The identity `get_identity` currently reports. Mutable so a test can move it + * the way a mid-session key import does, keeping a refetch consistent with the + * cache write that drove the switch. + */ +let currentIdentityPubkey = SELF; + +before(() => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + localStorage: dom.window.localStorage, + window: dom.window, + }); + dom.window.__TAURI_INTERNALS__ = { + invoke: (command, args) => { + if (command === "get_identity") { + const identity = { pubkey: currentIdentityPubkey, display_name: "Me" }; + if (!holdIdentity) return Promise.resolve(identity); + return new Promise((resolve) => { + heldIdentity.push(() => resolve(identity)); + }); + } + if (command === "start_managed_agent") { + startCalls.push(args); + const started = { pubkey: args.pubkey, status: "running" }; + if (!holdStarts) return Promise.resolve(started); + return new Promise((resolve, reject) => { + heldStarts.push({ + resolve: () => resolve(started), + reject: () => reject(new Error("spawn failed")), + }); + }); + } + return Promise.reject(new Error(`unmocked Tauri command: ${command}`)); + }, + transformCallback: () => 1, + }; + globalThis.__TAURI_INTERNALS__ = dom.window.__TAURI_INTERNALS__; +}); + +after(() => dom.window.close()); + +afterEach(async () => { + // Tests that pin suppression leave their wake deliberately in flight, and + // node:test never finishes a file with a promise that will not settle. The + // same goes for an identity query a test deliberately left pending. + const outstanding = heldStarts; + heldStarts = []; + for (const held of outstanding) held.resolve(); + const outstandingIdentity = heldIdentity; + heldIdentity = []; + for (const resolveIdentity of outstandingIdentity) resolveIdentity(); + await new Promise((resolve) => setTimeout(resolve, 0)); +}); + +beforeEach(async () => { + startCalls = []; + heldStarts = []; + holdStarts = false; + heldIdentity = []; + holdIdentity = false; + currentIdentityPubkey = SELF; + // Toasts queue on a module-level store with no mounted, so one + // test's warning would otherwise be visible to the next. + const { toast } = await import("sonner"); + toast.dismiss(); + // The in-flight map is a module singleton, so a start held open by one test + // would otherwise suppress the next test's. + const { resetDetachedAgentStarts } = await import( + "./useDetachedAgentStart.ts" + ); + resetDetachedAgentStarts(); + // The toast-scope mirror is a module singleton too. Point it at community A + // — what `useCommunityInit` does when A's apply completes — so failure + // warnings deliver by default, as in the running app. + const { resetDetachedToastScope, setDetachedToastScope } = await import( + "@/features/messages/lib/detachedToastScope.ts" + ); + resetDetachedToastScope(); + setDetachedToastScope({ relayUrl: RELAY_A, signerPubkey: SELF }); + window.localStorage.clear(); + window.localStorage.setItem( + "buzz-communities", + JSON.stringify([ + { + id: "community-a", + name: "Tenant A", + relayUrl: RELAY_A, + pubkey: SELF, + addedAt: "2026-01-01T00:00:00Z", + }, + { + id: "community-b", + name: "Tenant B", + relayUrl: RELAY_B, + pubkey: SELF, + addedAt: "2026-01-02T00:00:00Z", + }, + ]), + ); + window.localStorage.setItem("buzz-active-community-id", "community-a"); +}); + +/** + * Renders the real hook under the real communities provider, exposing the + * detached-start callback alongside `switchCommunity` so a test can move the + * active community out from under an already-captured callback. + * + * `act` is returned too, so a test can drive the render that follows a + * deliberately-delayed identity resolution, and the `QueryClient` so a test can + * move the signing identity the way a mid-session import does. + */ +async function renderDetachedStart() { + const { default: React } = await import("react"); + const { act, renderHook } = await import("@testing-library/react"); + const { QueryClient, QueryClientProvider } = await import( + "@tanstack/react-query" + ); + const { CommunitiesProvider, useCommunities } = await import( + "@/features/communities/useCommunities.tsx" + ); + const { useIdentityQuery } = await import("@/shared/api/hooks.ts"); + const { useDetachedAgentStart } = await import("./useDetachedAgentStart.ts"); + + const client = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0 }, + mutations: { gcTime: 0 }, + }, + }); + const wrapper = ({ children }) => + React.createElement( + QueryClientProvider, + { client }, + React.createElement(CommunitiesProvider, null, children), + ); + const rendered = renderHook( + () => ({ + identityPubkey: useIdentityQuery().data?.pubkey, + startDetached: useDetachedAgentStart(), + switchCommunity: useCommunities().switchCommunity, + }), + { wrapper }, + ); + if (!holdIdentity) await waitForIdentity(act, rendered); + return { act, client, rendered }; +} + +/** + * Flushes until the identity query has landed (on `expected`, when given), + * rather than for a fixed number of ticks: a wake fired before the signer + * scope resolves is now refused, so an under-wait would surface as a phantom + * suppression bug. + */ +async function waitForIdentity(act, rendered, expected) { + for (let attempt = 0; attempt < 20; attempt += 1) { + const current = rendered.result.current.identityPubkey; + if (expected ? current === expected : Boolean(current)) return; + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + } + assert.fail( + expected + ? `the identity query never reported ${expected}` + : "the identity query never resolved", + ); +} + +/** + * Puts a different signing key in force mid-session, through the exact seam + * production uses: the membership-denied onboarding overlay imports an nsec and + * writes the new identity straight into the live identity query cache + * (`CommunityOnboardingFlow`), while the app underneath — module singletons, + * in-flight mutations, this map — keeps running. + */ +async function importIdentity(act, rendered, client, pubkey) { + currentIdentityPubkey = pubkey; + await act(async () => { + client.setQueryData(["identity"], { pubkey, display_name: "Me" }); + }); + await waitForIdentity(act, rendered, pubkey); +} + +const AGENT_RECORD = { pubkey: AGENT, name: "fizz" }; +const OTHER_AGENT_RECORD = { pubkey: OTHER_AGENT, name: "buzz" }; + +/** Lets queued microtasks (the mutation, and the map's `finally`) run. */ +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); + +test("a detached start carries the active community and identity as its scope", async () => { + const { act, rendered } = await renderDetachedStart(); + + await act(async () => { + rendered.result.current.startDetached(AGENT_RECORD); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + assert.equal(startCalls.length, 1); + assert.equal( + startCalls[0].expectedRelayUrl, + RELAY_A, + "the stored relay URL must reach the case-sensitive backend check verbatim", + ); + assert.equal(startCalls[0].expectedSignerPubkey, SELF); + // The replay floor still rides along — scoping must not displace it. + assert.ok(startCalls[0].replayFloorUnix > 0); + rendered.unmount(); +}); + +test("an explicit replay floor reaches the start payload verbatim", async () => { + // The send path stamps the floor when it queues the wake (pre-publish, so + // the floor can never exceed the published message's created_at) and only + // flushes the wake after the relay accepts the publish — a flush-time + // stamp could push the harness's startup watermark past that message. + const { act, rendered } = await renderDetachedStart(); + + await act(async () => { + rendered.result.current.startDetached(AGENT_RECORD, 1_234_567); + await settle(); + }); + + assert.equal(startCalls.length, 1); + assert.equal(startCalls[0].replayFloorUnix, 1_234_567); + rendered.unmount(); +}); + +test("a wake with no queued floor captures fire time", async () => { + const { act, rendered } = await renderDetachedStart(); + const before = Math.floor(Date.now() / 1000); + + await act(async () => { + rendered.result.current.startDetached(AGENT_RECORD); + await settle(); + }); + + const after = Math.floor(Date.now() / 1000); + assert.equal(startCalls.length, 1); + assert.ok( + startCalls[0].replayFloorUnix >= before && + startCalls[0].replayFloorUnix <= after, + "callers with no queued floor keep the fire-time capture", + ); + rendered.unmount(); +}); + +test("a start captured before a community switch keeps the pre-switch scope", async () => { + const { act, rendered } = await renderDetachedStart(); + // What a send in flight holds: the callback from the render that fired it. + const capturedStart = rendered.result.current.startDetached; + + await act(async () => { + rendered.result.current.switchCommunity("community-b"); + }); + assert.notEqual( + rendered.result.current.startDetached, + capturedStart, + "the switch must produce a new callback, so the captured one is stale", + ); + + await act(async () => { + capturedStart(AGENT_RECORD); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + assert.equal(startCalls.length, 1); + assert.equal( + startCalls[0].expectedRelayUrl, + RELAY_A, + "the stale wake must name the community it was fired in, not the active one", + ); + rendered.unmount(); +}); + +test("a second wake for the same agent is suppressed while the first is in flight", async () => { + holdStarts = true; + const { act, rendered } = await renderDetachedStart(); + let first; + let second; + + await act(async () => { + first = rendered.result.current.startDetached(AGENT_RECORD); + second = rendered.result.current.startDetached(AGENT_RECORD); + await settle(); + }); + + assert.equal(startCalls.length, 1, "one wake serves both messages"); + assert.equal(first, true); + assert.equal( + second, + false, + "the suppressed call must report that it fired nothing", + ); + rendered.unmount(); +}); + +test("wakes for different agents in one window are not collapsed", async () => { + holdStarts = true; + const { act, rendered } = await renderDetachedStart(); + + await act(async () => { + rendered.result.current.startDetached(AGENT_RECORD); + rendered.result.current.startDetached(OTHER_AGENT_RECORD); + await settle(); + }); + + assert.deepEqual( + startCalls.map((call) => call.pubkey), + [AGENT, OTHER_AGENT], + "the key is per agent, not a global lock on wakes", + ); + rendered.unmount(); +}); + +test("a wake fires again once the previous one has settled", async () => { + holdStarts = true; + const { act, rendered } = await renderDetachedStart(); + + await act(async () => { + rendered.result.current.startDetached(AGENT_RECORD); + await settle(); + }); + await act(async () => { + heldStarts[0].resolve(); + await settle(); + }); + await act(async () => { + rendered.result.current.startDetached(AGENT_RECORD); + await settle(); + }); + + assert.equal(startCalls.length, 2, "suppression must not outlive the start"); + rendered.unmount(); +}); + +test("an A→B→A community round-trip does not reopen the in-flight window", async () => { + // The production seam — `resetCommunityState` deliberately not clearing the + // map on switch — is pinned E2E, where the real switch path runs. This pins + // the hook contract that makes retention sufficient: the round-trip hands + // back a callback carrying A's scope again, its key matches the retained + // entry, and the wake stays suppressed while the first start (whose scope + // is valid again now that A is re-applied) is still deploying. + holdStarts = true; + const { act, rendered } = await renderDetachedStart(); + + await act(async () => { + rendered.result.current.startDetached(AGENT_RECORD); + await settle(); + }); + await act(async () => { + rendered.result.current.switchCommunity("community-b"); + }); + await act(async () => { + rendered.result.current.switchCommunity("community-a"); + }); + + let refire; + await act(async () => { + refire = rendered.result.current.startDetached(AGENT_RECORD); + await settle(); + }); + + assert.equal(refire, false, "the retained entry must still suppress"); + assert.equal( + startCalls.length, + 1, + "a round-trip must not duplicate a deploy the first start is still performing", + ); + rendered.unmount(); +}); + +test("a wake re-fires once the start held across a round-trip has rejected", async () => { + // Retention ends at settlement, not at some later reset: the `finally` + // self-cleans, so a failed start never latches the agent — the user's next + // send after the failure toast gets a real wake. + holdStarts = true; + const { act, rendered } = await renderDetachedStart(); + + await act(async () => { + rendered.result.current.startDetached(AGENT_RECORD); + await settle(); + }); + await act(async () => { + rendered.result.current.switchCommunity("community-b"); + }); + await act(async () => { + rendered.result.current.switchCommunity("community-a"); + }); + await act(async () => { + heldStarts[0].reject(); + await settle(); + }); + await act(async () => { + assert.equal(rendered.result.current.startDetached(AGENT_RECORD), true); + await settle(); + }); + + assert.equal( + startCalls.length, + 2, + "settlement must end the suppression even across a round-trip", + ); + rendered.unmount(); +}); + +test("a failed wake clears the key instead of latching the agent", async () => { + holdStarts = true; + const { act, rendered } = await renderDetachedStart(); + + await act(async () => { + rendered.result.current.startDetached(AGENT_RECORD); + await settle(); + }); + await act(async () => { + heldStarts[0].reject(); + await settle(); + }); + // The user saw "your message was sent, but the agent may not respond" and + // retries; clearing on success only would refuse every retry for the session. + await act(async () => { + assert.equal(rendered.result.current.startDetached(AGENT_RECORD), true); + await settle(); + }); + + assert.equal(startCalls.length, 2); + rendered.unmount(); +}); + +/** The titles of every toast raised since the last `beforeEach`. */ +async function toastTitles() { + const { toast } = await import("sonner"); + return toast.getToasts().map((entry) => String(entry.title ?? "")); +} + +const MAY_NOT_RESPOND = /your message was sent, but the agent may not respond/; + +test("a wake is refused while the identity that would scope it is unresolved", async () => { + // The window before `get_identity` lands. `expectedSignerPubkey` would be + // undefined here, and the backend reads that as "no assertion" — so the + // wake would resolve the signing identity at execution time, which is the + // cross-tenant spawn the scope exists to prevent. + holdIdentity = true; + const { act, rendered } = await renderDetachedStart(); + + let fired; + await act(async () => { + fired = rendered.result.current.startDetached(AGENT_RECORD); + await settle(); + }); + + assert.equal(fired, false, "a refused wake must not be counted as one"); + assert.equal(startCalls.length, 0, "an unscoped wake must not be fired"); + assert.match( + (await toastTitles()).join("\n"), + MAY_NOT_RESPOND, + "publish-first means the message went out; the user has to be told the agent did not wake", + ); + + // The refusal covers this moment, not the session: once the query lands the + // next send fires a fully scoped wake. + for (const resolveIdentity of heldIdentity.splice(0)) resolveIdentity(); + await waitForIdentity(act, rendered); + await act(async () => { + assert.equal(rendered.result.current.startDetached(AGENT_RECORD), true); + await settle(); + }); + + assert.equal(startCalls.length, 1); + assert.equal(startCalls[0].expectedRelayUrl, RELAY_A); + assert.equal(startCalls[0].expectedSignerPubkey, SELF); + rendered.unmount(); +}); + +test("a wake is refused when no community is active to scope it", async () => { + window.localStorage.setItem("buzz-communities", JSON.stringify([])); + window.localStorage.removeItem("buzz-active-community-id"); + const { act, rendered } = await renderDetachedStart(); + + let fired; + await act(async () => { + fired = rendered.result.current.startDetached(AGENT_RECORD); + await settle(); + }); + + assert.equal(fired, false); + assert.equal(startCalls.length, 0); + assert.match((await toastTitles()).join("\n"), MAY_NOT_RESPOND); + rendered.unmount(); +}); + +test("a blank relay URL is refused rather than sent as no assertion", async () => { + // `assert_expected_relay_scope` discards a whitespace-only scope exactly as + // it discards a missing one, so emptiness has to be judged on the trimmed + // form here — a blank stored URL is an unscoped wake, not a scoped one. + window.localStorage.setItem( + "buzz-communities", + JSON.stringify([ + { + id: "community-blank", + name: "Blank", + relayUrl: " ", + pubkey: SELF, + addedAt: "2026-01-01T00:00:00Z", + }, + ]), + ); + window.localStorage.setItem("buzz-active-community-id", "community-blank"); + const { act, rendered } = await renderDetachedStart(); + + await act(async () => { + assert.equal(rendered.result.current.startDetached(AGENT_RECORD), false); + await settle(); + }); + + assert.equal(startCalls.length, 0); + rendered.unmount(); +}); + +/** The scope-mirror module, driven directly in place of `useCommunityInit`. */ +async function toastScopeModule() { + return import("@/features/messages/lib/detachedToastScope.ts"); +} + +test("a start failure warns while the community it was fired in is on screen", async () => { + // The positive control for the fence: with the mirror still pointing at the + // scope the wake captured (beforeEach models A's completed apply), the + // failure toast must deliver — a fence that suppresses everything would + // silently drop the only signal that the agent never woke. + holdStarts = true; + const { act, rendered } = await renderDetachedStart(); + + await act(async () => { + rendered.result.current.startDetached(AGENT_RECORD); + await settle(); + }); + await act(async () => { + heldStarts[0].reject(); + await settle(); + }); + + assert.match( + (await toastTitles()).join("\n"), + MAY_NOT_RESPOND, + "an on-scope failure must keep warning the user", + ); + rendered.unmount(); +}); + +test("a start failure fired in one community stays silent while another is on screen", async (t) => { + holdStarts = true; + const { act, rendered } = await renderDetachedStart(); + + await act(async () => { + rendered.result.current.startDetached(AGENT_RECORD); + await settle(); + }); + + // What a real switch does to the mirror: `resetCommunityState` clears it, + // B's completed apply repoints it. The rejection then lands with B's UI on + // screen — where a toast naming A's agent would read as a bug in B. + const { resetDetachedToastScope, setDetachedToastScope } = + await toastScopeModule(); + resetDetachedToastScope(); + setDetachedToastScope({ relayUrl: RELAY_B, signerPubkey: SELF }); + + const warn = t.mock.method(console, "warn", () => {}); + await act(async () => { + heldStarts[0].reject(); + await settle(); + }); + + assert.doesNotMatch( + (await toastTitles()).join("\n"), + MAY_NOT_RESPOND, + "community A's failure must not toast over community B", + ); + assert.ok( + warn.mock.calls.some((call) => + String(call.arguments[0]).includes( + "suppressed a start-failure warning for fizz", + ), + ), + "suppression must stay diagnosable in the console", + ); + rendered.unmount(); +}); + +test("a start failure that settles mid-switch stays silent", async (t) => { + // The window between `resetCommunityState` and the next apply: no scope is + // on screen at all, so delivery fails closed exactly like a mismatch. + holdStarts = true; + const { act, rendered } = await renderDetachedStart(); + + await act(async () => { + rendered.result.current.startDetached(AGENT_RECORD); + await settle(); + }); + + const { resetDetachedToastScope } = await toastScopeModule(); + resetDetachedToastScope(); + + const warn = t.mock.method(console, "warn", () => {}); + await act(async () => { + heldStarts[0].reject(); + await settle(); + }); + + assert.doesNotMatch((await toastTitles()).join("\n"), MAY_NOT_RESPOND); + assert.ok( + warn.mock.calls.some((call) => + String(call.arguments[0]).includes( + "suppressed a start-failure warning for fizz", + ), + ), + ); + rendered.unmount(); +}); + +test("an A→B→A round-trip keeps the warning deliverable back in A", async () => { + // The fence compares scopes at toast time — deliberately not "has a reset + // happened since capture". A generation check would get this wrong: the + // user is back in A when the slow start settles, the warning concerns the + // community on screen, and re-mentioning the agent is actionable right + // there. + holdStarts = true; + const { act, rendered } = await renderDetachedStart(); + + await act(async () => { + rendered.result.current.startDetached(AGENT_RECORD); + await settle(); + }); + + const { resetDetachedToastScope, setDetachedToastScope } = + await toastScopeModule(); + resetDetachedToastScope(); + setDetachedToastScope({ relayUrl: RELAY_B, signerPubkey: SELF }); + resetDetachedToastScope(); + setDetachedToastScope({ relayUrl: RELAY_A, signerPubkey: SELF }); + + await act(async () => { + heldStarts[0].reject(); + await settle(); + }); + + assert.match( + (await toastTitles()).join("\n"), + MAY_NOT_RESPOND, + "back in A, the warning is on-scope again and must show", + ); + rendered.unmount(); +}); + +test("a wake for the same agent in another community is not suppressed", async () => { + holdStarts = true; + const { act, rendered } = await renderDetachedStart(); + + await act(async () => { + rendered.result.current.startDetached(AGENT_RECORD); + await settle(); + }); + await act(async () => { + rendered.result.current.switchCommunity("community-b"); + }); + await act(async () => { + rendered.result.current.startDetached(AGENT_RECORD); + await settle(); + }); + + assert.deepEqual( + startCalls.map((call) => call.expectedRelayUrl), + [RELAY_A, RELAY_B], + "the key carries the relay, so one tenant's in-flight wake never suppresses another's", + ); + rendered.unmount(); +}); + +test("a wake under a newly imported identity is not suppressed by one held under the old", async () => { + // The signer half of the same boundary. `start_managed_agent` asserts the + // expected signer before it spawns or deploys — and a provider deploy + // re-asserts it against the payload rebuilt after the deploy lock — so a + // start held under the previous identity is not the wake this one is owed. + // Suppressing it would drop every send for the length of a cold spawn or + // first deploy, and leave the agent deployed under the stale owner. + holdStarts = true; + const { act, client, rendered } = await renderDetachedStart(); + + await act(async () => { + rendered.result.current.startDetached(AGENT_RECORD); + await settle(); + }); + await importIdentity(act, rendered, client, IMPORTED_SELF); + + let refire; + await act(async () => { + refire = rendered.result.current.startDetached(AGENT_RECORD); + await settle(); + }); + + assert.equal(refire, true, "the identity now in force is owed its own wake"); + assert.deepEqual( + startCalls.map((call) => call.expectedSignerPubkey), + [SELF, IMPORTED_SELF], + "the key carries the signer, so one identity's in-flight wake never suppresses another's", + ); + // Same agent on the same relay: the signer is the only thing separating the + // two operations, which is exactly what the pre-fix key could not see. + assert.deepEqual( + startCalls.map((call) => call.pubkey), + [AGENT, AGENT], + ); + assert.deepEqual( + startCalls.map((call) => call.expectedRelayUrl), + [RELAY_A, RELAY_A], + ); + rendered.unmount(); +}); + +test("settling one signer's start leaves the other signer's suppression intact", async () => { + // Per-signer settlement independence: the `finally` delete closes over the + // key it registered, so freeing the imported identity's entry must not lift + // the suppression the still-held old-identity start is providing (nor the + // reverse). + holdStarts = true; + const { act, client, rendered } = await renderDetachedStart(); + // What a send fired before the import holds: the callback from the render + // that fired it, still carrying the old signer. + const startAsOldIdentity = rendered.result.current.startDetached; + + await act(async () => { + startAsOldIdentity(AGENT_RECORD); + await settle(); + }); + await importIdentity(act, rendered, client, IMPORTED_SELF); + await act(async () => { + rendered.result.current.startDetached(AGENT_RECORD); + await settle(); + }); + assert.equal(startCalls.length, 2, "both signers have a start in flight"); + + await act(async () => { + heldStarts[1].resolve(); + await settle(); + }); + + let refireImported; + let refireOld; + await act(async () => { + refireImported = rendered.result.current.startDetached(AGENT_RECORD); + refireOld = startAsOldIdentity(AGENT_RECORD); + await settle(); + }); + + assert.equal(refireImported, true, "the settled signer's key is free again"); + assert.equal( + refireOld, + false, + "the still-held signer's entry must survive another signer's settlement", + ); + assert.equal(startCalls.length, 3); + assert.equal(startCalls[2].expectedSignerPubkey, IMPORTED_SELF); + rendered.unmount(); +}); diff --git a/desktop/src/features/messages/ui/useDetachedAgentStart.ts b/desktop/src/features/messages/ui/useDetachedAgentStart.ts new file mode 100644 index 00000000000..0303e98e193 --- /dev/null +++ b/desktop/src/features/messages/ui/useDetachedAgentStart.ts @@ -0,0 +1,223 @@ +import * as React from "react"; +import { toast } from "sonner"; +import { useStartManagedAgentMutation } from "@/features/agents/hooks"; +import { useCommunities } from "@/features/communities/useCommunities"; +import { matchesDetachedToastScope } from "@/features/messages/lib/detachedToastScope"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import type { ManagedAgent } from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { getErrorMessage } from "./useMentionSendFlow.helpers"; + +/** + * Detached starts still in flight, keyed by the full tenant scope the wake + * asserts: `(scoped relay URL, expected signer, agent pubkey)`. + * + * Awaiting the start used to make a duplicate unreachable: `isPending` was a + * hard early return in the composer's send handler, so no second send could + * begin, and by the time it lifted the mutation's `onSuccess` had written the + * `running`/`deployed` record into the query cache. Detaching removes both — + * for the whole in-flight window the cache still reads `stopped`, so a second + * send re-fires. Module-level rather than a ref because the overlaps worth + * collapsing include cross-composer ones (channel composer, thread panel, + * `NewMessageScreen` each hold their own `useMentionSendFlow`). + * + * The key is the asserted scope, not the backend's `ManagedAgentRuntimeKey` + * (which is `(pubkey, relay_url)` — it tracks *runtimes*, while this map tracks + * scoped start *operations*). `start_managed_agent` asserts relay **and** + * signer before it spawns or deploys, so coalescing on the relay alone would + * let a start held under one signing identity suppress another identity's wake + * for the same agent on the same relay — a mid-session key import (the + * membership-denied overlay writes a new identity straight into the live query + * cache) inside a deploy window is enough to reach it. Both halves are in the + * key, so a wake is only ever suppressed by one asserting exactly the same + * scope. + * + * Entries deliberately survive community switches (`resetCommunityState` does + * NOT clear this map). A held start is not invalidated by leaving its + * community: the backend's scope assertion is a current-state check, so a + * start fired in A is valid again the moment A is re-applied — an A→B→A + * round-trip that cleared the map would let a second send duplicate a + * provider deploy the first start is still performing (and hand the harness + * the second message's replay floor, past the first message). The key is the + * tenant scope, so a retained entry cannot affect another community, and the + * `finally` below self-cleans on settlement, which the deploy op bounds. + */ +const inFlightDetachedStarts = new Map>(); + +/** + * Drops every tracked in-flight start. Test-only isolation seam — one test's + * held start must not suppress the next test's. Production deliberately never + * calls this: see the map's doc for why entries survive community switches. + */ +export function resetDetachedAgentStarts(): void { + inFlightDetachedStarts.clear(); +} + +/** + * The backend fails a scope-mismatched start closed with a message ending in + * "not sent". That reads wrong here: publish-first means the message *was* + * published — only the wake was refused — so say what actually happened. + */ +function detachedStartFailureDetail(error: unknown): string { + const message = getErrorMessage(error, "Could not start agent."); + return message.includes("active community changed") || + message.includes("active identity changed") + ? "You switched community or identity before it could start." + : message; +} + +/** + * The one wording for "the wake did not happen". The send path flushes its + * queued wakes only after the relay accepts the publish, so whenever this + * toast can appear the message really was sent — both the refused-before- + * firing and the failed-after-firing cases owe the user the same warning, + * differing only in the detail that follows it. + */ +function warnAgentMayNotRespond(agentName: string, detail: string): void { + toast.error( + `Could not start ${agentName} — your message was sent, but the agent may not respond. ${detail}`, + ); +} + +/** + * Fire-and-forget managed-agent start for the publish-first mention send, + * bound to the tenant scope that was active when the send fired. + * + * Detaching the start means the call outlives the send, the channel, and — + * since a community switch only remounts the React subtree — the community + * itself. `start_managed_agent` resolves the workspace relay and the signing + * identity at *execution* time, so an unscoped detached start can spawn or + * deploy the agent against whichever tenant is active when it lands, carrying + * the previous community's replay floor. The relay URL and the signing keys + * change under separate locks during a switch, so both are captured (the + * relay alone would still let the new identity act for the old tenant) and + * `start_managed_agent` fails closed when either no longer matches. This is + * the same binding `submitProjectAgentMessage` applies for the same + * outlives-its-caller reason. + * + * Capture is per render: the callback closes over the community and identity + * that were active when the composer last rendered, which is the send the + * user pressed — never a value re-read after the switch it guards against. + * + * If either half of that scope is not yet known — no active community, or an + * identity query that has not resolved — the wake is refused rather than fired + * unscoped. The backend reads a missing value as "no assertion", so an + * unscoped detached start is exactly the cross-tenant spawn this hook exists + * to prevent, and its dedupe key would collapse to a relay-less one shared + * across communities. Waiting for the query instead of refusing is not an + * option: reading the scope once it resolves is a post-send read, which is the + * thing the per-render capture rules out. Refusing is visible (a toast, and a + * `false` return) and the user's next send re-fires it. + * + * Returns whether this call actually fired a wake: a start already in flight + * for the same agent under the same asserted scope — relay *and* signer — is + * suppressed, since the wake is per-agent rather than per-message and the + * first start's replay floor is earlier than the second message. + * + * `replayFloorUnix` lets a caller pass a floor captured earlier than this + * call — the send path queues its wakes during preparation and flushes them + * only after the publish succeeds, so the floor must be the enqueue-time + * capture (≤ the message's `created_at` by construction), not flush time, + * which could exceed it and push the harness's startup watermark past the + * very message the floor exists to cover. Callers with no queued floor omit + * it and get fire time. + */ +export function useDetachedAgentStart(): ( + agent: ManagedAgent, + replayFloorUnix?: number, +) => boolean { + const startAgentMutateAsync = useStartManagedAgentMutation().mutateAsync; + const { activeCommunity } = useCommunities(); + const identityQuery = useIdentityQuery(); + // Handed over verbatim: `assert_expected_relay_scope` runs both sides + // through `relay_http_base_url` (trim, strip trailing slash, ws→http), and + // that comparison is case-sensitive. Lowercasing here — as the shared + // storage-key normalizer does — would turn a stored `wss://Relay.Example` + // into a permanent spurious mismatch that refuses every wake. Emptiness is + // judged on the trimmed form because the backend does the same, and reads a + // blank scope as no assertion at all. + const expectedRelayUrl = activeCommunity?.relayUrl?.trim() + ? activeCommunity.relayUrl + : undefined; + // The signer check is case-insensitive, so canonicalizing is free here. + const expectedSignerPubkey = + normalizePubkey(identityQuery.data?.pubkey ?? "") || undefined; + return React.useCallback( + (agent: ManagedAgent, replayFloorUnix?: number) => { + if (!expectedRelayUrl || !expectedSignerPubkey) { + // Fail closed: an unscoped start resolves the relay and the signing + // identity at execution time, so it can land on whichever tenant is + // active by then — and its dedupe key would be shared across + // communities. + warnAgentMayNotRespond( + agent.name, + "Buzz is still connecting to this community — mention the agent again in a moment.", + ); + return false; + } + // No synchronisation is needed or possible: the check, the call and the + // registration below sit in one synchronous block, so no other send can + // interleave between "is it in the set?" and "put it in the set". + // + // Relay verbatim, because its backend comparison is case-sensitive; + // signer and agent normalized. `expectedSignerPubkey` is already + // canonicalized at capture, matching `assert_expected_signer`'s + // case-insensitive compare, so two casings of one identity cannot split + // the key. + const key = `${expectedRelayUrl}\u0000${expectedSignerPubkey}\u0000${normalizePubkey(agent.pubkey)}`; + if (inFlightDetachedStarts.has(key)) { + // One wake serves both messages. A local duplicate is a backend no-op + // anyway, but a provider redeploy can replace a harness that had just + // come up to answer the first message — and the user would get two + // failure toasts for one problem. + return false; + } + // Publish-first: the send no longer waits for the agent start. The + // replay floor tells the spawned harness to replay at least back to + // the message that wanted this wake — the enqueue-time capture when the + // send path queued it, or this moment for a caller with no queue — so + // that message is inside the harness's first subscription window + // however long the spawn takes. + const started = startAgentMutateAsync({ + pubkey: agent.pubkey, + expectedRelayUrl, + expectedSignerPubkey, + replayFloorUnix: replayFloorUnix ?? Math.floor(Date.now() / 1000), + }) + .catch((error: unknown) => { + // This settles arbitrarily long after the send, and `` + // mounts outside the community remount boundary — so an unfenced + // warning would render this community's agent name and error detail + // over whichever community is on screen by then. Deliver only while + // the scope captured above is the one being looked at; on an A→B→A + // round-trip the mirror matches again and the warning lands exactly + // where re-mentioning the agent is possible. Suppression is logged, + // not reworded: any wording still names another community's agent. + if ( + !matchesDetachedToastScope(expectedRelayUrl, expectedSignerPubkey) + ) { + console.warn( + `[useDetachedAgentStart] suppressed a start-failure warning for ${agent.name}: the community it was fired in is no longer on screen`, + error, + ); + return; + } + warnAgentMayNotRespond(agent.name, detachedStartFailureDetail(error)); + }) + .finally(() => { + // Identity-guarded: only test isolation clears this map now + // (production retains entries across community switches), but if a + // reset-and-re-register did interleave while this start was in + // flight, an unguarded delete would drop the newer entry. Clearing + // in `finally` rather than on success is what keeps a failed start + // from latching the agent permanently. + if (inFlightDetachedStarts.get(key) === started) { + inFlightDetachedStarts.delete(key); + } + }); + inFlightDetachedStarts.set(key, started); + return true; + }, + [expectedRelayUrl, expectedSignerPubkey, startAgentMutateAsync], + ); +} diff --git a/desktop/src/features/messages/ui/useEnsureAgentMentionsReady.test.mjs b/desktop/src/features/messages/ui/useEnsureAgentMentionsReady.test.mjs new file mode 100644 index 00000000000..4646bff0fd4 --- /dev/null +++ b/desktop/src/features/messages/ui/useEnsureAgentMentionsReady.test.mjs @@ -0,0 +1,178 @@ +/** + * The mentioned-agent readiness pass queues detached wakes instead of firing + * them (PR #7154 review point 1). A wake fired during send preparation runs + * before the relay has accepted the publish, so a fast start rejection could + * toast "your message was sent" while the send outcome was unknown — and any + * abort after the wake stranded a started harness for a message that never + * landed. These tests pin the queue contract: nothing starts during the + * pass, every mentioned agent that is not up lands in `agentsToWake`, and + * each entry's replay floor is stamped at enqueue time. + */ + +import assert from "node:assert/strict"; +import { after, before, beforeEach, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +const MEMBER_AGENT = "a".repeat(64); +const OTHER_AGENT = "b".repeat(64); +const CHANNEL_ID = "11111111-2222-3333-4444-555555555555"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +/** Every backend command reached during a test — the pass must reach none. */ +let tauriInvocations = []; + +before(() => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + localStorage: dom.window.localStorage, + window: dom.window, + }); + dom.window.__TAURI_INTERNALS__ = { + invoke: (command) => { + tauriInvocations.push(command); + return Promise.reject(new Error(`unexpected Tauri command: ${command}`)); + }, + transformCallback: () => 1, + }; + globalThis.__TAURI_INTERNALS__ = dom.window.__TAURI_INTERNALS__; +}); + +after(() => dom.window.close()); + +beforeEach(() => { + tauriInvocations = []; +}); + +function managedAgent(overrides = {}) { + return { + pubkey: MEMBER_AGENT, + name: "fizz", + personaId: null, + status: "stopped", + backend: { type: "local" }, + respondTo: "owner-only", + respondToAllowlist: [], + ...overrides, + }; +} + +/** Renders the real hook with injected seams; `overrides` vary per test. */ +async function renderEnsureReady(overrides = {}) { + const { renderHook } = await import("@testing-library/react"); + const { useEnsureAgentMentionsReady } = await import( + "./useEnsureAgentMentionsReady.ts" + ); + const options = { + attachAgentToChannel: async () => { + throw new Error("attach must not run for an existing member"); + }, + getManagedAgentsByPubkey: async () => new Map(), + getPersonas: async () => [], + memberPubkeys: new Set(), + ...overrides, + }; + return renderHook(() => useEnsureAgentMentionsReady(options)); +} + +test("a stopped member agent is queued for a post-publish wake, never fired", async () => { + const agent = managedAgent(); + const rendered = await renderEnsureReady({ + getManagedAgentsByPubkey: async () => new Map([[MEMBER_AGENT, agent]]), + memberPubkeys: new Set([MEMBER_AGENT]), + }); + + const floorLowerBound = Math.floor(Date.now() / 1000); + const result = await rendered.result.current([MEMBER_AGENT], CHANNEL_ID); + const floorUpperBound = Math.floor(Date.now() / 1000); + + assert.deepEqual(result.errors, []); + assert.deepEqual(result.pubkeys, [MEMBER_AGENT]); + assert.equal(result.agentsToWake.length, 1); + assert.equal(result.agentsToWake[0].agent, agent); + // Stamped at enqueue time — before the publish — so the floor can never + // exceed the published message's created_at, however long the flush waits. + assert.ok( + result.agentsToWake[0].replayFloorUnix >= floorLowerBound && + result.agentsToWake[0].replayFloorUnix <= floorUpperBound, + "the replay floor must be the enqueue-time capture", + ); + assert.deepEqual( + tauriInvocations, + [], + "the readiness pass must not start the agent (or touch the backend)", + ); + rendered.unmount(); +}); + +test("only agents that are not up are queued", async () => { + const runningLocal = managedAgent({ status: "running" }); + const undeployedProvider = managedAgent({ + pubkey: OTHER_AGENT, + name: "portal", + status: "not_deployed", + backend: { type: "provider", id: "portal", config: {} }, + }); + const rendered = await renderEnsureReady({ + getManagedAgentsByPubkey: async () => + new Map([ + [MEMBER_AGENT, runningLocal], + [OTHER_AGENT, undeployedProvider], + ]), + memberPubkeys: new Set([MEMBER_AGENT, OTHER_AGENT]), + }); + + const result = await rendered.result.current( + [MEMBER_AGENT, OTHER_AGENT], + CHANNEL_ID, + ); + + assert.deepEqual(result.errors, []); + assert.deepEqual( + result.agentsToWake.map((wake) => wake.agent.pubkey), + [OTHER_AGENT], + "a running agent needs no wake; a not-deployed provider agent does", + ); + rendered.unmount(); +}); + +test("a non-member agent's wake queues through the attach seam instead of firing", async () => { + const agent = managedAgent(); + const attachCalls = []; + const rendered = await renderEnsureReady({ + attachAgentToChannel: async (input) => { + attachCalls.push(input); + // The production attach invokes detachedStart for a not-up agent once + // its membership write lands; the collector must queue, not start. + input.detachedStart(input.agent); + return {}; + }, + getManagedAgentsByPubkey: async () => new Map([[MEMBER_AGENT, agent]]), + }); + + const result = await rendered.result.current([MEMBER_AGENT], CHANNEL_ID); + + assert.equal(attachCalls.length, 1); + assert.equal(attachCalls[0].channelId, CHANNEL_ID); + assert.equal( + result.wroteRelayState, + true, + "the awaited membership write still marks the publish-boundary pass", + ); + assert.deepEqual( + result.agentsToWake.map((wake) => wake.agent.pubkey), + [MEMBER_AGENT], + ); + assert.ok(result.agentsToWake[0].replayFloorUnix > 0); + assert.deepEqual( + tauriInvocations, + [], + "no start may fire while the send is still preparing", + ); + rendered.unmount(); +}); diff --git a/desktop/src/features/messages/ui/useEnsureAgentMentionsReady.ts b/desktop/src/features/messages/ui/useEnsureAgentMentionsReady.ts new file mode 100644 index 00000000000..4c3485275ed --- /dev/null +++ b/desktop/src/features/messages/ui/useEnsureAgentMentionsReady.ts @@ -0,0 +1,159 @@ +import * as React from "react"; +import { applyReusableAgentAccessPolicy } from "@/features/agents/channelAgents"; +import type { AgentPersona, ManagedAgent } from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { + enqueueAgentWake, + getErrorMessage, + isManagedAgentRunning, + isProviderBackedAgent, + type QueuedAgentWake, + uniqueNormalizedPubkeys, +} from "./useMentionSendFlow.helpers"; + +/** What the send path learned while making the mentioned agents ready. */ +export type EnsureAgentMentionsReadyResult = { + errors: string[]; + pubkeys: string[]; + /** + * Whether an awaited relay write ran. Informational only: the publish + * boundary revalidates mention authorization unconditionally, so nothing + * consumes this to decide anything — it stays because the signal is + * truthful by construction and unit-pinned. + */ + wroteRelayState: boolean; + /** + * Detached wakes this pass queued instead of firing. The caller flushes + * them only after the relay accepts the publish, so a wake — and its + * failure toast claiming "your message was sent" — can never precede the + * publish outcome, and an aborted send simply drops the queue. Each entry's + * replay floor was stamped at enqueue time (see `QueuedAgentWake`). + */ + agentsToWake: QueuedAgentWake[]; +}; + +export type EnsureAgentMentionsReady = ( + mentionPubkeys: string[], + capturedChannelId: string, + preparedParticipantPubkeys?: string[], + preparedManagedAgents?: ManagedAgent[], +) => Promise; + +type AttachAgentToChannel = (input: { + channelId: string; + agent: ManagedAgent; + role: "bot"; + detachedStart: (agent: ManagedAgent) => void; +}) => Promise; + +type UseEnsureAgentMentionsReadyOptions = { + attachAgentToChannel: AttachAgentToChannel; + getManagedAgentsByPubkey: () => Promise>; + getPersonas: () => Promise; + memberPubkeys: ReadonlySet; +}; + +/** + * Reconcile every mentioned managed agent into a state where it will see the + * message about to be published: access policy applied, channel membership + * written, and — for an agent that is not already up — a detached wake + * queued on the result for the caller to flush once the publish succeeds. + * + * The membership write is awaited because the harness only subscribes to + * channels it belongs to; only the start itself is detached, and it is + * queued rather than fired so it cannot outrun the publish it exists for. + */ +export function useEnsureAgentMentionsReady({ + attachAgentToChannel, + getManagedAgentsByPubkey, + getPersonas, + memberPubkeys, +}: UseEnsureAgentMentionsReadyOptions): EnsureAgentMentionsReady { + return React.useCallback( + async ( + mentionPubkeys: string[], + capturedChannelId: string, + preparedParticipantPubkeys: string[] = [], + preparedManagedAgents: ManagedAgent[] = [], + ) => { + if (!capturedChannelId || mentionPubkeys.length === 0) { + return { + errors: [] as string[], + pubkeys: [] as string[], + wroteRelayState: false, + agentsToWake: [] as QueuedAgentWake[], + }; + } + const [managedAgentsByPubkey, personas] = await Promise.all([ + getManagedAgentsByPubkey(), + getPersonas(), + ]); + for (const agent of preparedManagedAgents) { + managedAgentsByPubkey.set(normalizePubkey(agent.pubkey), agent); + } + const existingMembers = new Set([...memberPubkeys].map(normalizePubkey)); + const participants = new Set([ + ...existingMembers, + ...preparedParticipantPubkeys.map(normalizePubkey), + ]); + const errors: string[] = []; + const pubkeys: string[] = []; + let wroteRelayState = false; + const agentsToWake: QueuedAgentWake[] = []; + for (const pubkey of uniqueNormalizedPubkeys(mentionPubkeys)) { + const agent = managedAgentsByPubkey.get(pubkey); + if (!agent) continue; + try { + const { agent: readyAgent, wrote } = existingMembers.has(pubkey) + ? { agent, wrote: false } + : await applyReusableAgentAccessPolicy( + agent, + {}, + personas.find((persona) => persona.id === agent.personaId), + ); + if (wrote) { + // The access-policy reconciliation hit the relay; a matching + // policy reports `wrote: false`. + wroteRelayState = true; + } + if (participants.has(pubkey)) { + if ( + (isProviderBackedAgent(readyAgent) && + readyAgent.status !== "deployed") || + (!isProviderBackedAgent(readyAgent) && + !isManagedAgentRunning(readyAgent)) + ) { + enqueueAgentWake(agentsToWake, readyAgent); + } + } else { + await attachAgentToChannel({ + channelId: capturedChannelId, + agent: readyAgent, + role: "bot", + detachedStart: (agentToWake) => + enqueueAgentWake(agentsToWake, agentToWake), + }); + wroteRelayState = true; + } + pubkeys.push(pubkey); + } catch (error) { + errors.push( + `${agent.name}: ${getErrorMessage(error, "Could not prepare agent.")}`, + ); + } + } + return { + errors, + pubkeys: uniqueNormalizedPubkeys(pubkeys), + wroteRelayState, + agentsToWake, + }; + }, + [ + attachAgentToChannel, + getManagedAgentsByPubkey, + getPersonas, + memberPubkeys, + ], + ); +} diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts b/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts index f0c71bd393e..e4c4f0d806e 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts @@ -11,6 +11,51 @@ import { MENTION_REFERENCE_TAG } from "@/shared/lib/resolveMentionNames"; export { MENTION_REFERENCE_TAG }; +/** + * A detached managed-agent wake queued while the send path prepared a + * message. Queued wakes are flushed fire-and-forget only after the relay + * accepts the publish: firing earlier lets a fast start failure toast "your + * message was sent" before the publish outcome is known, and every abort + * path (cancel, readiness error, prompt dismissal, publish rejection) would + * strand a wake for a message that never landed. + */ +export type QueuedAgentWake = { + agent: ManagedAgent; + /** + * Unix seconds captured at enqueue time — before the publish — so the + * floor can never exceed the published message's `created_at`. Stamping at + * flush time instead could push the spawned harness's startup watermark + * past the very message the floor exists to cover: a background upload + * makes the enqueue-to-flush gap arbitrarily long. + */ + replayFloorUnix: number; +}; + +/** Queue a wake for `agent`, stamping its replay floor now (enqueue time). */ +export function enqueueAgentWake( + queue: QueuedAgentWake[], + agent: ManagedAgent, +): void { + queue.push({ agent, replayFloorUnix: Math.floor(Date.now() / 1000) }); +} + +/** + * Collapse queued wakes to one per agent, keeping the first: the earliest + * enqueue carries the earliest replay floor, and the floor is a lower bound, + * so the first wake covers every later mention in the same send. + */ +export function dedupeQueuedAgentWakes( + wakes: readonly QueuedAgentWake[], +): QueuedAgentWake[] { + const seen = new Set(); + return wakes.filter((wake) => { + const pubkey = normalizePubkey(wake.agent.pubkey); + if (seen.has(pubkey)) return false; + seen.add(pubkey); + return true; + }); +} + export type PendingNonMemberMentionSend = { addressedAgentPubkeys: string[]; inlineAgentMentionPubkeys: string[]; @@ -25,6 +70,12 @@ export type PendingNonMemberMentionSend = { outgoingTags?: string[][]; preparedLinkPreviews?: PreparedBackgroundLinkPreviews | null; preparedManagedAgents?: ManagedAgent[]; + /** + * Wakes queued while creating mentioned persona agents, carried on the + * draft so they survive the non-member prompt and flush with the readiness + * pass's queue after the publish succeeds — a dismissed prompt drops them. + */ + queuedAgentWakes?: QueuedAgentWake[]; readyAgentPubkeys?: string[]; savedContent: string; savedImeta: ImetaMedia[]; diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index 021b4ea441a..a8de65a75ca 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -8,9 +8,7 @@ import { useManagedAgentsQuery, usePersonasQuery, useProvisionChannelManagedAgentMutation, - useStartManagedAgentMutation, } from "@/features/agents/hooks"; -import { applyReusableAgentAccessPolicy } from "@/features/agents/channelAgents"; import { resolvePersonaRuntime } from "@/features/agents/lib/resolvePersonaRuntime"; import { useAddChannelMembersMutation } from "@/features/channels/hooks"; import { useCanAddChannelMembers } from "@/features/channels/useCanAddChannelMembers"; @@ -25,19 +23,22 @@ import { type ImetaMedia, } from "@/features/messages/lib/imetaMediaMarkdown"; import { useActivePreparedLinkPreviews } from "./useActivePreparedLinkPreviews"; +import { useDetachedAgentStart } from "./useDetachedAgentStart"; +import { useEnsureAgentMentionsReady } from "./useEnsureAgentMentionsReady"; import { invokeTauri } from "@/shared/api/tauri"; import type { AcpRuntime, ManagedAgent } from "@/shared/api/types"; import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; import { buildCustomEmojiTags } from "@/shared/lib/customEmojiTags"; import { + dedupeQueuedAgentWakes, + enqueueAgentWake, formatMessageSendError, getErrorMessage, - isManagedAgentRunning, - isProviderBackedAgent, mergeMentionRecipients, MENTION_REFERENCE_TAG, mergeOutgoingTagsWithReferenceMentions, type PendingNonMemberMentionSend, + type QueuedAgentWake, type SendMessageWithMentionFlowInput, resolvePreviewTags, uniqueNormalizedPubkeys, @@ -99,7 +100,14 @@ export function useMentionSendFlow({ const availableRuntimesQuery = useAvailableAcpRuntimes(); const managedAgentsQuery = useManagedAgentsQuery(); const personasQuery = usePersonasQuery(); - const startAgentMutation = useStartManagedAgentMutation(); + // Detached (publish-first) agent wake, bound to the community and identity + // active at this render so a start that outlives a community switch fails + // closed instead of spawning against the new tenant. The send path never + // calls it while preparing a message: wakes are queued during preparation + // and flushed through this callback only after the relay accepts the + // publish, so a start failure can never toast "your message was sent" + // before the publish outcome is known. + const startAgentDetached = useDetachedAgentStart(); const getManagedAgentsByPubkey = React.useCallback(async () => { const agents = managedAgentsQuery.data ?? @@ -131,79 +139,12 @@ export function useMentionSendFlow({ availableRuntimesQuery.isLoading, availableRuntimesQuery.refetch, ]); - const ensureManagedAgentMentionsReady = React.useCallback( - async ( - mentionPubkeys: string[], - capturedChannelId: string, - preparedParticipantPubkeys: string[] = [], - preparedManagedAgents: ManagedAgent[] = [], - ) => { - if (!capturedChannelId || mentionPubkeys.length === 0) { - return { - errors: [] as string[], - pubkeys: [] as string[], - }; - } - const [managedAgentsByPubkey, personas] = await Promise.all([ - getManagedAgentsByPubkey(), - getPersonas(), - ]); - for (const agent of preparedManagedAgents) { - managedAgentsByPubkey.set(normalizePubkey(agent.pubkey), agent); - } - const existingMembers = new Set( - [...mentions.memberPubkeys].map(normalizePubkey), - ); - const participants = new Set([ - ...existingMembers, - ...preparedParticipantPubkeys.map(normalizePubkey), - ]); - const errors: string[] = []; - const pubkeys: string[] = []; - for (const pubkey of uniqueNormalizedPubkeys(mentionPubkeys)) { - const agent = managedAgentsByPubkey.get(pubkey); - if (!agent) continue; - try { - const readyAgent = existingMembers.has(pubkey) - ? agent - : await applyReusableAgentAccessPolicy( - agent, - {}, - personas.find((persona) => persona.id === agent.personaId), - ); - if (participants.has(pubkey)) { - if ( - (isProviderBackedAgent(readyAgent) && - readyAgent.status !== "deployed") || - (!isProviderBackedAgent(readyAgent) && - !isManagedAgentRunning(readyAgent)) - ) { - await startAgentMutation.mutateAsync(readyAgent.pubkey); - } - } else { - await attachAgentMutation.mutateAsync({ - channelId: capturedChannelId, - agent: readyAgent, - role: "bot", - }); - } - pubkeys.push(pubkey); - } catch (error) { - errors.push( - `${agent.name}: ${getErrorMessage(error, "Could not prepare agent.")}`, - ); - } - } - return { errors, pubkeys: uniqueNormalizedPubkeys(pubkeys) }; - }, - [ - attachAgentMutation, - getManagedAgentsByPubkey, - getPersonas, - mentions.memberPubkeys, - startAgentMutation, - ], - ); + const ensureManagedAgentMentionsReady = useEnsureAgentMentionsReady({ + attachAgentToChannel: attachAgentMutation.mutateAsync, + getManagedAgentsByPubkey, + getPersonas, + memberPubkeys: mentions.memberPubkeys, + }); const createMentionedPersonaAgents = React.useCallback( async (trimmed: string, capturedChannelId: string) => { const personaMentions = mentions.extractMentionPersonas(trimmed); @@ -212,6 +153,7 @@ export function useMentionSendFlow({ errors: [] as string[], agents: [] as ManagedAgent[], pubkeys: [] as string[], + agentsToWake: [] as QueuedAgentWake[], }; } const runtimes = await getAvailableRuntimes(); @@ -219,6 +161,10 @@ export function useMentionSendFlow({ const errors: string[] = []; const agents: ManagedAgent[] = []; const pubkeys: string[] = []; + // Queued, not fired: the wakes ride the pending draft and flush only + // after the publish succeeds, so a persona created for a send the + // non-member prompt later cancels never wakes at all. + const agentsToWake: QueuedAgentWake[] = []; const seenPersonaIds = new Set(); const shouldProvisionForDm = channelType === "dm" && Boolean(onPrepareSendChannel); @@ -249,6 +195,8 @@ export function useMentionSendFlow({ model: persona.model ?? undefined, role: "bot", ensureRunning: true, + detachedStart: (agentToWake) => + enqueueAgentWake(agentsToWake, agentToWake), }; const result = shouldProvisionForDm ? await provisionPersonaAgentMutation.mutateAsync(input) @@ -272,6 +220,7 @@ export function useMentionSendFlow({ agents, errors, pubkeys: uniqueNormalizedPubkeys(pubkeys), + agentsToWake, }; }, [ @@ -480,6 +429,17 @@ export function useMentionSendFlow({ onPrepareSendChannel ? preparedAgentPubkeys : [], [...managedAgentsByPubkey.values()], ); + // Every wake this send queued: persona creates carried on the draft + // (enqueued before the non-member prompt could defer us here), then + // the readiness pass's. Flushed only after the relay accepts the + // publish — every abort path between here and there just drops them, + // so no wake (or "your message was sent" failure toast) can exist for + // a message that never landed. First entry wins the dedupe because it + // carries the earliest replay floor, and the floor is a lower bound. + const agentsToWake = dedupeQueuedAgentWakes([ + ...(draft.queuedAgentWakes ?? []), + ...agentReadiness.agentsToWake, + ]); if (isSendCancelled()) return restoreComposerAfterFailure(); if (!isMountedRef.current) { persistPreflightDraft(); @@ -488,8 +448,8 @@ export function useMentionSendFlow({ if (agentReadiness.errors.length > 0) { const message = agentReadiness.errors.length === 1 - ? `Could not start agent mention: ${agentReadiness.errors[0]}` - : `Could not start agent mentions: ${agentReadiness.errors.join( + ? `Could not prepare agent mention: ${agentReadiness.errors[0]}` + : `Could not prepare agent mentions: ${agentReadiness.errors.join( "; ", )}`; setNonMemberPromptError(message); @@ -538,6 +498,10 @@ export function useMentionSendFlow({ ); if (!finalOutgoingTags || signal?.aborted || isSendCancelled()) return; + // The pass immediately before signing/publish is always fresh: + // mention authorization is re-validated here unconditionally, + // whatever did or did not separate it from the admission pass + // above (#5681). const revalidatedMentionPubkeys = await mentions.revalidateMentionPubkeys(mentionPubkeys); if (signal?.aborted || isSendCancelled()) return; @@ -556,6 +520,15 @@ export function useMentionSendFlow({ draft.capturedThreadContext, draft.preparedLinkPreviews != null, ); + // The relay accepted the publish: flush the queued wakes now, + // before the post-send cancellation check — a cancellation racing + // a successful publish must not drop the wake for a message that + // did land. Fire-and-forget: the send awaits nothing here, and + // each wake carries its enqueue-time replay floor so the spawned + // harness replays back past this message however late the flush. + for (const wake of agentsToWake) { + startAgentDetached(wake.agent, wake.replayFloorUnix); + } if (signal?.aborted || isSendCancelled()) return; const sentMentionPubkeys = new Set( revalidatedMentionPubkeys.map(normalizePubkey), @@ -659,6 +632,7 @@ export function useMentionSendFlow({ onSendRef, richText.setContent, setContent, + startAgentDetached, setPendingImeta, restoreQueuedAttachments, setSpoileredAttachmentUrls, @@ -791,6 +765,7 @@ export function useMentionSendFlow({ outgoingTags, preparedLinkPreviews, preparedManagedAgents: personaMentionResult.agents, + queuedAgentWakes: personaMentionResult.agentsToWake, readyAgentPubkeys: channelType === "dm" && onPrepareSendChannel ? [] @@ -950,12 +925,14 @@ export function useMentionSendFlow({ setNonMemberPromptError(null); }, []); return { + // Agent starts are detached (publish-first), so useDetachedAgentStart's + // in-flight state deliberately does not gate the composer — a background + // start must not block the next send. isPreparingMentionSend: isMentionSendPending || isCompleteSendPending || attachAgentMutation.isPending || - createPersonaAgentMutation.isPending || - startAgentMutation.isPending, + createPersonaAgentMutation.isPending, nonMemberPromptProps: { canInvite: canInviteNonMembers, error: nonMemberPromptError, @@ -964,8 +941,7 @@ export function useMentionSendFlow({ isCompleteSendPending || addMembersMutation.isPending || attachAgentMutation.isPending || - createPersonaAgentMutation.isPending || - startAgentMutation.isPending, + createPersonaAgentMutation.isPending, names: pendingNonMemberNames, onDismiss: dismissNonMemberPrompt, onDoNothing: handleSendWithoutInviting, diff --git a/desktop/src/shared/api/tauriManagedAgents.ts b/desktop/src/shared/api/tauriManagedAgents.ts index 8afd03c4a0f..ed7e053f259 100644 --- a/desktop/src/shared/api/tauriManagedAgents.ts +++ b/desktop/src/shared/api/tauriManagedAgents.ts @@ -18,12 +18,19 @@ export async function startManagedAgent( /** Signer identity captured with the relay scope; the backend fails * closed when the active workspace identity no longer matches. */ expectedSignerPubkey?: string; + /** Unix-seconds replay floor for a publish-first mention send: the + * spawned harness's first REQ replays at least back to this moment, so + * the already-published triggering message lands in its window however + * long the spawn takes. Local spawns receive it as process env; provider + * deploys carry it in the payload's launch.policy_env. */ + replayFloorUnix?: number; }, ): Promise { const response = await invokeTauri("start_managed_agent", { pubkey, expectedRelayUrl: options?.expectedRelayUrl ?? null, expectedSignerPubkey: options?.expectedSignerPubkey ?? null, + replayFloorUnix: options?.replayFloorUnix ?? null, }); return fromRawManagedAgent(response); } diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 63ae9f7df83..7236ac5e820 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -316,6 +316,11 @@ type E2eConfig = { addAgentToHuddleError?: string; /** Delay an invocation-time huddle snapshot to exercise hydration ordering. */ huddleStateReadDelayMs?: number; + /** Delay (ms) for `sync_agents_to_active_huddle` so e2e tests can hold the + * send path open across a leg that writes nothing to the relay (no active + * huddle) and revoke a mention mid-hold. + * Releasable early via `__BUZZ_E2E_RELEASE_HUDDLE_AGENT_SYNCS__()`. */ + syncAgentsToActiveHuddleDelayMs?: number; /** Delay companion creation to expose the newly-started huddle handoff state. */ openHuddleWindowDelayMs?: number; /** Delay the native start result after membership arrives in the channel list. */ @@ -374,7 +379,8 @@ type E2eConfig = { openDmDelayMs?: number; sendMessageDelayMs?: number; /** Delay (ms) for `start_managed_agent` so e2e tests can switch the - * community mid-startup and observe the fail-closed scope check. */ + * community mid-startup and observe the fail-closed scope check. + * Releasable early via `__BUZZ_E2E_RELEASE_MANAGED_AGENT_STARTS__()`. */ startManagedAgentDelayMs?: number; /** Hold the media proxy at port 0 until the E2E release seam is invoked. */ mediaProxyInitiallyUnavailable?: boolean; @@ -1513,6 +1519,15 @@ declare global { /** Count of `get_event` invocations for the current defer-target ID since * the last time `__BUZZ_E2E_DEFER_GET_EVENT__` was set. */ __BUZZ_E2E_GET_EVENT_CALL_COUNT__?: number; + /** Release every `start_managed_agent` currently held behind + * `startManagedAgentDelayMs`, letting it settle (scope checks, then any + * armed `startManagedAgentErrors` rejection) without waiting out the + * delay. Returns the number of holds flushed. */ + __BUZZ_E2E_RELEASE_MANAGED_AGENT_STARTS__?: () => number; + /** Release every `sync_agents_to_active_huddle` currently held behind + * `syncAgentsToActiveHuddleDelayMs`, letting it resolve without waiting + * out the delay. Returns the number of holds flushed. */ + __BUZZ_E2E_RELEASE_HUDDLE_AGENT_SYNCS__?: () => number; /** Hold the next channel read until released. */ __BUZZ_E2E_DEFER_NEXT_CHANNELS_READ__?: () => void; /** Disarm the latch and release the held channel read, if any. */ @@ -1645,6 +1660,16 @@ let deferredGetEventQueue: DeferredGetEvent[] = []; let deferredLinkPreviewMetadataQueue: Array<() => void> = []; let deferredLinkPreviewUploadQueue: Array<() => void> = []; let deferredThreadRepliesQueue: Array<() => void> = []; +// Starts currently held behind `startManagedAgentDelayMs`, releasable early +// via `__BUZZ_E2E_RELEASE_MANAGED_AGENT_STARTS__()`: a spec that holds a +// start across a community round-trip needs the hold long enough to be +// deterministic AND a way to settle it on demand afterwards. +let heldManagedAgentStartReleases: Array<() => void> = []; +// Huddle agent syncs currently held behind `syncAgentsToActiveHuddleDelayMs`, +// releasable early via `__BUZZ_E2E_RELEASE_HUDDLE_AGENT_SYNCS__()`. The hold +// must outlast the mid-send mutation a spec injects, then settle on demand +// rather than on a timer, so the publish never races the injection. +let heldHuddleAgentSyncReleases: Array<() => void> = []; let cancelledMediaUploadIds = new Set(); let cancelledMediaFetchIds = new Set(); let mockMediaFetchControllers = new Map(); @@ -9603,7 +9628,20 @@ async function handleStartManagedAgent( ): Promise { const delayMs = config?.mock?.startManagedAgentDelayMs ?? 0; if (delayMs > 0) { - await new Promise((resolve) => window.setTimeout(resolve, delayMs)); + await new Promise((resolve) => { + let settled = false; + const release = () => { + if (settled) return; + settled = true; + window.clearTimeout(timer); + heldManagedAgentStartReleases = heldManagedAgentStartReleases.filter( + (held) => held !== release, + ); + resolve(); + }; + const timer = window.setTimeout(release, delayMs); + heldManagedAgentStartReleases.push(release); + }); } // After the injected delay, like the real command's checks before any // spawn/deploy side effect: a caller-captured tenant scope and signer @@ -11527,6 +11565,18 @@ export function maybeInstallE2eTauriMocks() { window.__BUZZ_E2E_GET_EVENT_CALL_COUNT__ = 0; window.__BUZZ_E2E_DEFER_GET_EVENT__ = null; deferredGetEventQueue = []; + heldManagedAgentStartReleases = []; + window.__BUZZ_E2E_RELEASE_MANAGED_AGENT_STARTS__ = () => { + const held = heldManagedAgentStartReleases.splice(0); + for (const release of held) release(); + return held.length; + }; + heldHuddleAgentSyncReleases = []; + window.__BUZZ_E2E_RELEASE_HUDDLE_AGENT_SYNCS__ = () => { + const held = heldHuddleAgentSyncReleases.splice(0); + for (const release of held) release(); + return held.length; + }; deferNextChannelsRead = false; deferredChannelsReadResolve = null; window.__BUZZ_E2E_CHANNELS_READ_PENDING__ = 0; @@ -11961,6 +12011,24 @@ export function maybeInstallE2eTauriMocks() { channelId?: string; agentPubkeys?: string[]; }; + const syncDelayMs = + activeConfig?.mock?.syncAgentsToActiveHuddleDelayMs ?? 0; + if (syncDelayMs > 0) { + await new Promise((resolve) => { + let settled = false; + const release = () => { + if (settled) return; + settled = true; + window.clearTimeout(timer); + heldHuddleAgentSyncReleases = heldHuddleAgentSyncReleases.filter( + (held) => held !== release, + ); + resolve(); + }; + const timer = window.setTimeout(release, syncDelayMs); + heldHuddleAgentSyncReleases.push(release); + }); + } if ( !mockHuddle || !request.channelId || @@ -13741,10 +13809,18 @@ export function maybeInstallE2eTauriMocks() { activeConfig, ); case "start_managed_agent": + // The settled marker (distinct from the invocation entry, so exact- + // match commandCount("start_managed_agent") is unaffected) lets a + // spec wait deterministically for a delayed start to resolve or + // reject — required to assert the *absence* of the failure toast, + // which is only falsifiable once the rejection is known to have + // landed. return handleStartManagedAgent( payload as Parameters[0], activeConfig, - ); + ).finally(() => { + window.__BUZZ_E2E_COMMANDS__?.push("start_managed_agent:settled"); + }); case "stop_managed_agent": return handleStopManagedAgent( payload as Parameters[0], diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index c3303f2e630..9db4370b7cd 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -935,6 +935,13 @@ test("routes a managed relay-agent mention from an existing DM to the expanded c expect(sendCommands.map((entry) => entry.command)).not.toContain( "add_channel_members", ); + // The awaited DM expansion is a relay round-trip between the + // pre-side-effect authorization pass and the publish, so the publish + // boundary re-validates instead of reusing the earlier pass. + expect( + sendCommands.filter((entry) => entry.command === "revalidate_relay_agents") + .length, + ).toBe(2); }); test("does not reroute an expanded DM after the user navigates away", async ({ @@ -1098,8 +1105,13 @@ test("drops an expanded DM after the first message fails", async ({ page }) => { ).toHaveAttribute("data-channel-id", retryChannelId ?? ""); }); -test("drops an expanded DM after agent startup fails", async ({ page }) => { - const retryMessage = "Retry after agent startup failed"; +test("publishes into an expanded DM even when agent startup fails", async ({ + page, +}) => { + // Agent starts are detached from the send: the message publishes into the + // expanded DM and the start failure surfaces as a post-send toast. Failures + // that still block the publish (and drop the expanded DM) are covered by + // the preceding "drops an expanded DM after the first message fails" spec. const startError = "Mock agent startup failed."; await installMockBridge(page, { activePersonaIds: ["builtin:fizz"], @@ -1125,53 +1137,32 @@ test("drops an expanded DM after agent startup fails", async ({ page }) => { await page.keyboard.type(" before startup fails"); await page.getByTestId("send-message").click(); - await expect( - page.getByText(startError, { exact: false }).first(), - ).toBeVisible(); - await expect(input).toContainText("Fizz"); - - const commandsAfterFailure = await readCommandPayloadLog(page); - const openDmCallsAfterFailure = commandsAfterFailure.filter( - (entry) => entry.command === "open_dm", - ); - expect(openDmCallsAfterFailure).toHaveLength(2); - expect( - (openDmCallsAfterFailure.at(-1)?.payload as { pubkeys?: string[] }) - ?.pubkeys, - ).toEqual(expect.arrayContaining([TEST_IDENTITIES.charlie.pubkey])); - expect( - (openDmCallsAfterFailure.at(-1)?.payload as { pubkeys?: string[] }) - ?.pubkeys, - ).toHaveLength(2); - - await input.fill(retryMessage); - const retryBaseline = commandsAfterFailure.length; - // The first send left the cursor parked over the bottom-right error toast, - // which overlaps the send button. Sonner pauses its dismiss timer while the - // toaster is hovered, so move the cursor away and let the transient toast - // clear before retrying — otherwise the retry click is intercepted for the - // full timeout. - await page.mouse.move(0, 0); - await expect(page.locator("[data-sonner-toast]")).toHaveCount(0, { - timeout: 10_000, - }); - await page.getByTestId("send-message").click(); - - await expect(page.getByTestId("chat-title")).toHaveText("charlie"); + // The message lands in the expanded DM despite the failed start. + await expect(page.getByTestId("chat-title")).toContainText("Fizz"); await expect(page.getByTestId("message-timeline")).toContainText( - retryMessage, + "before startup fails", ); - const retryCommands = (await readCommandPayloadLog(page)).slice( - retryBaseline, - ); - const retryOpenDm = retryCommands.find( - (entry) => entry.command === "open_dm", - ); - expect( - (retryOpenDm?.payload as { pubkeys?: string[] } | undefined)?.pubkeys, - ).toEqual([TEST_IDENTITIES.charlie.pubkey]); - await expect(page.getByTestId("chat-title")).not.toContainText("Fizz"); + // The start failure surfaces as a toast, and the sent text is not restored + // into the composer — the send succeeded, so there is nothing to retry. + // (The persistent agent audience may legitimately re-seed a "@Fizz" + // auto-mention, so only the message body proves there was no restore.) + await expect( + page.getByText(startError, { exact: false }).first(), + ).toBeVisible(); + await expect(input).not.toContainText("before startup fails"); + + const commands = await readCommandPayloadLog(page); + const lastOpenDm = commands + .filter((entry) => entry.command === "open_dm") + .at(-1); + const openDmPubkeys = ( + lastOpenDm?.payload as { pubkeys?: string[] } | undefined + )?.pubkeys; + expect(openDmPubkeys).toEqual( + expect.arrayContaining([TEST_IDENTITIES.charlie.pubkey]), + ); + expect(openDmPubkeys).toHaveLength(2); }); test("closes direct message results while opening", async ({ page }) => { diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index ddde6607ba6..d7097e9e54c 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -33,6 +33,7 @@ const PROFILE_ONLY_AGENT_PUBKEY = "8f83d6b7f3d74f7d933ae3a54dd8c6cc85c7f98e531c16e5a827b953441a8d67"; const OWNED_AGENT_PROFILE_PUBKEY = "1212121212121212121212121212121212121212121212121212121212121212"; +const HUDDLE_EPHEMERAL_CHANNEL_ID = "3f9f2c4e-8b7a-4b1c-9d2e-5a6f7c8d9e0f"; const SYSTEM_MESSAGE_KIND = 40099; const DM_THREAD_AGENT_MENTION_ERROR_TEXT = "Agents must already be in a DM to be mentioned in its threads. Start a new conversation that includes the agent."; @@ -1335,11 +1336,14 @@ test("selecting a managed agent mention inserts @Name into input", async ({ await expect(agentMentionChip).toHaveCSS("border-top-width", "0px"); }); -test("selecting a persona mention creates a channel agent before sending", async ({ +test("selecting a persona mention creates a channel agent before sending and starts it detached", async ({ page, }) => { await installMockBridge(page, { activePersonaIds: ["builtin:fizz"], + // Far longer than the test runs: sign_event landing below proves the + // publish no longer waits for start_managed_agent to resolve. + startManagedAgentDelayMs: 45_000, }); await page.goto("/"); await page.getByTestId("channel-general").click(); @@ -1401,11 +1405,19 @@ test("selecting a persona mention creates a channel agent before sending", async const commandsAfterSend = (await readCommandLog(page)).slice( baselineCommands.length, ); - const startIndex = commandsAfterSend.indexOf("start_managed_agent"); + const createIndex = commandsAfterSend.indexOf("create_managed_agent"); + const addIndex = commandsAfterSend.indexOf("add_channel_members"); const sendIndex = commandsAfterSend.indexOf("sign_event"); - expect(startIndex).toBeGreaterThanOrEqual(0); + expect(createIndex).toBeGreaterThanOrEqual(0); + expect(addIndex).toBeGreaterThanOrEqual(0); expect(sendIndex).toBeGreaterThanOrEqual(0); - expect(startIndex).toBeLessThan(sendIndex); + // Publish-first: creation and the membership write still precede the + // publish (the outgoing tags need the agent's pubkey, and the harness only + // subscribes to channels it is a member of), but the start is detached — + // sign_event landed while start_managed_agent was still pending behind the + // injected 45s delay, which the old start-blocking send could never do. + expect(createIndex).toBeLessThan(sendIndex); + expect(addIndex).toBeLessThan(sendIndex); const mentionChip = page .getByTestId("message-row") @@ -1908,6 +1920,9 @@ test("relay-only allowlisted agents emit a p tag when sent", async ({ .toContain(ALLOWLIST_RELAY_AGENT_PUBKEY); const commands = await readCommandLog(page); + // Two targeted revalidations: the pre-side-effect admission pass, then the + // publish-boundary pass, which is unconditional — even this fast path (member + // agent, no deferred upload/preview, no DM expansion, no huddle) re-runs it. expect(commandCount(commands, "revalidate_relay_agents")).toBe( commandCount(baselineCommands, "revalidate_relay_agents") + 2, ); @@ -2017,6 +2032,7 @@ test("targeted revocation before send causes no agent side effects", async ({ .poll(() => readOutgoingMentionPubkeys(page, "@quinn hello")) .not.toContain(ALLOWLIST_RELAY_AGENT_PUBKEY); const commands = await readCommandLog(page); + // Admission pass plus the unconditional publish-boundary pass. expect(commandCount(commands, "revalidate_relay_agents")).toBe( commandCount(baselineCommands, "revalidate_relay_agents") + 2, ); @@ -2035,6 +2051,385 @@ test("targeted revocation before send causes no agent side effects", async ({ } }); +test("deferred-upload sends revalidate agent authorization at the publish boundary", async ({ + page, +}) => { + // A background media upload can hold the publish open for arbitrarily long — + // authorization revoked during that window must still strip the p tag. This + // pins the publish-boundary revalidation on the deferred path. + await installMockBridge(page, { + deferredComposerUploads: true, + uploadDelayMs: 1_500, + uploadDescriptors: [ + { + url: `https://mock.relay/media/${"c".repeat(64)}.mp4`, + sha256: "c".repeat(64), + size: 1024 * 1024, + type: "video/mp4", + uploaded: Math.floor(Date.now() / 1000), + filename: "upload-race.mp4", + }, + ], + relayAgents: [ + { + pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY, + name: "quinn", + respondTo: "allowlist", + respondToAllowlist: [MOCK_VIEWER_PUBKEY], + channelNames: ["general"], + }, + ], + }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await page.evaluate( + async ({ channelId, pubkey }) => { + const invoke = window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__; + if (!invoke) throw new Error("Mock bridge is not installed."); + await invoke("add_channel_members", { + channelId, + pubkeys: [pubkey], + role: "bot", + }); + await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({ + queryKey: ["channels"], + }); + }, + { + channelId: GENERAL_CHANNEL_ID, + pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY, + }, + ); + + const input = page.getByTestId("message-input"); + await input.fill("@quinn"); + const quinnRow = autocomplete(page).locator("button", { hasText: "quinn" }); + await expect(quinnRow).toBeVisible(); + await quinnRow.click(); + await page.keyboard.type("hello"); + + // Only video files queue until send; anything else uploads at attach time. + const [chooser] = await Promise.all([ + page.waitForEvent("filechooser"), + page.getByRole("button", { name: "Attach file" }).click(), + ]); + await chooser.setFiles({ + buffer: Buffer.alloc(1024 * 1024, 1), + mimeType: "video/mp4", + name: "upload-race.mp4", + }); + await expect( + page.getByTestId("composer-queued-media-attachment"), + ).toBeVisible(); + + const baselineCommands = await readCommandLog(page); + await page.getByTestId("send-message").click(); + + // Revoke after the pre-side-effect pass has been admitted but while the + // deferred upload (1.5s mock delay) still holds the publish open. + await expect + .poll(async () => + commandCount(await readCommandLog(page), "revalidate_relay_agents"), + ) + .toBe(commandCount(baselineCommands, "revalidate_relay_agents") + 1); + await page.evaluate(() => { + window.__BUZZ_E2E__.mock ??= {}; + window.__BUZZ_E2E__.mock.relayAgentListErrors = Array(100).fill( + "mock directory revoked during deferred upload", + ); + }); + + const outgoingContent = `@quinn hello\n![video](https://mock.relay/media/${"c".repeat(64)}.mp4)`; + await expect + .poll(() => readOutgoingMentionPubkeys(page, outgoingContent)) + .not.toBeNull(); + await expect + .poll(() => readOutgoingMentionPubkeys(page, outgoingContent)) + .not.toContain(ALLOWLIST_RELAY_AGENT_PUBKEY); + const commands = await readCommandLog(page); + expect(commandCount(commands, "revalidate_relay_agents")).toBe( + commandCount(baselineCommands, "revalidate_relay_agents") + 2, + ); +}); + +test("sends that attach a mentioned agent revalidate at the publish boundary", async ({ + page, +}) => { + // The awaited membership write for a non-member managed agent is a relay + // round-trip between the pre-side-effect authorization pass and the publish + // — authorization revoked during that window must still strip the p tag. + await installMockBridge(page, { + managedAgents: [ + { + pubkey: OUT_OF_CHANNEL_MANAGED_AGENT_PUBKEY, + name: "fizz", + status: "running", + // Already matching the reusable-agent policy: no update_managed_agent + // write below, so the attach write alone re-opens the window. + respondTo: "owner-only", + respondToAllowlist: [], + }, + ], + relayAgents: [ + { + pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY, + name: "quinn", + respondTo: "allowlist", + respondToAllowlist: [MOCK_VIEWER_PUBKEY], + channelNames: ["general"], + }, + ], + }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await page.evaluate( + async ({ channelId, pubkey }) => { + const invoke = window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__; + if (!invoke) throw new Error("Mock bridge is not installed."); + await invoke("add_channel_members", { + channelId, + pubkeys: [pubkey], + role: "bot", + }); + await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({ + queryKey: ["channels"], + }); + }, + { + channelId: GENERAL_CHANNEL_ID, + pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY, + }, + ); + + const input = page.getByTestId("message-input"); + await input.fill("@quinn"); + const quinnRow = autocomplete(page).locator("button", { hasText: "quinn" }); + await expect(quinnRow).toBeVisible(); + await quinnRow.click(); + await page.keyboard.type("@fizz"); + const fizzRow = autocomplete(page).locator("button", { hasText: "fizz" }); + await expect(fizzRow).toBeVisible(); + await expect(fizzRow.getByText("not in channel")).toBeVisible(); + await fizzRow.click(); + await page.keyboard.type("hello"); + await expect(input).toHaveText("@quinn @fizz hello"); + + // Hold the attach's membership write open so the revocation below lands + // inside the pass-to-publish window. + await page.evaluate(() => { + window.__BUZZ_E2E__.mock ??= {}; + window.__BUZZ_E2E__.mock.addChannelMembersDelayMs = 1_500; + }); + const baselineCommands = await readCommandLog(page); + await page.getByTestId("send-message").click(); + await expect(page.getByRole("alertdialog")).toHaveCount(0); + + // Revoke quinn after the pre-side-effect pass has been admitted but while + // the attach still holds the publish open. + await expect + .poll(async () => + commandCount(await readCommandLog(page), "revalidate_relay_agents"), + ) + .toBe(commandCount(baselineCommands, "revalidate_relay_agents") + 1); + await page.evaluate((pubkey) => { + window.__BUZZ_E2E__.mock ??= {}; + window.__BUZZ_E2E__.mock.relayAgentRevalidationRevokedPubkeys = [pubkey]; + }, ALLOWLIST_RELAY_AGENT_PUBKEY); + + await expect + .poll(() => readOutgoingMentionPubkeys(page, "@quinn @fizz hello")) + .not.toBeNull(); + const outgoingPubkeys = await readOutgoingMentionPubkeys( + page, + "@quinn @fizz hello", + ); + expect(outgoingPubkeys).toContain(OUT_OF_CHANNEL_MANAGED_AGENT_PUBKEY); + expect(outgoingPubkeys).not.toContain(ALLOWLIST_RELAY_AGENT_PUBKEY); + const commands = await readCommandLog(page); + expect(commandCount(commands, "revalidate_relay_agents")).toBe( + commandCount(baselineCommands, "revalidate_relay_agents") + 2, + ); + // The policy already matched: the attach's membership write was the only + // relay round-trip holding the publish open for the revocation to land in. + expect(commandCount(commands, "update_managed_agent")).toBe( + commandCount(baselineCommands, "update_managed_agent"), + ); +}); + +test("sends that enroll agents into an active huddle revalidate at the publish boundary", async ({ + page, +}) => { + // With a huddle live on the channel, the awaited huddle enrollment is a + // relay round-trip between the authorization pass and the publish; the + // publish boundary re-validates rather than trusting the earlier pass. + await installMockBridge(page, { + huddle: { + parentChannelId: GENERAL_CHANNEL_ID, + ephemeralChannelId: HUDDLE_EPHEMERAL_CHANNEL_ID, + members: [{ pubkey: TEST_IDENTITIES.tyler.pubkey, role: "member" }], + }, + relayAgents: [ + { + pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY, + name: "quinn", + respondTo: "allowlist", + respondToAllowlist: [MOCK_VIEWER_PUBKEY], + channelNames: ["general"], + }, + ], + }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await page.evaluate( + async ({ channelId, pubkey }) => { + const invoke = window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__; + if (!invoke) throw new Error("Mock bridge is not installed."); + await invoke("add_channel_members", { + channelId, + pubkeys: [pubkey], + role: "bot", + }); + await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({ + queryKey: ["channels"], + }); + }, + { + channelId: GENERAL_CHANNEL_ID, + pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY, + }, + ); + + const input = page.getByTestId("message-input"); + await input.fill("@quinn"); + const quinnRow = autocomplete(page).locator("button", { hasText: "quinn" }); + await expect(quinnRow).toBeVisible(); + await quinnRow.click(); + await page.keyboard.type("hello"); + + const baselineCommands = await readCommandLog(page); + await page.getByTestId("send-message").click(); + + await expect + .poll(() => readOutgoingMentionPubkeys(page, "@quinn hello")) + .toContain(ALLOWLIST_RELAY_AGENT_PUBKEY); + const commands = await readCommandLog(page); + expect(commandCount(commands, "sync_agents_to_active_huddle")).toBe( + commandCount(baselineCommands, "sync_agents_to_active_huddle") + 1, + ); + expect(commandCount(commands, "revalidate_relay_agents")).toBe( + commandCount(baselineCommands, "revalidate_relay_agents") + 2, + ); +}); + +test("a send held open by a no-write step still revalidates at the publish boundary", async ({ + page, +}) => { + // The publish boundary revalidates unconditionally, however brief the gap: + // here the only thing separating the authorization pass from the publish is + // the huddle sync — which with no active huddle writes nothing to the relay + // — and the revocation is released with zero further hold. A revocation + // landing in any admission-to-publish gap must strip the p tag; this is the + // reviewer's sub-threshold probe of the since-removed elapsed-time bound, + // which deliberately accepted this very staleness. + await installMockBridge(page, { + // Released on demand below; long enough that it is never waited out. + syncAgentsToActiveHuddleDelayMs: 45_000, + relayAgents: [ + { + pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY, + name: "quinn", + respondTo: "allowlist", + respondToAllowlist: [MOCK_VIEWER_PUBKEY], + channelNames: ["general"], + }, + ], + }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + // Already a member, so readiness short-circuits: no access-policy read, no + // membership write, no wake. + await page.evaluate( + async ({ channelId, pubkey }) => { + const invoke = window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__; + if (!invoke) throw new Error("Mock bridge is not installed."); + await invoke("add_channel_members", { + channelId, + pubkeys: [pubkey], + role: "bot", + }); + await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({ + queryKey: ["channels"], + }); + }, + { + channelId: GENERAL_CHANNEL_ID, + pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY, + }, + ); + + const input = page.getByTestId("message-input"); + await input.fill("@quinn"); + const quinnRow = autocomplete(page).locator("button", { hasText: "quinn" }); + await expect(quinnRow).toBeVisible(); + await quinnRow.click(); + await page.keyboard.type("hello"); + await expect(input).toHaveText("@quinn hello"); + + const baselineCommands = await readCommandLog(page); + await page.getByTestId("send-message").click(); + + // The pre-side-effect pass has admitted quinn; the huddle sync now holds the + // publish open. Revoke before releasing, so the ordering is deterministic by + // construction rather than by timing. + await expect + .poll(async () => + commandCount(await readCommandLog(page), "revalidate_relay_agents"), + ) + .toBe(commandCount(baselineCommands, "revalidate_relay_agents") + 1); + await page.evaluate((pubkey) => { + window.__BUZZ_E2E__.mock ??= {}; + window.__BUZZ_E2E__.mock.relayAgentRevalidationRevokedPubkeys = [pubkey]; + }, ALLOWLIST_RELAY_AGENT_PUBKEY); + + // Release immediately — no post-revocation hold. Any conditional reuse of + // the admission pass (a trigger enumeration, an elapsed-time bound) would + // publish quinn's stale p tag here. + await expect + .poll(() => + page.evaluate( + () => window.__BUZZ_E2E_RELEASE_HUDDLE_AGENT_SYNCS__?.() ?? 0, + ), + ) + .toBeGreaterThan(0); + + await expect + .poll(() => readOutgoingMentionPubkeys(page, "@quinn hello")) + .not.toBeNull(); + await expect + .poll(() => readOutgoingMentionPubkeys(page, "@quinn hello")) + .not.toContain(ALLOWLIST_RELAY_AGENT_PUBKEY); + + const commands = await readCommandLog(page); + expect(commandCount(commands, "revalidate_relay_agents")).toBe( + commandCount(baselineCommands, "revalidate_relay_agents") + 2, + ); + expect(commandCount(commands, "sync_agents_to_active_huddle")).toBe( + commandCount(baselineCommands, "sync_agents_to_active_huddle") + 1, + ); + // Nothing on this leg wrote relay state — the second pass exists only + // because the publish boundary is unconditional. + for (const command of [ + "add_channel_members", + "attach_managed_agent", + "update_managed_agent", + "start_managed_agent", + ]) { + expect(commandCount(commands, command)).toBe( + commandCount(baselineCommands, command), + ); + } +}); + test("selected relay agents are invited as bots before sending", async ({ page, }) => { @@ -2378,7 +2773,7 @@ test("shared agents wait for initial directory authorization", async ({ }); }); -test("mentioning an in-channel stopped managed agent starts it before sending", async ({ +test("mentioning an in-channel stopped managed agent publishes first and starts it detached", async ({ page, }) => { await installMockBridge(page, { @@ -2390,6 +2785,9 @@ test("mentioning an in-channel stopped managed agent starts it before sending", channelNames: ["general"], }, ], + // Far longer than the test runs: the publish landing below proves the + // send no longer waits for start_managed_agent to resolve. + startManagedAgentDelayMs: 45_000, }); await page.goto("/"); await page.getByTestId("channel-general").click(); @@ -2404,26 +2802,521 @@ test("mentioning an in-channel stopped managed agent starts it before sending", await input.press("Enter"); await page.keyboard.type(" can you help?"); + const baselineCommands = await readCommandLog(page); const baselineStartCount = commandCount( - await readCommandLog(page), + baselineCommands, "start_managed_agent", ); + const baselineSignCount = commandCount(baselineCommands, "sign_event"); + const baselinePayloadCount = (await readCommandPayloadLog(page)).length; await page.getByTestId("send-message").click(); + // Publish-first: the message signs and renders while start_managed_agent + // is still pending behind the injected delay. + await expect + .poll(async () => commandCount(await readCommandLog(page), "sign_event")) + .toBeGreaterThan(baselineSignCount); await expect .poll(async () => commandCount(await readCommandLog(page), "start_managed_agent"), ) .toBeGreaterThan(baselineStartCount); + // The detached start carries a replay floor so the spawned harness's first + // REQ replays past the just-published message. + const startCall = (await readCommandPayloadLog(page)) + .slice(baselinePayloadCount) + .find((entry) => entry.command === "start_managed_agent"); + expect( + (startCall?.payload as { replayFloorUnix?: number } | undefined) + ?.replayFloorUnix, + ).toBeGreaterThan(0); + // It also carries the tenant scope active at the send. The start now + // outlives the send, and a community switch only remounts the React + // subtree, so an unscoped wake would spawn against whichever relay/identity + // is current when it lands; the backend fails closed on these instead. + const activeRelayUrl = await page.evaluate(() => { + const communities = JSON.parse( + window.localStorage.getItem("buzz-communities") ?? "[]", + ) as { id: string; relayUrl: string }[]; + const activeId = window.localStorage.getItem("buzz-active-community-id"); + return ( + communities.find((community) => community.id === activeId)?.relayUrl ?? "" + ); + }); + expect(activeRelayUrl).not.toBe(""); + expect(startCall?.payload).toMatchObject({ + expectedRelayUrl: activeRelayUrl, + expectedSignerPubkey: MOCK_VIEWER_PUBKEY, + }); + // The wake is queued during send preparation and flushed only after the + // relay accepts the publish, so the sign always precedes the start — a + // wake can never exist (nor its failure toast "your message was sent" + // appear) for a message whose publish outcome is still unknown. + const commandsAfterSend = (await readCommandLog(page)).slice( + baselineCommands.length, + ); + expect(commandsAfterSend.indexOf("sign_event")).toBeLessThan( + commandsAfterSend.indexOf("start_managed_agent"), + ); + + const mentionChip = page + .getByTestId("message-row") + .last() + .locator("[data-mention].agent-mention-highlight", { hasText: "fizz" }); + await expect(mentionChip).toBeVisible(); +}); + +test("a second mention while the first wake is in flight does not start the agent twice", async ({ + page, +}) => { + await installMockBridge(page, { + managedAgents: [ + { + pubkey: IN_CHANNEL_MANAGED_AGENT_PUBKEY, + name: "fizz", + status: "stopped", + channelNames: ["general"], + }, + ], + // Held open for the whole test. Awaiting the start used to make a + // duplicate unreachable — the composer refused to send while one was + // pending, and by the time it lifted the success handler had cached a + // running record. Detached, the record keeps reading "stopped" for the + // whole spawn, which is precisely when a second send re-fires. + startManagedAgentDelayMs: 45_000, + }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + const input = page.getByTestId("message-input"); + const dropdown = autocomplete(page); + const baselineCommands = await readCommandLog(page); + const baselineStartCount = commandCount( + baselineCommands, + "start_managed_agent", + ); + + await input.fill("Hey @fizz"); + await expect(dropdown.getByText("fizz")).toBeVisible(); + await input.press("Enter"); + await page.keyboard.type(" do X"); + await page.getByTestId("send-message").click(); + await expect( + page.getByTestId("message-row").filter({ hasText: "do X" }), + ).toBeVisible(); + await expect + .poll(async () => + commandCount(await readCommandLog(page), "start_managed_agent"), + ) + .toBe(baselineStartCount + 1); + + await input.fill("Hey @fizz"); + await expect(dropdown.getByText("fizz")).toBeVisible(); + await input.press("Enter"); + await page.keyboard.type(" also Y"); + await page.getByTestId("send-message").click(); + + // The second message publishes on its own — suppression is of the wake, not + // of the send; the composer is never gated on a pending start again. + await expect( + page.getByTestId("message-row").filter({ hasText: "also Y" }), + ).toBeVisible(); + // One wake serves both messages: its replay floor predates the first + // message, and the floor is a lower bound, so one harness boot covers both. + expect(commandCount(await readCommandLog(page), "start_managed_agent")).toBe( + baselineStartCount + 1, + ); +}); + +test("a detached agent start failure surfaces as a toast after the message sends", async ({ + page, +}) => { + const startError = "Mock agent startup failed."; + await installMockBridge(page, { + managedAgents: [ + { + pubkey: IN_CHANNEL_MANAGED_AGENT_PUBKEY, + name: "fizz", + status: "stopped", + channelNames: ["general"], + }, + ], + startManagedAgentErrors: [startError], + }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + const input = page.getByTestId("message-input"); + await input.fill("Hey @fizz"); + + const dropdown = autocomplete(page); + await expect(dropdown.getByText("fizz")).toBeVisible(); + await input.press("Enter"); + await page.keyboard.type(" can you help?"); + + await page.getByTestId("send-message").click(); + + // The message still publishes — the start runs off the critical path. + const mentionChip = page + .getByTestId("message-row") + .last() + .locator("[data-mention].agent-mention-highlight", { hasText: "fizz" }); + await expect(mentionChip).toBeVisible(); + + // The failed start surfaces as a post-send toast instead of blocking the + // send, and the sent text is not restored into the composer. (The + // persistent agent audience may legitimately re-seed an "@fizz" + // auto-mention, so only the message body proves there was no + // failed-send restore.) + await expect(page.getByText(startError, { exact: false })).toBeVisible(); + await expect(input).not.toContainText("can you help"); +}); + +test("a failed publish drops the queued agent wake and never claims the message was sent", async ({ + page, +}) => { + await installMockBridge(page, { + managedAgents: [ + { + pubkey: IN_CHANNEL_MANAGED_AGENT_PUBKEY, + name: "fizz", + status: "stopped", + channelNames: ["general"], + }, + ], + // Reject the publish itself. The wake is queued behind it, so a publish + // that never lands must fire no wake at all — before this ordering, the + // wake fired during send preparation, rejected fast (the injected start + // error below), and toasted "your message was sent" while the publish + // went on to fail with no corrective message. + sendMessageErrors: ["Mock relay rejected the event."], + // Armed so that IF a wake still fired it would reject immediately and + // raise the false-success toast whose absence this spec pins. + startManagedAgentErrors: ["Mock agent startup failed."], + }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + const input = page.getByTestId("message-input"); + await input.fill("Hey @fizz"); + await expect(autocomplete(page).getByText("fizz")).toBeVisible(); + await input.press("Enter"); + await page.keyboard.type(" do X"); + + const baselineStartCount = commandCount( + await readCommandLog(page), + "start_managed_agent", + ); + await page.getByTestId("send-message").click(); + + // Deterministic completion signal: the failed send restores the draft into + // the composer. On the pre-fix ordering the wake had already fired — and + // toasted — before this point, so the assertions below need no timing games. + await expect(input).toContainText("do X"); + + // The message never landed: the optimistic row was rolled back... + await expect( + page.getByTestId("message-row").filter({ hasText: "do X" }), + ).toHaveCount(0); + // ...so the queued wake was dropped rather than flushed... + expect(commandCount(await readCommandLog(page), "start_managed_agent")).toBe( + baselineStartCount, + ); + // ...and nothing on screen claims the message was sent. + await expect( + page.getByText("your message was sent", { exact: false }), + ).toHaveCount(0); +}); + +test("a detached start fired before a real community switch fails closed and keeps its warning out of the new community", async ({ + page, +}) => { + // Two seeded communities and a real rail-button switch: the click drives + // the actual provider → remount → resetCommunityState path (which clears + // and repoints the toast-scope mirror), and persists the active community + // id the mock's scope check reads — so the held start is refused exactly + // as the real backend would refuse it. The predecessor of this spec moved + // localStorage directly, which exercised the fail-closed refusal but never + // the switch itself, and pinned the stale toast's *presence* — the outcome + // the delivery fence now forbids. + const COMMUNITY_A = { + id: "ws-a", + name: "Alpha", + relayUrl: "ws://localhost:3000", + addedAt: "2026-01-01T00:00:00.000Z", + }; + const COMMUNITY_B = { + id: "ws-b", + name: "Bravo", + relayUrl: "ws://localhost:3001", + addedAt: "2026-01-02T00:00:00.000Z", + }; + await installMockBridge( + page, + { + managedAgents: [ + { + pubkey: IN_CHANNEL_MANAGED_AGENT_PUBKEY, + name: "fizz", + status: "stopped", + channelNames: ["general"], + }, + ], + // Holds the start open long enough for the real switch below to + // complete under it — the window the detached (publish-first) wake + // opened. + startManagedAgentDelayMs: 3_000, + }, + { skipCommunitySeed: true }, + ); + await page.addInitScript( + ({ list, active }) => { + window.localStorage.setItem("buzz-communities", JSON.stringify(list)); + window.localStorage.setItem("buzz-active-community-id", active); + }, + { list: [COMMUNITY_A, COMMUNITY_B], active: COMMUNITY_A.id }, + ); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + const input = page.getByTestId("message-input"); + await input.fill("Hey @fizz"); + await expect(autocomplete(page).getByText("fizz")).toBeVisible(); + await input.press("Enter"); + await page.keyboard.type(" can you help?"); + + const baselineCommands = await readCommandLog(page); + const baselineStartCount = commandCount( + baselineCommands, + "start_managed_agent", + ); + const baselineSettledCount = commandCount( + baselineCommands, + "start_managed_agent:settled", + ); + const baselinePayloadCount = (await readCommandPayloadLog(page)).length; + await page.getByTestId("send-message").click(); + + // The message published in A — only the wake is at stake from here on. const mentionChip = page .getByTestId("message-row") .last() .locator("[data-mention].agent-mention-highlight", { hasText: "fizz" }); await expect(mentionChip).toBeVisible(); + await expect + .poll(async () => + commandCount(await readCommandLog(page), "start_managed_agent"), + ) + .toBeGreaterThan(baselineStartCount); + // The wake carries A's scope, so the backend fails it closed once B is + // active — the unit tests can't pin the real invoke payload. + const startCall = (await readCommandPayloadLog(page)) + .slice(baselinePayloadCount) + .find((entry) => entry.command === "start_managed_agent"); + expect(startCall?.payload).toMatchObject({ + expectedRelayUrl: COMMUNITY_A.relayUrl, + expectedSignerPubkey: MOCK_VIEWER_PUBKEY, + }); + + // The real switch, while the start is still held. + await page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`).click(); + await expect( + page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`), + ).toHaveAttribute("aria-current", "true"); + + // Wait for the held start to actually settle (the scope refusal fires + // after the injected delay), then give the rejection a beat to reach the + // hook's catch. Only past this point is the negative assertion below + // falsifiable: pre-fence, the stale toast appeared at settlement and stayed + // on screen for seconds. + await expect + .poll( + async () => + commandCount(await readCommandLog(page), "start_managed_agent:settled"), + { timeout: 10_000 }, + ) + .toBeGreaterThan(baselineSettledCount); + await page.waitForTimeout(500); + + // B is on screen and community A's failure never toasts over it. The + // suppression is logged to the console instead; an A→B→A round-trip would + // re-arm delivery (pinned at the unit level). These counts are immediate + // snapshots, not retrying toHaveCount(0) assertions — a retry would simply + // wait out the toast's auto-dismiss and pass against the very toast it + // forbids. + await expect(page.getByTestId("channel-general")).toBeVisible(); + expect( + await page.getByText("Could not start fizz", { exact: false }).count(), + ).toBe(0); + expect( + await page.getByText("your message was sent", { exact: false }).count(), + ).toBe(0); +}); + +test("a deploy held across an A→B→A community round-trip is not fired twice", async ({ + page, +}) => { + // The in-flight detached-start map used to be cleared by every community + // switch, and the backend's scope assertion is a current-state check — so a + // deploy still held from community A became valid again the moment A was + // re-applied, and a second mention back in A deployed the agent a second + // time (carrying the second message's replay floor, past the first + // message). The entries are tenant-keyed and self-cleaning, so they now + // survive the switch. This drives the real rail-switch path (provider → + // remount → resetCommunityState) that did the clearing; the map contract + // itself is pinned at the unit level. + const COMMUNITY_A = { + id: "ws-a", + name: "Alpha", + relayUrl: "ws://localhost:3000", + addedAt: "2026-01-01T00:00:00.000Z", + }; + const COMMUNITY_B = { + id: "ws-b", + name: "Bravo", + relayUrl: "ws://localhost:3001", + addedAt: "2026-01-02T00:00:00.000Z", + }; + await installMockBridge( + page, + { + managedAgents: [ + { + pubkey: OUT_OF_CHANNEL_PROVIDER_AGENT_PUBKEY, + name: "portal", + status: "not_deployed", + channelNames: ["general"], + backend: { + type: "provider", + id: "portal", + config: { region: "test" }, + }, + }, + ], + // Far longer than the round-trip below ever takes, so the first deploy + // is deterministically still in flight when the second send fires; + // settled on demand via the release seam for the retry leg. + startManagedAgentDelayMs: 45_000, + // Arms the first settlement to reject. A successful mock settle writes + // `deployed` into the record, and the third send below would then skip + // the wake on status alone — the retry leg has to prove the *map entry* + // self-cleaned, so the record must still read `not_deployed`. + startManagedAgentErrors: ["Mock provider deploy failed."], + }, + { skipCommunitySeed: true }, + ); + await page.addInitScript( + ({ list, active }) => { + window.localStorage.setItem("buzz-communities", JSON.stringify(list)); + window.localStorage.setItem("buzz-active-community-id", active); + }, + { list: [COMMUNITY_A, COMMUNITY_B], active: COMMUNITY_A.id }, + ); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + const sendMention = async (text: string) => { + const input = page.getByTestId("message-input"); + await input.fill("Hey @portal"); + await expect(autocomplete(page).getByText("portal")).toBeVisible(); + await input.press("Enter"); + await page.keyboard.type(` ${text}`); + // Enter rather than the send button: the third send happens while the + // first deploy's failure toast is on screen, and the toast overlay + // intercepts pointer events aimed at the composer's corner. + await input.press("Enter"); + await expect( + page.getByTestId("message-row").filter({ hasText: text }), + ).toBeVisible(); + }; + + const baselineCommands = await readCommandLog(page); + const baselineStartCount = commandCount( + baselineCommands, + "start_managed_agent", + ); + const baselineSettledCount = commandCount( + baselineCommands, + "start_managed_agent:settled", + ); + const baselinePayloadCount = (await readCommandPayloadLog(page)).length; + + await sendMention("do X"); + await expect + .poll(async () => + commandCount(await readCommandLog(page), "start_managed_agent"), + ) + .toBe(baselineStartCount + 1); + const startCall = (await readCommandPayloadLog(page)) + .slice(baselinePayloadCount) + .find((entry) => entry.command === "start_managed_agent"); + expect(startCall?.payload).toMatchObject({ + expectedRelayUrl: COMMUNITY_A.relayUrl, + expectedSignerPubkey: MOCK_VIEWER_PUBKEY, + }); + + // The round trip, while the deploy is still held. + await page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`).click(); + await expect( + page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`), + ).toHaveAttribute("aria-current", "true"); + await page.getByTestId(`community-rail-button-${COMMUNITY_A.id}`).click(); + await expect( + page.getByTestId(`community-rail-button-${COMMUNITY_A.id}`), + ).toHaveAttribute("aria-current", "true"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + // Back in A, the held deploy's scope is valid again and the record still + // reads `not_deployed`, so this send queues a wake — the retained map entry + // is the only thing standing between it and a duplicate deploy. A + // suppressed wake makes no call, so give the post-publish flush a moment + // before snapshotting: pre-fix the duplicate invoke landed well inside it. + await sendMention("also Y"); + await page.waitForTimeout(500); + expect(commandCount(await readCommandLog(page), "start_managed_agent")).toBe( + baselineStartCount + 1, + ); + + // Settle the held deploy on demand (the armed rejection). Retention must + // end at settlement rather than latching the agent for the session. + const released = await page.evaluate( + () => + ( + window as { + __BUZZ_E2E_RELEASE_MANAGED_AGENT_STARTS__?: () => number; + } + ).__BUZZ_E2E_RELEASE_MANAGED_AGENT_STARTS__?.() ?? 0, + ); + expect(released).toBe(1); + await expect + .poll(async () => + commandCount(await readCommandLog(page), "start_managed_agent:settled"), + ) + .toBe(baselineSettledCount + 1); + // The failure settled with A on screen, so its warning delivers here — the + // round-trip kept it out of B without dropping it. + await expect( + page.getByText("Could not start portal", { exact: false }), + ).toBeVisible(); + + // The map entry self-cleaned at settlement, so a third mention of the + // still-undeployed agent re-fires the wake. + await sendMention("try again"); + await expect + .poll(async () => + commandCount(await readCommandLog(page), "start_managed_agent"), + ) + .toBe(baselineStartCount + 2); }); -test("mentioning an in-channel provider managed agent deploys it before sending", async ({ +test("mentioning an in-channel provider managed agent publishes first and deploys it detached", async ({ page, }) => { await installMockBridge(page, { @@ -2440,6 +3333,9 @@ test("mentioning an in-channel provider managed agent deploys it before sending" }, }, ], + // Far longer than the test runs: the publish landing below proves the + // send no longer waits for the deploy to resolve. + startManagedAgentDelayMs: 45_000, }); await page.goto("/"); await page.getByTestId("channel-general").click(); @@ -2454,18 +3350,37 @@ test("mentioning an in-channel provider managed agent deploys it before sending" await input.press("Enter"); await page.keyboard.type(" can you help?"); + const baselineCommands = await readCommandLog(page); const baselineStartCount = commandCount( - await readCommandLog(page), + baselineCommands, "start_managed_agent", ); + const baselineSignCount = commandCount(baselineCommands, "sign_event"); + const baselinePayloadCount = (await readCommandPayloadLog(page)).length; await page.getByTestId("send-message").click(); + // Publish-first: the message signs and renders while the deploy is still + // pending behind the injected delay. + await expect + .poll(async () => commandCount(await readCommandLog(page), "sign_event")) + .toBeGreaterThan(baselineSignCount); await expect .poll(async () => commandCount(await readCommandLog(page), "start_managed_agent"), ) .toBeGreaterThan(baselineStartCount); + // The detached deploy carries the replay floor too — the backend threads it + // into the provider payload's launch.policy_env so the remote harness + // replays past the just-published message like a local spawn. + const startCall = (await readCommandPayloadLog(page)) + .slice(baselinePayloadCount) + .find((entry) => entry.command === "start_managed_agent"); + expect( + (startCall?.payload as { replayFloorUnix?: number } | undefined) + ?.replayFloorUnix, + ).toBeGreaterThan(0); + const mentionChip = page .getByTestId("message-row") .last() @@ -2473,7 +3388,7 @@ test("mentioning an in-channel provider managed agent deploys it before sending" await expect(mentionChip).toBeVisible(); }); -test("mentioning a non-member managed agent adds and starts it before sending", async ({ +test("mentioning a non-member managed agent adds it before sending and starts it detached", async ({ page, }) => { await installMockBridge(page, { @@ -2494,6 +3409,9 @@ test("mentioning a non-member managed agent adds and starts it before sending", respondToAllowlist: [TEST_IDENTITIES.outsider.pubkey], }, ], + // Far longer than the test runs: the publish landing below proves the + // send no longer waits for start_managed_agent to resolve. + startManagedAgentDelayMs: 45_000, }); await page.goto("/"); await page.getByTestId("channel-general").click(); @@ -2527,6 +3445,11 @@ test("mentioning a non-member managed agent adds and starts it before sending", commandCount(await readCommandLog(page), "add_channel_members"), ) .toBeGreaterThan(baselineAddCount); + // Publish-first: the message signs while start_managed_agent is still + // pending behind the injected delay. + await expect + .poll(async () => commandCount(await readCommandLog(page), "sign_event")) + .toBeGreaterThan(commandCount(baselineCommands, "sign_event")); await expect .poll(async () => commandCount(await readCommandLog(page), "start_managed_agent"), @@ -2545,9 +3468,16 @@ test("mentioning a non-member managed agent adds and starts it before sending", const startIndex = sendCommands.findIndex( (entry) => entry.command === "start_managed_agent", ); + const sendIndex = sendCommands.findIndex( + (entry) => entry.command === "sign_event", + ); expect(updateIndex).toBeGreaterThanOrEqual(0); expect(updateIndex).toBeLessThan(addIndex); expect(updateIndex).toBeLessThan(startIndex); + // The access-policy write and the membership write stay ahead of the + // publish; only the start itself is detached from the send. + expect(sendIndex).toBeGreaterThanOrEqual(0); + expect(addIndex).toBeLessThan(sendIndex); expect(sendCommands[updateIndex]?.payload).toMatchObject({ input: { pubkey: OUT_OF_CHANNEL_MANAGED_AGENT_PUBKEY, @@ -2555,6 +3485,15 @@ test("mentioning a non-member managed agent adds and starts it before sending", respondToAllowlist: [], }, }); + // The detached start carries a replay floor so the spawned harness's first + // REQ replays past the just-published message. + expect( + ( + sendCommands[startIndex]?.payload as + | { replayFloorUnix?: number } + | undefined + )?.replayFloorUnix, + ).toBeGreaterThan(0); const mentionChip = page .getByTestId("message-row") @@ -2584,7 +3523,7 @@ test("mentioning a non-member managed agent adds and starts it before sending", }); }); -test("mentioning a non-member provider managed agent deploys it before sending", async ({ +test("mentioning a non-member provider managed agent adds it before sending and deploys it detached", async ({ page, }) => { await installMockBridge(page, { @@ -2600,6 +3539,9 @@ test("mentioning a non-member provider managed agent deploys it before sending", }, }, ], + // Far longer than the test runs: the publish landing below proves the + // send no longer waits for the deploy to resolve. + startManagedAgentDelayMs: 45_000, }); await page.goto("/"); await page.getByTestId("channel-general").click(); @@ -2623,6 +3565,7 @@ test("mentioning a non-member provider managed agent deploys it before sending", baselineCommands, "start_managed_agent", ); + const baselineSignCount = commandCount(baselineCommands, "sign_event"); await page.getByTestId("send-message").click(); await expect(page.getByRole("alertdialog")).toHaveCount(0); @@ -2632,6 +3575,11 @@ test("mentioning a non-member provider managed agent deploys it before sending", commandCount(await readCommandLog(page), "add_channel_members"), ) .toBeGreaterThan(baselineAddCount); + // Publish-first: the message signs and renders while the deploy is still + // pending behind the injected delay. + await expect + .poll(async () => commandCount(await readCommandLog(page), "sign_event")) + .toBeGreaterThan(baselineSignCount); await expect .poll(async () => commandCount(await readCommandLog(page), "start_managed_agent"), diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 97c7096d5a5..c3f4ed69f4c 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -229,6 +229,10 @@ type MockBridgeOptions = { }; /** Delay an invocation-time huddle snapshot to exercise hydration ordering. */ huddleStateReadDelayMs?: number; + /** Delay (ms) for `sync_agents_to_active_huddle` so e2e tests can hold the + * send path open across a leg that writes nothing to the relay. + * Releasable early via `__BUZZ_E2E_RELEASE_HUDDLE_AGENT_SYNCS__()`. */ + syncAgentsToActiveHuddleDelayMs?: number; /** Delay companion creation to expose the newly-started huddle handoff state. */ openHuddleWindowDelayMs?: number; /** Delay the native start result after membership arrives in the channel list. */